fix: credit tracking, voice clone TTL, avatar upload ui, asset serving fallback, OAuth encryption, free plan video renders, backlink outreach sprint
This commit is contained in:
@@ -36,6 +36,7 @@ import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
|
||||
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
|
||||
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
||||
import { useBlogAsset } from '../../hooks/useBlogAsset';
|
||||
import { blogAssetAPI } from '../../api/blogAsset';
|
||||
|
||||
const BlogWriter: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -210,6 +211,12 @@ const BlogWriter: React.FC = () => {
|
||||
// When true (Re-Content), polling callback skips auto-confirm and SEO navigation
|
||||
const skipContentAutoConfirmRef = React.useRef<boolean>(false);
|
||||
|
||||
// Lifted keywords from ManualResearchForm (for header chip "Click To Research" label)
|
||||
const [researchKeywords, setResearchKeywords] = useState<string>('');
|
||||
const researchBlogLengthRef = useRef<string>('1000');
|
||||
// Shared ref exposed by ManualResearchForm / ResearchAction for header-triggered research
|
||||
const startResearchRef = useRef<((keywords: string, blogLength?: string) => Promise<any>) | null>(null);
|
||||
|
||||
// Normalize section keys to match outline IDs when updating from API responses
|
||||
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
|
||||
if (outline && outline.length > 0 && Object.keys(newSections).length > 0) {
|
||||
@@ -271,17 +278,46 @@ const BlogWriter: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Create/get blog asset before research starts (saves to Asset Library immediately)
|
||||
const handleBeforeResearchSubmit = useCallback(async (keywords: string, blogLength: string) => {
|
||||
const id = await createAsset(keywords, keywords, parseInt(blogLength));
|
||||
if (id) saveLastAssetId(id);
|
||||
}, [createAsset, saveLastAssetId]);
|
||||
|
||||
// Wrap handlers to also update the blog ContentAsset
|
||||
const wrappedHandleResearchComplete = useCallback((researchData: any) => {
|
||||
const wrappedHandleResearchComplete = useCallback(async (researchData: any) => {
|
||||
handleResearchComplete(researchData);
|
||||
if (assetId) { updatePhase('research', researchData); saveLastAssetId(assetId); }
|
||||
}, [handleResearchComplete, assetId, updatePhase, saveLastAssetId]);
|
||||
const kw = researchData?.original_keywords
|
||||
? (Array.isArray(researchData.original_keywords) ? researchData.original_keywords.join(', ') : researchData.original_keywords)
|
||||
: (researchKeywords || '');
|
||||
const bl = researchBlogLengthRef.current || researchData?.word_count_target?.toString() || '1000';
|
||||
if (assetId) {
|
||||
// Re-Research: update existing asset
|
||||
updatePhase('research', researchData);
|
||||
saveLastAssetId(assetId);
|
||||
} else {
|
||||
// First research: create blog asset AFTER research completes
|
||||
const id = await createAsset(kw, kw, parseInt(bl));
|
||||
if (id) {
|
||||
saveLastAssetId(id);
|
||||
// Direct API call: createAsset sets React state but the closure is stale
|
||||
await blogAssetAPI.update(id, { phase: 'research', research_data: researchData });
|
||||
}
|
||||
}
|
||||
}, [handleResearchComplete, researchKeywords, assetId, createAsset, saveLastAssetId, updatePhase]);
|
||||
|
||||
// Handler for header chip "Click To Research" / "Re-Research"
|
||||
const handleResearchStartAction = useCallback(async () => {
|
||||
// Navigate first so ManualResearchForm mounts and sets the ref (for non-CopilotKit path)
|
||||
navigateToPhase('research');
|
||||
let kw = researchKeywords;
|
||||
if (!kw && research) {
|
||||
kw = Array.isArray(research.original_keywords)
|
||||
? research.original_keywords.join(', ')
|
||||
: research.original_keywords || '';
|
||||
}
|
||||
const bl = researchBlogLengthRef.current || (research as any)?.word_count_target?.toString() || '1000';
|
||||
if (!kw) return;
|
||||
// Yield to React so the navigation renders and the form sets startResearchRef
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (startResearchRef.current) {
|
||||
await startResearchRef.current(kw, bl);
|
||||
}
|
||||
}, [navigateToPhase, researchKeywords, research]);
|
||||
|
||||
const wrappedHandleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||
handleSEOAnalysisComplete(analysis);
|
||||
@@ -386,6 +422,7 @@ const BlogWriter: React.FC = () => {
|
||||
currentPhase,
|
||||
isSEOAnalysisModalOpen,
|
||||
resetUserSelection,
|
||||
restoreAttempted,
|
||||
});
|
||||
|
||||
const handlePhaseClick = useCallback((phaseId: string) => {
|
||||
@@ -483,6 +520,7 @@ const BlogWriter: React.FC = () => {
|
||||
const {
|
||||
handleResearchAction,
|
||||
handleOutlineAction,
|
||||
handleOutlineStartAction,
|
||||
handleContentAction,
|
||||
handleSEOAction,
|
||||
handleApplySEORecommendations,
|
||||
@@ -555,7 +593,8 @@ const BlogWriter: React.FC = () => {
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
sections={sections}
|
||||
selectedTitle={selectedTitle}
|
||||
onResearchComplete={wrappedHandleResearchComplete}
|
||||
onResearchComplete={wrappedHandleResearchComplete}
|
||||
startResearchRef={startResearchRef}
|
||||
onOutlineCreated={setOutline}
|
||||
onOutlineUpdated={setOutline}
|
||||
onTitleOptionsSet={setTitleOptions}
|
||||
@@ -636,12 +675,15 @@ const BlogWriter: React.FC = () => {
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={{
|
||||
onResearchAction: handleResearchAction,
|
||||
onResearchStartAction: handleResearchStartAction,
|
||||
onOutlineAction: handleOutlineAction,
|
||||
onOutlineStartAction: handleOutlineStartAction,
|
||||
onContentAction: handleContentAction,
|
||||
onSEOAction: handleSEOAction,
|
||||
onApplySEORecommendations: handleApplySEORecommendations,
|
||||
onPublishAction: handlePublishAction,
|
||||
}}
|
||||
researchKeywords={researchKeywords}
|
||||
hasResearch={!!research}
|
||||
hasOutline={outline.length > 0}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
@@ -663,7 +705,9 @@ const BlogWriter: React.FC = () => {
|
||||
currentPhase={currentPhase}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onResearchComplete={wrappedHandleResearchComplete}
|
||||
onBeforeResearchSubmit={handleBeforeResearchSubmit}
|
||||
onKeywordsChange={setResearchKeywords}
|
||||
blogLengthRef={researchBlogLengthRef}
|
||||
startResearchRef={startResearchRef}
|
||||
restoreAttempted={restoreAttempted}
|
||||
/>
|
||||
|
||||
@@ -699,6 +743,9 @@ const BlogWriter: React.FC = () => {
|
||||
onCustomTitle={handleCustomTitle}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
onResearchComplete={wrappedHandleResearchComplete}
|
||||
onKeywordsChange={setResearchKeywords}
|
||||
blogLengthRef={researchBlogLengthRef}
|
||||
startResearchRef={startResearchRef}
|
||||
onOutlineGenerationStart={(taskId) => {
|
||||
setOutlineTaskId(taskId);
|
||||
outlinePolling.startPolling(taskId);
|
||||
|
||||
@@ -9,7 +9,9 @@ interface BlogWriterLandingSectionProps {
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
onResearchComplete: (research: any) => void;
|
||||
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
|
||||
onKeywordsChange?: (kw: string) => void;
|
||||
blogLengthRef?: React.MutableRefObject<string>;
|
||||
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
|
||||
restoreAttempted?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,12 +23,21 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
onResearchComplete,
|
||||
onBeforeResearchSubmit,
|
||||
onKeywordsChange,
|
||||
blogLengthRef,
|
||||
startResearchRef,
|
||||
restoreAttempted = false,
|
||||
}) => {
|
||||
if (!research) {
|
||||
if (currentPhase === 'research') {
|
||||
return <ManualResearchForm onResearchComplete={onResearchComplete} onBeforeResearchSubmit={onBeforeResearchSubmit} />;
|
||||
return (
|
||||
<ManualResearchForm
|
||||
onResearchComplete={onResearchComplete}
|
||||
onKeywordsChange={onKeywordsChange}
|
||||
blogLengthRef={blogLengthRef}
|
||||
researchRef={startResearchRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface CopilotKitComponentsProps {
|
||||
sections: Record<string, string>;
|
||||
selectedTitle: string | null;
|
||||
onResearchComplete: (research: any) => void;
|
||||
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
|
||||
onOutlineCreated: (outline: any[]) => void;
|
||||
onOutlineUpdated: (outline: any[]) => void;
|
||||
onTitleOptionsSet: (titles: any[]) => void;
|
||||
@@ -37,6 +38,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
sections,
|
||||
selectedTitle,
|
||||
onResearchComplete,
|
||||
startResearchRef,
|
||||
onOutlineCreated,
|
||||
onOutlineUpdated,
|
||||
onTitleOptionsSet,
|
||||
@@ -59,7 +61,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
|
||||
/>
|
||||
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
|
||||
<ResearchAction onResearchComplete={onResearchComplete} navigateToPhase={navigateToPhase} />
|
||||
<ResearchAction onResearchComplete={onResearchComplete} researchRef={startResearchRef} navigateToPhase={navigateToPhase} />
|
||||
|
||||
<ResearchDataActions
|
||||
research={research}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface HeaderBarProps {
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
copilotKitAvailable?: boolean;
|
||||
actionHandlers?: PhaseActionHandlers;
|
||||
researchKeywords?: string;
|
||||
hasResearch?: boolean;
|
||||
hasOutline?: boolean;
|
||||
outlineConfirmed?: boolean;
|
||||
@@ -39,7 +40,7 @@ interface HeaderBarProps {
|
||||
|
||||
export const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
phases, currentPhase, onPhaseClick, copilotKitAvailable = true, actionHandlers,
|
||||
hasResearch = false, hasOutline = false, outlineConfirmed = false,
|
||||
researchKeywords = '', hasResearch = false, hasOutline = false, outlineConfirmed = false,
|
||||
hasContent = false, contentConfirmed = false, hasSEOAnalysis = false,
|
||||
seoRecommendationsApplied = false, hasSEOMetadata = false,
|
||||
onNewBlog, onMyBlogs, onHelp,
|
||||
@@ -168,6 +169,7 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
onPhaseClick={onPhaseClick}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={actionHandlers}
|
||||
researchKeywords={researchKeywords}
|
||||
hasResearch={hasResearch}
|
||||
hasOutline={hasOutline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
|
||||
@@ -3,9 +3,7 @@ import ResearchResults from '../ResearchResults';
|
||||
import EnhancedTitleSelector from '../EnhancedTitleSelector';
|
||||
import EnhancedOutlineEditor from '../EnhancedOutlineEditor';
|
||||
import { BlogEditor } from '../WYSIWYG';
|
||||
import OutlineCtaBanner from './OutlineCtaBanner';
|
||||
import ManualResearchForm from '../ManualResearchForm';
|
||||
import ManualOutlineButton from '../ManualOutlineButton';
|
||||
import ManualContentButton from '../ManualContentButton';
|
||||
import PublishContent from './PublishContent';
|
||||
|
||||
@@ -39,6 +37,9 @@ interface PhaseContentProps {
|
||||
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
|
||||
copilotKitAvailable?: boolean; // Whether CopilotKit is available
|
||||
onResearchComplete?: (research: any) => void; // Callback when research completes (for manual form)
|
||||
onKeywordsChange?: (kw: string) => void; // Sync keywords to parent for header chip label
|
||||
blogLengthRef?: React.MutableRefObject<string>; // Ref to sync blog length to parent
|
||||
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>; // Ref to expose startResearch
|
||||
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
|
||||
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
|
||||
buildFullMarkdown?: () => string;
|
||||
@@ -75,6 +76,9 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
setSectionImages,
|
||||
copilotKitAvailable = true,
|
||||
onResearchComplete,
|
||||
onKeywordsChange,
|
||||
blogLengthRef,
|
||||
startResearchRef,
|
||||
onOutlineGenerationStart,
|
||||
onContentGenerationStart,
|
||||
buildFullMarkdown,
|
||||
@@ -95,7 +99,12 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
<p>Use the copilot to begin researching your blog topic.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ManualResearchForm onResearchComplete={onResearchComplete} />
|
||||
<ManualResearchForm
|
||||
onResearchComplete={onResearchComplete}
|
||||
onKeywordsChange={onKeywordsChange}
|
||||
blogLengthRef={blogLengthRef}
|
||||
researchRef={startResearchRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -104,20 +113,16 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
|
||||
{currentPhase === 'outline' && research && (
|
||||
<>
|
||||
{outline.length === 0 && (
|
||||
<>
|
||||
{copilotKitAvailable ? (
|
||||
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
|
||||
) : (
|
||||
<ManualOutlineButton
|
||||
outlineGenRef={outlineGenRef}
|
||||
hasResearch={!!research}
|
||||
onGenerationStart={onOutlineGenerationStart}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{outline.length > 0 ? (
|
||||
{outline.length === 0 ? (
|
||||
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#64748b' }}>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>📝</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#334155' }}>Creating Your Outline</h3>
|
||||
<p style={{ margin: 0, fontSize: '14px', lineHeight: '1.6' }}>
|
||||
Your outline is being generated from the research data.
|
||||
The progress modal shows detailed status — once complete, you can review and refine the sections here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<EnhancedTitleSelector
|
||||
titleOptions={titleOptions}
|
||||
@@ -141,17 +146,6 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
setSectionImages={setSectionImages}
|
||||
/>
|
||||
</>
|
||||
) : !copilotKitAvailable ? (
|
||||
<ManualOutlineButton
|
||||
outlineGenRef={outlineGenRef}
|
||||
hasResearch={!!research}
|
||||
onGenerationStart={onOutlineGenerationStart}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Create Your Outline</h3>
|
||||
<p>Use the copilot to generate an outline based on your research.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface UseBlogWriterRefsProps {
|
||||
currentPhase: string;
|
||||
isSEOAnalysisModalOpen: boolean;
|
||||
resetUserSelection: () => void;
|
||||
restoreAttempted?: boolean;
|
||||
}
|
||||
|
||||
export const useBlogWriterRefs = ({
|
||||
@@ -21,7 +22,23 @@ export const useBlogWriterRefs = ({
|
||||
currentPhase,
|
||||
isSEOAnalysisModalOpen,
|
||||
resetUserSelection,
|
||||
restoreAttempted,
|
||||
}: UseBlogWriterRefsProps) => {
|
||||
// Skip resetUserSelection during state restoration to avoid overriding
|
||||
// the user's last known phase. After restoration completes, we allow
|
||||
// resets for natural user-driven transitions.
|
||||
const isRestoringRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoreAttempted) {
|
||||
// Give React a render cycle to settle before allowing resets
|
||||
const timer = setTimeout(() => {
|
||||
isRestoringRef.current = false;
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [restoreAttempted]);
|
||||
|
||||
// Track when outlines/content become available for the first time
|
||||
const prevOutlineLenRef = useRef<number>(outline.length);
|
||||
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
|
||||
@@ -30,7 +47,9 @@ export const useBlogWriterRefs = ({
|
||||
useEffect(() => {
|
||||
const prevLen = prevOutlineLenRef.current;
|
||||
if (research && prevLen === 0 && outline.length > 0) {
|
||||
resetUserSelection();
|
||||
if (!isRestoringRef.current) {
|
||||
resetUserSelection();
|
||||
}
|
||||
}
|
||||
prevOutlineLenRef.current = outline.length;
|
||||
}, [research, outline.length, resetUserSelection]);
|
||||
@@ -39,7 +58,9 @@ export const useBlogWriterRefs = ({
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevOutlineConfirmedRef.current;
|
||||
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
|
||||
resetUserSelection(); // Allow auto-progression to content phase
|
||||
if (!isRestoringRef.current) {
|
||||
resetUserSelection();
|
||||
}
|
||||
}
|
||||
prevOutlineConfirmedRef.current = outlineConfirmed;
|
||||
}, [outlineConfirmed, sections, resetUserSelection]);
|
||||
@@ -47,7 +68,9 @@ export const useBlogWriterRefs = ({
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevContentConfirmedRef.current;
|
||||
if (!wasConfirmed && contentConfirmed) {
|
||||
resetUserSelection(); // Allow auto-progression to SEO phase
|
||||
if (!isRestoringRef.current) {
|
||||
resetUserSelection();
|
||||
}
|
||||
}
|
||||
prevContentConfirmedRef.current = contentConfirmed;
|
||||
}, [contentConfirmed, resetUserSelection]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { mediumBlogApi } from '../../../services/blogWriterApi';
|
||||
import { researchCache } from '../../../services/researchCache';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
|
||||
interface UsePhaseActionHandlersProps {
|
||||
@@ -58,27 +57,20 @@ export const usePhaseActionHandlers = ({
|
||||
alert('Please complete research first before generating an outline.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||
|
||||
if (cachedOutline) {
|
||||
debug.log('[BlogWriter] Using cached outline from localStorage', { sections: cachedOutline.outline.length });
|
||||
setOutline(cachedOutline.outline);
|
||||
if (onOutlineComplete) {
|
||||
onOutlineComplete({ outline: cachedOutline.outline, title_options: cachedOutline.title_options });
|
||||
}
|
||||
navigateToPhase('outline');
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToPhase('outline');
|
||||
if (outlineGenRef.current) {
|
||||
try {
|
||||
const result = await outlineGenRef.current.generateNow();
|
||||
if (!result.success) {
|
||||
alert(result.message || 'Failed to generate outline');
|
||||
} else if (result.cached && result.outline) {
|
||||
// Cached result: set state directly (onOutlineCreated was already called by generateNow)
|
||||
setOutline(result.outline);
|
||||
if (result.title_options) {
|
||||
if (onOutlineComplete) {
|
||||
onOutlineComplete({ outline: result.outline, title_options: result.title_options });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Outline generation failed:', error);
|
||||
@@ -88,6 +80,37 @@ export const usePhaseActionHandlers = ({
|
||||
debug.log('[BlogWriter] Outline action triggered');
|
||||
}, [research, navigateToPhase, outlineGenRef, setOutline, onOutlineComplete]);
|
||||
|
||||
const handleOutlineStartAction = useCallback(async () => {
|
||||
if (!research) {
|
||||
alert('Please complete research first before generating an outline.');
|
||||
return;
|
||||
}
|
||||
navigateToPhase('outline');
|
||||
// Clear cached outline + title options to force re-generation
|
||||
try {
|
||||
localStorage.removeItem('blog_outline');
|
||||
localStorage.removeItem('blog_title_options');
|
||||
} catch { /* noop */ }
|
||||
if (outlineGenRef.current) {
|
||||
try {
|
||||
const result = await outlineGenRef.current.generateNow();
|
||||
if (!result.success) {
|
||||
alert(result.message || 'Failed to generate outline');
|
||||
} else if (result.cached && result.outline) {
|
||||
// Should not normally happen since we cleared cache, but handle defensively
|
||||
setOutline(result.outline);
|
||||
if (result.title_options && onOutlineComplete) {
|
||||
onOutlineComplete({ outline: result.outline, title_options: result.title_options });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Outline re-generation failed:', error);
|
||||
alert(`Outline re-generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
debug.log('[BlogWriter] Outline re-generation triggered');
|
||||
}, [research, navigateToPhase, outlineGenRef, setOutline, onOutlineComplete]);
|
||||
|
||||
const handleContentAction = useCallback(async () => {
|
||||
if (!outline || outline.length === 0) {
|
||||
alert('Please generate an outline first.');
|
||||
@@ -207,6 +230,7 @@ export const usePhaseActionHandlers = ({
|
||||
return {
|
||||
handleResearchAction,
|
||||
handleOutlineAction,
|
||||
handleOutlineStartAction,
|
||||
handleContentAction,
|
||||
handleSEOAction,
|
||||
handleApplySEORecommendations,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface UsePhaseRestorationProps {
|
||||
@@ -18,10 +18,12 @@ export const usePhaseRestoration = ({
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
}: UsePhaseRestorationProps) => {
|
||||
// When CopilotKit is unavailable and there's no research, ensure we're on research phase
|
||||
const hasRestoredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copilotKitAvailable && !research && phases.length > 0 && currentPhase !== 'research') {
|
||||
if (!copilotKitAvailable && !research && phases.length > 0 && currentPhase !== 'research' && !hasRestoredRef.current) {
|
||||
navigateToPhase('research');
|
||||
hasRestoredRef.current = true;
|
||||
debug.log('[BlogWriter] Auto-navigating to research phase (CopilotKit unavailable)');
|
||||
}
|
||||
}, [copilotKitAvailable, research, phases.length, currentPhase, navigateToPhase]);
|
||||
|
||||
@@ -482,17 +482,16 @@ export const useSEOManager = ({
|
||||
// Mark SEO phase as completed when recommendations are applied
|
||||
useEffect(() => {
|
||||
if (seoRecommendationsApplied && seoAnalysis) {
|
||||
// SEO phase is considered complete when recommendations are applied
|
||||
// But stay in SEO phase to show updated content
|
||||
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content (override auto-progression)
|
||||
// Ensure we stay in SEO phase only once when recommendations are first applied
|
||||
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
|
||||
debug.log('[BlogWriter] Navigated to SEO phase to show updated content');
|
||||
}
|
||||
}
|
||||
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seoRecommendationsApplied, seoAnalysis]);
|
||||
|
||||
const confirmBlogContent = useCallback(() => {
|
||||
debug.log('[BlogWriter] Blog content confirmed by user');
|
||||
|
||||
@@ -6,13 +6,25 @@ import { BrainstormButton } from './BrainstormButton';
|
||||
|
||||
interface ManualResearchFormProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
|
||||
onKeywordsChange?: (kw: string) => void;
|
||||
blogLengthRef?: React.MutableRefObject<string>;
|
||||
researchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
|
||||
}
|
||||
|
||||
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onBeforeResearchSubmit }) => {
|
||||
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onKeywordsChange, blogLengthRef, researchRef }) => {
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [blogLength, setBlogLength] = useState('1000');
|
||||
|
||||
// Sync keywords to parent for header chip label
|
||||
React.useEffect(() => {
|
||||
onKeywordsChange?.(keywords);
|
||||
}, [keywords, onKeywordsChange]);
|
||||
|
||||
// Sync blog length to parent ref
|
||||
React.useEffect(() => {
|
||||
if (blogLengthRef) blogLengthRef.current = blogLength;
|
||||
}, [blogLength, blogLengthRef]);
|
||||
|
||||
const {
|
||||
startResearch,
|
||||
isSubmitting,
|
||||
@@ -24,6 +36,12 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
error,
|
||||
} = useResearchSubmit({ onResearchComplete });
|
||||
|
||||
// Expose startResearch to parent for header chip "Click To Research"
|
||||
React.useEffect(() => {
|
||||
if (researchRef) researchRef.current = startResearch;
|
||||
return () => { if (researchRef) researchRef.current = null; };
|
||||
}, [startResearch, researchRef]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = keywords.trim();
|
||||
if (!trimmed) {
|
||||
@@ -31,7 +49,6 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onBeforeResearchSubmit?.(trimmed, blogLength);
|
||||
await startResearch(trimmed, blogLength);
|
||||
} catch (err) {
|
||||
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
@@ -112,7 +129,7 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
opacity: isSubmitting ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? '⏳ Starting Research...' : '🚀 Start Research'}
|
||||
{isSubmitting ? '⏳ Researching...' : '🔍 Click To Research'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,11 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
|
||||
if (cachedOutline) {
|
||||
console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length });
|
||||
// Update parent state and navigate — same as CopilotKit action for cached outlines
|
||||
navigateToPhase?.('outline');
|
||||
if (onOutlineCreated) {
|
||||
onOutlineCreated(cachedOutline.outline, cachedOutline.title_options);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
cached: true,
|
||||
|
||||
@@ -30,46 +30,46 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
|
||||
// Outline phase messages
|
||||
if (message.includes('Starting outline generation')) {
|
||||
return '🧩 Starting to create your blog outline...';
|
||||
return '🧩 Launching outline generation — analyzing your research to build a structured blog plan. This usually takes 20–40 seconds. Next up: you will review and refine the outline, then generate each section.';
|
||||
}
|
||||
if (message.includes('Analyzing research data and building content strategy')) {
|
||||
return '📊 Analyzing your research data to build the perfect content strategy...';
|
||||
return '📊 Analyzing your research data — identifying key themes, content gaps, and strategic angles for your blog. This shapes the structure and flow of your outline.';
|
||||
}
|
||||
if (message.includes('Generating AI-powered outline with research insights')) {
|
||||
return '🤖 Creating an intelligent outline using AI and your research insights...';
|
||||
return '🤖 AI is generating your outline using research insights — selecting the best structure, ordering sections logically, and incorporating source citations.';
|
||||
}
|
||||
if (message.includes('Making AI request to generate structured outline')) {
|
||||
return '🔄 Generating your structured blog outline...';
|
||||
return '🔄 Sending request to AI — crafting a structured outline with section headings, key points, and word-count targets.';
|
||||
}
|
||||
if (message.includes('Calling Gemini API for outline generation')) {
|
||||
return '🤖 AI is crafting your personalized blog structure...';
|
||||
return '🤖 AI is crafting your personalized blog structure — this step involves complex reasoning about your research topic.';
|
||||
}
|
||||
if (message.includes('Processing outline structure and validating sections')) {
|
||||
return '📝 Processing and validating your outline sections...';
|
||||
return '📝 Processing and validating your outline — checking section ordering, heading clarity, and ensuring each section has actionable key points.';
|
||||
}
|
||||
if (message.includes('Running parallel processing for maximum speed')) {
|
||||
return '⚡ Optimizing processing speed for faster results...';
|
||||
return '⚡ Running parallel processing — optimizing multiple sections simultaneously for faster results.';
|
||||
}
|
||||
if (message.includes('Applying intelligent source-to-section mapping')) {
|
||||
return '🔗 Intelligently matching your research sources to outline sections...';
|
||||
return '🔗 Mapping research sources to outline sections — each section is linked to the most relevant sources for credibility.';
|
||||
}
|
||||
if (message.includes('Extracting grounding metadata insights')) {
|
||||
return '🧠 Extracting valuable insights from your research data...';
|
||||
return '🧠 Extracting grounding insights — identifying statistics, quotes, and expert opinions from your research to include in each section.';
|
||||
}
|
||||
if (message.includes('Enhancing sections with grounding insights')) {
|
||||
return '✨ Enhancing your outline sections with research-backed insights...';
|
||||
return '✨ Enhancing outline sections with research-backed insights — adding data points, expert quotes, and content angles for stronger sections.';
|
||||
}
|
||||
if (message.includes('Optimizing outline for better flow and engagement')) {
|
||||
return '🎯 Optimizing your outline for maximum reader engagement...';
|
||||
return '🎯 Optimizing outline flow — ensuring smooth transitions between sections, logical progression of ideas, and maximum reader engagement.';
|
||||
}
|
||||
if (message.includes('Rebalancing word count distribution')) {
|
||||
return '⚖️ Balancing content distribution across sections...';
|
||||
return '⚖️ Rebalancing word counts — distributing content across sections to ensure depth where needed and concise treatment of supporting points.';
|
||||
}
|
||||
if (message.includes('Outline generation and optimization completed successfully')) {
|
||||
return '✅ Your blog outline has been successfully created and optimized!';
|
||||
return '✅ Outline complete! Review and confirm your sections, then proceed to the Content phase to generate full blog text for each section.';
|
||||
}
|
||||
if (message.includes('Outline generated successfully')) {
|
||||
return '🎉 Success! Your personalized blog outline is ready!';
|
||||
return '🎉 Outline ready! You can now review the section structure, adjust headings, and confirm before generating content.';
|
||||
}
|
||||
|
||||
// Content generation phase messages
|
||||
@@ -163,7 +163,11 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
}}>
|
||||
{titleOverride
|
||||
? (status === 'complete' ? '🎉 Content Ready!' : status === 'error' ? '❌ Generation Failed' : '📝 Generating Your Blog Content')
|
||||
: (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
|
||||
: (status === 'complete'
|
||||
? '🎉 Outline Ready! Review it, then proceed to the Content phase.'
|
||||
: status === 'error'
|
||||
? '❌ Outline Generation Failed — you can retry from the Outline chip.'
|
||||
: '🧩 Creating Your Blog Outline (20–40 seconds)')}
|
||||
</h2>
|
||||
|
||||
{/* Progress Bar */}
|
||||
@@ -196,10 +200,10 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
? 'Content generation encountered an issue. You can retry from the content phase.'
|
||||
: 'Alwrity is writing your blog content using AI...')
|
||||
: (status === 'complete'
|
||||
? '✅ Your blog outline is ready! Review and confirm it, then proceed to generate content.'
|
||||
? '✅ Your outline is ready! Review section headings and key points, then confirm to proceed to the Content phase.'
|
||||
: status === 'error'
|
||||
? 'Outline generation encountered an issue. Please try again.'
|
||||
: 'Alwrity is analyzing your research and building your blog structure...')}
|
||||
? 'Outline generation encountered an issue. Please try again from the Outline chip.'
|
||||
: 'Analyzing your research and building a structured outline. After this, you will confirm the outline, generate content for each section, then optimize for SEO.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,9 @@ export interface Phase {
|
||||
|
||||
export interface PhaseActionHandlers {
|
||||
onResearchAction?: () => void;
|
||||
onResearchStartAction?: () => void;
|
||||
onOutlineAction?: () => void;
|
||||
onOutlineStartAction?: () => void;
|
||||
onContentAction?: () => void;
|
||||
onSEOAction?: () => void;
|
||||
onApplySEORecommendations?: () => void;
|
||||
@@ -29,6 +31,7 @@ interface PhaseNavigationProps {
|
||||
currentPhase: string;
|
||||
copilotKitAvailable?: boolean;
|
||||
actionHandlers?: PhaseActionHandlers;
|
||||
researchKeywords?: string;
|
||||
hasResearch?: boolean;
|
||||
hasOutline?: boolean;
|
||||
outlineConfirmed?: boolean;
|
||||
@@ -71,6 +74,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
currentPhase,
|
||||
copilotKitAvailable = true,
|
||||
actionHandlers,
|
||||
researchKeywords = '',
|
||||
hasResearch = false,
|
||||
hasOutline = false,
|
||||
outlineConfirmed = false,
|
||||
@@ -91,13 +95,22 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
|
||||
switch (phaseId) {
|
||||
case 'research':
|
||||
if (!hasResearch) {
|
||||
return { label: 'Start Research', handler: actionHandlers.onResearchAction || null };
|
||||
if (!hasResearch && !researchKeywords) {
|
||||
return { label: 'Start Now', handler: actionHandlers.onResearchAction || null };
|
||||
}
|
||||
if (!hasResearch && researchKeywords) {
|
||||
return { label: 'Click To Research', handler: actionHandlers.onResearchStartAction || null };
|
||||
}
|
||||
if (hasResearch) {
|
||||
return { label: 'Re-Research', handler: actionHandlers.onResearchStartAction || null };
|
||||
}
|
||||
break;
|
||||
case 'outline':
|
||||
if (hasResearch && !outlineConfirmed) {
|
||||
return { label: 'Create Outline', handler: actionHandlers.onOutlineAction || null };
|
||||
if (!hasOutline) {
|
||||
return { label: 'Create Now', handler: actionHandlers.onOutlineAction || null };
|
||||
}
|
||||
if (hasOutline) {
|
||||
return { label: 'Re-Generate', handler: actionHandlers.onOutlineStartAction || null };
|
||||
}
|
||||
break;
|
||||
case 'content':
|
||||
@@ -181,10 +194,6 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
const isDisabled = phase.disabled;
|
||||
const action = getActionForPhase(phase.id);
|
||||
|
||||
const isResearchPhase = phase.id === 'research' && action.handler;
|
||||
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
|
||||
const isSEOPhase = phase.id === 'seo' && action.handler;
|
||||
|
||||
/* Phase state derivation:
|
||||
- Active: phase is current AND not yet completed (user needs to work on it)
|
||||
- Done: phase is completed (show green regardless of whether it's current)
|
||||
@@ -204,16 +213,9 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
/* Show action button only when phase is NOT completed.
|
||||
Research action: only on landing page (not current), to invite start.
|
||||
Other phase actions: show when current, pending, or next-actionable.
|
||||
Content and SEO phases use only the chip (no separate action button). */
|
||||
const showAction = action.handler && !isDone && phase.id !== 'content' && phase.id !== 'seo' && (
|
||||
(!isCurrent && phase.id === 'research' && !hasResearch) ||
|
||||
(isCurrent && phase.id !== 'research') ||
|
||||
(!isCurrent && !isDisabled && phase.id !== 'research') ||
|
||||
(phase.id !== 'research' && (isResearchPhase || isOutlinePhase || isSEOPhase))
|
||||
);
|
||||
/* No separate action buttons — every phase chip is self-contained.
|
||||
Chip click directly triggers the action (create, run analysis, publish, etc.). */
|
||||
const showAction = false;
|
||||
|
||||
const iconOnly = isDone && !isCurrent;
|
||||
|
||||
@@ -334,7 +336,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
title={
|
||||
<Box>
|
||||
<Box sx={{ fontWeight: 700, mb: 0.5, fontSize: '0.875rem' }}>
|
||||
{phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
|
||||
{phase.id === 'research' && hasResearch ? 'Re-Research' : phase.id === 'research' && !hasResearch && researchKeywords ? 'Click To Research' : phase.id === 'research' && !hasResearch ? 'Start Now' : phase.id === 'outline' && hasOutline ? 'Re-Generate' : phase.id === 'outline' && !hasOutline ? 'Create Now' : phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
|
||||
</Box>
|
||||
<Box sx={{ fontSize: '0.75rem', opacity: 0.9 }}>
|
||||
{isDisabled
|
||||
@@ -358,7 +360,9 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
sx={chipSx}
|
||||
>
|
||||
<Box component="span" sx={iconSx}>{phase.icon}</Box>
|
||||
<Box component="span" sx={{ flexShrink: 0 }}>{phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}</Box>
|
||||
<Box component="span" sx={{ flexShrink: 0 }}>
|
||||
{phase.id === 'research' && hasResearch ? 'Re-Research' : phase.id === 'research' && !hasResearch && researchKeywords ? 'Click To Research' : phase.id === 'research' && !hasResearch ? 'Start Now' : phase.id === 'outline' && hasOutline ? 'Re-Generate' : phase.id === 'outline' && !hasOutline ? 'Create Now' : phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
|
||||
</Box>
|
||||
{isDone && (
|
||||
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}>✓</Box>
|
||||
)}
|
||||
|
||||
@@ -10,9 +10,10 @@ const useCopilotActionTyped = useCopilotAction as any;
|
||||
interface ResearchActionProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
researchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
|
||||
}
|
||||
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase, researchRef }) => {
|
||||
const [copilotKeywords, setCopilotKeywords] = useState('');
|
||||
const [copilotBlogLength, setCopilotBlogLength] = useState('1000');
|
||||
const hasNavigatedRef = useRef<boolean>(false);
|
||||
@@ -30,6 +31,12 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
result,
|
||||
} = useResearchSubmit({ onResearchComplete, navigateToPhase });
|
||||
|
||||
// Expose startResearch to parent for header chip "Re-Research"
|
||||
React.useEffect(() => {
|
||||
if (researchRef) researchRef.current = startResearch;
|
||||
return () => { if (researchRef) researchRef.current = null; };
|
||||
}, [startResearch, researchRef]);
|
||||
|
||||
// Close modal when research completes (status becomes a completed state or polling stops with a result)
|
||||
const COMPLETED_STATUSES = React.useMemo(
|
||||
() => new Set(['completed', 'success', 'succeeded', 'finished']),
|
||||
@@ -141,21 +148,21 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
onKeywordsChange={setCopilotKeywords}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const kw = copilotKeywords.trim();
|
||||
const bl = copilotBlogLength;
|
||||
if (!kw) return;
|
||||
try {
|
||||
await startResearch(kw, bl);
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
}
|
||||
}}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const kw = copilotKeywords.trim();
|
||||
const bl = copilotBlogLength;
|
||||
if (!kw) return;
|
||||
try {
|
||||
await startResearch(kw, bl);
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{ padding: '12px 24px', backgroundColor: isSubmitting ? '#ccc' : '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: isSubmitting ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
{isSubmitting ? '⏳ Starting Research...' : '🚀 Start Research'}
|
||||
{isSubmitting ? '⏳ Researching...' : '🔍 Click To Research'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,25 +77,39 @@ const stageDefinitions = [
|
||||
keywords: ['cache', 'cached', 'stored']
|
||||
},
|
||||
{
|
||||
id: 'discovery',
|
||||
label: 'Source Discovery',
|
||||
description: 'Exploring trusted sources across the web.',
|
||||
icon: '🔎',
|
||||
keywords: ['search', 'source', 'gather', 'google', 'discover']
|
||||
id: 'validation',
|
||||
label: 'Request Validation',
|
||||
description: 'Verifying your topic and preparing the research pipeline.',
|
||||
icon: '✅',
|
||||
keywords: ['starting', 'launching', 'bootstrap', 'validat']
|
||||
},
|
||||
{
|
||||
id: 'exa',
|
||||
label: 'Deep Web Search (Exa)',
|
||||
description: 'Searching academic databases, research papers, and structured content.',
|
||||
icon: '🌐',
|
||||
keywords: ['exa', 'neural search']
|
||||
},
|
||||
{
|
||||
id: 'tavily',
|
||||
label: 'AI Web Search (Tavily)',
|
||||
description: 'Scanning news, blogs, and real-time web content.',
|
||||
icon: '🔍',
|
||||
keywords: ['tavily', 'ai search']
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: 'Insight Extraction',
|
||||
description: 'Extracting data points, statistics, and quotes.',
|
||||
label: 'Content Analysis',
|
||||
description: 'Extracting key data points, statistics, and actionable insights.',
|
||||
icon: '🧠',
|
||||
keywords: ['analysis', 'analyz', 'extract', 'insight', 'processing']
|
||||
keywords: ['analyz', 'analyz', 'extract', 'insight', 'keywords', 'angles', 'filter']
|
||||
},
|
||||
{
|
||||
id: 'assembly',
|
||||
label: 'Structuring Findings',
|
||||
description: 'Packaging insights and preparing summaries.',
|
||||
icon: '📝',
|
||||
keywords: ['assembling', 'structuring', 'summary', 'completed', 'ready', 'post-processing']
|
||||
label: 'Structuring Results',
|
||||
description: 'Packaging findings into a ready-to-use research brief.',
|
||||
icon: '📦',
|
||||
keywords: ['caching', 'assembling', 'structuring', 'post-processing', 'completed', 'ready']
|
||||
}
|
||||
] as const;
|
||||
|
||||
@@ -144,72 +158,205 @@ const friendlyMappings: Array<{
|
||||
tone: Tone;
|
||||
stage?: StageId;
|
||||
}> = [
|
||||
// ── Cache stage ─────────────────────────────────────────────────
|
||||
{
|
||||
keywords: ['checking cache', 'cache'],
|
||||
title: 'Checking existing research cache',
|
||||
subtitle: 'Looking for previously generated insights so we can respond instantly.',
|
||||
keywords: ['checking cache', 'looking for saved'],
|
||||
title: 'Checking for saved research results',
|
||||
subtitle: 'If you have run this topic before, we skip straight to the cached results — saving 30–50 seconds.',
|
||||
icon: '🗂️',
|
||||
tone: 'info',
|
||||
stage: 'cache'
|
||||
},
|
||||
{
|
||||
keywords: ['found cached research', 'loading cached'],
|
||||
title: 'Loaded cached research results',
|
||||
subtitle: 'Serving saved insights to keep things fast.',
|
||||
keywords: ['found cached research', 'found cached', 'loading cached', 'returning instantly'],
|
||||
title: 'Using cached research — no fresh search needed',
|
||||
subtitle: 'Previous results loaded instantly. You can review them and proceed directly to the Outline phase.',
|
||||
icon: '⚡',
|
||||
tone: 'success',
|
||||
stage: 'cache'
|
||||
},
|
||||
{
|
||||
keywords: ['starting research'],
|
||||
title: 'Launching fresh research',
|
||||
subtitle: 'Bootstrapping the workflow and validating your request.',
|
||||
keywords: ['cache miss', 'no cached'],
|
||||
title: 'No cached results found — starting fresh research',
|
||||
subtitle: 'This will take 40–60 seconds as we search multiple sources, extract insights, and build your research brief.',
|
||||
icon: '🔍',
|
||||
tone: 'active',
|
||||
stage: 'cache'
|
||||
},
|
||||
|
||||
// ── Validation / Start stage ──────────────────────────────────
|
||||
{
|
||||
keywords: ['starting research', 'starting research operation', 'launching fresh'],
|
||||
title: 'Launching research pipeline',
|
||||
subtitle: 'We validate your topic, then fan out across multiple search engines (Exa, Tavily) to gather diverse perspectives. This runs in parallel so you get results faster.',
|
||||
icon: '🚀',
|
||||
tone: 'active',
|
||||
stage: 'discovery'
|
||||
stage: 'validation'
|
||||
},
|
||||
{
|
||||
keywords: ['search', 'query', 'sources', 'web'],
|
||||
title: 'Collecting authoritative sources',
|
||||
subtitle: 'Evaluating top-ranked pages, studies, and reports.',
|
||||
icon: '🔎',
|
||||
keywords: ['user id is required', 'validation error'],
|
||||
title: 'Validation check in progress',
|
||||
subtitle: 'Ensuring your account and request parameters are properly configured before the search begins.',
|
||||
icon: '🔐',
|
||||
tone: 'info',
|
||||
stage: 'validation'
|
||||
},
|
||||
|
||||
// ── Exa neural search stage ──────────────────────────────────
|
||||
{
|
||||
keywords: ['connecting to exa', 'exa neural search'],
|
||||
title: 'Connecting to deep-web search engine (Exa)',
|
||||
subtitle: 'Exa searches academic databases, technical documentation, and structured content repositories. This is the most thorough search layer and typically takes 10–15 seconds.',
|
||||
icon: '🌐',
|
||||
tone: 'active',
|
||||
stage: 'discovery'
|
||||
stage: 'exa'
|
||||
},
|
||||
{
|
||||
keywords: ['extracting', 'analyzing', 'analysis', 'insight'],
|
||||
title: 'Extracting key insights',
|
||||
subtitle: 'Summarising statistics, trends, and quotes that matter.',
|
||||
keywords: ['executing exa neural search', 'exa research'],
|
||||
title: 'Running deep-web search via Exa AI',
|
||||
subtitle: 'Exa scans millions of indexed pages for authoritative, high-signal content. Results feed into your research brief with source citations and relevance scores.',
|
||||
icon: '🤖',
|
||||
tone: 'active',
|
||||
stage: 'exa'
|
||||
},
|
||||
{
|
||||
keywords: ['exa research failed', 'exa research did not return'],
|
||||
title: 'Exa search completed with limited results',
|
||||
subtitle: 'This is normal for niche topics. We fall back to Tavily for broader web coverage. Your research will still be comprehensive.',
|
||||
icon: '⚠️',
|
||||
tone: 'warning',
|
||||
stage: 'exa'
|
||||
},
|
||||
|
||||
// ── Tavily AI search stage ────────────────────────────────────
|
||||
{
|
||||
keywords: ['connecting to tavily', 'tavily ai search'],
|
||||
title: 'Connecting to real-time web search (Tavily)',
|
||||
subtitle: 'Tavily searches news articles, blog posts, and current web content. It provides up-to-date information from a broader range of sources than traditional search.',
|
||||
icon: '🔍',
|
||||
tone: 'active',
|
||||
stage: 'tavily'
|
||||
},
|
||||
{
|
||||
keywords: ['executing tavily ai search', 'tavily research'],
|
||||
title: 'Running real-time web search via Tavily AI',
|
||||
subtitle: 'Tavily fetches and ranks results based on relevance, authority, and recency. Combined with Exa results, this gives you both depth and breadth of coverage.',
|
||||
icon: '🤖',
|
||||
tone: 'active',
|
||||
stage: 'tavily'
|
||||
},
|
||||
{
|
||||
keywords: ['tavily research failed', 'tavily api call limit'],
|
||||
title: 'Tavily search hit a rate limit',
|
||||
subtitle: 'We already have results from Exa. Continuing with what we have — your research will still contain valuable data.',
|
||||
icon: '⚠️',
|
||||
tone: 'warning',
|
||||
stage: 'tavily'
|
||||
},
|
||||
{
|
||||
keywords: ['tavily research did not return'],
|
||||
title: 'Tavily returned minimal results for this topic',
|
||||
subtitle: 'Combining available Exa and Tavily data to build a complete picture. Niche or emerging topics sometimes have sparse web coverage.',
|
||||
icon: 'ℹ️',
|
||||
tone: 'info',
|
||||
stage: 'tavily'
|
||||
},
|
||||
|
||||
// ── Analysis / Processing stage ───────────────────────────────
|
||||
{
|
||||
keywords: ['analyz', 'analyz', 'keywords and content angles'],
|
||||
title: 'Analyzing keywords and content angles',
|
||||
subtitle: 'We cross-reference your search results to identify the strongest angles, key statistics, trending subtopics, and gaps in existing coverage. This shapes the strategic direction of your blog.',
|
||||
icon: '🧠',
|
||||
tone: 'active',
|
||||
stage: 'analysis'
|
||||
},
|
||||
{
|
||||
keywords: ['assembling', 'compiling', 'structuring', 'post-processing'],
|
||||
title: 'Structuring the research package',
|
||||
subtitle: 'Organising findings into ready-to-use sections.',
|
||||
icon: '🧩',
|
||||
keywords: ['filtering', 'cleaning research data'],
|
||||
title: 'Filtering and ranking research data',
|
||||
subtitle: 'Removing duplicates, low-authority sources, and irrelevant content. Every source gets a quality score so the Outline phase can prioritize the best material.',
|
||||
icon: '🔬',
|
||||
tone: 'active',
|
||||
stage: 'analysis'
|
||||
},
|
||||
{
|
||||
keywords: ['extracting', 'insight'],
|
||||
title: 'Extracting key insights and statistics',
|
||||
subtitle: 'Pulling out data points, quotes, statistics, and authoritative references. Your outline will use these to build credible, well-supported content.',
|
||||
icon: '📊',
|
||||
tone: 'active',
|
||||
stage: 'analysis'
|
||||
},
|
||||
|
||||
// ── Assembly / Caching stage ─────────────────────────────────
|
||||
{
|
||||
keywords: ['caching results', 'caching for future'],
|
||||
title: 'Saving results to cache for next time',
|
||||
subtitle: 'Your research is being cached so revisiting or regenerating this topic will be instant next time.',
|
||||
icon: '💾',
|
||||
tone: 'info',
|
||||
stage: 'assembly'
|
||||
},
|
||||
{
|
||||
keywords: ['completed successfully', 'research completed', 'ready'],
|
||||
title: 'Research completed successfully',
|
||||
subtitle: 'All insights are ready for the outline phase.',
|
||||
keywords: ['post-processing', 'assembling', 'structuring'],
|
||||
title: 'Assembling the final research brief',
|
||||
subtitle: 'Organizing all findings into a structured brief with source mappings, competitor analysis, and suggested angles — ready for the Outline phase.',
|
||||
icon: '🧩',
|
||||
tone: 'info',
|
||||
stage: 'assembly'
|
||||
},
|
||||
|
||||
// ── Completion ────────────────────────────────────────────────
|
||||
{
|
||||
keywords: ['completed successfully', 'research completed', 'found', 'sources'],
|
||||
title: 'Research complete! Ready for Outline phase.',
|
||||
subtitle: 'Your research brief is ready. Next up: the Outline phase turns this research into a structured blog outline. Click the Outline chip or navigate to it to continue.',
|
||||
icon: '✅',
|
||||
tone: 'success',
|
||||
stage: 'assembly'
|
||||
},
|
||||
{
|
||||
keywords: ['failed', 'error', 'limit exceeded'],
|
||||
title: 'Research encountered an issue',
|
||||
subtitle: 'Review the error message below and try again.',
|
||||
keywords: ['subscription limit exceeded', '429'],
|
||||
title: 'Search provider rate limit hit',
|
||||
subtitle: 'One of our search providers is temporarily rate-limited. The system will retry automatically. If it persists, try again in a few minutes.',
|
||||
icon: '⏳',
|
||||
tone: 'warning'
|
||||
},
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────
|
||||
{
|
||||
keywords: ['failed with error', 'research failed'],
|
||||
title: 'Research encountered an error',
|
||||
subtitle: 'Something went wrong during the research process. Review the error details below and try again. Common causes: network issues, API timeouts, or invalid keywords.',
|
||||
icon: '❌',
|
||||
tone: 'error'
|
||||
},
|
||||
{
|
||||
keywords: ['failed', 'error', 'unknown status'],
|
||||
title: 'Research operation reported an issue',
|
||||
subtitle: 'The research pipeline encountered a problem. Please check the error details below and consider refining your keywords before trying again.',
|
||||
icon: '⚠️',
|
||||
tone: 'error'
|
||||
}
|
||||
];
|
||||
|
||||
const sanitizeTitle = (text: string) => text.replace(/^[^a-zA-Z0-9]+/, '').trim();
|
||||
const sanitizeTitle = (text: string) => {
|
||||
// Strip leading emoji/whitespace, capitalize first letter
|
||||
const cleaned = text.replace(/^[^\w\s]+/, '').trim();
|
||||
if (!cleaned) return '';
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
};
|
||||
|
||||
// Fallback icons based on message content
|
||||
const inferFallbackIcon = (text: string): string => {
|
||||
const lower = text.toLowerCase();
|
||||
if (/error|fail|timeout|limit/i.test(lower)) return '⚠️';
|
||||
if (/done|complete|success|finish|ready/i.test(lower)) return '✅';
|
||||
if (/fetch|load|retriev|download/i.test(lower)) return '📥';
|
||||
if (/writ|generat|creat|build/i.test(lower)) return '✍️';
|
||||
if (/check|validat|verif/i.test(lower)) return '🔍';
|
||||
return '📝';
|
||||
};
|
||||
|
||||
const mapMessageToMeta = (message: { timestamp: string; message: string }): MessageMeta => {
|
||||
const raw = message.message || '';
|
||||
@@ -233,13 +380,15 @@ const mapMessageToMeta = (message: { timestamp: string; message: string }): Mess
|
||||
}
|
||||
|
||||
const stage = inferStage(raw);
|
||||
const fallbackTitle = sanitizeTitle(raw);
|
||||
|
||||
return {
|
||||
timestamp: message.timestamp,
|
||||
timeLabel: formatTime(message.timestamp),
|
||||
raw,
|
||||
title: sanitizeTitle(raw) || 'Update received',
|
||||
icon: '📝',
|
||||
title: fallbackTitle || 'Processing research data…',
|
||||
subtitle: 'Your research is being assembled. This may take a moment as we process multiple data sources in parallel.',
|
||||
icon: inferFallbackIcon(raw),
|
||||
tone: 'info',
|
||||
stage
|
||||
};
|
||||
@@ -416,7 +565,10 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#475569', fontSize: 14 }}>
|
||||
We are gathering sources, extracting insights, and assembling a research brief tailored to your topic.
|
||||
Research takes 40–60 seconds. We search multiple engines (Exa, Tavily), extract key insights,
|
||||
and assemble a structured research brief. After this, you will move to the <strong>Outline phase</strong>
|
||||
where AI generates a blog structure, then <strong>Content</strong> writes each section, followed by
|
||||
<strong> SEO</strong> optimization and <strong>Publish</strong>.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -369,7 +369,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
|
||||
// Precompute hash when modal opens and trigger cache check
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isOpen && !contentHash) {
|
||||
(async () => {
|
||||
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(h);
|
||||
@@ -381,18 +381,17 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
}, 100);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// Reset hash when modal closes
|
||||
setContentHash('');
|
||||
}
|
||||
}, [isOpen, blogContent, blogTitle, metadataResult, generateMetadata]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, blogContent, blogTitle]);
|
||||
|
||||
// Fallback: if modal opens and hash is already computed, check cache immediately
|
||||
useEffect(() => {
|
||||
if (isOpen && !metadataResult && contentHash) {
|
||||
generateMetadata(false);
|
||||
}
|
||||
}, [isOpen, metadataResult, contentHash, generateMetadata]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, contentHash]);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||
setTabValue(newValue);
|
||||
|
||||
Reference in New Issue
Block a user