package image import ( "context" "crypto/md5" "encoding/hex" "fmt" "io" "math/rand" "net/http" "os" "path/filepath" "strings" "time" "github.com/sashabaranov/go-openai" ) // OpenAIClient implements ImageSearcher for OpenAI DALL-E image generation type OpenAIClient struct { client *openai.Client apiKey string model string // dall-e-2 or dall-e-3 size string // 256x256, 512x512, 1024x1024 quality string // standard or hd (dall-e-3 only) style string // natural or vivid (dall-e-3 only) cacheDir string enableCache bool lastPrompt string // Store the last used prompt for attribution } // OpenAIConfig holds configuration for the OpenAI image provider type OpenAIConfig struct { APIKey string Model string Size string Quality string Style string CacheDir string EnableCache bool } // NewOpenAIClient creates a new OpenAI DALL-E client func NewOpenAIClient(config *OpenAIConfig) *OpenAIClient { if config.APIKey == "" { // Return nil client that will fail on operations return &OpenAIClient{} } client := openai.NewClient(config.APIKey) // Set defaults if config.Model == "" { config.Model = "dall-e-3" } if config.Size == "" { config.Size = "1024x1024" } if config.Quality == "" { config.Quality = "standard" } if config.Style == "" { config.Style = "natural" } if config.CacheDir == "" { config.CacheDir = "./.image_cache" } oc := &OpenAIClient{ client: client, apiKey: config.APIKey, model: config.Model, size: config.Size, quality: config.Quality, style: config.Style, cacheDir: config.CacheDir, enableCache: config.EnableCache, } // Create cache directory if caching is enabled if oc.enableCache && oc.cacheDir != "" { os.MkdirAll(oc.cacheDir, 0755) } return oc } // Search generates an image for the Bulgarian word using DALL-E func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) { if c.client == nil { return nil, &SearchError{ Provider: "openai", Code: "NO_API_KEY", Message: "OpenAI API key not configured", } } // Check cache first if c.enableCache { cacheFile := c.getCacheFilePath(opts.Query) if info, err := os.Stat(cacheFile); err == nil && info.Size() > 0 { // Return cached result fmt.Printf("Using cached image for '%s'\n", opts.Query) result := SearchResult{ ID: c.generateImageID(opts.Query), URL: cacheFile, ThumbnailURL: cacheFile, Width: c.getSizeWidth(), Height: c.getSizeHeight(), Description: fmt.Sprintf("Generated image for %s", opts.Query), Attribution: "Generated by OpenAI DALL-E", Source: "openai", } return []SearchResult{result}, nil } } // Use provided translation if available, otherwise translate Bulgarian word to English var translatedWord string if opts.Translation != "" { // Use the translation that was already provided (from UI or user input) translatedWord = opts.Translation fmt.Printf("Using provided translation: %s -> %s\n", opts.Query, translatedWord) } else { // Translate Bulgarian word to English for better results var err error translatedWord, err = c.translateBulgarianToEnglish(ctx, opts.Query) if err != nil { // If translation fails, fall back to using the original word fmt.Printf("Translation failed: %v, using original word\n", err) translatedWord = opts.Query } } // Create prompt - use custom if provided, otherwise generate educational prompt var prompt string if opts.CustomPrompt != "" && strings.TrimSpace(opts.CustomPrompt) != "" { prompt = strings.TrimSpace(opts.CustomPrompt) fmt.Printf("Using custom prompt: %s\n", prompt) } else { prompt = c.createEducationalPrompt(opts.Query, translatedWord) if prompt == "" { return nil, &SearchError{ Provider: "openai", Code: "PROMPT_GENERATION_FAILED", Message: "Failed to generate image prompt - artistic styles could not be loaded", } } } // Store the prompt for attribution c.lastPrompt = prompt // Log the prompt to stdout for debugging fmt.Printf("OpenAI Image Generation Prompt: %s\n", prompt) fmt.Printf("OpenAI Image Generation: Using model '%s' with size '%s'\n", c.model, c.size) // Create the image generation request req := openai.ImageRequest{ Prompt: prompt, Model: c.model, Size: c.size, ResponseFormat: openai.CreateImageResponseFormatURL, N: 1, } // Add model-specific parameters if c.model == "dall-e-3" { req.Quality = c.quality req.Style = c.style } // Generate the image resp, err := c.client.CreateImage(ctx, req) if err != nil { return nil, &SearchError{ Provider: "openai", Code: "API_ERROR", Message: fmt.Sprintf("Failed to generate image: %v", err), } } if len(resp.Data) == 0 { return nil, &SearchError{ Provider: "openai", Code: "NO_RESULTS", Message: "No image generated", } } // Get the generated image URL imageURL := resp.Data[0].URL // Download and cache the image if caching is enabled if c.enableCache { cacheFile := c.getCacheFilePath(opts.Query) if err := c.downloadAndCache(ctx, imageURL, cacheFile); err == nil { // Update URL to point to cached file imageURL = cacheFile } // Continue even if caching fails } // Create result result := SearchResult{ ID: c.generateImageID(opts.Query), URL: imageURL, ThumbnailURL: imageURL, Width: c.getSizeWidth(), Height: c.getSizeHeight(), Description: fmt.Sprintf("Generated educational image for %s (%s)", opts.Query, translatedWord), Attribution: "Generated by OpenAI DALL-E", Source: "openai", } return []SearchResult{result}, nil } // Download downloads an image from the given URL func (c *OpenAIClient) Download(ctx context.Context, url string) (io.ReadCloser, error) { // If it's a local cached file (not an HTTP/HTTPS URL), open it directly if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { file, err := os.Open(url) if err != nil { return nil, fmt.Errorf("failed to open cached file: %w", err) } return file, nil } // Otherwise download from URL req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) } return resp.Body, nil } // GetAttribution returns the required attribution text func (c *OpenAIClient) GetAttribution(result *SearchResult) string { attribution := fmt.Sprintf("Image generated by OpenAI DALL-E\n\n") attribution += fmt.Sprintf("Model: %s\n", c.model) attribution += fmt.Sprintf("Size: %s\n", c.size) if c.model == "dall-e-3" { attribution += fmt.Sprintf("Quality: %s\n", c.quality) attribution += fmt.Sprintf("Style: %s\n", c.style) } attribution += fmt.Sprintf("\nPrompt used:\n%s\n", c.lastPrompt) attribution += fmt.Sprintf("\nGenerated at: %s\n", time.Now().Format("2006-01-02 15:04:05")) return attribution } // Name returns the name of the provider func (c *OpenAIClient) Name() string { return "openai" } // GetLastPrompt returns the last prompt used for image generation func (c *OpenAIClient) GetLastPrompt() string { return c.lastPrompt } // createEducationalPrompt generates a prompt optimized for language learning func (c *OpenAIClient) createEducationalPrompt(bulgarianWord, englishTranslation string) string { // Generate a scene description for the word scene, err := c.generateSceneDescription(context.Background(), bulgarianWord, englishTranslation) if err != nil { fmt.Printf(" Failed to generate scene: %v, using basic prompt\n", err) scene = "" } // Get styles from file or generate them styles, err := c.getArtisticStyles(context.Background()) if err != nil { // If we can't get styles, return an error by returning empty prompt fmt.Printf(" ERROR: Failed to load artistic styles: %v\n", err) return "" } // Shuffle the styles to avoid bias rand.Shuffle(len(styles), func(i, j int) { styles[i], styles[j] = styles[j], styles[i] }) // Select a random style from the shuffled list selectedStyle := styles[0] fmt.Printf(" Using image style: %s\n", selectedStyle) // Create prompt with scene if available if scene != "" { return fmt.Sprintf( "Generate a %s depicting: %s. "+ "The image should be educational and suitable for language learning flashcards. "+ "Requirements: The main subject must be clearly visible, easily recognizable, and prominent in the image. It should occupy the central area with sharp focus and proper lighting. Ensure the subject is shown from an angle that makes it immediately identifiable. "+ "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.", selectedStyle, scene, ) } // Fallback to basic prompt if no scene return fmt.Sprintf( "Generate a %s of %s. "+ "The image should be educational and suitable for language learning flashcards. "+ "Requirements: The %s must be clearly visible and easily recognizable. Show it prominently centered with excellent lighting and sharp focus. Use an angle or perspective that makes the subject immediately identifiable. Keep the background plain or simple to ensure maximum clarity of the subject. "+ "IMPORTANT: No text whatsoever. Do not include any words, letters, typography, labels, captions, or writing of any kind. Image only, without any text elements.", selectedStyle, englishTranslation, englishTranslation, ) } // translateBulgarianToEnglish translates a Bulgarian word to English using OpenAI func (c *OpenAIClient) translateBulgarianToEnglish(ctx context.Context, word string) (string, error) { // Use OpenAI chat completion to translate fmt.Printf("OpenAI Translation: Using model 'gpt-4o-mini' to translate '%s'\n", word) req := openai.ChatCompletionRequest{ Model: openai.GPT4oMini, Messages: []openai.ChatCompletionMessage{ { Role: openai.ChatMessageRoleUser, Content: fmt.Sprintf("Translate the Bulgarian word '%s' to English. Respond with only the English translation, nothing else.", word), }, }, Temperature: 0.3, // Lower temperature for more consistent translations MaxTokens: 50, } resp, err := c.client.CreateChatCompletion(ctx, req) if err != nil { return "", fmt.Errorf("translation failed: %w", err) } if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" { return "", fmt.Errorf("no translation received") } translation := strings.TrimSpace(resp.Choices[0].Message.Content) fmt.Printf("Translated '%s' to '%s'\n", word, translation) return translation, nil } // generateSceneDescription generates a contextual scene description for the word func (c *OpenAIClient) generateSceneDescription(ctx context.Context, bulgarianWord, englishTranslation string) (string, error) { // Use OpenAI to generate a scene description fmt.Printf("OpenAI Scene Generation: Creating scene for '%s' (%s)\n", bulgarianWord, englishTranslation) req := openai.ChatCompletionRequest{ Model: openai.GPT4oMini, Messages: []openai.ChatCompletionMessage{ { Role: openai.ChatMessageRoleSystem, Content: "You are helping create educational flashcards for language learning. Generate a brief, vivid scene description that incorporates the given English word in a memorable, contextual way. The scene should be visually interesting and help with memory retention. Keep it to 1-2 sentences, focusing on visual elements that can be illustrated. The subject (the English word) should be the clear focal point of the image, prominent and centered.", }, { Role: openai.ChatMessageRoleUser, Content: fmt.Sprintf("Create a scene description for the English word '%s' that would make a memorable flashcard image. Make sure '%s' is the main focus and most prominent element in the scene.", englishTranslation, englishTranslation), }, }, Temperature: 0.7, // Balanced temperature for creativity with consistency MaxTokens: 100, } resp, err := c.client.CreateChatCompletion(ctx, req) if err != nil { return "", fmt.Errorf("scene generation failed: %w", err) } if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" { return "", fmt.Errorf("no scene description received") } scene := strings.TrimSpace(resp.Choices[0].Message.Content) fmt.Printf("Generated scene: %s\n", scene) return scene, nil } // getCacheFilePath generates a cache file path for the given word func (c *OpenAIClient) getCacheFilePath(word string) string { // Create a hash of the word and settings h := md5.New() h.Write([]byte(word)) h.Write([]byte(c.model)) h.Write([]byte(c.size)) h.Write([]byte(c.quality)) h.Write([]byte(c.style)) hash := hex.EncodeToString(h.Sum(nil)) // Use first 2 chars as subdirectory for better file system performance subdir := hash[:2] filename := hash[2:] + ".png" return filepath.Join(c.cacheDir, subdir, filename) } // downloadAndCache downloads an image and saves it to the cache func (c *OpenAIClient) downloadAndCache(ctx context.Context, url, cacheFile string) error { // Ensure directory exists dir := filepath.Dir(cacheFile) if err := os.MkdirAll(dir, 0755); err != nil { return err } // Download the image resp, err := c.Download(ctx, url) if err != nil { return err } defer resp.Close() // Create the cache file out, err := os.Create(cacheFile) if err != nil { return err } defer out.Close() // Copy the data _, err = io.Copy(out, resp) return err } // generateImageID creates a unique ID for the image func (c *OpenAIClient) generateImageID(word string) string { h := md5.New() h.Write([]byte(word)) h.Write([]byte(c.model)) return "openai_" + hex.EncodeToString(h.Sum(nil))[:8] } // getSizeWidth returns the width based on the size setting func (c *OpenAIClient) getSizeWidth() int { switch c.size { case "256x256": return 256 case "512x512": return 512 case "1024x1024": return 1024 case "1024x1792", "1792x1024": // DALL-E 3 sizes if strings.HasPrefix(c.size, "1024") { return 1024 } return 1792 default: return 512 } } // getSizeHeight returns the height based on the size setting func (c *OpenAIClient) getSizeHeight() int { switch c.size { case "256x256": return 256 case "512x512": return 512 case "1024x1024": return 1024 case "1024x1792": return 1792 case "1792x1024": return 1024 default: return 512 } } // getArtisticStyles loads artistic styles from cache or generates them via OpenAI func (c *OpenAIClient) getArtisticStyles(ctx context.Context) ([]string, error) { // Define the styles cache file path stylesFile := filepath.Join(c.cacheDir, "artistic_styles.txt") // Check if file exists and is less than a week old fileInfo, err := os.Stat(stylesFile) needsRegeneration := false if err != nil { if os.IsNotExist(err) { needsRegeneration = true fmt.Println(" Artistic styles file not found, will generate new styles") } else { return nil, fmt.Errorf("error checking styles file: %w", err) } } else { // Check if file is older than a week weekAgo := time.Now().Add(-7 * 24 * time.Hour) if fileInfo.ModTime().Before(weekAgo) { needsRegeneration = true fmt.Println(" Artistic styles file is older than a week, will regenerate") } } // If we need to regenerate, do it if needsRegeneration { styles, err := c.generateArtisticStyles(ctx) if err != nil { return nil, fmt.Errorf("failed to generate artistic styles: %w", err) } // Save to file if err := c.saveStylesToFile(stylesFile, styles); err != nil { // Log error but continue with generated styles fmt.Printf(" Warning: Could not save styles to file: %v\n", err) } return styles, nil } // Load from file return c.loadStylesFromFile(stylesFile) } // generateArtisticStyles asks OpenAI to generate a list of artistic styles func (c *OpenAIClient) generateArtisticStyles(ctx context.Context) ([]string, error) { fmt.Println(" Generating artistic styles via OpenAI...") req := openai.ChatCompletionRequest{ Model: openai.GPT4oMini, Messages: []openai.ChatCompletionMessage{ { Role: openai.ChatMessageRoleSystem, Content: "You are an art expert helping to create diverse visual styles for educational flashcards. Generate exactly 42 different artistic styles that could be used for images. Include a mix of: photography styles (macro, portrait, landscape, etc.), traditional art techniques (watercolor, oil painting, pencil sketch, etc.), digital art styles (3D render, pixel art, vector illustration, etc.), artistic movements (impressionist, pop art, art deco, etc.), and other creative visual approaches. Each style should be concise (2-5 words) and distinct from the others. Format your response as a simple list with one style per line, no numbers or bullets.", }, { Role: openai.ChatMessageRoleUser, Content: "Please generate 42 diverse artistic styles for creating educational images. Include various photography types, painting techniques, illustration styles, and artistic movements.", }, }, Temperature: 0.8, MaxTokens: 500, } resp, err := c.client.CreateChatCompletion(ctx, req) if err != nil { return nil, fmt.Errorf("OpenAI API error: %w", err) } if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" { return nil, fmt.Errorf("no response from OpenAI") } // Parse the response into lines content := strings.TrimSpace(resp.Choices[0].Message.Content) lines := strings.Split(content, "\n") // Clean up and filter valid styles var styles []string for _, line := range lines { style := strings.TrimSpace(line) // Remove any numbering or bullets style = strings.TrimPrefix(style, "- ") style = strings.TrimPrefix(style, "* ") style = strings.TrimPrefix(style, "• ") // Remove numbers like "1. " or "42. " if idx := strings.Index(style, ". "); idx > 0 && idx <= 3 { style = style[idx+2:] } style = strings.TrimSpace(style) if style != "" && len(style) <= 50 { // Reasonable length check styles = append(styles, style) } } // Ensure we have at least some styles if len(styles) < 10 { return nil, fmt.Errorf("insufficient styles generated (got %d, need at least 10)", len(styles)) } fmt.Printf(" Generated %d artistic styles\n", len(styles)) return styles, nil } // saveStylesToFile saves the styles to a file func (c *OpenAIClient) saveStylesToFile(filename string, styles []string) error { // Ensure directory exists dir := filepath.Dir(filename) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } // Write styles to file content := strings.Join(styles, "\n") if err := os.WriteFile(filename, []byte(content), 0644); err != nil { return fmt.Errorf("failed to write file: %w", err) } fmt.Printf(" Saved %d styles to %s\n", len(styles), filename) return nil } // loadStylesFromFile loads styles from a file func (c *OpenAIClient) loadStylesFromFile(filename string) ([]string, error) { data, err := os.ReadFile(filename) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } // Parse lines lines := strings.Split(string(data), "\n") var styles []string for _, line := range lines { style := strings.TrimSpace(line) if style != "" { styles = append(styles, style) } } if len(styles) == 0 { return nil, fmt.Errorf("no styles found in file") } fmt.Printf(" Loaded %d styles from cache\n", len(styles)) return styles, nil }