Add Image Generation Settings Endpoint and Update Frontend Modal: Implement a new API endpoint to fetch image generation settings, enhance the ImageQueueModal to display progress and status, and integrate the settings into the image generation workflow.

This commit is contained in:
Desktop
2025-11-12 03:50:34 +05:00
parent e89eaab0f2
commit 27ec18727c
6 changed files with 590 additions and 286 deletions

View File

@@ -1,131 +1,81 @@
/**
* Image Queue Modal - Shows per-image progress for image generation
* Similar to WordPress plugin's image queue processor
* ImageQueueModal - Displays image generation queue with individual progress bars
* Similar to WP plugin's image-queue-processor.js modal
* Stage 1: Shows all progress bars immediately when Generate button is clicked
*/
import React, { useState, useEffect, useRef } from 'react';
import { Modal } from '../ui/modal';
import React, { useEffect, useState } from 'react';
import { CloseIcon as XIcon } from '../../icons';
export interface ImageQueueItem {
image_id: number;
imageId: number | null;
index: number;
label: string;
content_title: string;
type: 'featured' | 'in_article';
position?: number;
contentTitle: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress: number;
image_url: string | null;
imageUrl: string | null;
error: string | null;
}
interface ImageQueueModalProps {
isOpen: boolean;
queue: ImageQueueItem[];
onClose: () => void;
queue: ImageQueueItem[];
totalImages: number;
onUpdateQueue?: (queue: ImageQueueItem[]) => void;
}
export default function ImageQueueModal({
isOpen,
queue,
onClose,
queue,
totalImages,
onUpdateQueue,
}: ImageQueueModalProps) {
const [localQueue, setLocalQueue] = useState<ImageQueueItem[]>(queue);
const progressIntervalsRef = useRef<Map<number, NodeJS.Timeout>>(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;
if (onUpdateQueue) {
onUpdateQueue(localQueue);
}
}, [localQueue, onUpdateQueue]);
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);
}
if (!isOpen) return null;
// 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) => {
const getStatusIcon = (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 'pending':
return '';
case 'processing':
return 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800';
return '🔄';
case 'completed':
return '✅';
case 'failed':
return '❌';
default:
return 'bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700';
return '';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'pending':
return 'Pending';
case 'processing':
return 'Generating...';
case 'completed':
return 'Complete';
case 'failed':
return 'Failed';
default:
return 'Pending';
}
};
@@ -142,140 +92,127 @@ export default function ImageQueueModal({
}
};
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';
}
};
const isProcessing = localQueue.some(item => item.status === 'processing');
const completedCount = localQueue.filter(item => item.status === 'completed').length;
const failedCount = localQueue.filter(item => item.status === 'failed').length;
const allDone = localQueue.every(item => item.status === 'completed' || item.status === 'failed');
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-4xl"
showCloseButton={!allCompleted}
>
<div className="p-6">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 text-center">
🎨 Generating Images
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
Total: {localQueue.length} image{localQueue.length !== 1 ? 's' : ''} in queue
{allCompleted && (
<span className="ml-2">
({completedCount} completed{failedCount > 0 ? `, ${failedCount} failed` : ''})
</span>
)}
</p>
</div>
{/* Image Queue */}
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
{localQueue.map((item) => (
<div
key={item.image_id}
className={`p-4 rounded-lg border-2 transition-colors ${getStatusColor(
item.status
)}`}
>
<div className="flex items-center gap-4">
{/* Left side: Info and progress */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center flex-shrink-0">
{item.index}
</div>
<span className="font-semibold text-sm text-gray-900 dark:text-white">
{item.label}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-1 truncate">
{item.content_title}
</span>
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">
{getStatusIcon(item.status)} {getStatusText(item.status)}
</span>
</div>
{/* Progress bar */}
<div className="relative h-5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${getProgressColor(
item.status
)} transition-all duration-300 ease-out`}
style={{ width: `${item.progress}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold text-gray-900 dark:text-white">
{item.status === 'processing'
? `${Math.round(item.progress)}%`
: item.status === 'completed'
? '100%'
: item.status === 'failed'
? 'Failed'
: '0%'}
</span>
</div>
</div>
{/* Error message */}
{item.status === 'failed' && item.error && (
<div className="mt-2 p-2 bg-red-100 dark:bg-red-900/30 border-l-3 border-red-500 rounded text-xs text-red-700 dark:text-red-300">
{item.error}
</div>
)}
</div>
{/* Right side: Thumbnail */}
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-center overflow-hidden flex-shrink-0">
{item.image_url ? (
<img
src={item.image_url}
alt={item.label}
className="w-full h-full object-cover"
/>
) : (
<span className="text-xs text-gray-400">No image</span>
)}
</div>
</div>
</div>
))}
</div>
{/* Success message when all done */}
{allCompleted && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<p className="text-sm text-green-800 dark:text-green-200 text-center font-medium">
Image generation complete! {completedCount} image
{completedCount !== 1 ? 's' : ''} generated successfully
{failedCount > 0 && `, ${failedCount} failed`}.
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
🎨 Generating Images
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Total: {totalImages} image{totalImages !== 1 ? 's' : ''} in queue
</p>
</div>
)}
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isProcessing}
title={isProcessing ? 'Please wait for generation to complete' : 'Close'}
>
<XIcon className="w-5 h-5" />
</button>
</div>
{/* Queue List */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-3">
{localQueue.map((item) => (
<div
key={item.index}
className={`p-4 rounded-lg border-2 transition-colors ${
item.status === 'processing'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
: item.status === 'completed'
? 'bg-green-50 dark:bg-green-900/20 border-green-500'
: item.status === 'failed'
? 'bg-red-50 dark:bg-red-900/20 border-red-500'
: 'bg-gray-50 dark:bg-gray-700/50 border-gray-300 dark:border-gray-600'
}`}
>
<div className="flex items-center gap-4">
{/* Left: Queue Info */}
<div className="flex-1">
{/* Header Row */}
<div className="flex items-center gap-3 mb-2">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold">
{item.index}
</span>
<span className="font-semibold text-sm text-gray-900 dark:text-white">
{item.label}
</span>
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate">
{item.contentTitle}
</span>
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap">
{getStatusIcon(item.status)} {getStatusText(item.status)}
</span>
</div>
{/* Progress Bar */}
<div className="relative h-5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
className={`h-full ${getProgressColor(item.status)} transition-all duration-300 ease-out`}
style={{ width: `${item.progress}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold text-gray-700 dark:text-gray-200">
{item.progress}%
</span>
</div>
</div>
{/* Error Message */}
{item.error && (
<div className="mt-2 p-2 bg-red-100 dark:bg-red-900/30 border-l-4 border-red-500 rounded text-xs text-red-700 dark:text-red-300">
{item.error}
</div>
)}
</div>
{/* Right: Thumbnail */}
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.label}
className="w-full h-full object-cover"
/>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500">
No image
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{completedCount} completed{failedCount > 0 ? `, ${failedCount} failed` : ''} of {totalImages} total
</div>
{allDone && (
<button
onClick={onClose}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Close
</button>
)}
</div>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -10,10 +10,12 @@ import {
ContentImagesGroup,
ContentImagesResponse,
generateImages,
fetchImageGenerationSettings,
} from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, DownloadIcon, BoltIcon } from '../../icons';
import { createImagesPageConfig } from '../../config/pages/images.config';
import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQueueModal';
export default function Images() {
const toast = useToast();
@@ -38,6 +40,11 @@ export default function Images() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [showContent, setShowContent] = useState(false);
// Image queue modal state
const [isQueueModalOpen, setIsQueueModalOpen] = useState(false);
const [imageQueue, setImageQueue] = useState<ImageQueueItem[]>([]);
const [currentContentId, setCurrentContentId] = useState<number | null>(null);
// Load images - wrapped in useCallback
const loadImages = useCallback(async () => {
setLoading(true);
@@ -137,87 +144,105 @@ export default function Images() {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}, [toast]);
// Generate images handler
// Build image queue structure
const buildImageQueue = useCallback((contentId: number, maxInArticleImages: number) => {
const contentImages = images.find(g => g.content_id === contentId);
if (!contentImages) return [];
const queue: ImageQueueItem[] = [];
let queueIndex = 1;
// Featured image (always first)
if (contentImages.featured_image?.status === 'pending' &&
contentImages.featured_image?.prompt) {
queue.push({
imageId: contentImages.featured_image.id || null,
index: queueIndex++,
label: 'Featured Image',
type: 'featured',
contentTitle: contentImages.content_title || `Content #${contentId}`,
status: 'pending',
progress: 0,
imageUrl: null,
error: null,
});
}
// In-article images (up to max_in_article_images)
const pendingInArticle = contentImages.in_article_images
.filter(img => img.status === 'pending' && img.prompt)
.slice(0, maxInArticleImages)
.sort((a, b) => (a.position || 0) - (b.position || 0));
pendingInArticle.forEach((img, idx) => {
queue.push({
imageId: img.id || null,
index: queueIndex++,
label: `In-Article Image ${img.position || idx + 1}`,
type: 'in_article',
position: img.position || idx + 1,
contentTitle: contentImages.content_title || `Content #${contentId}`,
status: 'pending',
progress: 0,
imageUrl: null,
error: null,
});
});
return queue;
}, [images]);
// Generate images handler - Stage 1: Open modal immediately
const handleGenerateImages = useCallback(async (contentId: number) => {
try {
// Get all pending images for this content
// Get content images
const contentImages = images.find(g => g.content_id === contentId);
if (!contentImages) {
toast.error('Content not found');
return;
}
// Collect all image IDs with prompts and pending status
const imageIds: number[] = [];
if (contentImages.featured_image?.id &&
contentImages.featured_image.status === 'pending' &&
contentImages.featured_image.prompt) {
imageIds.push(contentImages.featured_image.id);
}
contentImages.in_article_images.forEach(img => {
if (img.id && img.status === 'pending' && img.prompt) {
imageIds.push(img.id);
// Fetch image generation settings to get max_in_article_images
let maxInArticleImages = 2; // Default
try {
const settings = await fetchImageGenerationSettings();
if (settings.success && settings.config) {
maxInArticleImages = settings.config.max_in_article_images || 2;
}
});
} catch (error) {
console.warn('Failed to fetch image settings, using default:', error);
}
if (imageIds.length === 0) {
// Build image queue
const queue = buildImageQueue(contentId, maxInArticleImages);
if (queue.length === 0) {
toast.info('No pending images with prompts found for this content');
return;
}
console.log('[Generate Images] Request:', { imageIds, count: imageIds.length });
console.log('[Generate Images] Endpoint: /v1/writer/images/generate_images/');
// STAGE 1: Open modal immediately with all progress bars
setImageQueue(queue);
setCurrentContentId(contentId);
setIsQueueModalOpen(true);
const result = await generateImages(imageIds);
// Collect image IDs for API call (will be used in Stage 2)
const imageIds: number[] = queue
.map(item => item.imageId)
.filter((id): id is number => id !== null);
console.log('[Generate Images] Full Response:', result);
console.log('[Generate Images] Response Keys:', Object.keys(result));
console.log('[Generate Images] Stage 1 Complete: Modal opened with', queue.length, 'images');
console.log('[Generate Images] Image IDs to generate:', imageIds);
console.log('[Generate Images] Max in-article images from settings:', maxInArticleImages);
// TODO: Stage 2 - Start actual generation
// This will be implemented in Stage 2
if (result.success) {
// Log queued prompts if available (TEST MODE)
if (result.queued_prompts && result.queued_prompts.length > 0) {
console.log('[Generate Images] Queued Prompts (TEST MODE - NOT sent to AI):', result.queued_prompts);
console.log(`[Generate Images] Provider: ${result.provider}, Model: ${result.model}`);
result.queued_prompts.forEach((qp: any, idx: number) => {
console.log(`[Generate Images] Prompt ${idx + 1}/${result.queued_prompts.length}:`);
console.log(` - Image Type: ${qp.image_type}`);
console.log(` - Content: ${qp.content_title}`);
console.log(` - Prompt Length: ${qp.prompt_length} chars`);
console.log(` - Full Prompt:`, qp.formatted_prompt);
if (qp.negative_prompt) {
console.log(` - Negative Prompt:`, qp.negative_prompt);
}
});
}
// Show toast message (no progress modal)
const generated = result.images_generated || 0;
const failed = result.images_failed || 0;
if (generated > 0) {
toast.success(`Images generated: ${generated} image${generated !== 1 ? 's' : ''} created${failed > 0 ? `, ${failed} failed` : ''}`);
} else if (failed > 0) {
toast.error(`Image generation failed: ${failed} image${failed !== 1 ? 's' : ''} failed`);
} else {
toast.success('Image generation completed');
}
loadImages(); // Reload to show new images
} else {
console.error('[Generate Images] Error:', result.error);
console.error('[Generate Images] Full Error Response:', result);
toast.error(result.error || 'Failed to generate images');
}
} catch (error: any) {
console.error('[Generate Images] Exception:', error);
console.error('[Generate Images] Error Details:', {
message: error.message,
stack: error.stack,
response: error.response,
status: error.status,
statusText: error.statusText
});
toast.error(`Failed to generate images: ${error.message}`);
toast.error(`Failed to initialize image generation: ${error.message}`);
}
}, [toast, loadImages, images]);
}, [toast, images, buildImageQueue]);
// Get max in-article images from the data (to determine column count)
const maxInArticleImages = useMemo(() => {
@@ -303,6 +328,19 @@ export default function Images() {
setCurrentPage(1);
}}
/>
<ImageQueueModal
isOpen={isQueueModalOpen}
onClose={() => {
setIsQueueModalOpen(false);
setImageQueue([]);
setCurrentContentId(null);
// Reload images after closing if generation completed
loadImages();
}}
queue={imageQueue}
totalImages={imageQueue.length}
onUpdateQueue={setImageQueue}
/>
</>
);
}

View File

@@ -1039,6 +1039,23 @@ export async function generateImages(imageIds: number[]): Promise<any> {
});
}
export interface ImageGenerationSettings {
success: boolean;
config: {
provider: string;
model: string;
image_type: string;
max_in_article_images: number;
image_format: string;
desktop_enabled: boolean;
mobile_enabled: boolean;
};
}
export async function fetchImageGenerationSettings(): Promise<ImageGenerationSettings> {
return fetchAPI('/v1/system/integrations/image_generation/');
}
export async function deleteTaskImage(id: number): Promise<void> {
return fetchAPI(`/v1/writer/images/${id}/`, {
method: 'DELETE',