summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-01 19:32:58 +0300
committerPaul Buetow <paul@buetow.org>2026-04-01 19:32:58 +0300
commit008d6308ecb767df7194aa944328c6ead6473ca5 (patch)
treeed73bb9601b02a896b1d3993dfc043afaada1854 /internal
parent27b753192edca7005e6ee20b7be2a0c127294bd8 (diff)
z9: wire Nano Banana into GUI
Diffstat (limited to 'internal')
-rw-r--r--internal/gui/app.go7
-rw-r--r--internal/gui/app_test.go3
-rw-r--r--internal/gui/generator.go114
-rw-r--r--internal/gui/generator_test.go129
-rw-r--r--internal/gui/navigation.go12
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 = &copyOpts
+ 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
}
}