Allowing AI to generate suggestions for the blog writer

This commit is contained in:
ajaysi
2025-09-20 22:15:17 +05:30
parent 4d153b292d
commit f98d49cea7
22 changed files with 4248 additions and 96 deletions

View File

@@ -1,8 +1,8 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { CopilotSidebar } from '@copilotkit/react-ui';
import '@copilotkit/react-ui/styles.css';
import { blogWriterApi } from '../../services/blogWriterApi';
import { useOutlinePolling } from '../../hooks/usePolling';
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling } from '../../hooks/usePolling';
import { useClaimFixer } from '../../hooks/useClaimFixer';
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
@@ -20,11 +20,12 @@ import { EnhancedOutlineActions } from './EnhancedOutlineActions';
import HallucinationChecker from './HallucinationChecker';
import Publisher from './Publisher';
import OutlineGenerator from './OutlineGenerator';
import SectionGenerator from './SectionGenerator';
import OutlineRefiner from './OutlineRefiner';
import SEOProcessor from './SEOProcessor';
import BlogWriterLanding from './BlogWriterLanding';
import ResearchProgressModal from './ResearchProgressModal';
import { OutlineProgressModal } from './OutlineProgressModal';
import OutlineFeedbackForm from './OutlineFeedbackForm';
import { BlogEditor } from './WYSIWYG';
export const BlogWriter: React.FC = () => {
// Use custom hook for all state management
@@ -45,6 +46,7 @@ export const BlogWriter: React.FC = () => {
researchCoverage,
researchTitles,
aiGeneratedTitles,
outlineConfirmed,
setOutline,
setTitleOptions,
setSections,
@@ -55,10 +57,12 @@ export const BlogWriter: React.FC = () => {
handleResearchComplete,
handleOutlineComplete,
handleOutlineError,
handleSectionGenerated,
handleContinuityRefresh,
handleTitleSelect,
handleCustomTitle
handleCustomTitle,
handleOutlineConfirmed,
handleOutlineRefined,
handleContentUpdate,
handleContentSave
} = useBlogWriterState();
// Custom hooks for complex functionality
@@ -68,13 +72,16 @@ export const BlogWriter: React.FC = () => {
setSections
);
const { convertMarkdownToHTML, getTotalWords, getOutlineStats } = useMarkdownProcessor(
const { convertMarkdownToHTML } = useMarkdownProcessor(
outline,
sections
);
// Get suggestions
const suggestions = useSuggestions(research, outline);
// Research polling hook (for context awareness)
const researchPolling = useResearchPolling({
onComplete: handleResearchComplete,
onError: (error) => console.error('Research polling error:', error)
});
// Outline polling hook
const outlinePolling = useOutlinePolling({
@@ -82,22 +89,90 @@ export const BlogWriter: React.FC = () => {
onError: handleOutlineError
});
// Medium generation polling (used after confirm if short blog)
const mediumPolling = useMediumGenerationPolling({
onComplete: (result: any) => {
try {
if (result && result.sections) {
const newSections: Record<string, string> = {};
result.sections.forEach((s: any) => {
newSections[String(s.id)] = s.content || '';
});
setSections(newSections);
}
} catch (e) {
console.error('Failed to apply medium generation result:', e);
}
},
onError: (err) => console.error('Medium generation failed:', err)
});
// Get context-aware suggestions based on current task status
const suggestions = useSuggestions(
research,
outline,
outlineConfirmed,
{ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
{ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
{ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus }
);
// Add minimum display time for modal
const [showModal, setShowModal] = useState(false);
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
useEffect(() => {
if ((mediumPolling.isPolling || isMediumGenerationStarting) && !showModal) {
setShowModal(true);
setModalStartTime(Date.now());
} else if (!mediumPolling.isPolling && !isMediumGenerationStarting && showModal) {
const elapsed = Date.now() - (modalStartTime || 0);
const minDisplayTime = 2000; // 2 seconds minimum
if (elapsed < minDisplayTime) {
setTimeout(() => {
setShowModal(false);
setModalStartTime(null);
}, minDisplayTime - elapsed);
} else {
setShowModal(false);
setModalStartTime(null);
}
}
}, [mediumPolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
// Handle medium generation start from OutlineFeedbackForm
const handleMediumGenerationStarted = (taskId: string) => {
console.log('Starting medium generation polling for task:', taskId);
setIsMediumGenerationStarting(false); // Clear the starting state
mediumPolling.startPolling(taskId);
};
// Show modal immediately when copilot action is triggered
const handleMediumGenerationTriggered = () => {
console.log('Medium generation triggered - showing modal immediately');
setIsMediumGenerationStarting(true);
};
// Debug medium polling state
console.log('Medium polling state:', {
isPolling: mediumPolling.isPolling,
status: mediumPolling.currentStatus,
progressCount: mediumPolling.progressMessages.length
});
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Outline Progress Modal */}
<ResearchProgressModal
open={Boolean(outlineTaskId && (outlinePolling.isPolling || outlinePolling.currentStatus === 'pending' || outlinePolling.currentStatus === 'running'))}
title="Outline generation in progress"
status={outlinePolling.currentStatus}
messages={outlinePolling.progressMessages}
error={outlinePolling.error}
onClose={() => { /* informational while processing */ }}
/>
{/* Extracted Components */}
<KeywordInputForm onResearchComplete={handleResearchComplete} />
<KeywordInputForm
onResearchComplete={handleResearchComplete}
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
/>
<CustomOutlineForm onOutlineCreated={setOutline} />
<ResearchAction onResearchComplete={handleResearchComplete} />
<ResearchDataActions
@@ -109,6 +184,14 @@ export const BlogWriter: React.FC = () => {
outline={outline}
onOutlineUpdated={setOutline}
/>
<OutlineFeedbackForm
outline={outline}
research={research!}
onOutlineConfirmed={handleOutlineConfirmed}
onOutlineRefined={handleOutlineRefined}
onMediumGenerationStarted={handleMediumGenerationStarted}
onMediumGenerationTriggered={handleMediumGenerationTriggered}
/>
{/* New extracted functionality components */}
<OutlineGenerator
@@ -116,13 +199,6 @@ export const BlogWriter: React.FC = () => {
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
/>
<SectionGenerator
outline={outline}
research={research}
genMode={genMode}
onSectionGenerated={handleSectionGenerated}
onContinuityRefresh={handleContinuityRefresh}
/>
<OutlineRefiner
outline={outline}
onOutlineUpdated={setOutline}
@@ -161,57 +237,75 @@ export const BlogWriter: React.FC = () => {
{research && outline.length === 0 && <ResearchResults research={research} />}
{outline.length > 0 && (
<div>
{/* Enhanced Title Selection */}
<EnhancedTitleSelector
{outlineConfirmed ? (
/* WYSIWYG Editor - Show when outline is confirmed */
<BlogEditor
outline={outline}
research={research}
initialTitle={selectedTitle}
titleOptions={titleOptions}
selectedTitle={selectedTitle}
sections={outline}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
/>
) : (
/* Outline Editor - Show when outline is not confirmed */
<>
{/* Enhanced Title Selection */}
<EnhancedTitleSelector
titleOptions={titleOptions}
selectedTitle={selectedTitle}
sections={outline}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
/>
{/* Enhanced Outline Editor */}
<EnhancedOutlineEditor
outline={outline}
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
/>
{/* Enhanced Outline Editor */}
<EnhancedOutlineEditor
outline={outline}
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
/>
{/* Draft/Polished Mode Toggle */}
<div style={{ margin: '12px 0' }}>
<label style={{ marginRight: 8 }}>Generation mode:</label>
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
<option value="draft">Draft (faster, lower cost)</option>
<option value="polished">Polished (higher quality)</option>
</select>
</div>
{outline.map(s => (
<div key={s.id} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h4 style={{ margin: 0 }}>{s.heading}</h4>
{/* Continuity badge */}
{sections[s.id] && (
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
)}
{/* Draft/Polished Mode Toggle */}
<div style={{ margin: '12px 0' }}>
<label style={{ marginRight: 8 }}>Generation mode:</label>
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
<option value="draft">Draft (faster, lower cost)</option>
<option value="polished">Polished (higher quality)</option>
</select>
</div>
{sections[s.id] ? (
<>
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
<SEOMiniPanel analysis={seoAnalysis} />
</>
) : (
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
)}
</div>
))}
{outline.map(s => (
<div key={s.id} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h4 style={{ margin: 0 }}>{s.heading}</h4>
{/* Continuity badge */}
{sections[s.id] && (
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
)}
</div>
{sections[s.id] ? (
<>
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
<SEOMiniPanel analysis={seoAnalysis} />
</>
) : (
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
)}
</div>
))}
</>
)}
</div>
)}
</div>
@@ -231,6 +325,7 @@ export const BlogWriter: React.FC = () => {
// Get current state information
const hasResearch = research !== null;
const hasOutline = outline.length > 0;
const isOutlineConfirmed = outlineConfirmed;
const researchInfo = hasResearch ? {
sources: research.sources?.length || 0,
queries: research.search_queries?.length || 0,
@@ -239,6 +334,14 @@ export const BlogWriter: React.FC = () => {
searchIntent: research.keyword_analysis?.search_intent || 'informational'
} : null;
const outlineContext = hasOutline ? `
OUTLINE DETAILS:
- Total sections: ${outline.length}
- Section headings: ${outline.map(s => s.heading).join(', ')}
- Total target words: ${outline.reduce((sum, s) => sum + (s.target_words || 0), 0)}
- Section breakdown: ${outline.map(s => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`).join('; ')}
` : '';
const toolGuide = `
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
@@ -252,7 +355,8 @@ ${hasResearch && researchInfo ? `
- Search intent: ${researchInfo.searchIntent}
` : '❌ No research completed yet'}
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created` : '❌ No outline generated yet'}
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
${outlineContext}
Available tools:
- getResearchKeywords(prompt?: string) - Get keywords from user for research
@@ -261,9 +365,12 @@ Available tools:
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
- generateOutline()
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
- refineOutline(prompt?: string) - Refine outline based on user feedback
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
- generateSection(sectionId: string)
- generateAllSections()
- refineOutline(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
@@ -282,20 +389,48 @@ Available tools:
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
- When user asks to generate content, call generateSection or generateAllSections
- After outline generation, ALWAYS guide user to review and confirm the outline
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
- When user clicks "Confirm & Generate Content", ONLY call confirmOutlineAndGenerateContent() - DO NOT automatically generate content
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
ENGAGEMENT TACTICS:
- DO NOT ask for clarification - take action immediately with the information provided
- Always call the appropriate tool instead of just talking about what you could do
- Be aware of the current state and reference research results when relevant
- Guide users through the process: Research → Outline → Content → SEO → Publish
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → SEO → Publish
- Use encouraging language and highlight progress made
- If user seems lost, remind them of the current stage and suggest the next step
- When research is complete, emphasize the value of the data found and guide to outline creation
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
- Encourage users to make small manual edits to the outline UI before using AI for major changes
`;
return [toolGuide, additional].filter(Boolean).join('\n\n');
}}
/>
{/* Outline Progress Modal */}
{/* Outline modal */}
<OutlineProgressModal
isVisible={outlinePolling.isPolling}
status={outlinePolling.currentStatus}
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
error={outlinePolling.error}
/>
{/* Medium generation modal */}
<OutlineProgressModal
isVisible={showModal}
status={mediumPolling.currentStatus}
progressMessages={mediumPolling.progressMessages.map(m => m.message)}
latestMessage={mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''}
error={mediumPolling.error}
titleOverride={'📝 Generating Your Blog Content'}
/>
</div>
);
};

View File

@@ -9,6 +9,7 @@ const useCopilotActionTyped = useCopilotAction as any;
interface KeywordInputFormProps {
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
onResearchComplete?: (researchData: BlogResearchResponse) => void;
onTaskStart?: (taskId: string) => void;
}
// Separate component to manage form state
@@ -140,7 +141,7 @@ const ResearchForm: React.FC<{
);
};
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete }) => {
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
// Keyword input action with Human-in-the-Loop
@@ -214,9 +215,13 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
word_count_target: parseInt(blogLength)
};
// Store the blog length in localStorage for later use
localStorage.setItem('blog_length_target', blogLength);
// Start async research
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
onTaskStart?.(task_id); // Notify parent component to start polling
return {
success: true,

View File

@@ -0,0 +1,498 @@
import React, { useState } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi, mediumBlogApi } from '../../services/blogWriterApi';
import { useMediumGenerationPolling } from '../../hooks/usePolling';
// Simple toast notification function
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 8px;
color: white;
font-weight: 500;
z-index: 10000;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateX(100%);
transition: transform 0.3s ease;
background-color: ${type === 'success' ? '#4caf50' : '#f44336'};
`;
toast.textContent = message;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// Remove after 4 seconds
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 4000);
};
const useCopilotActionTyped = useCopilotAction as any;
interface OutlineFeedbackFormProps {
outline: BlogOutlineSection[];
research: BlogResearchResponse;
onOutlineConfirmed: () => void;
onOutlineRefined: (feedback: string) => void;
onMediumGenerationStarted?: (taskId: string) => void;
onMediumGenerationTriggered?: () => void;
}
// Separate component to manage feedback form state
const FeedbackForm: React.FC<{
prompt?: string;
onSubmit: (data: { feedback: string; action: 'refine' | 'confirm' }) => void;
onCancel: () => void;
}> = ({ prompt, onSubmit, onCancel }) => {
const [feedback, setFeedback] = useState('');
const [action, setAction] = useState<'refine' | 'confirm'>('refine');
const hasValidInput = feedback.trim().length > 0 || action === 'confirm';
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasValidInput) {
onSubmit({ feedback: feedback.trim(), action });
} else {
window.alert('Please provide feedback or confirm the outline.');
}
};
return (
<form
onSubmit={handleSubmit}
style={{
padding: '20px',
backgroundColor: '#f8f9fa',
borderRadius: '12px',
border: '1px solid #e0e0e0',
margin: '8px 0'
}}
>
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
📝 Outline Review & Feedback
</h4>
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
{prompt || 'Please review the generated outline and provide your feedback:'}
</p>
<div style={{ display: 'grid', gap: '12px' }}>
<div>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '14px',
fontWeight: '500',
color: '#333'
}}>
What would you like to do? *
</label>
<div style={{ display: 'flex', gap: '12px', marginBottom: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input
type="radio"
name="action"
value="refine"
checked={action === 'refine'}
onChange={(e) => setAction(e.target.value as 'refine' | 'confirm')}
style={{ margin: 0 }}
/>
<span style={{ fontSize: '14px' }}>🔧 Refine/Edit Outline</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input
type="radio"
name="action"
value="confirm"
checked={action === 'confirm'}
onChange={(e) => setAction(e.target.value as 'refine' | 'confirm')}
style={{ margin: 0 }}
/>
<span style={{ fontSize: '14px' }}> Confirm & Generate Content</span>
</label>
</div>
</div>
{action === 'refine' && (
<div>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '14px',
fontWeight: '500',
color: '#333'
}}>
Your Feedback & Suggestions *
</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="e.g., Add a section about implementation challenges, Remove the conclusion section, Make the introduction more engaging, Change the order of sections..."
style={{
width: '100%',
padding: '10px 12px',
border: '2px solid #1976d2',
borderRadius: '6px',
fontSize: '14px',
outline: 'none',
backgroundColor: 'white',
boxSizing: 'border-box',
minHeight: '100px',
resize: 'vertical',
fontFamily: 'inherit'
}}
autoFocus
spellCheck="true"
/>
<div style={{
marginTop: '8px',
fontSize: '12px',
color: '#666',
fontStyle: 'italic'
}}>
💡 Be specific about what you want to change. The AI will use your feedback to improve the outline.
</div>
</div>
)}
{action === 'confirm' && (
<div style={{
padding: '12px',
backgroundColor: '#e8f5e8',
borderRadius: '6px',
border: '1px solid #4caf50'
}}>
<p style={{ margin: 0, color: '#2e7d32', fontSize: '14px' }}>
Ready to generate content! Click "Submit" to proceed with content generation for all sections.
</p>
</div>
)}
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
<button
type="submit"
disabled={!hasValidInput}
style={{
flex: 1,
padding: '10px 16px',
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: hasValidInput ? 'pointer' : 'not-allowed',
transition: 'background-color 0.2s ease'
}}
>
{action === 'refine' ? '🔧 Refine Outline' : '✅ Confirm & Generate Content'}
</button>
<button
type="button"
onClick={onCancel}
style={{
padding: '10px 16px',
backgroundColor: 'transparent',
color: '#666',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
Cancel
</button>
</div>
</form>
);
};
export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
outline,
research,
onOutlineConfirmed,
onOutlineRefined,
onMediumGenerationStarted,
onMediumGenerationTriggered
}) => {
// Refine outline action with HITL
useCopilotActionTyped({
name: 'refineOutline',
description: 'Refine the outline based on user feedback',
parameters: [
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
],
handler: async ({ prompt, feedback }: { prompt?: string; feedback?: string }) => {
// Validate input
if (!feedback || feedback.trim().length === 0) {
return {
success: false,
message: 'Please provide specific feedback for outline refinement.',
suggestion: 'Try describing what you want to change, add, or remove from the outline.'
};
}
if (!research) {
return {
success: false,
message: 'No research data available for outline refinement.',
suggestion: 'Please complete research first before refining the outline.'
};
}
try {
// Create a refined outline request with user feedback
const refineRequest = {
research: research,
current_outline: outline,
user_feedback: feedback.trim(),
word_count: 1500
};
// Start async outline refinement
const { task_id } = await blogWriterApi.startOutlineGeneration(refineRequest);
return {
success: true,
message: `🔧 Outline refinement started based on your feedback! Task ID: ${task_id}. Progress will be shown below.`,
task_id: task_id,
next_step_suggestion: 'The outline is being refined based on your feedback. You can monitor progress below.'
};
} catch (error) {
console.error('Outline refinement error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
success: false,
message: `Outline refinement failed: ${errorMessage}`,
suggestion: 'Try providing more specific feedback or ask me to help clarify your requirements.'
};
}
},
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
if (status === 'complete') {
return (
<div style={{
padding: '16px',
backgroundColor: '#f0f8ff',
borderRadius: '8px',
border: '1px solid #1976d2'
}}>
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
Outline refinement completed! Check the progress below.
</p>
</div>
);
}
if (status === 'executing') {
return (
<div style={{
padding: '16px',
backgroundColor: '#fff3cd',
borderRadius: '8px',
border: '1px solid #ffc107'
}}>
<p style={{ margin: 0, color: '#856404', fontWeight: '500' }}>
Refining outline based on your feedback...
</p>
</div>
);
}
return (
<FeedbackForm
prompt={args.prompt}
onSubmit={(formData) => {
if (formData.action === 'confirm') {
onOutlineConfirmed();
} else {
onOutlineRefined(formData.feedback);
}
respond?.(JSON.stringify(formData));
}}
onCancel={() => respond?.('CANCEL')}
/>
);
}
});
// Outline confirmation action
useCopilotActionTyped({
name: 'confirmOutlineAndGenerateContent',
description: 'Confirm the outline and mark it as ready for content generation. This does NOT automatically generate content - it only confirms the outline.',
parameters: [],
handler: async () => {
// Validate that we have an outline to confirm
if (!outline || outline.length === 0) {
return {
success: false,
message: 'No outline available to confirm.',
suggestion: 'Please generate an outline first before confirming.'
};
}
try {
onOutlineConfirmed();
// If research specifies a short/medium blog (<=1000), kick off medium generation
const target = Number(
research?.keyword_analysis?.blog_length ||
(research as any)?.word_count_target ||
localStorage.getItem('blog_length_target') ||
0
);
if (target && target <= 1000) {
// Show modal immediately when medium generation is triggered
onMediumGenerationTriggered?.();
// Build payload for medium generation
const payload = {
title: (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || outline[0]?.heading || 'Untitled',
sections: outline.map(s => ({
id: s.id,
heading: s.heading,
keyPoints: s.key_points,
subheadings: s.subheadings,
keywords: s.keywords,
targetWords: s.target_words,
references: s.references,
})),
globalTargetWords: target,
researchKeywords: research.original_keywords || research.keyword_analysis?.primary || [], // Use original research keywords for better caching
};
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
// Notify parent to start polling for the medium generation task
onMediumGenerationStarted?.(task_id);
// Return message so the copilot shows feedback; UI will display modal via BlogWriter
return {
success: true,
message: `✅ Outline confirmed. Medium generation started (Task: ${task_id}). You can monitor progress in the modal.`,
task_id,
action_taken: 'outline_confirmed_medium_generation_started'
};
}
return {
success: true,
message: `✅ Outline confirmed! Ready to generate content for ${outline.length} sections.`,
next_step_suggestion: 'Now you can choose to generate content for individual sections or all sections at once using the available suggestions.',
outline_sections: outline.length,
action_taken: 'outline_confirmed_only'
};
} catch (error) {
console.error('Outline confirmation error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
success: false,
message: `Outline confirmation failed: ${errorMessage}`,
suggestion: 'Please try again or contact support if the problem persists.'
};
}
}
});
// Chat with Outline action
useCopilotActionTyped({
name: 'chatWithOutline',
description: 'Chat with the outline to get insights, summaries, and interesting questions about the content structure',
parameters: [
{ name: 'question', type: 'string', description: 'Question about the outline or content structure', required: false }
],
handler: async ({ question }: { question?: string }) => {
if (!outline || outline.length === 0) {
return {
success: false,
message: 'No outline available to chat with.',
suggestion: 'Please generate an outline first before chatting about it.'
};
}
if (!research) {
return {
success: false,
message: 'No research data available for outline discussion.',
suggestion: 'Please complete research first before chatting about the outline.'
};
}
try {
// Provide comprehensive outline and research context
const outlineContext = {
totalSections: outline.length,
sections: outline.map(section => ({
heading: section.heading,
subheadings: section.subheadings,
keyPoints: section.key_points,
targetWords: section.target_words
})),
researchSummary: {
sources: research.sources?.length || 0,
primaryKeywords: research.keyword_analysis?.primary || [],
searchIntent: research.keyword_analysis?.search_intent || 'informational',
contentAngles: research.suggested_angles || []
},
totalTargetWords: outline.reduce((sum, section) => sum + (section.target_words || 0), 0)
};
// If no specific question, provide a summary and interesting questions
if (!question) {
const summary = `I can see you have a well-structured outline with ${outlineContext.totalSections} sections targeting ${outlineContext.totalTargetWords} words total. The outline covers: ${outline.map(s => s.heading).join(', ')}.`;
const interestingQuestions = [
"What's the main narrative flow of this outline?",
"How does each section build upon the previous one?",
"What are the key takeaways readers will get from each section?",
"How well does this outline address the search intent: " + outlineContext.researchSummary.searchIntent + "?",
"What additional sections might strengthen this content?",
"How can we improve the engagement factor of each section?"
];
return {
success: true,
message: `${summary}\n\nHere are some interesting questions to explore:\n${interestingQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n')}`,
outlineContext: outlineContext,
next_step_suggestion: 'Ask me any specific questions about the outline structure, content flow, or how to improve it.'
};
}
// Handle specific questions about the outline
return {
success: true,
message: `Great question about the outline! Based on the current structure and research data, I can help you analyze and improve the outline.`,
outlineContext: outlineContext,
question: question,
next_step_suggestion: 'Feel free to ask more specific questions about sections, flow, or content strategy.'
};
} catch (error) {
console.error('Chat with outline error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
success: false,
message: `Failed to chat with outline: ${errorMessage}`,
suggestion: 'Please try again or ask a more specific question about the outline.'
};
}
}
});
return null; // This component only provides the copilot actions
};
export default OutlineFeedbackForm;

View File

@@ -0,0 +1,290 @@
import React from 'react';
interface OutlineProgressModalProps {
isVisible: boolean;
status: string;
progressMessages: string[];
latestMessage: string;
error: string | null;
titleOverride?: string;
}
export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
isVisible,
status,
progressMessages,
latestMessage,
error,
titleOverride
}) => {
if (!isVisible) return null;
const getUserFriendlyMessage = (message: string): string => {
// Map technical backend messages to user-friendly ones
if (message.includes('Starting outline generation')) {
return '🧩 Starting to create your blog outline...';
}
if (message.includes('Analyzing research data and building content strategy')) {
return '📊 Analyzing your research data to build the perfect content strategy...';
}
if (message.includes('Generating AI-powered outline with research insights')) {
return '🤖 Creating an intelligent outline using AI and your research insights...';
}
if (message.includes('Making AI request to generate structured outline')) {
return '🔄 Generating your structured blog outline...';
}
if (message.includes('Calling Gemini API for outline generation')) {
return '🤖 AI is crafting your personalized blog structure...';
}
if (message.includes('Processing outline structure and validating sections')) {
return '📝 Processing and validating your outline sections...';
}
if (message.includes('Running parallel processing for maximum speed')) {
return '⚡ Optimizing processing speed for faster results...';
}
if (message.includes('Applying intelligent source-to-section mapping')) {
return '🔗 Intelligently matching your research sources to outline sections...';
}
if (message.includes('Extracting grounding metadata insights')) {
return '🧠 Extracting valuable insights from your research data...';
}
if (message.includes('Enhancing sections with grounding insights')) {
return '✨ Enhancing your outline sections with research-backed insights...';
}
if (message.includes('Optimizing outline for better flow and engagement')) {
return '🎯 Optimizing your outline for maximum reader engagement...';
}
if (message.includes('Rebalancing word count distribution')) {
return '⚖️ Balancing content distribution across sections...';
}
if (message.includes('Outline generation and optimization completed successfully')) {
return '✅ Your blog outline has been successfully created and optimized!';
}
if (message.includes('Outline generated successfully')) {
return '🎉 Success! Your personalized blog outline is ready!';
}
// Return the original message if no mapping found
return message;
};
const getProgressPercentage = (): number => {
if (status === 'complete') return 100;
if (status === 'error') return 0;
// Estimate progress based on common message patterns
const messageCount = progressMessages.length;
if (messageCount === 0) return 0;
if (messageCount >= 10) return 90;
return Math.min(messageCount * 10, 90);
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '0',
maxWidth: '600px',
width: '90%',
maxHeight: '80vh',
overflow: 'hidden',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e5e7eb'
}}>
{/* Header with background image */}
<div style={{
backgroundImage: 'url(/blog-writer-bg.png)',
backgroundSize: 'cover',
backgroundPosition: 'center',
padding: '32px',
color: 'white',
textAlign: 'center',
position: 'relative'
}}>
{/* Dark overlay */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: '16px 16px 0 0'
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
<h2 style={{
margin: '0 0 16px 0',
fontSize: '24px',
fontWeight: '700',
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)'
}}>
{titleOverride || (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
</h2>
{/* Progress Bar */}
<div style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: '12px',
height: '8px',
overflow: 'hidden',
marginBottom: '16px'
}}>
<div style={{
backgroundColor: status === 'error' ? '#ef4444' : '#10b981',
height: '100%',
width: `${getProgressPercentage()}%`,
transition: 'width 0.3s ease',
borderRadius: '12px'
}} />
</div>
<p style={{
margin: 0,
fontSize: '16px',
opacity: 0.9,
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.3)'
}}>
{titleOverride
? (status === 'complete'
? 'Your AI-generated blog content is ready!'
: status === 'error'
? 'Something went wrong during generation'
: 'AI is generating your blog content...')
: (status === 'complete'
? 'Your AI-powered blog outline is ready to use!'
: status === 'error'
? 'Something went wrong during outline generation'
: 'AI is analyzing your research and creating the perfect blog structure...')}
</p>
</div>
</div>
{/* Content */}
<div style={{ padding: '24px' }}>
{error ? (
<div style={{
backgroundColor: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '16px',
color: '#dc2626'
}}>
<strong>Error:</strong> {error}
</div>
) : (
<>
{/* Current Status */}
<div style={{
backgroundColor: '#f0f9ff',
border: '1px solid #bae6fd',
borderRadius: '8px',
padding: '16px',
marginBottom: '20px'
}}>
<div style={{
fontSize: '14px',
fontWeight: '600',
color: '#0369a1',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: status === 'complete' ? '#10b981' : '#3b82f6',
animation: status === 'executing' ? 'pulse 2s infinite' : 'none'
}} />
Current Status
</div>
<div style={{
fontSize: '15px',
color: '#1e40af',
lineHeight: '1.5'
}}>
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing to generate your outline...'}
</div>
</div>
{/* Progress Messages */}
{progressMessages.length > 0 && (
<div>
<h4 style={{
margin: '0 0 12px 0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
Progress Timeline
</h4>
<div style={{
maxHeight: '200px',
overflowY: 'auto',
backgroundColor: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '12px'
}}>
{progressMessages.slice().reverse().slice(0, 8).map((message, index) => (
<div key={index} style={{
fontSize: '13px',
color: '#6b7280',
marginBottom: index < Math.min(progressMessages.length - 1, 7) ? '8px' : '0',
paddingLeft: '20px',
position: 'relative',
lineHeight: '1.4'
}}>
<span style={{
position: 'absolute',
left: '0',
top: '2px',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: index === 0 ? '#10b981' : '#d1d5db'
}} />
{getUserFriendlyMessage(message)}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
{/* CSS for pulse animation */}
<style>
{`
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
`}
</style>
</div>
</div>
);
};

View File

@@ -0,0 +1,108 @@
import React from 'react';
interface StyledSuggestion {
title: string;
message: string;
priority?: 'high' | 'normal';
}
interface StyledSuggestionsProps {
suggestions: StyledSuggestion[];
onSuggestionClick: (suggestion: StyledSuggestion) => void;
}
export const StyledSuggestions: React.FC<StyledSuggestionsProps> = ({
suggestions,
onSuggestionClick
}) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{suggestions.map((suggestion, index) => {
const isHighPriority = suggestion.priority === 'high';
return (
<button
key={index}
onClick={() => onSuggestionClick(suggestion)}
style={{
padding: isHighPriority ? '16px 20px' : '12px 16px',
backgroundColor: isHighPriority ? '#1976d2' : '#f5f5f5',
color: isHighPriority ? 'white' : '#333',
border: isHighPriority ? '2px solid #1976d2' : '1px solid #ddd',
borderRadius: '8px',
fontSize: isHighPriority ? '16px' : '14px',
fontWeight: isHighPriority ? '600' : '500',
textAlign: 'left',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: isHighPriority ? '0 2px 8px rgba(25, 118, 210, 0.2)' : '0 1px 3px rgba(0, 0, 0, 0.1)',
position: 'relative',
overflow: 'hidden'
}}
onMouseEnter={(e) => {
if (isHighPriority) {
e.currentTarget.style.backgroundColor = '#1565c0';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(25, 118, 210, 0.3)';
} else {
e.currentTarget.style.backgroundColor = '#e8e8e8';
e.currentTarget.style.borderColor = '#1976d2';
}
}}
onMouseLeave={(e) => {
if (isHighPriority) {
e.currentTarget.style.backgroundColor = '#1976d2';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(25, 118, 210, 0.2)';
} else {
e.currentTarget.style.backgroundColor = '#f5f5f5';
e.currentTarget.style.borderColor = '#ddd';
}
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: isHighPriority ? '4px' : '2px'
}}>
<span style={{
fontSize: isHighPriority ? '18px' : '16px',
filter: isHighPriority ? 'brightness(0) invert(1)' : 'none'
}}>
{suggestion.title.split(' ')[0]}
</span>
<span style={{
fontSize: isHighPriority ? '16px' : '14px',
fontWeight: isHighPriority ? '600' : '500'
}}>
{suggestion.title.split(' ').slice(1).join(' ')}
</span>
</div>
<div style={{
fontSize: isHighPriority ? '14px' : '12px',
opacity: isHighPriority ? '0.9' : '0.7',
lineHeight: '1.4'
}}>
{suggestion.message}
</div>
{isHighPriority && (
<div style={{
position: 'absolute',
top: '0',
right: '0',
width: '0',
height: '0',
borderLeft: '20px solid transparent',
borderTop: '20px solid rgba(255, 255, 255, 0.1)',
pointerEvents: 'none'
}} />
)}
</button>
);
})}
</div>
);
};
export default StyledSuggestions;

View File

@@ -4,19 +4,71 @@ import { BlogOutlineSection, BlogResearchResponse } from '../../services/blogWri
interface SuggestionsGeneratorProps {
research: BlogResearchResponse | null;
outline: BlogOutlineSection[];
outlineConfirmed?: boolean;
researchPolling?: { isPolling: boolean; currentStatus: string };
outlinePolling?: { isPolling: boolean; currentStatus: string };
mediumPolling?: { isPolling: boolean; currentStatus: string };
}
export const useSuggestions = (research: BlogResearchResponse | null, outline: BlogOutlineSection[]) => {
export const useSuggestions = (
research: BlogResearchResponse | null,
outline: BlogOutlineSection[],
outlineConfirmed: boolean = false,
researchPolling?: { isPolling: boolean; currentStatus: string },
outlinePolling?: { isPolling: boolean; currentStatus: string },
mediumPolling?: { isPolling: boolean; currentStatus: string }
) => {
return useMemo(() => {
const items = [] as { title: string; message: string }[];
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
// Check if any background tasks are currently running
const isResearchRunning = researchPolling?.isPolling && researchPolling?.currentStatus !== 'completed';
const isOutlineRunning = outlinePolling?.isPolling && outlinePolling?.currentStatus !== 'completed';
const isMediumGenerationRunning = mediumPolling?.isPolling && mediumPolling?.currentStatus !== 'completed';
// If research is running, show status instead of other suggestions
if (isResearchRunning) {
items.push({
title: '⏳ Research in Progress...',
message: `Research is currently running (${researchPolling?.currentStatus}). Please wait for completion.`,
priority: 'high'
});
return items;
}
// If outline generation is running, show status
if (isOutlineRunning) {
items.push({
title: '⏳ Outline Generation in Progress...',
message: `Outline is being generated (${outlinePolling?.currentStatus}). Please wait for completion.`,
priority: 'high'
});
return items;
}
// If medium generation is running, show status
if (isMediumGenerationRunning) {
items.push({
title: '⏳ Content Generation in Progress...',
message: `Blog content is being generated (${mediumPolling?.currentStatus}). Please wait for completion.`,
priority: 'high'
});
return items;
}
// Normal workflow suggestions based on current state
if (!research) {
items.push({ title: '🔎 Start research', message: "I want to research a topic for my blog" });
items.push({
title: '🔎 Start Research',
message: "I want to research a topic for my blog",
priority: 'high'
});
} else if (research && outline.length === 0) {
// Research completed, guide user to outline creation
items.push({
title: '🧩 Create Outline',
message: 'Let\'s proceed to create an outline based on the research results'
title: 'Next: Create Outline',
message: 'Let\'s proceed to create an outline based on the research results',
priority: 'high'
});
items.push({
title: '💬 Chat with Research Data',
@@ -26,13 +78,29 @@ export const useSuggestions = (research: BlogResearchResponse | null, outline: B
title: '🎨 Create Custom Outline',
message: 'I want to create an outline with my own specific instructions and requirements'
});
} else if (outline.length > 0) {
// Outline created, focus on content generation
} else if (outline.length > 0 && !outlineConfirmed) {
// Outline created but not confirmed - focus on outline review and confirmation
items.push({
title: 'Next: Confirm & Generate Content',
message: 'I\'m happy with the outline, let\'s generate content for all sections',
priority: 'high'
});
items.push({
title: '💬 Chat with Outline',
message: 'I want to discuss the outline and get insights about the content structure'
});
items.push({
title: '🔧 Refine Outline',
message: 'I want to refine the outline structure based on my feedback'
});
items.push({
title: '⚖️ Rebalance Word Counts',
message: 'Rebalance word count distribution across sections'
});
} else if (outline.length > 0 && outlineConfirmed) {
// Outline confirmed, focus on content generation
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
outline.forEach(s => items.push({ title: `✍️ Generate ${s.heading}`, message: `Generate the section: ${s.heading}` }));
items.push({ title: '🔧 Refine outline', message: 'Help me refine the outline structure' });
items.push({ title: '✨ Enhance outline', message: 'Optimize the entire outline for better flow and engagement' });
items.push({ title: '⚖️ Rebalance word counts', message: 'Rebalance word count distribution across sections' });
items.push({ title: '📈 Run SEO analysis', message: 'Analyze SEO for my blog post' });
items.push({ title: '🧾 Generate SEO metadata', message: 'Generate SEO metadata and title' });
items.push({ title: '🧪 Hallucination check', message: 'Check for any false claims in my content' });
@@ -40,11 +108,11 @@ export const useSuggestions = (research: BlogResearchResponse | null, outline: B
}
return items;
}, [research, outline]);
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling]);
};
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline }) => {
const suggestions = useSuggestions(research, outline);
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
useSuggestions(research, outline, outlineConfirmed);
return null; // This is just a utility component
};

View File

@@ -0,0 +1,293 @@
import React, { useState, useCallback, useEffect } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, TextField, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider } from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
} from '@mui/icons-material';
import { BlogOutlineSection, BlogResearchResponse } from '../../../services/blogWriterApi';
import BlogSection from './BlogSection';
// Helper to create a consistent theme
const theme = createTheme({
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
},
palette: {
primary: {
main: '#4f46e5',
},
},
});
interface BlogEditorProps {
outline: BlogOutlineSection[];
research: BlogResearchResponse | null;
initialTitle?: string;
titleOptions?: string[];
researchTitles?: string[];
aiGeneratedTitles?: string[];
sections?: Record<string, string>;
onContentUpdate?: (sections: any[]) => void;
onSave?: (content: any) => void;
}
const BlogEditor: React.FC<BlogEditorProps> = ({
outline,
research,
initialTitle,
titleOptions = [],
researchTitles = [],
aiGeneratedTitles = [],
sections: parentSections,
onContentUpdate,
onSave
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [sections, setSections] = useState<any[]>([]);
const [isTitleLoading, setIsTitleLoading] = useState(false);
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
const [showTitleModal, setShowTitleModal] = useState(false);
// Initialize sections from outline or use parent sections
useEffect(() => {
if (outline && outline.length > 0) {
const initialSections = outline.map((section, index) => ({
id: section.id || index + 1,
title: section.heading,
content: parentSections?.[section.id] || section.key_points?.join(' ') || '',
wordCount: section.target_words || 0,
sources: section.references?.length || 0,
outlineData: {
subheadings: section.subheadings || [],
keyPoints: section.key_points || [],
keywords: section.keywords || [],
references: section.references || [],
targetWords: section.target_words || 0
}
}));
setSections(initialSections);
}
}, [outline, parentSections]);
// Initialize title from parent when provided
useEffect(() => {
if (initialTitle && initialTitle.trim().length > 0) {
setBlogTitle(initialTitle);
}
}, [initialTitle]);
const handleSuggestTitle = useCallback(() => {
console.log('Available titles:', { researchTitles, aiGeneratedTitles, titleOptions });
setShowTitleModal(true);
}, [researchTitles, aiGeneratedTitles, titleOptions]);
const handleTitleSelect = useCallback((selectedTitle: string) => {
setBlogTitle(selectedTitle);
setShowTitleModal(false);
}, []);
const toggleSectionExpansion = useCallback((sectionId: any) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
return newSet;
});
}, []);
// Main Render - Exactly like your example
return (
<ThemeProvider theme={theme}>
<div className="bg-gray-50 min-h-screen font-sans">
<main className="w-full px-4 sm:px-6 lg:px-8 py-8">
<div className="w-full max-w-4xl mx-auto">
<Paper elevation={0} className="bg-white p-8 md:p-12 rounded-xl border border-gray-200/80 w-full">
<div className="mb-8 pb-6 border-b">
<div className="flex items-start gap-2 group">
<h1
className="flex-1 text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
style={{
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
lineHeight: '1.3'
}}
onClick={() => {
const newTitle = prompt('Edit blog title:', blogTitle);
if (newTitle !== null) {
setBlogTitle(newTitle);
}
}}
title="Click to edit title"
>
{blogTitle}
</h1>
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 mt-1">
<Tooltip title="✨ ALwrity it">
<IconButton onClick={handleSuggestTitle} disabled={isTitleLoading} size="small">
{isTitleLoading ? <CircularProgress size={20} /> : <AutoAwesomeIcon className="text-purple-500" fontSize="small"/>}
</IconButton>
</Tooltip>
</div>
</div>
<p className="mt-3 text-gray-500 text-sm">
This is where your blog's subtitle or a brief one-line description will appear. It's editable too!
</p>
<Divider sx={{ mt: 3, opacity: 0.3 }} />
</div>
<div>
{sections.map((section) => (
<BlogSection
key={section.id}
{...section}
onContentUpdate={onContentUpdate}
expandedSections={expandedSections}
toggleSectionExpansion={toggleSectionExpansion}
/>
))}
</div>
</Paper>
</div>
</main>
{/* Title Selection Modal */}
<Dialog
open={showTitleModal}
onClose={() => setShowTitleModal(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
Choose Your Blog Title
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{/* Research Titles */}
{researchTitles.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'primary.main' }}>
📊 Research-Based Titles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{researchTitles.map((title, index) => (
<Button
key={index}
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'primary.light',
color: 'white',
}
}}
>
{title}
</Button>
))}
</Box>
</Box>
)}
{/* AI Generated Titles */}
{aiGeneratedTitles.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'secondary.main' }}>
🤖 AI Generated Titles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{aiGeneratedTitles.map((title, index) => (
<Button
key={index}
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'secondary.light',
color: 'white',
}
}}
>
{title}
</Button>
))}
</Box>
</Box>
)}
{/* Title Options */}
{titleOptions.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'success.main' }}>
Additional Options
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{titleOptions.map((title, index) => (
<Button
key={index}
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'success.light',
color: 'white',
}
}}
>
{title}
</Button>
))}
</Box>
</Box>
)}
{researchTitles.length === 0 && aiGeneratedTitles.length === 0 && titleOptions.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No title options available. Please generate an outline first.
</Typography>
)}
{/* Debug info */}
{process.env.NODE_ENV === 'development' && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
Debug: Research titles: {researchTitles.length}, AI titles: {aiGeneratedTitles.length}, Options: {titleOptions.length}
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowTitleModal(false)}>
Cancel
</Button>
</DialogActions>
</Dialog>
</div>
</ThemeProvider>
);
};
export default BlogEditor;

View File

@@ -0,0 +1,373 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Paper,
IconButton,
Chip,
TextField,
Tooltip,
CircularProgress,
Divider,
Button
} from '@mui/material';
import {
Edit as EditIcon,
DeleteOutline as DeleteOutlineIcon,
FileCopyOutlined as FileCopyOutlinedIcon,
Link as LinkIcon,
AutoAwesome as AutoAwesomeIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from '@mui/icons-material';
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
interface BlogSectionProps {
id: any;
title: string;
content: string;
wordCount: number;
sources: number;
outlineData?: {
subheadings: string[];
keyPoints: string[];
keywords: string[];
references: any[];
targetWords: number;
};
onContentUpdate?: (sections: any[]) => void;
expandedSections: Set<any>;
toggleSectionExpansion: (sectionId: any) => void;
}
const BlogSection: React.FC<BlogSectionProps> = ({
id,
title,
content: initialContent,
wordCount,
sources,
outlineData,
onContentUpdate,
expandedSections,
toggleSectionExpansion
}) => {
const [isEditing, setIsEditing] = useState(false);
const [sectionTitle, setSectionTitle] = useState(title);
const [content, setContent] = useState(initialContent);
const [isGenerating, setIsGenerating] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
// Initialize assistive writing handler
const assistiveWriting = useBlogTextSelectionHandler(
contentRef,
(originalText: string, newText: string, editType: string) => {
// Handle text replacement in the textarea
if (contentRef.current) {
const textarea = contentRef.current;
const currentContent = textarea.value;
const updatedContent = currentContent.replace(originalText, newText);
setContent(updatedContent);
// Update parent state
if (onContentUpdate) {
onContentUpdate([{ id, content: updatedContent }]);
}
// Focus back to textarea and set cursor after the replaced text
setTimeout(() => {
if (contentRef.current) {
const newCursorPosition = updatedContent.indexOf(newText) + newText.length;
contentRef.current.focus();
contentRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
}
}, 100);
}
}
);
// Format content helper - ensures proper paragraph breaks
const formatContent = (rawContent: string) => {
if (!rawContent) return rawContent;
// Ensure double line breaks between paragraphs
// Replace single line breaks with double line breaks if they're not already double
let formatted = rawContent
.replace(/\n{3,}/g, '\n\n') // Replace 3+ line breaks with double
.replace(/\n(?!\n)/g, '\n\n') // Replace single line breaks with double
.trim();
return formatted;
};
// Sync content when initialContent changes (e.g., from AI generation)
useEffect(() => {
if (initialContent !== content) {
const formattedContent = formatContent(initialContent);
setContent(formattedContent);
}
}, [initialContent]);
const handleContentChange = (e: any) => {
const newContent = e.target.value;
setContent(newContent);
// Trigger smart typing assist
assistiveWriting.handleTypingChange(newContent);
};
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const handleGenerateContent = async () => {
setIsGenerating(true);
try {
// This would call your AI service for content generation
await new Promise(resolve => setTimeout(resolve, 2000));
const generated = `This is AI-generated content for "${sectionTitle}" with engaging, well-structured paragraphs grounded in your research.`;
setContent(generated);
// Update parent state if needed
if (onContentUpdate) {
onContentUpdate([{ id, content: generated }]);
}
} catch (error) {
console.error('Failed to generate content:', error);
} finally {
setIsGenerating(false);
}
};
return (
<div
className="group relative mb-6"
id={`section-${id}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isEditing ? (
<TextField
fullWidth
variant="standard"
value={sectionTitle}
onChange={(e) => setSectionTitle(e.target.value)}
onBlur={() => setIsEditing(false)}
autoFocus
InputProps={{ className: 'text-2xl md:text-3xl font-bold !font-serif text-gray-800 mb-4' }}
/>
) : (
<h2
className="text-2xl md:text-3xl font-bold font-serif text-gray-800 mb-4 cursor-pointer"
onClick={() => setIsEditing(true)}
>
{sectionTitle}
</h2>
)}
<div
className="relative"
onMouseUp={assistiveWriting.handleTextSelection}
onKeyUp={assistiveWriting.handleTextSelection}
>
<TextField
multiline
fullWidth
variant="standard"
value={content}
onChange={handleContentChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="Start writing your section here... Select text for assistive writing features!"
InputProps={{
disableUnderline: true,
className: 'text-gray-600 leading-relaxed text-base md:text-lg focus-within:bg-indigo-50/50 p-2 rounded-md transition-colors duration-200',
style: {
whiteSpace: 'pre-wrap', // Preserve line breaks and spaces
lineHeight: '1.8', // Better line spacing for readability
},
}}
inputRef={contentRef}
/>
{/* Render assistive writing selection menu */}
{assistiveWriting.renderSelectionMenu()}
{/* Simple AI generation button - only show when no text selection menu is active */}
{content && isFocused && !assistiveWriting.selectionMenu && (
<div
className="absolute z-10"
style={{
right: '8px',
bottom: '8px',
}}
>
<Tooltip title="✨ Generate Content">
<IconButton
size="small"
onClick={handleGenerateContent}
disabled={isGenerating}
sx={{
background: 'rgba(79, 70, 229, 0.1)',
color: '#4f46e5',
border: '1px solid rgba(79, 70, 229, 0.2)',
'&:hover': {
background: 'rgba(79, 70, 229, 0.2)',
transform: 'translateY(-1px)',
},
'&:disabled': {
background: 'rgba(255, 255, 255, 0.7)',
color: '#9CA3AF',
},
}}
>
{isGenerating ? (
<CircularProgress size={16} color="inherit" />
) : (
<AutoAwesomeIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
</div>
)}
</div>
{/* Outline Information Section */}
{outlineData && expandedSections.has(id) && (
<div className="mt-4">
<Paper elevation={0} sx={{ p: 2, bgcolor: '#f8f9fa', borderRadius: 2, mb: 2 }}>
<div className="flex flex-col gap-4">
{/* Key Points */}
{outlineData.keyPoints && outlineData.keyPoints.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Key Points:</div>
<div className="flex flex-wrap gap-1">
{outlineData.keyPoints.map((point: any, index: any) => (
<Chip
key={index}
label={point}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
))}
</div>
</div>
)}
{/* Subheadings */}
{outlineData.subheadings && outlineData.subheadings.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Subheadings:</div>
<div className="flex flex-wrap gap-1">
{outlineData.subheadings.map((subheading: any, index: any) => (
<Chip
key={index}
label={subheading}
size="small"
variant="outlined"
color="secondary"
sx={{ fontSize: '0.75rem' }}
/>
))}
</div>
</div>
)}
{/* Keywords */}
{outlineData.keywords && outlineData.keywords.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Keywords:</div>
<div className="flex flex-wrap gap-1">
{outlineData.keywords.map((keyword: any, index: any) => (
<Chip
key={index}
label={keyword}
size="small"
variant="filled"
color="primary"
sx={{ fontSize: '0.75rem' }}
/>
))}
</div>
</div>
)}
{/* Target Words */}
{outlineData.targetWords > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">
Target Words: {outlineData.targetWords}
</div>
</div>
)}
{/* References */}
{outlineData.references && outlineData.references.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">
References ({outlineData.references.length}):
</div>
<div className="flex flex-wrap gap-1">
{outlineData.references.slice(0, 3).map((ref: any, index: any) => (
<Chip
key={index}
label={ref.title || `Source ${index + 1}`}
size="small"
variant="outlined"
color="info"
sx={{ fontSize: '0.75rem' }}
/>
))}
{outlineData.references.length > 3 && (
<Chip
label={`+${outlineData.references.length - 3} more`}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
)}
</div>
</div>
)}
</div>
</Paper>
</div>
)}
<div className="absolute -bottom-4 right-0 flex items-center space-x-1" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.3s' }}>
<Chip label={`${content.split(' ').length} words`} size="small" variant="outlined" className="!text-gray-500" />
<Chip icon={<LinkIcon />} label={`${sources} sources`} size="small" variant="outlined" className="!text-gray-500" />
{outlineData && (
<Chip
icon={expandedSections.has(id) ? <ExpandLessIcon /> : <ExpandMoreIcon />}
label="Outline Info"
size="small"
variant="outlined"
clickable
onClick={() => toggleSectionExpansion(id)}
sx={{
fontSize: '0.75rem',
'&:hover': {
backgroundColor: 'rgba(25, 118, 210, 0.08)',
}
}}
/>
)}
<Tooltip title="Generate Content">
<IconButton size="small" onClick={handleGenerateContent}>
<AutoAwesomeIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Copy Section"><IconButton size="small"><FileCopyOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Edit Metadata"><IconButton size="small"><EditIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Delete Section"><IconButton size="small" className="text-red-500"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
</div>
{/* Section Divider */}
<Divider sx={{ mt: 1.2, mb: 1, opacity: 0.3 }} />
</div>
);
};
export default BlogSection;

View File

@@ -0,0 +1,793 @@
import React, { useState, useRef, useEffect } from 'react';
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
import FactCheckResults from '../../LinkedInWriter/components/FactCheckResults';
interface BlogTextSelectionHandlerProps {
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
onTextReplace?: (originalText: string, newText: string, editType: string) => void;
}
const useBlogTextSelectionHandler = (
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>,
onTextReplace?: (originalText: string, newText: string, editType: string) => void
) => {
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string } | null>(null);
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
const [isFactChecking, setIsFactChecking] = useState(false);
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Smart typing assist states
const [smartSuggestion, setSmartSuggestion] = useState<{ text: string; position: { x: number; y: number } } | null>(null);
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fact-checking functionality
const handleCheckFacts = async (text: string) => {
console.log('🔍 [BlogTextSelectionHandler] handleCheckFacts called with text:', text);
if (!text.trim()) {
console.log('🔍 [BlogTextSelectionHandler] No text to check, returning');
return;
}
console.log('🔍 [BlogTextSelectionHandler] Starting fact check for:', text.trim());
setIsFactChecking(true);
setSelectionMenu(null);
// Progress tracking
const progressSteps = [
{ step: "Extracting verifiable claims...", progress: 20 },
{ step: "Searching for evidence...", progress: 40 },
{ step: "Analyzing claims against sources...", progress: 70 },
{ step: "Generating final assessment...", progress: 90 },
{ step: "Completing fact-check...", progress: 100 }
];
let currentStepIndex = 0;
// Start progress updates
const progressInterval = setInterval(() => {
if (currentStepIndex < progressSteps.length) {
setFactCheckProgress(progressSteps[currentStepIndex]);
currentStepIndex++;
}
}, 2000); // Update every 2 seconds
// Set a timeout for the fact check (30 seconds)
const timeoutId = setTimeout(() => {
console.log('🔍 [BlogTextSelectionHandler] Fact check timeout reached');
clearInterval(progressInterval);
setFactCheckProgress(null);
setIsFactChecking(false);
setFactCheckResults({
success: false,
claims: [],
overall_confidence: 0,
total_claims: 0,
supported_claims: 0,
refuted_claims: 0,
insufficient_claims: 0,
timestamp: new Date().toISOString(),
error: 'Fact check timed out after 30 seconds. Please try again with shorter text.'
});
}, 30000); // 30 second timeout
try {
console.log('🔍 [BlogTextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
const results = await hallucinationDetectorService.detectHallucinations({
text: text.trim(),
include_sources: true,
max_claims: 10
});
console.log('🔍 [BlogTextSelectionHandler] Fact check results received:', results);
setFactCheckResults(results);
} catch (error) {
console.error('🔍 [BlogTextSelectionHandler] Error checking facts:', error);
setFactCheckResults({
success: false,
claims: [],
overall_confidence: 0,
total_claims: 0,
supported_claims: 0,
refuted_claims: 0,
insufficient_claims: 0,
timestamp: new Date().toISOString(),
error: `Failed to check facts: ${error instanceof Error ? error.message : 'Unknown error'}`
});
} finally {
console.log('🔍 [BlogTextSelectionHandler] Fact check completed, setting isFactChecking to false');
clearInterval(progressInterval);
clearTimeout(timeoutId);
setFactCheckProgress(null);
setIsFactChecking(false);
}
};
const handleCloseFactCheckResults = () => {
setFactCheckResults(null);
};
// Blog-specific quick edit functionality for selected text
const handleQuickEdit = (editType: string, selectedText: string) => {
console.log('🔍 [BlogTextSelectionHandler] handleQuickEdit called:', editType, selectedText);
let editedText = selectedText;
switch (editType) {
case 'improve':
// Enhance readability and engagement
editedText = selectedText.replace(/\./g, '. ').replace(/\s+/g, ' ').trim();
if (!editedText.endsWith('.') && !editedText.endsWith('!') && !editedText.endsWith('?')) {
editedText += '.';
}
break;
case 'add-transition':
// Add transitional phrases
const transitions = ['Furthermore,', 'Additionally,', 'Moreover,', 'In essence,', 'As a result,'];
const randomTransition = transitions[Math.floor(Math.random() * transitions.length)];
editedText = `${randomTransition} ${selectedText.toLowerCase()}`;
break;
case 'shorten':
// Condense while maintaining meaning
editedText = selectedText
.replace(/\b(very|really|extremely|quite|rather|fairly)\s+/gi, '')
.replace(/\b(that|which) (is|are|was|were)\s+/gi, '')
.replace(/\bin order to\b/gi, 'to')
.replace(/\bdue to the fact that\b/gi, 'because')
.trim();
break;
case 'expand':
// Add explanatory content
editedText = selectedText + ' This approach provides significant value by offering concrete benefits and actionable insights that readers can immediately implement.';
break;
case 'professionalize':
// Make more formal and professional
editedText = selectedText
.replace(/\bcan't\b/gi, 'cannot')
.replace(/\bwon't\b/gi, 'will not')
.replace(/\bdon't\b/gi, 'do not')
.replace(/\bisn't\b/gi, 'is not')
.replace(/\baren't\b/gi, 'are not')
.replace(/\bI think\b/gi, 'It is evident that')
.replace(/\bI believe\b/gi, 'Research indicates that');
break;
case 'add-data':
// Add statistical backing
editedText = selectedText + ' According to recent industry studies, this approach has shown measurable improvements in key performance metrics.';
break;
default:
return;
}
// Call the callback with the edited text
if (onTextReplace) {
onTextReplace(selectedText, editedText, editType);
}
// Also dispatch custom event for broader compatibility
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
detail: {
originalText: selectedText,
editedText: editedText,
editType: editType
}
}));
// Close the selection menu
setSelectionMenu(null);
};
// Smart typing assist functionality
const generateSmartSuggestion = async (currentText: string) => {
if (currentText.length < 20) return; // Only suggest after some meaningful content
setIsGeneratingSuggestion(true);
try {
// Simulate AI generation with contextual suggestions
await new Promise(resolve => setTimeout(resolve, 1500));
const suggestions = [
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
"Moreover, this framework addresses common challenges while providing practical solutions."
];
const randomSuggestion = suggestions[Math.floor(Math.random() * suggestions.length)];
// Get cursor position for suggestion placement
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const x = rect.left + 20;
const y = rect.bottom + 5;
setSmartSuggestion({
text: randomSuggestion,
position: { x, y }
});
}
} catch (error) {
console.error('Failed to generate smart suggestion:', error);
} finally {
setIsGeneratingSuggestion(false);
}
};
const handleTypingChange = (newText: string) => {
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Clear any existing suggestion when user types
setSmartSuggestion(null);
// Set new timeout for suggestion generation
typingTimeoutRef.current = setTimeout(() => {
// First time suggestion appears automatically
if (!hasShownFirstSuggestion && newText.length > 20) {
generateSmartSuggestion(newText);
setHasShownFirstSuggestion(true);
}
// After first time, only suggest after longer pauses or more content
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
generateSmartSuggestion(newText);
}
}, 3000); // 3 second pause before suggesting
};
const handleAcceptSuggestion = () => {
if (smartSuggestion && onTextReplace && contentRef.current) {
const element = contentRef.current;
const currentContent = (element as HTMLTextAreaElement).value || (element as HTMLDivElement).textContent || '';
const newContent = currentContent + ' ' + smartSuggestion.text;
// Use the text replacement callback
onTextReplace(currentContent, newContent, 'smart-suggestion');
setSmartSuggestion(null);
}
};
const handleRejectSuggestion = () => {
setSmartSuggestion(null);
};
// Cleanup progress and timeouts on unmount
useEffect(() => {
return () => {
setFactCheckProgress(null);
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
// Text selection handler with debouncing
const handleTextSelection = () => {
console.log('🔍 [BlogTextSelectionHandler] handleTextSelection called');
// Clear any existing timeout
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
// Debounce the selection handling
selectionTimeoutRef.current = setTimeout(() => {
try {
const sel = window.getSelection();
console.log('🔍 [BlogTextSelectionHandler] Selection object (debounced):', sel);
if (!sel || sel.rangeCount === 0) {
console.log('🔍 [BlogTextSelectionHandler] No selection or range count is 0');
setSelectionMenu(null);
return;
}
const text = (sel.toString() || '').trim();
console.log('🔍 [BlogTextSelectionHandler] Selected text (debounced):', text, 'Length:', text.length);
if (!text || text.length < 10) {
console.log('🔍 [BlogTextSelectionHandler] Text too short or empty, hiding menu');
setSelectionMenu(null);
return;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
console.log('🔍 [BlogTextSelectionHandler] Range rect:', rect);
// Use viewport coordinates for absolute positioning
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280)); // Account for menu width
const y = Math.max(8, rect.top + window.scrollY);
const menuPosition = { x, y, text };
console.log('🔍 [BlogTextSelectionHandler] Setting selection menu at position (debounced):', menuPosition);
setSelectionMenu(menuPosition);
} catch (error) {
console.error('🔍 [BlogTextSelectionHandler] Error handling text selection:', error);
setSelectionMenu(null);
}
}, 150); // 150ms debounce
};
return {
selectionMenu,
setSelectionMenu,
factCheckResults,
isFactChecking,
factCheckProgress,
smartSuggestion,
isGeneratingSuggestion,
handleTextSelection,
handleCheckFacts,
handleCloseFactCheckResults,
handleQuickEdit,
handleTypingChange,
handleAcceptSuggestion,
handleRejectSuggestion,
// Render the selection menu and fact-check components
renderSelectionMenu: () => (
<>
{/* Text Selection Menu */}
{selectionMenu && (
<div
onClick={(e) => {
console.log('🔍 [BlogTextSelectionHandler] Selection menu clicked!', e.target);
e.stopPropagation();
}}
style={{
position: 'fixed',
top: selectionMenu.y - 60,
left: Math.max(8, selectionMenu.x - 140),
background: 'rgba(79, 70, 229, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
padding: '12px 16px',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.35)',
backdropFilter: 'blur(12px)',
zIndex: 10000,
minWidth: '240px',
maxWidth: '280px'
}}
>
{/* Fact Check Button */}
<button
onClick={(e) => {
console.log('🔍 [BlogTextSelectionHandler] Check Facts button clicked!', selectionMenu.text);
e.preventDefault();
e.stopPropagation();
handleCheckFacts(selectionMenu.text);
}}
disabled={isFactChecking}
style={{
background: isFactChecking ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: isFactChecking ? 'not-allowed' : 'pointer',
opacity: isFactChecking ? 0.6 : 1,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
if (!isFactChecking) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}
}}
onMouseLeave={(e) => {
if (!isFactChecking) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}
}}
>
{isFactChecking ? (
<>
<div style={{
width: '14px',
height: '14px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
Fact-checking...
</>
) : (
<>
🔍 Fact Check
</>
)}
</button>
{/* Quick Edit Options */}
<div style={{
borderTop: '1px solid rgba(255, 255, 255, 0.2)',
paddingTop: '10px',
marginTop: '6px'
}}>
<div style={{
color: 'rgba(255, 255, 255, 0.9)',
fontSize: '11px',
fontWeight: '600',
marginBottom: '8px',
textAlign: 'center',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Assistive Writing
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '6px'
}}>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('improve', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Improve
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('add-transition', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
🔗 Transition
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('shorten', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Shorten
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('expand', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
📝 Expand
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('professionalize', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
🎓 Professional
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('add-data', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
📊 Add Data
</button>
</div>
</div>
</div>
)}
{/* Fact Check Progress */}
{factCheckProgress && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
background: 'rgba(79, 70, 229, 0.95)',
color: 'white',
padding: '12px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
display: 'flex',
alignItems: 'center',
gap: '12px',
minWidth: '280px'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<div>
<div style={{ fontSize: '13px', fontWeight: '600' }}>
Fact-checking content...
</div>
<div style={{ fontSize: '11px', opacity: 0.8 }}>
{factCheckProgress.step}
</div>
</div>
</div>
)}
{/* Fact Check Results */}
{factCheckResults && (
<FactCheckResults
results={factCheckResults}
onClose={handleCloseFactCheckResults}
/>
)}
{/* Smart Typing Suggestion */}
{smartSuggestion && (
<div
style={{
position: 'fixed',
top: smartSuggestion.position.y,
left: smartSuggestion.position.x,
background: 'rgba(34, 197, 94, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '12px',
padding: '16px 20px',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(12px)',
zIndex: 10002,
maxWidth: '400px',
minWidth: '320px',
color: 'white'
}}
>
<div style={{
fontSize: '12px',
fontWeight: '600',
marginBottom: '8px',
opacity: 0.9,
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Smart Writing Suggestion
</div>
<div style={{
fontSize: '14px',
lineHeight: '1.4',
marginBottom: '16px',
fontStyle: 'italic'
}}>
"{smartSuggestion.text}"
</div>
<div style={{
display: 'flex',
gap: '8px',
justifyContent: 'flex-end'
}}>
<button
onClick={handleRejectSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 12px',
color: 'white',
fontSize: '12px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Dismiss
</button>
<button
onClick={handleAcceptSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '6px',
padding: '6px 12px',
color: 'white',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
>
Accept
</button>
</div>
</div>
)}
{/* Smart Suggestion Loading Indicator */}
{isGeneratingSuggestion && (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
background: 'rgba(34, 197, 94, 0.95)',
color: 'white',
padding: '12px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
display: 'flex',
alignItems: 'center',
gap: '12px',
minWidth: '240px'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<div>
<div style={{ fontSize: '13px', fontWeight: '600' }}>
Generating suggestion...
</div>
<div style={{ fontSize: '11px', opacity: 0.8 }}>
AI is crafting helpful content
</div>
</div>
</div>
)}
{/* CSS for spinner animation */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</>
)
};
};
export default useBlogTextSelectionHandler;
export type { BlogTextSelectionHandlerProps };

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Paper, Button, Chip } from '@mui/material';
import {
Add as AddIcon,
AutoAwesome as AutoAwesomeIcon,
BarChart as BarChartIcon,
Hub as HubIcon,
GpsFixed as GpsFixedIcon,
} from '@mui/icons-material';
interface EditorSidebarProps {
sections: any[];
totalWords: number;
}
const EditorSidebar: React.FC<EditorSidebarProps> = ({ sections, totalWords }) => {
return (
<div className="sticky top-24 hidden lg:block">
<Paper elevation={2} className="p-4 rounded-xl shadow-lg border border-gray-100">
<h3 className="font-bold text-lg mb-4 text-gray-700">Editor's Toolkit</h3>
<div className="space-y-3 mb-6">
<Button
fullWidth
variant="contained"
startIcon={<AutoAwesomeIcon />}
className="!bg-gradient-to-r !from-indigo-500 !to-purple-500 !capitalize !font-semibold !rounded-lg"
>
ALwrity it
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
className="!capitalize !rounded-lg"
>
Add Section
</Button>
</div>
<div className="mb-6">
<h4 className="font-semibold text-sm text-gray-600 mb-3">Outline</h4>
<ul className="space-y-2">
{sections.map(section => (
<li key={section.id}>
<a
href={`#section-${section.id}`}
className="text-sm text-gray-500 hover:text-indigo-600 transition-colors flex items-start"
>
<span className="mr-2 font-semibold">{section.id}.</span>
<span className="flex-1">{section.title}</span>
</a>
</li>
))}
</ul>
</div>
<div className="border-t pt-4">
<h4 className="font-semibold text-sm text-gray-600 mb-3">SuperPowers</h4>
<div className="flex flex-wrap gap-2">
<Chip
icon={<BarChartIcon />}
label="Research"
size="small"
clickable
variant="outlined"
/>
<Chip
icon={<HubIcon />}
label="Source Mapping"
size="small"
clickable
variant="outlined"
/>
<Chip
icon={<GpsFixedIcon />}
label="Grounding"
size="small"
clickable
variant="outlined"
/>
</div>
</div>
</Paper>
<div className="text-center text-xs text-gray-400 mt-4">
<span>{sections.length} sections</span> &bull; <span>{totalWords} words total</span>
</div>
</div>
);
};
export default EditorSidebar;

View File

@@ -0,0 +1,318 @@
import React from 'react';
import {
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Divider,
Chip
} from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
Edit as EditIcon,
Add as AddIcon,
DeleteOutline as DeleteOutlineIcon,
GpsFixed as GpsFixedIcon,
BarChart as BarChartIcon,
Link as LinkIcon,
CheckCircle as CheckCircleIcon,
ContentCopy as ContentCopyIcon,
TrendingUp as TrendingUpIcon,
} from '@mui/icons-material';
interface HoverMenuProps {
anchorEl: HTMLElement | null;
open: boolean;
onClose: () => void;
type: 'title' | 'section' | 'content';
onAction: (action: string) => void;
context?: {
sectionId?: string;
hasContent?: boolean;
sources?: number;
wordCount?: number;
};
}
const HoverMenu: React.FC<HoverMenuProps> = ({
anchorEl,
open,
onClose,
type,
onAction,
context
}) => {
const handleAction = (action: string) => {
onAction(action);
onClose();
};
// Don't render if anchor is invalid
if (!anchorEl || !open) {
return null;
}
const getTitleMenuItems = () => [
{
icon: <AutoAwesomeIcon fontSize="small" />,
text: 'Generate Alternative Titles',
action: 'generate-titles',
description: 'AI-powered title variations'
},
{
icon: <TrendingUpIcon fontSize="small" />,
text: 'SEO Optimization',
action: 'seo-optimize',
description: 'Keyword density and optimization'
},
{
icon: <BarChartIcon fontSize="small" />,
text: 'A/B Testing',
action: 'ab-test',
description: 'Create multiple title versions'
},
{
icon: <GpsFixedIcon fontSize="small" />,
text: 'Research-Based Titles',
action: 'research-titles',
description: 'Titles based on research findings'
}
];
const getSectionMenuItems = () => [
{
icon: <AutoAwesomeIcon fontSize="small" />,
text: 'Generate Content',
action: 'generate-content',
description: 'AI content generation for this section'
},
{
icon: <EditIcon fontSize="small" />,
text: 'Enhance Section',
action: 'enhance-section',
description: 'Improve existing content with AI'
},
{
icon: <AddIcon fontSize="small" />,
text: 'Add Subsection',
action: 'add-subsection',
description: 'Insert new content blocks'
},
{
icon: <CheckCircleIcon fontSize="small" />,
text: 'Fact Check',
action: 'fact-check',
description: 'Verify claims against research data'
},
{
icon: <LinkIcon fontSize="small" />,
text: 'Source Mapping',
action: 'source-mapping',
description: 'Link content to research sources'
},
{
icon: <TrendingUpIcon fontSize="small" />,
text: 'SEO Analysis',
action: 'seo-analysis',
description: 'Section-level SEO optimization'
}
];
const getContentMenuItems = () => [
{
icon: <AutoAwesomeIcon fontSize="small" />,
text: 'Continue Writing',
action: 'continue-writing',
description: 'AI-powered content continuation'
},
{
icon: <EditIcon fontSize="small" />,
text: 'Improve Clarity',
action: 'improve-clarity',
description: 'Enhance readability and flow'
},
{
icon: <AddIcon fontSize="small" />,
text: 'Add Examples',
action: 'add-examples',
description: 'Insert relevant examples and case studies'
},
{
icon: <LinkIcon fontSize="small" />,
text: 'Cite Sources',
action: 'cite-sources',
description: 'Add research-backed citations'
},
{
icon: <TrendingUpIcon fontSize="small" />,
text: 'Optimize for SEO',
action: 'optimize-seo',
description: 'Keyword optimization suggestions'
}
];
const getMenuItems = () => {
switch (type) {
case 'title':
return getTitleMenuItems();
case 'section':
return getSectionMenuItems();
case 'content':
return getContentMenuItems();
default:
return [];
}
};
const menuItems = getMenuItems();
return (
<Menu
anchorEl={anchorEl}
open={open}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
PaperProps={{
sx: {
minWidth: 280,
maxWidth: 320,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
border: '1px solid rgba(0, 0, 0, 0.08)',
borderRadius: '12px',
}
}}
>
{/* Context Information */}
{context && (
<>
<div className="px-4 py-2 bg-gray-50 border-b">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600 uppercase tracking-wide">
{type} Actions
</span>
{context.wordCount && (
<Chip
label={`${context.wordCount} words`}
size="small"
variant="outlined"
className="!text-xs"
/>
)}
</div>
{context.sources && (
<div className="flex items-center gap-1 mt-1">
<LinkIcon fontSize="small" className="text-gray-400" />
<span className="text-xs text-gray-500">{context.sources} sources available</span>
</div>
)}
</div>
</>
)}
{/* Menu Items */}
{menuItems.map((item, index) => (
<MenuItem
key={item.action}
onClick={() => handleAction(item.action)}
sx={{
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'rgba(79, 70, 229, 0.04)',
}
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.text}
secondary={item.description}
primaryTypographyProps={{
fontSize: '14px',
fontWeight: 500,
color: 'text.primary'
}}
secondaryTypographyProps={{
fontSize: '12px',
color: 'text.secondary',
lineHeight: 1.3
}}
/>
</MenuItem>
))}
{/* Additional Actions */}
{type === 'section' && (
<>
<Divider />
<MenuItem
onClick={() => handleAction('copy-section')}
sx={{
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'rgba(79, 70, 229, 0.04)',
}
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<ContentCopyIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Copy Section"
secondary="Duplicate this section"
primaryTypographyProps={{
fontSize: '14px',
fontWeight: 500,
color: 'text.primary'
}}
secondaryTypographyProps={{
fontSize: '12px',
color: 'text.secondary',
lineHeight: 1.3
}}
/>
</MenuItem>
<MenuItem
onClick={() => handleAction('delete-section')}
sx={{
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'rgba(239, 68, 68, 0.04)',
}
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<DeleteOutlineIcon fontSize="small" className="text-red-500" />
</ListItemIcon>
<ListItemText
primary="Delete Section"
secondary="Remove this section permanently"
primaryTypographyProps={{
fontSize: '14px',
fontWeight: 500,
color: 'text.primary'
}}
secondaryTypographyProps={{
fontSize: '12px',
color: 'text.secondary',
lineHeight: 1.3
}}
/>
</MenuItem>
</>
)}
</Menu>
);
};
export default HoverMenu;

View File

@@ -0,0 +1,361 @@
import React, { useState, useCallback } from 'react';
import {
Paper,
Chip,
IconButton,
Dialog,
DialogTitle,
DialogContent,
Button
} from '@mui/material';
import {
Link as LinkIcon,
GpsFixed as GpsFixedIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Info as InfoIcon,
BarChart as BarChartIcon,
Hub as HubIcon,
AutoAwesome as AutoAwesomeIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
interface ResearchIntegrationProps {
research: BlogResearchResponse | null;
content: string;
onSourceInsert?: (source: any) => void;
onFactCheck?: (content: string) => void;
}
interface SourceMapping {
content: string;
sources: any[];
confidence: number;
}
const ResearchIntegration: React.FC<ResearchIntegrationProps> = ({
research,
content,
onSourceInsert,
onFactCheck
}) => {
const [sourceMapping, setSourceMapping] = useState<SourceMapping[]>([]);
const [factCheckResults, setFactCheckResults] = useState<any[]>([]);
const [showSourceDialog, setShowSourceDialog] = useState(false);
const [showFactCheckDialog, setShowFactCheckDialog] = useState(false);
// Analyze content for source mapping
const analyzeSourceMapping = useCallback(() => {
if (!research || !content) return;
// Simulate source mapping analysis
const mapping: SourceMapping[] = [
{
content: "AI healthcare market projection",
sources: research.sources?.slice(0, 2) || [],
confidence: 0.95
},
{
content: "predictive analytics in healthcare",
sources: research.sources?.slice(1, 3) || [],
confidence: 0.88
}
];
setSourceMapping(mapping);
}, [research, content]);
// Perform fact checking
const performFactCheck = useCallback(() => {
if (!research || !content) return;
// Simulate fact checking
const results = [
{
claim: "AI healthcare market is projected to reach $29 billion",
status: 'verified',
sources: research.sources?.slice(0, 2) || [],
confidence: 0.92
},
{
claim: "Predictive analytics can identify at-risk patients",
status: 'verified',
sources: research.sources?.slice(1, 3) || [],
confidence: 0.89
},
{
claim: "AI reduces administrative tasks by 50%",
status: 'needs_verification',
sources: [],
confidence: 0.45
}
];
setFactCheckResults(results);
}, [research, content]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'verified':
return <CheckCircleIcon className="text-green-500" fontSize="small" />;
case 'needs_verification':
return <WarningIcon className="text-yellow-500" fontSize="small" />;
case 'unverified':
return <WarningIcon className="text-red-500" fontSize="small" />;
default:
return <InfoIcon className="text-gray-500" fontSize="small" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'verified':
return 'success';
case 'needs_verification':
return 'warning';
case 'unverified':
return 'error';
default:
return 'default';
}
};
return (
<div className="space-y-4">
{/* Research Status Overview */}
<Paper elevation={1} className="p-4 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-sm text-gray-700">Research Integration</h4>
<div className="flex items-center gap-2">
<Chip
icon={<LinkIcon />}
label={`${research?.sources?.length || 0} sources`}
size="small"
variant="outlined"
/>
<Chip
icon={<GpsFixedIcon />}
label="Google Search"
size="small"
variant="outlined"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-indigo-600">
{sourceMapping.length}
</div>
<div className="text-xs text-gray-500">Content Mapped</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{factCheckResults.filter(r => r.status === 'verified').length}
</div>
<div className="text-xs text-gray-500">Facts Verified</div>
</div>
</div>
</Paper>
{/* Source Mapping */}
<Paper elevation={1} className="p-4 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-sm text-gray-700">Source Mapping</h4>
<Button
size="small"
startIcon={<HubIcon />}
onClick={analyzeSourceMapping}
className="!text-indigo-600"
>
Analyze
</Button>
</div>
{sourceMapping.length > 0 ? (
<div className="space-y-2">
{sourceMapping.map((mapping, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<div className="flex-1">
<div className="text-sm font-medium text-gray-700">
{mapping.content}
</div>
<div className="text-xs text-gray-500">
{mapping.sources.length} sources {Math.round(mapping.confidence * 100)}% confidence
</div>
</div>
<Chip
label={`${Math.round(mapping.confidence * 100)}%`}
size="small"
color={mapping.confidence > 0.8 ? 'success' : 'warning'}
/>
</div>
))}
</div>
) : (
<div className="text-center py-4">
<InfoIcon className="text-gray-400 mb-2" />
<div className="text-sm text-gray-500">No source mapping available</div>
<div className="text-xs text-gray-400">Click "Analyze" to map content to sources</div>
</div>
)}
</Paper>
{/* Fact Checking */}
<Paper elevation={1} className="p-4 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-sm text-gray-700">Fact Checking</h4>
<Button
size="small"
startIcon={<CheckCircleIcon />}
onClick={performFactCheck}
className="!text-green-600"
>
Check Facts
</Button>
</div>
{factCheckResults.length > 0 ? (
<div className="space-y-2">
{factCheckResults.map((result, index) => (
<div key={index} className="flex items-start gap-3 p-2 bg-gray-50 rounded-md">
<div className="mt-1">
{getStatusIcon(result.status)}
</div>
<div className="flex-1">
<div className="text-sm text-gray-700 mb-1">
{result.claim}
</div>
<div className="flex items-center gap-2">
<Chip
label={result.status.replace('_', ' ')}
size="small"
color={getStatusColor(result.status) as any}
/>
<span className="text-xs text-gray-500">
{result.sources.length} sources {Math.round(result.confidence * 100)}% confidence
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-4">
<CheckCircleIcon className="text-gray-400 mb-2" />
<div className="text-sm text-gray-500">No fact checking results</div>
<div className="text-xs text-gray-400">Click "Check Facts" to verify claims</div>
</div>
)}
</Paper>
{/* Research Insights */}
{research && (
<Paper elevation={1} className="p-4 rounded-lg">
<h4 className="font-semibold text-sm text-gray-700 mb-3">Research Insights</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<BarChartIcon fontSize="small" className="text-indigo-500" />
<span className="text-sm text-gray-600">
Primary Keywords: {research.keyword_analysis?.primary?.join(', ') || 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<GpsFixedIcon fontSize="small" className="text-green-500" />
<span className="text-sm text-gray-600">
Search Intent: {research.keyword_analysis?.search_intent || 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<AutoAwesomeIcon fontSize="small" className="text-purple-500" />
<span className="text-sm text-gray-600">
Content Angles: {research.suggested_angles?.length || 0} suggested
</span>
</div>
</div>
</Paper>
)}
{/* Source Dialog */}
<Dialog open={showSourceDialog} onClose={() => setShowSourceDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
<div className="flex items-center justify-between">
<span>Research Sources</span>
<IconButton onClick={() => setShowSourceDialog(false)}>
<CloseIcon />
</IconButton>
</div>
</DialogTitle>
<DialogContent>
{research?.sources?.map((source, index) => (
<Paper key={index} elevation={1} className="p-3 mb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<h5 className="font-medium text-sm text-gray-800 mb-1">
{source.title || `Source ${index + 1}`}
</h5>
<p className="text-xs text-gray-600 mb-2">
{source.url || source.excerpt || 'No description available'}
</p>
<div className="flex items-center gap-2">
<Chip label="Verified" size="small" color="success" />
<Chip label="High Relevance" size="small" variant="outlined" />
</div>
</div>
<Button
size="small"
onClick={() => onSourceInsert?.(source)}
className="!text-indigo-600"
>
Insert
</Button>
</div>
</Paper>
))}
</DialogContent>
</Dialog>
{/* Fact Check Dialog */}
<Dialog open={showFactCheckDialog} onClose={() => setShowFactCheckDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
<div className="flex items-center justify-between">
<span>Fact Check Results</span>
<IconButton onClick={() => setShowFactCheckDialog(false)}>
<CloseIcon />
</IconButton>
</div>
</DialogTitle>
<DialogContent>
{factCheckResults.map((result, index) => (
<Paper key={index} elevation={1} className="p-3 mb-3">
<div className="flex items-start gap-3">
{getStatusIcon(result.status)}
<div className="flex-1">
<h5 className="font-medium text-sm text-gray-800 mb-1">
{result.claim}
</h5>
<div className="flex items-center gap-2 mb-2">
<Chip
label={result.status.replace('_', ' ')}
size="small"
color={getStatusColor(result.status) as any}
/>
<span className="text-xs text-gray-500">
{Math.round(result.confidence * 100)}% confidence
</span>
</div>
{result.sources.length > 0 && (
<div className="text-xs text-gray-600">
Supported by {result.sources.length} sources
</div>
)}
</div>
</div>
</Paper>
))}
</DialogContent>
</Dialog>
</div>
);
};
export default ResearchIntegration;

View File

@@ -0,0 +1,3 @@
export { default as BlogEditor } from './BlogEditor';
export { default as HoverMenu } from './HoverMenu';
export { default as ResearchIntegration } from './ResearchIntegration';