diff --git a/backend/igny8_core/ai/functions/generate_images_from_prompts.py b/backend/igny8_core/ai/functions/generate_images_from_prompts.py index 7b541541..b5dfe116 100644 --- a/backend/igny8_core/ai/functions/generate_images_from_prompts.py +++ b/backend/igny8_core/ai/functions/generate_images_from_prompts.py @@ -169,8 +169,41 @@ class GenerateImagesFromPromptsFunction(BaseAIFunction): images_failed = 0 errors = [] + # Initialize image queue in meta for frontend + image_queue = [] + for idx, img in enumerate(images, 1): + content_obj = img.content + if not content_obj: + if img.task: + content_title = img.task.title + else: + content_title = "Content" + else: + content_title = content_obj.title or content_obj.meta_title or "Content" + + image_queue.append({ + 'image_id': img.id, + 'index': idx, + 'label': f"{img.image_type.replace('_', ' ').title()} Image", + 'content_title': content_title, + 'status': 'pending', + 'progress': 0, + 'image_url': None, + 'error': None + }) + + # Send initial queue to frontend + if progress_tracker: + initial_meta = step_tracker.get_meta() if step_tracker else {} + initial_meta['image_queue'] = image_queue + progress_tracker.update("PREP", 10, f"Preparing to generate {total_images} image{'s' if total_images != 1 else ''}", meta=initial_meta) + # Process each image sequentially for index, image in enumerate(images, 1): + queue_item = image_queue[index - 1] + queue_item['status'] = 'processing' + queue_item['progress'] = 0 + try: # Get content title content = image.content @@ -202,16 +235,32 @@ class GenerateImagesFromPromptsFunction(BaseAIFunction): if progress_tracker and step_tracker: prep_msg = f"Generating image {index} of {total_images}: {image.image_type}" step_tracker.add_request_step("PREP", "success", prep_msg) + queue_item['progress'] = 10 + # Update queue in meta + meta = step_tracker.get_meta() + meta['image_queue'] = image_queue progress_pct = 10 + int((index - 1) / total_images * 15) # 10-25% for PREP - progress_tracker.update("PREP", progress_pct, prep_msg, meta=step_tracker.get_meta()) + progress_tracker.update("PREP", progress_pct, prep_msg, meta=meta) - # Generate image + # Generate image - update progress incrementally if progress_tracker and step_tracker: ai_msg = f"Generating {image.image_type} image {index} of {total_images} with AI" step_tracker.add_response_step("AI_CALL", "success", ai_msg) + queue_item['progress'] = 25 + meta = step_tracker.get_meta() + meta['image_queue'] = image_queue progress_pct = 25 + int((index - 1) / total_images * 45) # 25-70% for AI_CALL - progress_tracker.update("AI_CALL", progress_pct, ai_msg, meta=step_tracker.get_meta()) + progress_tracker.update("AI_CALL", progress_pct, ai_msg, meta=meta) + # Update progress to 50% (simulating API call start) + queue_item['progress'] = 50 + if progress_tracker and step_tracker: + meta = step_tracker.get_meta() + meta['image_queue'] = image_queue + progress_tracker.update("AI_CALL", progress_pct, ai_msg, meta=meta) + + # Generate image (this is the actual API call) + # Frontend will simulate smooth progress from 50% to 95% while waiting result = ai_core.generate_image( prompt=formatted_prompt, provider=provider, @@ -221,8 +270,18 @@ class GenerateImagesFromPromptsFunction(BaseAIFunction): function_name='generate_images_from_prompts' ) + # Update progress to 90% (API call completed, processing response) + queue_item['progress'] = 90 + if progress_tracker and step_tracker: + meta = step_tracker.get_meta() + meta['image_queue'] = image_queue + progress_tracker.update("AI_CALL", progress_pct, ai_msg, meta=meta) + if result.get('error'): # Mark as failed + queue_item['status'] = 'failed' + queue_item['progress'] = 100 + queue_item['error'] = result['error'] with transaction.atomic(): image.status = 'failed' image.save(update_fields=['status', 'updated_at']) @@ -235,14 +294,19 @@ class GenerateImagesFromPromptsFunction(BaseAIFunction): if progress_tracker and step_tracker: parse_msg = f"Image {index} failed: {result['error']}" step_tracker.add_response_step("PARSE", "error", parse_msg) + meta = step_tracker.get_meta() + meta['image_queue'] = image_queue progress_pct = 70 + int((index - 1) / total_images * 15) # 70-85% for PARSE - progress_tracker.update("PARSE", progress_pct, parse_msg, meta=step_tracker.get_meta()) + progress_tracker.update("PARSE", progress_pct, parse_msg, meta=meta) continue image_url = result.get('url') if not image_url: # Mark as failed + queue_item['status'] = 'failed' + queue_item['progress'] = 100 + queue_item['error'] = 'No URL returned' with transaction.atomic(): image.status = 'failed' image.save(update_fields=['status', 'updated_at']) @@ -255,17 +319,22 @@ class GenerateImagesFromPromptsFunction(BaseAIFunction): if progress_tracker and step_tracker: parse_msg = f"Image {index} failed: No URL returned" step_tracker.add_response_step("PARSE", "error", parse_msg) + meta = step_tracker.get_meta() + meta['image_queue'] = image_queue progress_pct = 70 + int((index - 1) / total_images * 15) - progress_tracker.update("PARSE", progress_pct, parse_msg, meta=step_tracker.get_meta()) + progress_tracker.update("PARSE", progress_pct, parse_msg, meta=meta) continue - # Update progress: PARSE phase + # Update progress: PARSE phase (90%) + queue_item['progress'] = 90 if progress_tracker and step_tracker: parse_msg = f"Image {index} of {total_images} generated successfully" step_tracker.add_response_step("PARSE", "success", parse_msg) + meta = step_tracker.get_meta() + meta['image_queue'] = image_queue progress_pct = 70 + int((index - 1) / total_images * 15) # 70-85% for PARSE - progress_tracker.update("PARSE", progress_pct, parse_msg, meta=step_tracker.get_meta()) + progress_tracker.update("PARSE", progress_pct, parse_msg, meta=meta) # Update image record with transaction.atomic(): @@ -273,6 +342,10 @@ class GenerateImagesFromPromptsFunction(BaseAIFunction): image.status = 'generated' image.save(update_fields=['image_url', 'status', 'updated_at']) + # Mark queue item as completed + queue_item['status'] = 'completed' + queue_item['progress'] = 100 + queue_item['image_url'] = image_url images_generated += 1 logger.info(f"Image {image.id} ({image.image_type}) generated successfully: {image_url}") @@ -280,8 +353,10 @@ class GenerateImagesFromPromptsFunction(BaseAIFunction): if progress_tracker and step_tracker: save_msg = f"Saved image {index} of {total_images}" step_tracker.add_request_step("SAVE", "success", save_msg) + meta = step_tracker.get_meta() + meta['image_queue'] = image_queue progress_pct = 85 + int((index - 1) / total_images * 13) # 85-98% for SAVE - progress_tracker.update("SAVE", progress_pct, save_msg, meta=step_tracker.get_meta()) + progress_tracker.update("SAVE", progress_pct, save_msg, meta=meta) except Exception as e: # Mark as failed diff --git a/frontend/src/components/common/ImageQueueModal.tsx b/frontend/src/components/common/ImageQueueModal.tsx new file mode 100644 index 00000000..dbc3e7cb --- /dev/null +++ b/frontend/src/components/common/ImageQueueModal.tsx @@ -0,0 +1,281 @@ +/** + * Image Queue Modal - Shows per-image progress for image generation + * Similar to WordPress plugin's image queue processor + */ +import React, { useState, useEffect, useRef } from 'react'; +import { Modal } from '../ui/modal'; + +export interface ImageQueueItem { + image_id: number; + index: number; + label: string; + content_title: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress: number; + image_url: string | null; + error: string | null; +} + +interface ImageQueueModalProps { + isOpen: boolean; + queue: ImageQueueItem[]; + onClose: () => void; +} + +export default function ImageQueueModal({ + isOpen, + queue, + onClose, +}: ImageQueueModalProps) { + const [localQueue, setLocalQueue] = useState(queue); + const progressIntervalsRef = useRef>(new Map()); + + // Update local queue when prop changes + useEffect(() => { + setLocalQueue(queue); + }, [queue]); + + // Simulate smooth progress for processing images (like WP plugin) + useEffect(() => { + if (!isOpen) return; + + localQueue.forEach((item) => { + if (item.status === 'processing' && item.progress < 95) { + // Clear existing interval for this item + const existing = progressIntervalsRef.current.get(item.image_id); + if (existing) { + clearInterval(existing); + } + + // Progressive loading: 50% in 7s, 75% in next 5s, then 5% every second until 95% + let currentProgress = item.progress; + let phase = 1; + let phaseStartTime = Date.now(); + + const interval = setInterval(() => { + const elapsed = Date.now() - phaseStartTime; + + if (phase === 1 && currentProgress < 50) { + // Phase 1: 0% to 50% in 7 seconds (7.14% per second) + currentProgress += 0.714; + if (currentProgress >= 50 || elapsed >= 7000) { + currentProgress = 50; + phase = 2; + phaseStartTime = Date.now(); + } + } else if (phase === 2 && currentProgress < 75) { + // Phase 2: 50% to 75% in 5 seconds (5% per second) + currentProgress += 0.5; + if (currentProgress >= 75 || elapsed >= 5000) { + currentProgress = 75; + phase = 3; + phaseStartTime = Date.now(); + } + } else if (phase === 3 && currentProgress < 95) { + // Phase 3: 75% to 95% - 5% every second + if (elapsed >= 1000) { + currentProgress = Math.min(95, currentProgress + 5); + phaseStartTime = Date.now(); + } + } + + // Update local state + setLocalQueue((prev) => + prev.map((q) => + q.image_id === item.image_id + ? { ...q, progress: Math.min(95, currentProgress) } + : q + ) + ); + }, 100); + + progressIntervalsRef.current.set(item.image_id, interval); + } else { + // Clear interval if not processing + const existing = progressIntervalsRef.current.get(item.image_id); + if (existing) { + clearInterval(existing); + progressIntervalsRef.current.delete(item.image_id); + } + } + }); + + return () => { + // Cleanup intervals on unmount + progressIntervalsRef.current.forEach((interval) => clearInterval(interval)); + progressIntervalsRef.current.clear(); + }; + }, [isOpen, localQueue]); + + // Check if all images are completed + const allCompleted = localQueue.every( + (item) => item.status === 'completed' || item.status === 'failed' + ); + const completedCount = localQueue.filter( + (item) => item.status === 'completed' + ).length; + const failedCount = localQueue.filter((item) => item.status === 'failed').length; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': + return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800'; + case 'failed': + return 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800'; + case 'processing': + return 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800'; + default: + return 'bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700'; + } + }; + + const getProgressColor = (status: string) => { + switch (status) { + case 'completed': + return 'bg-green-500'; + case 'failed': + return 'bg-red-500'; + case 'processing': + return 'bg-blue-500'; + default: + return 'bg-gray-300'; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'completed': + return '✅'; + case 'failed': + return '❌'; + case 'processing': + return '⏳'; + default: + return '⏸️'; + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case 'completed': + return 'Complete'; + case 'failed': + return 'Failed'; + case 'processing': + return 'Generating...'; + default: + return 'Pending'; + } + }; + + return ( + +
+ {/* Header */} +
+

+ 🎨 Generating Images +

+

+ Total: {localQueue.length} image{localQueue.length !== 1 ? 's' : ''} in queue + {allCompleted && ( + + ({completedCount} completed{failedCount > 0 ? `, ${failedCount} failed` : ''}) + + )} +

+
+ + {/* Image Queue */} +
+ {localQueue.map((item) => ( +
+
+ {/* Left side: Info and progress */} +
+
+
+ {item.index} +
+ + {item.label} + + + {item.content_title} + + + {getStatusIcon(item.status)} {getStatusText(item.status)} + +
+ + {/* Progress bar */} +
+
+
+ + {item.status === 'processing' + ? `${Math.round(item.progress)}%` + : item.status === 'completed' + ? '100%' + : item.status === 'failed' + ? 'Failed' + : '0%'} + +
+
+ + {/* Error message */} + {item.status === 'failed' && item.error && ( +
+ {item.error} +
+ )} +
+ + {/* Right side: Thumbnail */} +
+ {item.image_url ? ( + {item.label} + ) : ( + No image + )} +
+
+
+ ))} +
+ + {/* Success message when all done */} + {allCompleted && ( +
+

+ Image generation complete! {completedCount} image + {completedCount !== 1 ? 's' : ''} generated successfully + {failedCount > 0 && `, ${failedCount} failed`}. +

+
+ )} +
+ + ); +} + diff --git a/frontend/src/hooks/useProgressModal.ts b/frontend/src/hooks/useProgressModal.ts index 9958e27d..127993e8 100644 --- a/frontend/src/hooks/useProgressModal.ts +++ b/frontend/src/hooks/useProgressModal.ts @@ -14,6 +14,17 @@ export interface ProgressState { }; } +export interface ImageQueueItem { + image_id: number; + index: number; + label: string; + content_title: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress: number; + image_url: string | null; + error: string | null; +} + export interface UseProgressModalReturn { progress: ProgressState; isOpen: boolean; @@ -32,6 +43,7 @@ export interface UseProgressModalReturn { message: string; timestamp?: number; }>; // Step logs for debugging + imageQueue?: ImageQueueItem[]; // Image queue for image generation } export function useProgressModal(): UseProgressModalReturn { @@ -54,6 +66,9 @@ export function useProgressModal(): UseProgressModalReturn { timestamp?: number; }>>([]); + // Image queue state for image generation + const [imageQueue, setImageQueue] = useState(undefined); + // Track displayed percentage and current step for step-based progress const displayedPercentageRef = useRef(0); const currentStepRef = useRef(null); @@ -458,6 +473,11 @@ export function useProgressModal(): UseProgressModalReturn { } } + // Extract image queue if available (for image generation) + if (meta.image_queue && Array.isArray(meta.image_queue)) { + setImageQueue(meta.image_queue as ImageQueueItem[]); + } + // Update step logs if available if (meta.request_steps || meta.response_steps) { // Collect all steps for display in modal @@ -736,6 +756,7 @@ export function useProgressModal(): UseProgressModalReturn { displayedPercentageRef.current = 0; currentStepRef.current = null; setStepLogs([]); // Clear step logs when closing modal + setImageQueue(undefined); // Clear image queue when closing modal setIsOpen(false); // Clear taskId to stop polling when modal closes setTaskId(null); @@ -768,6 +789,8 @@ export function useProgressModal(): UseProgressModalReturn { message: 'Getting started...', status: 'pending', }); + setStepLogs([]); + setImageQueue(undefined); setTaskId(null); setTitle(''); setIsOpen(false); @@ -785,6 +808,7 @@ export function useProgressModal(): UseProgressModalReturn { taskId, // Expose taskId for use in ProgressModal functionId, // Expose functionId for use in ProgressModal stepLogs, // Expose step logs for debugging + imageQueue, // Expose image queue for image generation }; } diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx index d993f091..10627722 100644 --- a/frontend/src/pages/Writer/Images.tsx +++ b/frontend/src/pages/Writer/Images.tsx @@ -15,6 +15,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer'; import { FileIcon, DownloadIcon, BoltIcon } from '../../icons'; import { createImagesPageConfig } from '../../config/pages/images.config'; import ProgressModal from '../../components/common/ProgressModal'; +import ImageQueueModal from '../../components/common/ImageQueueModal'; import { useProgressModal } from '../../hooks/useProgressModal'; export default function Images() { @@ -284,29 +285,49 @@ export default function Images() { }} /> - {/* Progress Modal for AI Functions */} - { - const wasCompleted = progressModal.progress.status === 'completed'; - progressModal.closeModal(); - // Reload data after modal closes (if completed) - if (wasCompleted && !hasReloadedRef.current) { - hasReloadedRef.current = true; - loadImages(); - setTimeout(() => { - hasReloadedRef.current = false; - }, 1000); - } - }} - /> + {/* Image Queue Modal for Image Generation */} + {progressModal.imageQueue && progressModal.imageQueue.length > 0 ? ( + { + const wasCompleted = progressModal.progress.status === 'completed'; + progressModal.closeModal(); + // Reload data after modal closes (if completed) + if (wasCompleted && !hasReloadedRef.current) { + hasReloadedRef.current = true; + loadImages(); + setTimeout(() => { + hasReloadedRef.current = false; + }, 1000); + } + }} + /> + ) : ( + /* Progress Modal for other AI Functions */ + { + const wasCompleted = progressModal.progress.status === 'completed'; + progressModal.closeModal(); + // Reload data after modal closes (if completed) + if (wasCompleted && !hasReloadedRef.current) { + hasReloadedRef.current = true; + loadImages(); + setTimeout(() => { + hasReloadedRef.current = false; + }, 1000); + } + }} + /> + )} ); }