/** * BoostMedia AI Content Generator Admin - Generate Step * * @package BoostMedia_AI * @license GPL-2.0-or-later */ import { useState, useEffect, useRef, useCallback } from 'react' import { Loader2, CheckCircle, XCircle, AlertTriangle, ChevronRight, RefreshCw } from 'lucide-react' import type { GenerationConfig } from './SelectPostTypeStep' import type { ContentPlan, GeneratedPost, TopicTreeArticle, TopicTreeCluster, TopicTreePillar } from '../../types' import { Button, Card } from '../common' import { endpoints, getErrorMessage } from '../../api/client' import { t, tf, isRtl } from '../../lib/i18n' interface GenerateStepProps { config: GenerationConfig conversation?: { technicalSummary?: string technicalAnswers?: Record[] contentPlan?: ContentPlan contentAnswers?: Record[] } onComplete: (posts: GeneratedPost[]) => void onBack: () => void } type GenerationPhase = 'idle' | 'submitting' | 'polling' | 'success' | 'error' | 'partial' interface JobState { jobId: string index: number } const POLL_INTERVAL_MS = 5000 const MAX_POLL_ATTEMPTS = 120 const DAILY_ARTICLE_LIMIT = 1000 const MAX_CONSECUTIVE_FAILURES = 3 interface BatchArticleFailure { index: number error: string } interface RetryBatchPayload { indices: number[] existingPosts: GeneratedPost[] } function errorMessageFromUnknown(err: unknown): string { return getErrorMessage(err, t('Unknown error')) } function isInsufficientCreditsError(err: unknown): boolean { if (err && typeof err === 'object') { const e = err as Record if (e.status === 402) return true if (e.code === 'insufficient_credits') return true const msg = String(e.message || '').toLowerCase() if (msg.includes('insufficient') && msg.includes('credit')) return true if (msg.includes('insufficient credits')) return true } return false } function isSiteBlockedError(err: unknown): boolean { if (err && typeof err === 'object') { const e = err as Record if (e.status === 403) return true if (e.code === 'site_blocked') return true } return false } function isAuthError(err: unknown): boolean { if (err && typeof err === 'object') { const e = err as Record if (e.status === 401) return true if (e.code === 'unauthorized') return true } return false } interface PlannedArticleContext { angle: string pillarTitle: string clusterTitle: string clusterSize: number } interface GeneratedListResponse { items?: GeneratedPost[] } interface JobStatusResponse { status: string generated_post?: GeneratedPost error_message?: string } function flattenTopicTree(plan?: ContentPlan | null): PlannedArticleContext[] { const pillars = plan?.topic_tree?.pillars || [] const flattened: PlannedArticleContext[] = [] pillars.forEach((pillar: TopicTreePillar) => { const clusters = pillar.clusters || [] clusters.forEach((cluster: TopicTreeCluster) => { const articles = (cluster.articles || []).filter((article: TopicTreeArticle) => Boolean(article.angle?.trim())) articles.forEach((article: TopicTreeArticle) => { flattened.push({ angle: article.angle.trim(), pillarTitle: pillar.title || '', clusterTitle: cluster.title || '', clusterSize: articles.length || cluster.article_count || 0, }) }) }) }) return flattened } function titleFromPost(post: GeneratedPost): string { const contentTitle = typeof post.content === 'object' && post.content && typeof post.content.title === 'string' ? post.content.title : '' return post.title || contentTitle || '' } export function GenerateStep({ config, conversation, onComplete, onBack }: GenerateStepProps) { const [phase, setPhase] = useState('idle') const [currentJob, setCurrentJob] = useState(0) const [totalJobs] = useState(config.count) const [message, setMessage] = useState(t('Preparing for creation...')) const [errorMsg, setErrorMsg] = useState('') const [, setJobs] = useState([]) const [completedPosts, setCompletedPosts] = useState([]) const [batchFailures, setBatchFailures] = useState([]) const abortRef = useRef(false) const pollTimerRef = useRef | null>(null) const currentJobRef = useRef(0) const totalJobsRef = useRef(config.count) const completedPostsRef = useRef([]) const retryPayloadRef = useRef(null) useEffect(() => { completedPostsRef.current = completedPosts }, [completedPosts]) const cleanup = useCallback(() => { abortRef.current = true if (pollTimerRef.current) { clearTimeout(pollTimerRef.current) pollTimerRef.current = null } }, []) const pollJobStatus = useCallback(async (jobId: string, attempt: number): Promise => { if (abortRef.current) return null try { const res = await endpoints.getJobStatus(jobId) const data = res.data as JobStatusResponse const status = data.status if (status === 'completed' || status === 'done' || status === 'delivered') { if (data.generated_post?.content) { return data.generated_post } const listRes = await endpoints.getGenerated() const raw = listRes.data as GeneratedPost[] | GeneratedListResponse const items = Array.isArray(raw) ? raw : (raw?.items ?? []) const stub = data.generated_post ?? items[0] if (stub?.id) { try { const fullRes = await endpoints.getGeneratedById(stub.id) const fullPost = fullRes.data as GeneratedPost if (fullPost?.content || fullPost?.generated_content) return fullPost } catch { /* fall through to stub */ } } return stub ?? null } if (status === 'failed' || status === 'error') { const errMsg = data.error_message || t('The job failed on the server') throw new Error(errMsg) } if (status === 'processing' || status === 'queued') { setMessage(tf('AI is working on post %d of %d... (status: %s)', currentJobRef.current + 1, totalJobsRef.current, t(status === 'processing' ? 'Processing' : 'Queued'))) } if (attempt >= MAX_POLL_ATTEMPTS) { throw new Error(t('Maximum wait time exceeded')) } await new Promise((resolve) => { pollTimerRef.current = setTimeout(resolve, POLL_INTERVAL_MS) }) return pollJobStatus(jobId, attempt + 1) } catch (err) { if (abortRef.current) return null throw err } }, []) const runGeneration = useCallback(async () => { abortRef.current = false setPhase('submitting') setErrorMsg('') const retryPayload = retryPayloadRef.current retryPayloadRef.current = null const isRetryRun = Boolean(retryPayload?.indices?.length) if (!isRetryRun) { setCompletedPosts([]) setBatchFailures([]) setJobs([]) } else { setBatchFailures([]) } let allPosts: GeneratedPost[] = isRetryRun && retryPayload ? [...retryPayload.existingPosts] : [] if (isRetryRun && retryPayload) { setCompletedPosts([...allPosts]) } const runFailures: BatchArticleFailure[] = [] let consecutiveFailures = 0 let stoppedConsecutive = false let creditExhaustedPartial = false try { if (!isRetryRun && config.count > DAILY_ARTICLE_LIMIT) { setPhase('error') setMessage(t('Too many requested posts')) setErrorMsg(tf('You can generate up to %d posts per plan.', DAILY_ARTICLE_LIMIT)) return } const plannedArticles = flattenTopicTree(conversation?.contentPlan) const topicTreeTotal = conversation?.contentPlan?.topic_tree?.total_articles || 0 let maxRunnablePosts = config.count try { const creditRes = await endpoints.getCreditsStatus() const creditData = creditRes.data as { boost_credits?: { remaining: number } daily_usage?: { generated_today?: number limit?: number remaining?: number articles_generated?: number article_limit?: number articles_remaining?: number } } const remaining = creditData?.boost_credits?.remaining ?? 0 const dailyRemaining = creditData?.daily_usage?.remaining ?? creditData?.daily_usage?.articles_remaining if (typeof dailyRemaining === 'number') { if (dailyRemaining <= 0) { setPhase('error') setMessage(t('Daily generation limit reached')) setErrorMsg(tf('You have already used today\'s limit of %d articles. The limit resets at midnight UTC.', DAILY_ARTICLE_LIMIT)) return } maxRunnablePosts = Math.min(maxRunnablePosts, dailyRemaining) if (!isRetryRun && dailyRemaining < config.count) { setMessage(tf('Daily limit allows %d of %d posts today. We will generate what is still available.', dailyRemaining, config.count)) } } if (remaining <= 0) { setPhase('error') setMessage(t('Insufficient credits')) setErrorMsg(t('Your credit balance is too low to generate content. Visit the Usage page to purchase credits.')) return } } catch { // Non-fatal: proceed even if credit check fails } totalJobsRef.current = maxRunnablePosts const indicesToRun = isRetryRun && retryPayload ? [...new Set(retryPayload.indices)].sort((a, b) => a - b) : Array.from({ length: maxRunnablePosts }, (_, idx) => idx) for (let loopPos = 0; loopPos < indicesToRun.length; loopPos++) { if (abortRef.current) break if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { stoppedConsecutive = true setErrorMsg( t('Generation stopped: too many consecutive errors. You can retry failed articles or try again in a few minutes.'), ) break } const i = indicesToRun[loopPos] setCurrentJob(i) currentJobRef.current = i setMessage(tf('Sending creation request %d of %d...', i + 1, maxRunnablePosts)) setPhase('submitting') const plannedArticle = plannedArticles[i] const recentTitles = allPosts .map(titleFromPost) .filter(Boolean) .slice(-10) try { let submitRes try { submitRes = await endpoints.generate({ post_type: config.postType, taxonomy: config.taxonomy || undefined, taxonomy_term: config.term || undefined, reporter_id: config.reporterId || undefined, topic: config.topic, keywords: config.keywords, length: config.length, technical_summary: conversation?.technicalSummary || '', technical_answers: conversation?.technicalAnswers || [], content_plan: conversation?.contentPlan || {}, content_answers: conversation?.contentAnswers || [], batch_context: { article_index: i, total_articles: maxRunnablePosts, article_angle: plannedArticle?.angle || config.topic, pillar_title: plannedArticle?.pillarTitle || '', cluster_title: plannedArticle?.clusterTitle || '', cluster_size: plannedArticle?.clusterSize || 0, topic_tree_total: topicTreeTotal || maxRunnablePosts, recent_titles: recentTitles, }, }) } catch (submitErr) { if (isInsufficientCreditsError(submitErr)) { if (allPosts.length > 0) { creditExhaustedPartial = true setPhase('partial') setMessage(tf('%d of %d posts created. Generation stopped — credits ran out.', allPosts.length, config.count)) setErrorMsg(t('Your credit balance is too low to generate more posts. Visit the Usage page to purchase additional credits.')) break } setPhase('error') setMessage(t('Insufficient credits')) setErrorMsg(t('Your credit balance is too low to generate content. Visit the Usage page to purchase credits.')) return } throw submitErr } const submitData = submitRes.data as { job_id?: string; status?: string } const jobId = submitData.job_id if (!jobId) { throw new Error(t('No job ID received from server')) } setJobs((prev) => [...prev, { jobId, index: i }]) setPhase('polling') setMessage(tf('Waiting for post %d of %d results... (AI is working)', i + 1, maxRunnablePosts)) const post = await pollJobStatus(jobId, 0) if (post) { allPosts.push(post) setCompletedPosts([...allPosts]) consecutiveFailures = 0 } else if (!abortRef.current) { throw new Error(t('No post was returned after generation')) } if (loopPos < indicesToRun.length - 1 && !abortRef.current) { await new Promise((resolve) => setTimeout(resolve, 1000)) } } catch (err) { if (abortRef.current) break const errMsg = errorMessageFromUnknown(err) if (isInsufficientCreditsError(err)) { if (allPosts.length > 0) { creditExhaustedPartial = true setPhase('partial') setMessage(tf('%d of %d posts created. Generation stopped — credits ran out.', allPosts.length, config.count)) setErrorMsg(t('Your credit balance is too low to generate more posts. Visit the Usage page to purchase additional credits.')) break } setPhase('error') setMessage(t('Insufficient credits')) setErrorMsg(t('Your credit balance is too low to generate content. Visit the Usage page to purchase credits.')) return } if (isSiteBlockedError(err) || isAuthError(err)) { runFailures.push({ index: i, error: errMsg }) if (allPosts.length > 0) { setPhase('partial') setMessage(tf('%d of %d articles created successfully', allPosts.length, config.count)) } else { setPhase('error') setMessage(t('Error creating content')) } setErrorMsg(errMsg) setBatchFailures(runFailures) return } runFailures.push({ index: i, error: errMsg }) consecutiveFailures++ console.log( JSON.stringify({ event: 'batch_article_failed', index: i, total: maxRunnablePosts, error: errMsg, consecutive_failures: consecutiveFailures, continuing: consecutiveFailures < MAX_CONSECUTIVE_FAILURES, }), ) if (loopPos < indicesToRun.length - 1 && !abortRef.current) { await new Promise((resolve) => setTimeout(resolve, 1000)) } } } setBatchFailures(runFailures) if (creditExhaustedPartial) { return } if (abortRef.current) { return } if (allPosts.length > 0) { if (runFailures.length > 0 || stoppedConsecutive) { setPhase('partial') setMessage( stoppedConsecutive ? tf('%d of %d articles created successfully', allPosts.length, config.count) : tf('%d of %d articles created successfully', allPosts.length, config.count), ) if (!stoppedConsecutive) { setErrorMsg('') } } else if (allPosts.length < config.count) { setPhase('partial') setMessage(tf('%d of %d posts created successfully.', allPosts.length, config.count)) setErrorMsg(tf('This run stopped before all requested posts were submitted. The remaining plan may be limited by credits or the daily cap of %d articles.', DAILY_ARTICLE_LIMIT)) } else { setPhase('success') setMessage(tf('%d posts created successfully!', allPosts.length)) } } else if (runFailures.length > 0 || stoppedConsecutive) { setPhase('error') setMessage(t('Error creating content')) if (!stoppedConsecutive) { setErrorMsg(runFailures[0]?.error || t('No posts were created')) } } else { setPhase('error') setMessage(t('Error creating content')) setErrorMsg(t('No posts were created')) } } catch (err) { if (!abortRef.current) { if (isInsufficientCreditsError(err) && allPosts.length > 0) { setPhase('partial') setMessage(tf('%d of %d posts created. Generation stopped — credits ran out.', allPosts.length, config.count)) setErrorMsg(t('Your credit balance is too low to generate more posts. Visit the Usage page to purchase additional credits.')) setBatchFailures(runFailures) return } setPhase('error') setMessage(t('Error creating content')) setErrorMsg(errorMessageFromUnknown(err)) setBatchFailures(runFailures) } } }, [config, conversation, pollJobStatus]) useEffect(() => { runGeneration() return cleanup }, [cleanup, runGeneration]) const handleRetry = () => { cleanup() runGeneration() } const handleRetryFailedArticles = useCallback(() => { if (batchFailures.length === 0) return cleanup() retryPayloadRef.current = { indices: batchFailures.map((f) => f.index), existingPosts: [...completedPostsRef.current], } runGeneration() }, [batchFailures, cleanup, runGeneration]) const handleContinue = () => { onComplete(completedPosts) } const progressPercentage = phase === 'success' ? 100 : phase === 'partial' ? (completedPosts.length / totalJobs) * 100 : totalJobs > 0 ? ((currentJob + (phase === 'polling' ? 0.5 : 0)) / totalJobs) * 100 : 0 return (

{(phase === 'submitting' || phase === 'polling') && t('Creating content...')} {phase === 'success' && t('Creation completed!')} {phase === 'partial' && t('Partially completed')} {phase === 'error' && t('Creation error')} {phase === 'idle' && t('Preparing...')}

{message}

{/* Progress Bar */}
{t('Progress')} {completedPosts.length} / {totalJobs}
{/* Status Icon */}
{(phase === 'submitting' || phase === 'polling') && (
{currentJob + 1}
)} {phase === 'success' && (
)} {phase === 'partial' && (
)} {phase === 'error' && (
)}
{/* Error / Credit Warning Details */} {phase === 'error' && errorMsg && (

{errorMsg}

)} {phase === 'partial' && errorMsg && (

{errorMsg}

)} {/* Polling info */} {phase === 'polling' && (

{t('Topic:')} {config.topic}

{t('The process may take up to a minute. We check the status every few seconds...')}

{config.keywords.length > 0 && (

{t('Keywords:')}{' '} {config.keywords.join(', ')}

)}
)} {/* Success Summary */} {phase === 'success' && (

{t('Created')}{' '} {completedPosts.length} {' '} {t('new posts')}

)} {/* Partial Success Summary */} {phase === 'partial' && (

{t('Created')}{' '} {completedPosts.length} {' '} {tf('of %d requested posts', totalJobs)}

)} {batchFailures.length > 0 && (phase === 'partial' || phase === 'error') && (

{tf('%d articles failed:', batchFailures.length)}

    {batchFailures.map((f) => (
  • {tf('Article %d: %s', f.index + 1, f.error)}
  • ))}
)} {/* Navigation */}
{phase === 'error' && ( )} {batchFailures.length > 0 && (phase === 'partial' || phase === 'error') && ( )} {phase === 'partial' && completedPosts.length > 0 && ( )} {phase === 'success' && ( )}
) }