Allowing AI to generate suggestions for the blog writer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
498
frontend/src/components/BlogWriter/OutlineFeedbackForm.tsx
Normal file
498
frontend/src/components/BlogWriter/OutlineFeedbackForm.tsx
Normal 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;
|
||||
290
frontend/src/components/BlogWriter/OutlineProgressModal.tsx
Normal file
290
frontend/src/components/BlogWriter/OutlineProgressModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
frontend/src/components/BlogWriter/StyledSuggestions.tsx
Normal file
108
frontend/src/components/BlogWriter/StyledSuggestions.tsx
Normal 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;
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
293
frontend/src/components/BlogWriter/WYSIWYG/BlogEditor.tsx
Normal file
293
frontend/src/components/BlogWriter/WYSIWYG/BlogEditor.tsx
Normal 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;
|
||||
373
frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx
Normal file
373
frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
89
frontend/src/components/BlogWriter/WYSIWYG/EditorSidebar.tsx
Normal file
89
frontend/src/components/BlogWriter/WYSIWYG/EditorSidebar.tsx
Normal 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> • <span>{totalWords} words total</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorSidebar;
|
||||
318
frontend/src/components/BlogWriter/WYSIWYG/HoverMenu.tsx
Normal file
318
frontend/src/components/BlogWriter/WYSIWYG/HoverMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
3
frontend/src/components/BlogWriter/WYSIWYG/index.ts
Normal file
3
frontend/src/components/BlogWriter/WYSIWYG/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as BlogEditor } from './BlogEditor';
|
||||
export { default as HoverMenu } from './HoverMenu';
|
||||
export { default as ResearchIntegration } from './ResearchIntegration';
|
||||
Reference in New Issue
Block a user