diff --git a/backend/igny8_core/modules/system/integration_views.py b/backend/igny8_core/modules/system/integration_views.py index 003b8f69..bdce6a69 100644 --- a/backend/igny8_core/modules/system/integration_views.py +++ b/backend/igny8_core/modules/system/integration_views.py @@ -1041,6 +1041,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): 'phase': meta.get('phase', 'processing') if isinstance(meta, dict) else 'processing', 'current_item': meta.get('current_item') if isinstance(meta, dict) else None, 'completed': meta.get('completed', 0) if isinstance(meta, dict) else 0, + # Image generation progress fields + 'current_image': meta.get('current_image') if isinstance(meta, dict) else None, + 'current_image_id': meta.get('current_image_id') if isinstance(meta, dict) else None, + 'current_image_progress': meta.get('current_image_progress') if isinstance(meta, dict) else None, + 'total_images': meta.get('total_images') if isinstance(meta, dict) else None, + 'failed': meta.get('failed', 0) if isinstance(meta, dict) else 0, + 'results': meta.get('results', []) if isinstance(meta, dict) else [], } # Include step logs if available if isinstance(meta, dict): diff --git a/docker-compose.app.yml b/docker-compose.app.yml index 9628edac..fec9d10c 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -96,7 +96,7 @@ services: DEBUG: "False" volumes: - /data/app/igny8/backend:/app:rw - - /data/app/igny8:/data/app/igny8:ro + - /data/app/igny8:/data/app/igny8:rw - /data/app/logs:/app/logs:rw # Note: postgres and redis are external services from infra stack # Ensure they're running before starting this stack diff --git a/frontend/src/components/common/ImageQueueModal.tsx b/frontend/src/components/common/ImageQueueModal.tsx index f098ea0f..2f6f7cbd 100644 --- a/frontend/src/components/common/ImageQueueModal.tsx +++ b/frontend/src/components/common/ImageQueueModal.tsx @@ -16,6 +16,7 @@ export interface ImageQueueItem { type: 'featured' | 'in_article'; position?: number; contentTitle: string; + prompt?: string; // Image prompt text status: 'pending' | 'processing' | 'completed' | 'failed'; progress: number; imageUrl: string | null; @@ -67,55 +68,147 @@ export default function ImageQueueModal({ } }, [localQueue, onUpdateQueue]); - // Smooth progress animation (like reference plugin) + // Time-based progress: 1% → 50% in 5s, then 2% every 200ms until 80%, then event-based to 100% useEffect(() => { - // Start smooth progress for items that are processing - localQueue.forEach((item) => { - if (item.status === 'processing' && item.progress < 95) { - // Only start animation if not already running - if (!progressIntervalsRef.current[item.index]) { - // Start from current progress or 0 - let currentProgress = smoothProgress[item.index] || item.progress || 0; - let phase = currentProgress < 50 ? 1 : currentProgress < 75 ? 2 : 3; - 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(); - } + // Helper function for Phase 2: 2% every 200ms until 80% + const startPhase2Animation = (index: number, startProgress: number) => { + if (progressIntervalsRef.current[index]) { + clearInterval(progressIntervalsRef.current[index]); + delete progressIntervalsRef.current[index]; + } + + let currentPercent = Math.max(startProgress, 50); + + const interval = setInterval(() => { + setSmoothProgress(prev => { + const current = prev[index] ?? currentPercent; + + // Only increment if still below 80% and status is processing or pending + const item = localQueue.find(item => item.index === index); + if (current < 80 && (item?.status === 'processing' || item?.status === 'pending')) { + const newPercentage = Math.min(current + 2, 80); // 2% every 200ms + currentPercent = newPercentage; + + // Stop if we've reached 80% + if (newPercentage >= 80) { + clearInterval(interval); + delete progressIntervalsRef.current[index]; } - - setSmoothProgress(prev => ({ + + return { ...prev, - [item.index]: Math.round(currentProgress) - })); - }, 100); - + [index]: newPercentage + }; + } else { + // Stop if status changed or reached 80% + clearInterval(interval); + delete progressIntervalsRef.current[index]; + return prev; + } + }); + }, 200); // 2% every 200ms + + progressIntervalsRef.current[index] = interval; + }; + + localQueue.forEach((item) => { + // Apply progress logic to all items that are processing OR pending (waiting in queue) + // This ensures all images start progress animation, not just the currently processing one + if (item.status === 'processing' || item.status === 'pending') { + // Initialize smoothProgress if not set (start at 1% for new items) + const currentProgress = smoothProgress[item.index] ?? (item.progress > 0 ? item.progress : 1); + + // Phase 1: 1% → 50% at 100ms per 1% (regardless of events) + // Only start if we're below 50% and no interval is already running + if (currentProgress < 50 && !progressIntervalsRef.current[item.index]) { + let currentPercent = Math.max(currentProgress, 1); // Start from 1% or current if higher + + const interval = setInterval(() => { + setSmoothProgress(prev => { + const current = prev[item.index] ?? currentPercent; + + // Only increment if still below 50% and status is processing or pending + if (current < 50 && (item.status === 'processing' || item.status === 'pending')) { + const newPercentage = Math.min(current + 1, 50); // 1% every 100ms + currentPercent = newPercentage; + + // Stop if we've reached 50% + if (newPercentage >= 50) { + clearInterval(interval); + delete progressIntervalsRef.current[item.index]; + + // Start Phase 2: 2% every 200ms until 80% + if (newPercentage < 80) { + startPhase2Animation(item.index, newPercentage); + } + } + + return { + ...prev, + [item.index]: newPercentage + }; + } else { + // Stop if status changed or reached 50% + clearInterval(interval); + delete progressIntervalsRef.current[item.index]; + return prev; + } + }); + }, 200); // 1% every 100ms + progressIntervalsRef.current[item.index] = interval; } - } else { - // Stop animation if item is no longer processing + // Phase 2: 2% every 200ms until 80% + else if (currentProgress >= 50 && currentProgress < 80 && !progressIntervalsRef.current[item.index]) { + startPhase2Animation(item.index, currentProgress); + } + // Phase 3: Event-based smooth completion to 100% (after image is downloaded and shown) + // Check if image is ready (has imageUrl) even if still processing + else if (item.imageUrl && currentProgress < 100 && !progressIntervalsRef.current[item.index]) { + // Stop any existing animation + if (progressIntervalsRef.current[item.index]) { + clearInterval(progressIntervalsRef.current[item.index]); + delete progressIntervalsRef.current[item.index]; + } + + // Smoothly complete to 100% + if (!progressIntervalsRef.current[item.index]) { + let animatingProgress = Math.max(currentProgress, 80); // Start from 80% or current if higher + const startProgress = animatingProgress; + const endProgress = 100; + const duration = 800; // 500ms + const startTime = Date.now(); + + const interval = setInterval(() => { + const elapsed = Date.now() - startTime; + const progress = Math.min(1, elapsed / duration); + + // Ease-out quadratic + const eased = 1 - Math.pow(1 - progress, 2); + animatingProgress = startProgress + ((endProgress - startProgress) * eased); + + if (animatingProgress >= 100 || elapsed >= duration) { + animatingProgress = 100; + clearInterval(interval); + delete progressIntervalsRef.current[item.index]; + } + + setSmoothProgress(prev => ({ + ...prev, + [item.index]: Math.round(animatingProgress) + })); + }, 16); + + progressIntervalsRef.current[item.index] = interval; + } + } + } + }); + + // Handle non-processing items + localQueue.forEach((item) => { + if (item.status !== 'processing') { + // Stop animation for completed/failed items if (progressIntervalsRef.current[item.index]) { clearInterval(progressIntervalsRef.current[item.index]); delete progressIntervalsRef.current[item.index]; @@ -130,7 +223,7 @@ export default function ImageQueueModal({ } } }); - + // Cleanup on unmount return () => { Object.values(progressIntervalsRef.current).forEach(interval => clearInterval(interval)); @@ -256,6 +349,28 @@ export default function ImageQueueModal({ return () => clearInterval(pollInterval); }, [isOpen, taskId]); + // Helper function to convert image_path to frontend-accessible URL + const getImageUrlFromPath = (imagePath: string | null | undefined): string | null => { + if (!imagePath) return null; + + // If path contains 'ai-images', extract filename and convert to web URL + if (imagePath.includes('ai-images')) { + const filename = imagePath.split('ai-images/')[1] || imagePath.split('ai-images\\')[1]; + if (filename) { + return `/images/ai-images/${filename}`; + } + } + + // If path is already a web path, return as-is + if (imagePath.startsWith('/images/')) { + return imagePath; + } + + // Otherwise, try to extract filename and use ai-images path + const filename = imagePath.split('/').pop() || imagePath.split('\\').pop(); + return filename ? `/images/ai-images/${filename}` : null; + }; + const updateQueueFromTaskMeta = (meta: any) => { const { current_image, total_images, completed, failed, results, current_image_progress, current_image_id } = meta; @@ -272,20 +387,25 @@ export default function ImageQueueModal({ } } + // Only set imageUrl if image is completed AND image_path exists (image is saved) + const imageUrl = (result.status === 'completed' && result.image_path) + ? getImageUrlFromPath(result.image_path) + : null; + + // Use backend progress as target (0%, 50%, 75%, 90%, 100%) + // This works for both featured and in-article images + const backendProgress = (current_image_id === item.imageId && current_image_progress !== undefined) + ? current_image_progress + : (result.status === 'completed' ? 100 : + result.status === 'failed' ? 0 : + item.progress); + return { ...item, status: result.status === 'completed' ? 'completed' : result.status === 'failed' ? 'failed' : 'processing', - progress: result.status === 'completed' ? 100 : - result.status === 'failed' ? 0 : - // Use current_image_progress if this is the current image being processed - // Otherwise use smooth progress animation - (current_image_id === item.imageId && current_image_progress !== undefined) ? current_image_progress : - index + 1 < current_image ? 100 : - index + 1 === current_image ? (smoothProgress[item.index] || 0) : 0, - imageUrl: result.image_path - ? `/api/v1/writer/images/${item.imageId}/file/` - : (result.image_url || item.imageUrl), + progress: backendProgress, // Use backend progress as target for smooth animation + imageUrl: imageUrl || item.imageUrl, // Only update if we have a new URL, otherwise keep existing error: result.error || null }; } @@ -299,11 +419,11 @@ export default function ImageQueueModal({ } return { ...item, status: 'completed', progress: 100 }; } else if (index + 1 === current_image || current_image_id === item.imageId) { - // Currently processing - use current_image_progress if available, otherwise smooth progress - const progress = (current_image_progress !== undefined && current_image_id === item.imageId) + // Currently processing - use backend progress if available (works for featured and in-article) + const backendProgress = (current_image_progress !== undefined && current_image_id === item.imageId) ? current_image_progress - : (smoothProgress[item.index] || 0); - return { ...item, status: 'processing', progress }; + : (smoothProgress[item.index] ?? 0); + return { ...item, status: 'processing', progress: backendProgress }; } return item; @@ -325,13 +445,16 @@ export default function ImageQueueModal({ delete progressIntervalsRef.current[item.index]; } + // Only set imageUrl if image is completed AND image_path exists (image is saved) + const imageUrl = (taskResult.status === 'completed' && taskResult.image_path) + ? getImageUrlFromPath(taskResult.image_path) + : null; + return { ...item, status: taskResult.status === 'completed' ? 'completed' : 'failed', progress: taskResult.status === 'completed' ? 100 : 0, - imageUrl: taskResult.image_path - ? `/api/v1/writer/images/${item.imageId}/file/` - : (taskResult.image_url || item.imageUrl), + imageUrl: imageUrl || item.imageUrl, // Only update if we have a new URL, otherwise keep existing error: taskResult.error || null }; } @@ -451,7 +574,13 @@ export default function ImageQueueModal({ {item.label} - {item.contentTitle} + {item.prompt + ? (() => { + const words = item.prompt.split(' '); + const firstTenWords = words.slice(0, 10).join(' '); + return words.length > 10 ? `${firstTenWords}...` : item.prompt; + })() + : item.contentTitle} {getStatusIcon(item.status)} @@ -459,15 +588,15 @@ export default function ImageQueueModal({ - {/* Progress Bar */} + {/* Progress Bar - Use smooth progress for visual display */}
- {item.progress}% + {smoothProgress[item.index] ?? item.progress ?? 0}%
@@ -480,13 +609,22 @@ export default function ImageQueueModal({ )}
- {/* Right: Thumbnail */} + {/* Right: Thumbnail - Only show if image is completed and saved */}
- {item.imageUrl ? ( + {item.status === 'completed' && item.imageUrl ? ( {item.label} { + // Hide image if it fails to load (not yet available) + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + const parent = target.parentElement; + if (parent) { + parent.innerHTML = 'No image'; + } + }} /> ) : ( diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx index 0998ae6b..37f44949 100644 --- a/frontend/src/pages/Writer/Images.tsx +++ b/frontend/src/pages/Writer/Images.tsx @@ -229,6 +229,7 @@ export default function Images() { label: 'Featured Image', type: 'featured', contentTitle: contentImages.content_title || `Content #${contentId}`, + prompt: contentImages.featured_image.prompt, status: 'pending', progress: 0, imageUrl: null, @@ -250,6 +251,7 @@ export default function Images() { type: 'in_article', position: img.position || idx + 1, contentTitle: contentImages.content_title || `Content #${contentId}`, + prompt: img.prompt, status: 'pending', progress: 0, imageUrl: null,