From c29ecc1664cc41480d25389c65127cb08aa99f2b Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Wed, 12 Nov 2025 04:28:13 +0000 Subject: [PATCH] some improvements --- backend/igny8_core/ai/tasks.py | 46 ++++---- .../igny8_core/modules/writer/serializers.py | 20 ++++ backend/igny8_core/modules/writer/views.py | 52 +++++++- .../src/components/common/ImageQueueModal.tsx | 111 ++++++++++++++++-- 4 files changed, 196 insertions(+), 33 deletions(-) diff --git a/backend/igny8_core/ai/tasks.py b/backend/igny8_core/ai/tasks.py index 7e173f04..fc3f1f07 100644 --- a/backend/igny8_core/ai/tasks.py +++ b/backend/igny8_core/ai/tasks.py @@ -176,10 +176,11 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None logger.info(f"[process_image_generation_queue] Image generation settings found. Config keys: {list(config.keys())}") logger.info(f"[process_image_generation_queue] Full config: {config}") - # FORCE OPENAI DALL-E 2 ONLY FOR NOW - provider = 'openai' - model = 'dall-e-2' - logger.info(f"[process_image_generation_queue] FORCED PROVIDER: {provider}, MODEL: {model} (ignoring config provider: {config.get('provider', 'openai')}, model: {config.get('model', 'dall-e-3')})") + # Get provider and model from config (respect user settings) + provider = config.get('provider', 'openai') + # Get model - try 'model' first, then 'imageModel' as fallback + model = config.get('model') or config.get('imageModel') or 'dall-e-3' + logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings") image_type = config.get('image_type', 'realistic') image_format = config.get('image_format', 'webp') desktop_enabled = config.get('desktop_enabled', True) @@ -202,33 +203,32 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None # Get provider API key (using same approach as test image generation) # Note: API key is stored as 'apiKey' (camelCase) in IntegrationSettings.config - # FORCED: Always use 'openai' for provider (DALL-E 2 only) - logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key (FORCED: openai)") + logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key") try: provider_settings = IntegrationSettings.objects.get( account=account, - integration_type='openai', # FORCED: Always use 'openai' for DALL-E 2 + integration_type=provider, # Use the provider from settings is_active=True ) - logger.info(f"[process_image_generation_queue] OPENAI integration settings found") - logger.info(f"[process_image_generation_queue] OPENAI config keys: {list(provider_settings.config.keys()) if provider_settings.config else 'None'}") + logger.info(f"[process_image_generation_queue] {provider.upper()} integration settings found") + logger.info(f"[process_image_generation_queue] {provider.upper()} config keys: {list(provider_settings.config.keys()) if provider_settings.config else 'None'}") api_key = provider_settings.config.get('apiKey') if provider_settings.config else None if not api_key: - logger.error(f"[process_image_generation_queue] OPENAI API key not found in config") - logger.error(f"[process_image_generation_queue] OPENAI config: {provider_settings.config}") - return {'success': False, 'error': 'OPENAI API key not configured'} + logger.error(f"[process_image_generation_queue] {provider.upper()} API key not found in config") + logger.error(f"[process_image_generation_queue] {provider.upper()} config: {provider_settings.config}") + return {'success': False, 'error': f'{provider.upper()} API key not configured'} # Log API key presence (but not the actual key for security) api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***" - logger.info(f"[process_image_generation_queue] OPENAI API key retrieved successfully (length: {len(api_key)}, preview: {api_key_preview})") + logger.info(f"[process_image_generation_queue] {provider.upper()} API key retrieved successfully (length: {len(api_key)}, preview: {api_key_preview})") except IntegrationSettings.DoesNotExist: - logger.error(f"[process_image_generation_queue] ERROR: OPENAI integration settings not found") - logger.error(f"[process_image_generation_queue] Account: {account.id if account else 'None'}, integration_type: 'openai'") - return {'success': False, 'error': 'OPENAI integration not found or not active'} + logger.error(f"[process_image_generation_queue] ERROR: {provider.upper()} integration settings not found") + logger.error(f"[process_image_generation_queue] Account: {account.id if account else 'None'}, integration_type: '{provider}'") + return {'success': False, 'error': f'{provider.upper()} integration not found or not active'} except Exception as e: - logger.error(f"[process_image_generation_queue] ERROR getting OPENAI API key: {e}", exc_info=True) - return {'success': False, 'error': f'Error retrieving OPENAI API key: {str(e)}'} + logger.error(f"[process_image_generation_queue] ERROR getting {provider.upper()} API key: {e}", exc_info=True) + return {'success': False, 'error': f'Error retrieving {provider.upper()} API key: {str(e)}'} # Get image prompt template (has placeholders: {image_type}, {post_title}, {image_prompt}) try: @@ -599,8 +599,14 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None with open(file_path, 'wb') as f: f.write(response.content) - saved_file_path = file_path - logger.info(f"[process_image_generation_queue] Image {image_id} - Saved to: {file_path} ({len(response.content)} bytes)") + # Verify file was actually saved and exists + if os.path.exists(file_path) and os.path.getsize(file_path) > 0: + saved_file_path = file_path + logger.info(f"[process_image_generation_queue] Image {image_id} - Saved to: {file_path} ({len(response.content)} bytes, verified: {os.path.getsize(file_path)} bytes on disk)") + else: + logger.error(f"[process_image_generation_queue] Image {image_id} - File write appeared to succeed but file not found or empty: {file_path}") + saved_file_path = None + raise Exception(f"File was not saved successfully to {file_path}") except Exception as download_error: logger.error(f"[process_image_generation_queue] Image {image_id} - Failed to download/save image: {download_error}", exc_info=True) diff --git a/backend/igny8_core/modules/writer/serializers.py b/backend/igny8_core/modules/writer/serializers.py index 17aed9ac..dd083893 100644 --- a/backend/igny8_core/modules/writer/serializers.py +++ b/backend/igny8_core/modules/writer/serializers.py @@ -153,6 +153,8 @@ class ImagesSerializer(serializers.ModelSerializer): class ContentImageSerializer(serializers.ModelSerializer): """Serializer for individual image in grouped content images""" + image_url = serializers.SerializerMethodField() + class Meta: model = Images fields = [ @@ -166,6 +168,24 @@ class ContentImageSerializer(serializers.ModelSerializer): 'created_at', 'updated_at', ] + + def get_image_url(self, obj): + """ + Return proper HTTP URL for image. + Priority: If image_path exists, return file endpoint URL, otherwise return image_url (API URL). + """ + if obj.image_path: + # Return file endpoint URL for locally saved images + request = self.context.get('request') + if request: + # Build absolute URL for file endpoint + file_url = request.build_absolute_uri(f'/api/v1/writer/images/{obj.id}/file/') + return file_url + else: + # Fallback: return relative URL if no request context + return f'/api/v1/writer/images/{obj.id}/file/' + # Fallback to original image_url (API URL) if no local path + return obj.image_url class ContentImagesGroupSerializer(serializers.Serializer): diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index e53a1a42..fe68a8d4 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -395,11 +395,47 @@ class ImagesViewSet(SiteSectorModelViewSet): file_path = image.image_path - # Verify file exists + # Verify file exists - if not, try alternative locations if not os.path.exists(file_path): - return Response({ - 'error': f'Image file not found at: {file_path}' - }, status=status.HTTP_404_NOT_FOUND) + logger.warning(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}, trying alternative locations...") + + # Try alternative locations based on the filename + filename = os.path.basename(file_path) + alternative_paths = [ + '/data/app/igny8/images/' + filename, # Primary location + '/data/app/images/' + filename, # Fallback location + ] + + # Also try project-relative path + try: + from django.conf import settings + from pathlib import Path + base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent.parent + alternative_paths.append(str(base_dir / 'data' / 'app' / 'images' / filename)) + except Exception: + pass + + # Try each alternative path + found = False + for alt_path in alternative_paths: + if os.path.exists(alt_path): + file_path = alt_path + logger.info(f"[serve_image_file] Image {pk} - Found file at alternative location: {file_path}") + # Update database with correct path + try: + image.image_path = file_path + image.save(update_fields=['image_path']) + logger.info(f"[serve_image_file] Image {pk} - Updated database with correct path: {file_path}") + except Exception as update_error: + logger.warning(f"[serve_image_file] Image {pk} - Failed to update database path: {update_error}") + found = True + break + + if not found: + logger.error(f"[serve_image_file] Image {pk} - File not found in any location. Tried: {[file_path] + alternative_paths}") + return Response({ + 'error': f'Image file not found at: {file_path} (also checked alternative locations)' + }, status=status.HTTP_404_NOT_FOUND) # Check if file is readable if not os.access(file_path, os.R_OK): @@ -603,11 +639,15 @@ class ImagesViewSet(SiteSectorModelViewSet): else: overall_status = 'pending' + # Create serializer instances with request context for proper URL generation + featured_serializer = ContentImageSerializer(featured_image, context={'request': request}) if featured_image else None + in_article_serializers = [ContentImageSerializer(img, context={'request': request}) for img in in_article_images] + grouped_data.append({ 'content_id': content.id, 'content_title': content.title or content.meta_title or f"Content #{content.id}", - 'featured_image': ContentImageSerializer(featured_image).data if featured_image else None, - 'in_article_images': [ContentImageSerializer(img).data for img in in_article_images], + 'featured_image': featured_serializer.data if featured_serializer else None, + 'in_article_images': [s.data for s in in_article_serializers], 'overall_status': overall_status, }) except Content.DoesNotExist: diff --git a/frontend/src/components/common/ImageQueueModal.tsx b/frontend/src/components/common/ImageQueueModal.tsx index a6ddf3b2..f098ea0f 100644 --- a/frontend/src/components/common/ImageQueueModal.tsx +++ b/frontend/src/components/common/ImageQueueModal.tsx @@ -4,7 +4,7 @@ * Stage 1: Shows all progress bars immediately when Generate button is clicked */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { Modal } from '../ui/modal'; import { FileIcon, TimeIcon, CheckCircleIcon, ErrorIcon } from '../../icons'; import { fetchAPI } from '../../services/api'; @@ -53,6 +53,9 @@ export default function ImageQueueModal({ onLog, }: ImageQueueModalProps) { const [localQueue, setLocalQueue] = useState(queue); + // Track smooth progress animation for each item + const [smoothProgress, setSmoothProgress] = useState>({}); + const progressIntervalsRef = useRef>({}); useEffect(() => { setLocalQueue(queue); @@ -64,6 +67,77 @@ export default function ImageQueueModal({ } }, [localQueue, onUpdateQueue]); + // Smooth progress animation (like reference plugin) + 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(); + } + } + + setSmoothProgress(prev => ({ + ...prev, + [item.index]: Math.round(currentProgress) + })); + }, 100); + + progressIntervalsRef.current[item.index] = interval; + } + } else { + // Stop animation if item is no longer processing + if (progressIntervalsRef.current[item.index]) { + clearInterval(progressIntervalsRef.current[item.index]); + delete progressIntervalsRef.current[item.index]; + } + // Clear smooth progress for completed/failed items + if (item.status === 'completed' || item.status === 'failed') { + setSmoothProgress(prev => { + const next = { ...prev }; + delete next[item.index]; + return next; + }); + } + } + }); + + // Cleanup on unmount + return () => { + Object.values(progressIntervalsRef.current).forEach(interval => clearInterval(interval)); + progressIntervalsRef.current = {}; + }; + }, [localQueue, smoothProgress]); + // Polling for task status updates useEffect(() => { if (!isOpen || !taskId) return; @@ -190,6 +264,14 @@ export default function ImageQueueModal({ const result = results?.find((r: any) => r.image_id === item.imageId); if (result) { + // Stop smooth animation for completed/failed items + if (result.status === 'completed' || result.status === 'failed') { + if (progressIntervalsRef.current[item.index]) { + clearInterval(progressIntervalsRef.current[item.index]); + delete progressIntervalsRef.current[item.index]; + } + } + return { ...item, status: result.status === 'completed' ? 'completed' : @@ -197,23 +279,30 @@ export default function ImageQueueModal({ 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 ? 0 : 0, - imageUrl: result.image_url || item.imageUrl, + 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), error: result.error || null }; } // Update based on current_image index and progress if (index + 1 < current_image) { - // Already completed + // Already completed - stop animation + if (progressIntervalsRef.current[item.index]) { + clearInterval(progressIntervalsRef.current[item.index]); + delete progressIntervalsRef.current[item.index]; + } 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 + // Currently processing - use current_image_progress if available, otherwise smooth progress const progress = (current_image_progress !== undefined && current_image_id === item.imageId) ? current_image_progress - : 0; + : (smoothProgress[item.index] || 0); return { ...item, status: 'processing', progress }; } @@ -230,11 +319,19 @@ export default function ImageQueueModal({ const taskResult = results?.find((r: any) => r.image_id === item.imageId); if (taskResult) { + // Stop smooth animation + if (progressIntervalsRef.current[item.index]) { + clearInterval(progressIntervalsRef.current[item.index]); + delete progressIntervalsRef.current[item.index]; + } + return { ...item, status: taskResult.status === 'completed' ? 'completed' : 'failed', progress: taskResult.status === 'completed' ? 100 : 0, - imageUrl: taskResult.image_url || item.imageUrl, + imageUrl: taskResult.image_path + ? `/api/v1/writer/images/${item.imageId}/file/` + : (taskResult.image_url || item.imageUrl), error: taskResult.error || null }; }