diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-01 19:32:58 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-01 19:32:58 +0300 |
| commit | 008d6308ecb767df7194aa944328c6ead6473ca5 (patch) | |
| tree | ed73bb9601b02a896b1d3993dfc043afaada1854 | |
| parent | 27b753192edca7005e6ee20b7be2a0c127294bd8 (diff) | |
z9: wire Nano Banana into GUI
| -rw-r--r-- | internal/gui/app.go | 7 | ||||
| -rw-r--r-- | internal/gui/app_test.go | 3 | ||||
| -rw-r--r-- | internal/gui/generator.go | 114 | ||||
| -rw-r--r-- | internal/gui/generator_test.go | 129 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 12 |
5 files changed, 215 insertions, 50 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index 6a7919f..8c9d6f4 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -117,6 +117,11 @@ type Config struct { AutoPlay bool // Whether to automatically play audio when generated or navigated to } +const ( + imageProviderOpenAI = "openai" + imageProviderNanoBanana = "nanobanana" +) + // DefaultConfig returns default GUI configuration func DefaultConfig() *Config { homeDir, _ := os.UserHomeDir() @@ -126,7 +131,7 @@ func DefaultConfig() *Config { return &Config{ OutputDir: outputDir, AudioFormat: "mp3", - ImageProvider: "openai", + ImageProvider: imageProviderNanoBanana, TranslationProvider: translation.ProviderOpenAI, PhoneticProvider: phonetic.ProviderOpenAI, AutoPlay: true, // Auto-play enabled by default diff --git a/internal/gui/app_test.go b/internal/gui/app_test.go index 446b8a7..c5a30c8 100644 --- a/internal/gui/app_test.go +++ b/internal/gui/app_test.go @@ -12,6 +12,9 @@ func TestDefaultConfigUsesOpenAITranslationProvider(t *testing.T) { if config.TranslationProvider != translation.ProviderOpenAI { t.Fatalf("DefaultConfig() translation provider = %q, want %q", config.TranslationProvider, translation.ProviderOpenAI) } + if config.ImageProvider != imageProviderNanoBanana { + t.Fatalf("DefaultConfig() image provider = %q, want %q", config.ImageProvider, imageProviderNanoBanana) + } } func TestTranslationConfigForApp(t *testing.T) { diff --git a/internal/gui/generator.go b/internal/gui/generator.go index ee96fb1..7964e72 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -14,6 +14,19 @@ import ( "codeberg.org/snonux/totalrecall/internal/image" ) +type promptAwareImageClient interface { + image.ImageSearcher + SetPromptCallback(func(prompt string)) +} + +var newOpenAIImageClient = func(config *image.OpenAIConfig) promptAwareImageClient { + return image.NewOpenAIClient(config) +} + +var newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient { + return image.NewNanoBananaClient(config) +} + func randomVoiceAndSpeed(voices []string) (string, float64) { rng := rand.New(rand.NewSource(time.Now().UnixNano())) voice := voices[rng.Intn(len(voices))] @@ -232,27 +245,9 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi // generateImagesWithPrompt downloads a single image for a word with optional custom prompt and translation func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, customPrompt string, translation string, cardDir string) (string, error) { - // Create image searcher based on provider - var searcher image.ImageSearcher - var err error - - switch a.config.ImageProvider { - case "openai": - openaiConfig := &image.OpenAIConfig{ - APIKey: a.config.OpenAIKey, - Model: "dall-e-2", // DALL-E 2 supports 512x512 - Size: "512x512", // Half of 1024x1024 - Quality: "standard", - Style: "natural", - } - - openaiClient := image.NewOpenAIClient(openaiConfig) - searcher = openaiClient - if openaiConfig.APIKey == "" { - return "", fmt.Errorf("OpenAI API key is required for image generation") - } - default: - return "", fmt.Errorf("unknown image provider: %s", a.config.ImageProvider) + searcher, err := a.newImageSearcher() + if err != nil { + return "", err } // Use the provided card directory @@ -271,29 +266,8 @@ func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, downloader := image.NewDownloader(searcher, downloadOpts) - // Set up callback for OpenAI to update prompt immediately when it's generated - if a.config.ImageProvider == "openai" { - if openaiClient, ok := searcher.(*image.OpenAIClient); ok { - openaiClient.SetPromptCallback(func(prompt string) { - // Save the prompt to disk immediately for this word - promptFile := filepath.Join(cardDir, "image_prompt.txt") - if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil { - fmt.Printf("Warning: Failed to save prompt for '%s': %v\n", word, err) - } - - // Only update UI if this word is still the current word - a.mu.Lock() - isCurrentWord := a.currentWord == word - a.mu.Unlock() - - if isCurrentWord { - fyne.Do(func() { - a.imagePromptEntry.SetText(prompt) - }) - } - }) - } - } + // Set up a prompt callback so the GUI and on-disk metadata update as soon as the prompt exists. + searcher.SetPromptCallback(a.imagePromptCallback(cardDir, word)) // Create search options with custom prompt and translation if provided searchOpts := image.DefaultSearchOptions(word) @@ -315,6 +289,58 @@ func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, return path, nil } +func (a *Application) newImageSearcher() (promptAwareImageClient, error) { + switch a.config.ImageProvider { + case imageProviderOpenAI: + if a.config.OpenAIKey == "" { + return nil, fmt.Errorf("OpenAI API key is required for image generation") + } + + openaiConfig := &image.OpenAIConfig{ + APIKey: a.config.OpenAIKey, + Model: "dall-e-2", // DALL-E 2 supports 512x512 + Size: "512x512", // Half of 1024x1024 + Quality: "standard", + Style: "natural", + } + + return newOpenAIImageClient(openaiConfig), nil + case imageProviderNanoBanana: + if a.config.GoogleAPIKey == "" { + return nil, fmt.Errorf("Google API key is required for image generation") + } + + nanoBananaConfig := &image.NanoBananaConfig{ + APIKey: a.config.GoogleAPIKey, + } + + return newNanoBananaImageClient(nanoBananaConfig), nil + default: + return nil, fmt.Errorf("unknown image provider: %s", a.config.ImageProvider) + } +} + +func (a *Application) imagePromptCallback(cardDir, word string) func(prompt string) { + return func(prompt string) { + // Save the prompt to disk immediately for this word. + promptFile := filepath.Join(cardDir, "image_prompt.txt") + if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil { + fmt.Printf("Warning: Failed to save prompt for '%s': %v\n", word, err) + } + + // Only update UI if this word is still the current word. + a.mu.Lock() + isCurrentWord := a.currentWord == word + a.mu.Unlock() + + if isCurrentWord && a.imagePromptEntry != nil { + fyne.Do(func() { + a.imagePromptEntry.SetText(prompt) + }) + } + } +} + // saveAudioAttribution saves attribution info for generated audio func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed float64) error { attribution := audio.BuildOpenAIAttribution(audio.AttributionParams{ diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go new file mode 100644 index 0000000..b17e5ac --- /dev/null +++ b/internal/gui/generator_test.go @@ -0,0 +1,129 @@ +package gui + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/totalrecall/internal/image" +) + +type fakePromptAwareImageClient struct { + searchOpts *image.SearchOptions + promptCallback func(string) +} + +func (f *fakePromptAwareImageClient) Search(_ context.Context, opts *image.SearchOptions) ([]image.SearchResult, error) { + copyOpts := *opts + f.searchOpts = ©Opts + if f.promptCallback != nil { + f.promptCallback("nanobanana prompt") + } + + return []image.SearchResult{ + { + ID: "fake-id", + URL: "https://example.com/image.png", + ThumbnailURL: "https://example.com/image.png", + Width: 1, + Height: 1, + Description: "fake result", + Attribution: "fake attribution", + Source: imageProviderNanoBanana, + }, + }, nil +} + +func (f *fakePromptAwareImageClient) Download(_ context.Context, _ string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("fake image bytes")), nil +} + +func (f *fakePromptAwareImageClient) GetAttribution(*image.SearchResult) string { + return "fake attribution" +} + +func (f *fakePromptAwareImageClient) Name() string { + return imageProviderNanoBanana +} + +func (f *fakePromptAwareImageClient) SetPromptCallback(callback func(prompt string)) { + f.promptCallback = callback +} + +func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) { + originalNanoBananaClient := newNanoBananaImageClient + originalOpenAIClient := newOpenAIImageClient + t.Cleanup(func() { + newNanoBananaImageClient = originalNanoBananaClient + newOpenAIImageClient = originalOpenAIClient + }) + + fakeClient := &fakePromptAwareImageClient{} + var capturedConfig *image.NanoBananaConfig + + newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareImageClient { + capturedConfig = &image.NanoBananaConfig{ + APIKey: config.APIKey, + Model: config.Model, + TextModel: config.TextModel, + } + return fakeClient + } + newOpenAIImageClient = func(*image.OpenAIConfig) promptAwareImageClient { + t.Fatal("unexpected OpenAI image client construction") + return nil + } + + tempDir := t.TempDir() + app := &Application{ + config: &Config{ + ImageProvider: imageProviderNanoBanana, + GoogleAPIKey: "google-key", + OutputDir: tempDir, + }, + currentWord: "друго", + } + + outputPath, err := app.generateImagesWithPrompt(context.Background(), "ябълка", "custom prompt", "apple", tempDir) + if err != nil { + t.Fatalf("generateImagesWithPrompt() unexpected error: %v", err) + } + + if capturedConfig == nil { + t.Fatal("expected Nano Banana client constructor to be called") + } + if capturedConfig.APIKey != "google-key" { + t.Fatalf("Nano Banana API key = %q, want %q", capturedConfig.APIKey, "google-key") + } + if fakeClient.searchOpts == nil { + t.Fatal("expected search options to be captured") + } + if fakeClient.searchOpts.Query != "ябълка" { + t.Fatalf("search query = %q, want %q", fakeClient.searchOpts.Query, "ябълка") + } + if fakeClient.searchOpts.CustomPrompt != "custom prompt" { + t.Fatalf("custom prompt = %q, want %q", fakeClient.searchOpts.CustomPrompt, "custom prompt") + } + if fakeClient.searchOpts.Translation != "apple" { + t.Fatalf("translation = %q, want %q", fakeClient.searchOpts.Translation, "apple") + } + + promptPath := filepath.Join(tempDir, "image_prompt.txt") + promptData, err := os.ReadFile(promptPath) + if err != nil { + t.Fatalf("expected prompt file %q: %v", promptPath, err) + } + if got := strings.TrimSpace(string(promptData)); got != "nanobanana prompt" { + t.Fatalf("prompt file = %q, want %q", got, "nanobanana prompt") + } + + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected downloaded image at %q: %v", outputPath, err) + } + if !strings.HasSuffix(outputPath, ".png") { + t.Fatalf("outputPath = %q, want a PNG output file", outputPath) + } +} diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 8787080..59b5492 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -510,8 +510,8 @@ func (a *Application) loadExistingFiles(word string) { a.imageDisplay.SetImages([]string{a.currentImage}) }) - // Try to load the prompt from attribution file if using OpenAI - if a.config.ImageProvider == "openai" { + // Try to load the prompt from attribution file for AI image providers. + if a.config.ImageProvider == imageProviderOpenAI || a.config.ImageProvider == imageProviderNanoBanana { // Look for attribution file baseImagePath := a.currentImage attrPath := strings.TrimSuffix(baseImagePath, filepath.Ext(baseImagePath)) + "_attribution.txt" @@ -523,9 +523,11 @@ func (a *Application) loadExistingFiles(word string) { if strings.HasPrefix(line, "Prompt used:") && i+1 < len(lines) { // The prompt is on the next line prompt := strings.TrimSpace(lines[i+1]) - fyne.Do(func() { - a.imagePromptEntry.SetText(prompt) - }) + if a.imagePromptEntry != nil { + fyne.Do(func() { + a.imagePromptEntry.SetText(prompt) + }) + } break } } |
