Files
igny8/frontend/src/components/common/ImageGenerationCard.tsx
2025-11-09 10:27:02 +00:00

445 lines
16 KiB
TypeScript

import { ReactNode, useState, useEffect } from 'react';
import Button from '../ui/button/Button';
import { useToast } from '../ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface ImageGenerationCardProps {
title: string;
description?: string;
integrationId: string;
icon?: ReactNode;
}
interface GeneratedImage {
url: string;
revised_prompt?: string;
model?: string;
provider?: string;
size?: string;
format?: string;
cost?: string;
}
interface ImageSettings {
service?: string;
model?: string;
runwareModel?: string;
}
/**
* Image Generation Testing Card Component
* Full implementation with form fields and image display
*/
export default function ImageGenerationCard({
title,
description,
integrationId,
icon,
}: ImageGenerationCardProps) {
const toast = useToast();
const [isGenerating, setIsGenerating] = useState(false);
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title');
const [imageType, setImageType] = useState('realistic');
const [imageSize, setImageSize] = useState('1024x1024');
const [imageFormat, setImageFormat] = useState('webp');
const [imageSettings, setImageSettings] = useState<ImageSettings>({});
// Valid image sizes per model (from OpenAI official documentation)
const VALID_SIZES_BY_MODEL: Record<string, string[]> = {
'dall-e-3': ['1024x1024', '1024x1792', '1792x1024'],
'dall-e-2': ['256x256', '512x512', '1024x1024'],
};
// Get valid sizes for current model
const getValidSizes = (): string[] => {
const service = imageSettings.service || 'openai';
const model = service === 'openai'
? (imageSettings.model || 'dall-e-3')
: null;
if (model && VALID_SIZES_BY_MODEL[model]) {
return VALID_SIZES_BY_MODEL[model];
}
// Default to DALL-E 3 sizes if unknown
return VALID_SIZES_BY_MODEL['dall-e-3'];
};
// Update size if current size is invalid for the selected model
useEffect(() => {
const validSizes = getValidSizes();
if (validSizes.length > 0 && !validSizes.includes(imageSize)) {
// Reset to first valid size (usually the default)
setImageSize(validSizes[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageSettings.model, imageSettings.service]); // imageSize intentionally omitted to avoid infinite loop
const [generatedImage, setGeneratedImage] = useState<GeneratedImage | null>(null);
const [error, setError] = useState<string | null>(null);
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
// Load default image generation settings on mount
useEffect(() => {
const loadImageSettings = async () => {
try {
const response = await fetch(
`${API_BASE_URL}/v1/system/settings/integrations/image_generation/`,
{ credentials: 'include' }
);
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
setImageSettings(data.data);
}
}
} catch (error) {
console.error('Error loading image settings:', error);
}
};
loadImageSettings();
}, [API_BASE_URL]);
const handleGenerate = async () => {
console.log('[ImageGenerationCard] handleGenerate called');
if (!prompt.trim()) {
toast.error('Please enter a prompt description');
return;
}
setIsGenerating(true);
setError(null);
setGeneratedImage(null);
try {
// Get the default service and model from settings
const service = imageSettings.service || 'openai';
const model = service === 'openai'
? (imageSettings.model || 'dall-e-3')
: (imageSettings.runwareModel || 'runware:97@1');
console.log('[ImageGenerationCard] Service and model:', { service, model, imageSettings });
// Build prompt with template (similar to reference plugin)
const fullPrompt = `Create a high-quality ${imageType} image. ${prompt}`;
console.log('[ImageGenerationCard] Full prompt:', fullPrompt.substring(0, 100) + '...');
const requestBody = {
prompt: fullPrompt,
negative_prompt: negativePrompt,
image_type: imageType,
image_size: imageSize,
image_format: imageFormat,
provider: service,
model: model,
};
console.log('[ImageGenerationCard] Making request to image generation endpoint');
console.log('[ImageGenerationCard] Request body:', requestBody);
const data = await fetchAPI('/v1/system/settings/integrations/image_generation/generate/', {
method: 'POST',
body: JSON.stringify(requestBody),
});
console.log('[ImageGenerationCard] Response data:', data);
if (!data.success) {
throw new Error(data.error || 'Failed to generate image');
}
const imageData = {
url: data.image_url,
revised_prompt: data.revised_prompt,
model: data.model || model,
provider: data.provider || service,
size: imageSize,
format: imageFormat.toUpperCase(),
cost: data.cost,
};
setGeneratedImage(imageData);
// Emit custom event for ImageResultCard to listen to
window.dispatchEvent(
new CustomEvent('imageGenerated', {
detail: imageData,
})
);
console.log('[ImageGenerationCard] Image generation successful:', imageData);
toast.success('Image generated successfully!');
} catch (err: any) {
console.error('[ImageGenerationCard] Error in handleGenerate:', {
error: err,
message: err.message,
stack: err.stack,
});
const errorMessage = err.message || 'Failed to generate image';
setError(errorMessage);
// Emit error event for ImageResultCard
window.dispatchEvent(
new CustomEvent('imageGenerationError', {
detail: errorMessage,
})
);
toast.error(errorMessage);
} finally {
console.log('[ImageGenerationCard] handleGenerate completed');
setIsGenerating(false);
}
};
// Get display name for provider and model
const getProviderDisplay = () => {
const service = imageSettings.service || 'openai';
if (service === 'openai') {
const model = imageSettings.model || 'dall-e-3';
const modelNames: Record<string, string> = {
'dall-e-3': 'DALL·E 3',
'dall-e-2': 'DALL·E 2',
'gpt-image-1': 'GPT Image 1 (Full)',
'gpt-image-1-mini': 'GPT Image 1 Mini',
};
return `OpenAI ${modelNames[model] || model}`;
} else {
return 'Runware';
}
};
// Image size options - dynamically generated based on selected model
const sizeLabels: Record<string, string> = {
'1024x1024': 'Square - 1024 x 1024',
'1024x1792': 'Portrait - 1024 x 1792',
'1792x1024': 'Landscape - 1792 x 1024',
'256x256': 'Small - 256 x 256',
'512x512': 'Medium - 512 x 512',
};
const sizeOptions = getValidSizes().map(size => ({
value: size,
label: sizeLabels[size] || size,
}));
// Image type options
const typeOptions = [
{ value: 'realistic', label: 'Realistic' },
{ value: 'illustration', label: 'Illustration' },
{ value: '3D render', label: '3D Render' },
{ value: 'minimalist', label: 'Minimalist' },
{ value: 'cartoon', label: 'Cartoon' },
];
// Format options
const formatOptions = [
{ value: 'webp', label: 'WEBP' },
{ value: 'jpg', label: 'JPG' },
{ value: 'png', label: 'PNG' },
];
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-6">
{icon && (
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
)}
<h3 className="mb-2 text-base font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="border-t border-gray-200 p-5 dark:border-gray-800">
<div className="space-y-5">
{/* API Provider and Model Display */}
<div className="flex items-center gap-3 rounded-lg bg-blue-50 px-4 py-3 dark:bg-blue-900/20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="text-blue-600 dark:text-blue-400"
>
<path
d="M10 2L3 7V17C3 17.5304 3.21071 18.0391 3.58579 18.4142C3.96086 18.7893 4.46957 19 5 19H15C15.5304 19 16.0391 18.7893 16.4142 18.4142C16.7893 18.0391 17 17.5304 17 17V7L10 2Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div>
<p className="text-xs font-medium text-blue-600 dark:text-blue-400">Provider & Model</p>
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
{getProviderDisplay()}
</p>
</div>
</div>
{/* Prompt Description - Full Width */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Prompt Description *
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={6}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
placeholder="Describe the visual elements, style, mood, and composition you want in the image..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Describe the visual elements, style, mood, and composition you want in the image.
</p>
</div>
{/* Negative Prompt - Small */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Negative Prompt
</label>
<textarea
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
rows={2}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
placeholder="Describe what you DON'T want in the image..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Specify elements to avoid in the generated image (text, watermarks, logos, etc.).
</p>
</div>
{/* 3 Column Dropdowns */}
<div className="grid grid-cols-3 gap-4">
{/* Image Type */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Type
</label>
<select
value={imageType}
onChange={(e) => setImageType(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{typeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Image Size */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Size
</label>
<select
value={imageSize}
onChange={(e) => setImageSize(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{sizeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Image Format */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Format
</label>
<select
value={imageFormat}
onChange={(e) => setImageFormat(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{formatOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* Generate Button - Bottom Right */}
<div className="flex justify-end">
<Button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className="inline-flex items-center gap-2 px-6 py-2.5"
>
{isGenerating ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Generating...
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="M21 15l-3.086-3.086a2 2 0 00-2.828 0L6 21" />
</svg>
Generate Image
</>
)}
</Button>
</div>
</div>
</div>
{/* Error display */}
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
</article>
);
}