Spaces:
Sleeping
Sleeping
chore: enhance OpenAI integration with connection test endpoint, improved error handling, and validate API key presence
e61e906 | "use client"; | |
| import { useState, useRef } from "react"; | |
| import Image from "next/image"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; | |
| import { Alert, AlertDescription } from "@/components/ui/alert"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Progress } from "@/components/ui/progress"; | |
| import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; | |
| import { ImageIcon, Download, Trash2, Upload, Sparkles, AlertCircle, Eye, Package, Copy, RefreshCw } from "lucide-react"; | |
| interface ImageGenerationRequest { | |
| prompt: string; | |
| size?: string; | |
| n?: number; | |
| model?: string; | |
| reference_image?: string; | |
| } | |
| interface ImageGenerationResponse { | |
| success: boolean; | |
| message: string; | |
| filename?: string; | |
| filenames?: string[]; | |
| count?: number; | |
| } | |
| const DEFAULT_PROMPT = `這是我創作的角色,叫做狐狸貓,請完全保留圖片中的狐狸貓外觀,包括: | |
| 五官大小、頭身比例、身體各部位大小、耳朵大小及陰影角度。 | |
| 注意,狐狸貓角色「沒有」筆觸描邊、圖片上不要有任何文字 | |
| 請直接給我圖片,不需要回復任何文字`; | |
| export default function Home() { | |
| const [uploadedImage, setUploadedImage] = useState<string | null>(null); | |
| const [uploadedImageBase64, setUploadedImageBase64] = useState<string | null>(null); | |
| const [prompt, setPrompt] = useState(DEFAULT_PROMPT); | |
| const [numImages, setNumImages] = useState("1"); | |
| const [generatedImages, setGeneratedImages] = useState<string[]>([]); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [progress, setProgress] = useState(0); | |
| const [dragOver, setDragOver] = useState(false); | |
| const [selectedImage, setSelectedImage] = useState<string | null>(null); | |
| const [loadingImages, setLoadingImages] = useState<boolean[]>([]); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const handleImageUpload = (file: File) => { | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const result = e.target?.result as string; | |
| setUploadedImage(result); | |
| // Extract base64 data (remove data:image/...;base64, prefix) | |
| const base64Data = result.split(',')[1]; | |
| setUploadedImageBase64(base64Data); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }; | |
| const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (file) { | |
| handleImageUpload(file); | |
| } | |
| }; | |
| const handleDragOver = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setDragOver(true); | |
| }; | |
| const handleDragLeave = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setDragOver(false); | |
| }; | |
| const handleDrop = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setDragOver(false); | |
| const file = e.dataTransfer.files[0]; | |
| if (file && file.type.startsWith('image/')) { | |
| handleImageUpload(file); | |
| } | |
| }; | |
| const handleGenerateImage = async () => { | |
| if (!prompt.trim()) { | |
| setError("Please enter a prompt"); | |
| return; | |
| } | |
| setIsLoading(true); | |
| setError(null); | |
| setGeneratedImages([]); | |
| setProgress(0); | |
| // Initialize loading states | |
| const numImagesInt = parseInt(numImages); | |
| setLoadingImages(new Array(numImagesInt).fill(true)); | |
| try { | |
| const requestBody: ImageGenerationRequest = { | |
| prompt: prompt, | |
| size: "256x256", | |
| n: numImagesInt, | |
| model: "dall-e-3", | |
| reference_image: uploadedImageBase64 || undefined | |
| }; | |
| // Simulate realistic progress (25 seconds per image) with random increments | |
| const estimatedTotalTime = 25 * numImagesInt * 1000; // 25 seconds per image in milliseconds | |
| const updateInterval = 1000; // Update every second | |
| const totalUpdates = estimatedTotalTime / updateInterval; | |
| const baseIncrement = 90 / totalUpdates; // Base increment to reach 90% | |
| const progressInterval = setInterval(() => { | |
| setProgress(prev => { | |
| if (prev >= 90) { | |
| clearInterval(progressInterval); | |
| return 90; | |
| } | |
| // Add randomness: ±50% of base increment | |
| const randomFactor = 0.5 + Math.random(); // 0.5 to 1.5 | |
| const randomIncrement = baseIncrement * randomFactor; | |
| const newProgress = Math.min(prev + randomIncrement, 90); | |
| // Round to 1 decimal place to avoid floating point precision issues | |
| return Math.round(newProgress * 10) / 10; | |
| }); | |
| }, updateInterval); | |
| const response = await fetch("/api/v1/images/generate", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify(requestBody), | |
| }); | |
| clearInterval(progressInterval); | |
| setProgress(100); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const data: ImageGenerationResponse = await response.json(); | |
| if (data.success && (data.filenames || data.filename)) { | |
| const filenames = data.filenames || (data.filename ? [data.filename] : []); | |
| const imageUrls = filenames.map(filename => | |
| `/api/v1/images/download/${filename}` | |
| ); | |
| setGeneratedImages(imageUrls); | |
| setLoadingImages([]); | |
| if (data.count && data.count < numImagesInt) { | |
| setError(`Only generated ${data.count}/${numImages} images. Some attempts returned text instead of images.`); | |
| } | |
| } else { | |
| setError(data.message || "Failed to generate image"); | |
| setLoadingImages([]); | |
| } | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : "An error occurred"); | |
| setLoadingImages([]); | |
| } finally { | |
| setIsLoading(false); | |
| setTimeout(() => setProgress(0), 1000); | |
| } | |
| }; | |
| const handleRegenerateWithSameSettings = () => { | |
| handleGenerateImage(); | |
| }; | |
| const copyPromptToClipboard = () => { | |
| navigator.clipboard.writeText(prompt); | |
| // You could add a toast notification here | |
| }; | |
| const downloadAllImages = () => { | |
| generatedImages.forEach((imageUrl, index) => { | |
| const link = document.createElement('a'); | |
| link.href = imageUrl; | |
| link.download = `fox-cat-${index + 1}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }); | |
| }; | |
| const clearAll = () => { | |
| setUploadedImage(null); | |
| setUploadedImageBase64(null); | |
| setGeneratedImages([]); | |
| setPrompt(DEFAULT_PROMPT); | |
| setNumImages("1"); | |
| setError(null); | |
| setProgress(0); | |
| setLoadingImages([]); | |
| }; | |
| const renderImageGrid = () => { | |
| const numImagesInt = parseInt(numImages); | |
| const skeletonCards = Array.from({ length: numImagesInt - generatedImages.length }, (_, i) => ( | |
| <Card key={`skeleton-${i}`} className="overflow-hidden animate-pulse"> | |
| <CardContent className="p-0"> | |
| <div className="aspect-square bg-muted"></div> | |
| <div className="p-4 flex items-center justify-between bg-muted/30"> | |
| <div className="h-4 bg-muted rounded w-16"></div> | |
| <div className="h-8 bg-muted rounded w-20"></div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )); | |
| return ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | |
| {generatedImages.map((imageUrl, index) => ( | |
| <Card key={index} className="overflow-hidden group hover:shadow-lg transition-shadow"> | |
| <CardContent className="p-0"> | |
| <div className="relative aspect-square cursor-pointer" onClick={() => setSelectedImage(imageUrl)}> | |
| <Image | |
| src={imageUrl} | |
| alt={`Generated image ${index + 1}`} | |
| fill | |
| className="object-cover group-hover:scale-105 transition-transform duration-200" | |
| /> | |
| <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-200 flex items-center justify-center"> | |
| <Eye className="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200" /> | |
| </div> | |
| </div> | |
| <div className="p-4 flex items-center justify-between bg-muted/30"> | |
| <span className="text-sm font-medium"> | |
| Image {index + 1} | |
| </span> | |
| <Button size="sm" variant="ghost" asChild> | |
| <a href={imageUrl} download> | |
| <Download className="w-4 h-4 mr-1" /> | |
| Download | |
| </a> | |
| </Button> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ))} | |
| {isLoading && skeletonCards} | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50"> | |
| <div className="container mx-auto px-4 py-6"> | |
| <div className="grid grid-cols-1 xl:grid-cols-3 gap-6 max-w-7xl mx-auto"> | |
| {/* Input Section */} | |
| <Card className="xl:col-span-1"> | |
| <CardHeader className="pb-4"> | |
| <CardTitle className="flex items-center gap-2 text-lg"> | |
| <Upload className="w-5 h-5" /> | |
| Input Configuration | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-6"> | |
| {/* Reference Image Upload with Drag & Drop */} | |
| <div className="space-y-3"> | |
| <Label className="text-sm font-medium flex items-center gap-2"> | |
| Reference Image | |
| {uploadedImage && <Badge variant="secondary" className="text-xs">Uploaded</Badge>} | |
| </Label> | |
| <div | |
| className={`border-2 border-dashed rounded-lg p-6 text-center transition-all cursor-pointer group ${ | |
| dragOver | |
| ? 'border-blue-500 bg-blue-50' | |
| : 'border-muted-foreground/25 hover:border-muted-foreground/50' | |
| }`} | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| onClick={() => fileInputRef.current?.click()} | |
| > | |
| <Input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*" | |
| onChange={handleFileSelect} | |
| className="hidden" | |
| /> | |
| <div className="flex flex-col items-center gap-3"> | |
| {uploadedImage ? ( | |
| <div className="relative w-32 h-32 rounded-lg overflow-hidden border"> | |
| <Image | |
| src={uploadedImage} | |
| alt="Reference image" | |
| fill | |
| className="object-cover" | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="w-32 h-32 rounded-lg bg-muted flex items-center justify-center group-hover:bg-muted/80 transition-colors"> | |
| <ImageIcon className="w-8 h-8 text-muted-foreground" /> | |
| </div> | |
| )} | |
| <div className="text-center"> | |
| <p className="text-sm font-medium"> | |
| {dragOver ? "Drop image here" : uploadedImage ? "Change Image" : "Upload or drag image"} | |
| </p> | |
| <p className="text-xs text-muted-foreground"> | |
| PNG, JPG up to 10MB | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Prompt Input with Copy Button */} | |
| <div className="space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <Label htmlFor="prompt" className="text-sm font-medium"> | |
| Prompt <span className="text-destructive">*</span> | |
| </Label> | |
| <Button | |
| size="sm" | |
| variant="ghost" | |
| onClick={copyPromptToClipboard} | |
| className="h-8 px-2" | |
| > | |
| <Copy className="w-4 h-4" /> | |
| </Button> | |
| </div> | |
| <Textarea | |
| id="prompt" | |
| value={prompt} | |
| onChange={(e) => setPrompt(e.target.value)} | |
| placeholder="Describe the image you want to generate in detail..." | |
| className="min-h-[120px] resize-none" | |
| /> | |
| </div> | |
| {/* Number of Images */} | |
| <div className="space-y-3"> | |
| <Label htmlFor="num-images" className="text-sm font-medium"> | |
| Number of Images | |
| </Label> | |
| <Select value={numImages} onValueChange={setNumImages}> | |
| <SelectTrigger> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="1">1 image</SelectItem> | |
| <SelectItem value="2">2 images</SelectItem> | |
| <SelectItem value="3">3 images</SelectItem> | |
| <SelectItem value="4">4 images</SelectItem> | |
| <SelectItem value="5">5 images</SelectItem> | |
| <SelectItem value="6">6 images</SelectItem> | |
| <SelectItem value="7">7 images</SelectItem> | |
| <SelectItem value="8">8 images</SelectItem> | |
| <SelectItem value="9">9 images</SelectItem> | |
| <SelectItem value="10">10 images</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| {/* Progress Bar */} | |
| {isLoading && ( | |
| <div className="space-y-2"> | |
| <div className="flex justify-between text-sm"> | |
| <span>Generating images...</span> | |
| <span>{progress}%</span> | |
| </div> | |
| <Progress value={progress} className="w-full" /> | |
| </div> | |
| )} | |
| {/* Action Buttons */} | |
| <div className="space-y-3"> | |
| <div className="flex gap-3"> | |
| <Button | |
| onClick={handleGenerateImage} | |
| disabled={isLoading || !prompt.trim()} | |
| className="flex-1" | |
| size="lg" | |
| > | |
| {isLoading ? ( | |
| <> | |
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" /> | |
| Generating... | |
| </> | |
| ) : ( | |
| <> | |
| <Sparkles className="w-4 h-4 mr-2" /> | |
| Generate {numImages} Image{parseInt(numImages) > 1 ? 's' : ''} | |
| </> | |
| )} | |
| </Button> | |
| <Button | |
| onClick={clearAll} | |
| variant="outline" | |
| size="lg" | |
| disabled={isLoading} | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </Button> | |
| </div> | |
| {/* Quick Action Buttons */} | |
| {generatedImages.length > 0 && !isLoading && ( | |
| <div className="flex gap-2"> | |
| <Button | |
| onClick={handleRegenerateWithSameSettings} | |
| variant="outline" | |
| size="sm" | |
| className="flex-1" | |
| > | |
| <RefreshCw className="w-4 h-4 mr-1" /> | |
| Regenerate | |
| </Button> | |
| <Button | |
| onClick={downloadAllImages} | |
| variant="outline" | |
| size="sm" | |
| className="flex-1" | |
| > | |
| <Package className="w-4 h-4 mr-1" /> | |
| Download All | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Error Alert */} | |
| {error && ( | |
| <Alert variant="destructive"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <AlertDescription>{error}</AlertDescription> | |
| </Alert> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Results Section */} | |
| <Card className="xl:col-span-2"> | |
| <CardHeader className="pb-4"> | |
| <CardTitle className="flex items-center justify-between text-lg"> | |
| <div className="flex items-center gap-2"> | |
| <ImageIcon className="w-5 h-5" /> | |
| Generated Images | |
| </div> | |
| {generatedImages.length > 0 && ( | |
| <Badge variant="secondary"> | |
| {generatedImages.length} image{generatedImages.length !== 1 ? 's' : ''} | |
| </Badge> | |
| )} | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| {generatedImages.length > 0 || isLoading ? ( | |
| renderImageGrid() | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center py-12 text-center"> | |
| <div className="w-20 h-20 rounded-full bg-muted flex items-center justify-center mb-4"> | |
| <ImageIcon className="w-10 h-10 text-muted-foreground" /> | |
| </div> | |
| <h3 className="text-lg font-semibold mb-2">Ready to generate</h3> | |
| <p className="text-muted-foreground max-w-md"> | |
| {uploadedImage ? "Upload complete. Click generate to create images." : "Upload a reference image and click generate."} | |
| </p> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </div> | |
| {/* Image Lightbox */} | |
| <Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}> | |
| <DialogContent className="max-w-4xl"> | |
| <DialogHeader> | |
| <DialogTitle>Image Preview</DialogTitle> | |
| </DialogHeader> | |
| {selectedImage && ( | |
| <div className="relative w-full h-[70vh]"> | |
| <Image | |
| src={selectedImage} | |
| alt="Full size preview" | |
| fill | |
| className="object-contain" | |
| /> | |
| </div> | |
| )} | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| ); | |
| } | |