From 3ed1c1ba41709c0de75512643c50b064f7dce650 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 1 Apr 2026 20:07:10 +0300 Subject: z8: finish Nano Banana CLI wiring --- README.md | 9 +-- assets/config.yaml.example | 6 +- internal/cli/command.go | 10 ++-- internal/cli/command_test.go | 16 +++--- internal/processor/processor.go | 18 +++++- internal/processor/processor_test.go | 108 +++++++++++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d08c7a8..8de458d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ It has mainly been vibe coded using Claude Code CLI. -⚠️ **Important:** Audio generation uses OpenAI services. The no-arg GUI image path uses Google Gemini Nano Banana by default, while explicit CLI image generation currently remains OpenAI-only. See [Quick Start](#quick-start) for setup instructions. +⚠️ **Important:** Audio generation uses OpenAI services. The no-arg GUI image path uses Google Gemini Nano Banana by default, and explicit CLI runs can also use Nano Banana through `--image-api nanobanana` or `image.provider: nanobanana`. See [Quick Start](#quick-start) for setup instructions. [TotalRecall screenshot](assets/totalrecall.png) [🔊 Computer / Компютър audio example](assets/audio.mp3) (Download raw file, and then play locally) @@ -28,7 +28,8 @@ It has mainly been vibe coded using Claude Code CLI. - Saves translations to separate text files - Image generation: - **Google Gemini Nano Banana**: Default image path for the no-arg GUI launch - - **OpenAI DALL-E**: Explicit CLI image generation path for now + - **OpenAI DALL-E**: Explicit CLI image generation path + - **Config-driven selection**: Set `image.provider` to `openai` or `nanobanana` - Scene generation creates memorable contexts for each word - Batch processing of multiple words - Anki-compatible export @@ -94,7 +95,7 @@ After installation, you may need to log out and log back in for the icon to appe ## Quick Start -**Note:** By default, totalrecall uses OpenAI for audio. The no-arg GUI image path uses Nano Banana, while explicit CLI image generation remains OpenAI-only until `z8` lands. Make sure to set your OpenAI API key and Google API key: +**Note:** By default, totalrecall uses OpenAI for audio. The no-arg GUI image path uses Nano Banana, and explicit CLI runs can use either OpenAI or Nano Banana. Make sure to set your OpenAI API key and Google API key: ```bash export OPENAI_API_KEY="sk-..." export GOOGLE_API_KEY="..." @@ -117,7 +118,7 @@ Key features: ### CLI Mode -1. Generate materials for a single word (explicit CLI image generation uses OpenAI only for now; Nano Banana CLI selection is pending `z8`): +1. Generate materials for a single word. Explicit CLI image generation supports Nano Banana when selected via `--image-api nanobanana` or `image.provider: nanobanana`: ```bash totalrecall ябълка ``` diff --git a/assets/config.yaml.example b/assets/config.yaml.example index 461dfa9..84d35ce 100644 --- a/assets/config.yaml.example +++ b/assets/config.yaml.example @@ -42,7 +42,7 @@ google: # Image configuration image: - # Provider: currently only openai is supported + # Provider: openai or nanobanana provider: openai # OpenAI DALL-E settings @@ -51,6 +51,10 @@ image: openai_quality: standard # Options: standard, hd (dall-e-3 only) openai_style: natural # Options: natural, vivid (dall-e-3 only) + # Nano Banana settings + nanobanana_model: gemini-3.1-flash-image-preview + nanobanana_text_model: gemini-2.5-flash + # Output configuration output: directory: ~/Downloads diff --git a/internal/cli/command.go b/internal/cli/command.go index 2efe2a0..924740e 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -20,9 +20,9 @@ func CreateRootCommand(flags *Flags) *cobra.Command { Long: `totalrecall generates Anki flashcard materials from Bulgarian words. It creates audio pronunciation files using OpenAI TTS and downloads -representative images. Launching with no arguments opens the interactive GUI, which uses Nano Banana for images by default. Use --image-api for explicit CLI runs; it currently supports OpenAI only. Nano Banana CLI selection is pending z8. +representative images. Launching with no arguments opens the interactive GUI, which uses Nano Banana for images by default. Explicit CLI runs can use OpenAI or Nano Banana via --image-api, and config files can set image.provider too. -Nano Banana model and text-model flags are exposed for upcoming runtime wiring. +Nano Banana model and text-model flags are available for Nano Banana image generation. Examples: totalrecall # Launch interactive GUI (default) @@ -57,7 +57,7 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { // Local flags cmd.Flags().StringVarP(&flags.OutputDir, "output", "o", defaultOutputDir, "Output directory") cmd.Flags().StringVarP(&flags.AudioFormat, "format", "f", flags.AudioFormat, "Audio format (wav or mp3)") - cmd.Flags().StringVar(&flags.ImageAPI, "image-api", flags.ImageAPI, "Image source for explicit CLI runs (OpenAI only for now; GUI no-arg path defaults to Nano Banana)") + cmd.Flags().StringVar(&flags.ImageAPI, "image-api", flags.ImageAPI, "Image source for explicit CLI runs (OpenAI or Nano Banana; config file image.provider also applies when unset)") cmd.Flags().StringVar(&flags.BatchFile, "batch", "", "Process words from file (one per line)") cmd.Flags().BoolVar(&flags.SkipAudio, "skip-audio", false, "Skip audio generation") cmd.Flags().BoolVar(&flags.SkipImages, "skip-images", false, "Skip image download") @@ -82,8 +82,8 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { cmd.Flags().StringVar(&flags.OpenAIImageStyle, "openai-image-style", flags.OpenAIImageStyle, "Image style: natural or vivid (dall-e-3 only)") // Nano Banana Image Generation flags - cmd.Flags().StringVar(&flags.NanoBananaModel, "nanobanana-model", flags.NanoBananaModel, "Nano Banana image model for upcoming runtime wiring") - cmd.Flags().StringVar(&flags.NanoBananaTextModel, "nanobanana-text-model", flags.NanoBananaTextModel, "Nano Banana text model for upcoming runtime wiring") + cmd.Flags().StringVar(&flags.NanoBananaModel, "nanobanana-model", flags.NanoBananaModel, "Nano Banana image model used when Nano Banana image generation is selected") + cmd.Flags().StringVar(&flags.NanoBananaTextModel, "nanobanana-text-model", flags.NanoBananaTextModel, "Nano Banana text model used when Nano Banana image generation is selected") // Bind flags to viper if err := bindFlagsToViper(cmd); err != nil { diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 2efe405..67ef6c4 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -26,8 +26,8 @@ func TestCreateRootCommand(t *testing.T) { if !strings.Contains(cmd.Long, "uses Nano Banana for images by default") { t.Errorf("Expected Long description to describe the Nano Banana GUI default") } - if !strings.Contains(cmd.Long, "Use --image-api for explicit CLI runs; it currently supports OpenAI only") { - t.Errorf("Expected Long description to explain the current explicit CLI image-provider matrix") + if !strings.Contains(cmd.Long, "Explicit CLI runs can use OpenAI or Nano Banana via --image-api") { + t.Errorf("Expected Long description to describe explicit CLI Nano Banana support") } // Test that flags are set up @@ -109,8 +109,8 @@ func TestSetupFlags(t *testing.T) { if imageAPIFlag.DefValue != "openai" { t.Errorf("Expected default image-api to be openai, got %s", imageAPIFlag.DefValue) } - if imageAPIFlag.Usage != "Image source for explicit CLI runs (OpenAI only for now; GUI no-arg path defaults to Nano Banana)" { - t.Errorf("Expected image-api help to describe the GUI Nano Banana default and OpenAI-only CLI image path, got %q", imageAPIFlag.Usage) + if imageAPIFlag.Usage != "Image source for explicit CLI runs (OpenAI or Nano Banana; config file image.provider also applies when unset)" { + t.Errorf("Expected image-api help to describe CLI Nano Banana support and config fallback, got %q", imageAPIFlag.Usage) } nanoBananaModelFlag := cmd.Flags().Lookup("nanobanana-model") @@ -120,8 +120,8 @@ func TestSetupFlags(t *testing.T) { if nanoBananaModelFlag.DefValue != "gemini-3.1-flash-image-preview" { t.Errorf("Expected default nanobanana-model to be gemini-3.1-flash-image-preview, got %s", nanoBananaModelFlag.DefValue) } - if !strings.Contains(nanoBananaModelFlag.Usage, "upcoming runtime wiring") { - t.Errorf("Expected nanobanana-model help to describe future wiring, got %q", nanoBananaModelFlag.Usage) + if !strings.Contains(nanoBananaModelFlag.Usage, "selected") { + t.Errorf("Expected nanobanana-model help to describe supported Nano Banana selection, got %q", nanoBananaModelFlag.Usage) } nanoBananaTextModelFlag := cmd.Flags().Lookup("nanobanana-text-model") @@ -131,8 +131,8 @@ func TestSetupFlags(t *testing.T) { if nanoBananaTextModelFlag.DefValue != "gemini-2.5-flash" { t.Errorf("Expected default nanobanana-text-model to be gemini-2.5-flash, got %s", nanoBananaTextModelFlag.DefValue) } - if !strings.Contains(nanoBananaTextModelFlag.Usage, "upcoming runtime wiring") { - t.Errorf("Expected nanobanana-text-model help to describe future wiring, got %q", nanoBananaTextModelFlag.Usage) + if !strings.Contains(nanoBananaTextModelFlag.Usage, "selected") { + t.Errorf("Expected nanobanana-text-model help to describe supported Nano Banana selection, got %q", nanoBananaTextModelFlag.Usage) } } diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 51f530e..aac9733 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -546,14 +546,28 @@ func (p *Processor) guiConfigForRunMode() *gui.Config { } func (p *Processor) newImageSearcher() (image.ImageSearcher, error) { - switch p.flags.ImageAPI { + provider := p.imageProviderForRunMode() + + switch provider { case "openai": return p.newOpenAIImageSearcher() case "nanobanana": return p.newNanoBananaImageSearcher() default: - return nil, fmt.Errorf("unknown image provider: %s", p.flags.ImageAPI) + return nil, fmt.Errorf("unknown image provider: %s", provider) + } +} + +func (p *Processor) imageProviderForRunMode() string { + if p.flags.ImageAPISpecified { + return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) } + + if provider := strings.ToLower(strings.TrimSpace(viper.GetString("image.provider"))); provider != "" { + return provider + } + + return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) } func (p *Processor) newOpenAIImageSearcher() (image.ImageSearcher, error) { diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 982393d..ef9e655 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -289,6 +289,114 @@ func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *test } } +func TestDownloadImagesWithTranslationUsesConfiguredNanoBananaWhenImageAPINotSpecified(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-openai-key") + t.Setenv("GOOGLE_API_KEY", "test-google-key") + + originalConfig := viper.New() + *originalConfig = *viper.GetViper() + defer func() { + *viper.GetViper() = *originalConfig + }() + viper.Reset() + viper.Set("image.provider", "nanobanana") + viper.Set("image.nanobanana_model", "config-image-model") + viper.Set("image.nanobanana_text_model", "config-text-model") + + originalConstructor := newNanoBananaImageClient + stubSearcher := &stubImageSearcher{} + capturedConfig := new(image.NanoBananaConfig) + newNanoBananaImageClient = func(config *image.NanoBananaConfig) image.ImageSearcher { + *capturedConfig = *config + return stubSearcher + } + t.Cleanup(func() { + newNanoBananaImageClient = originalConstructor + }) + + flags := cli.NewFlags() + flags.OutputDir = t.TempDir() + flags.ImageAPI = "openai" + flags.ImageAPISpecified = false + + p := NewProcessor(flags) + if err := p.downloadImagesWithTranslation("ябълка", "apple"); err != nil { + t.Fatalf("downloadImagesWithTranslation() unexpected error: %v", err) + } + + if capturedConfig.APIKey != "test-google-key" { + t.Fatalf("NanoBanana APIKey = %q, want %q", capturedConfig.APIKey, "test-google-key") + } + if capturedConfig.Model != "config-image-model" { + t.Fatalf("NanoBanana Model = %q, want %q", capturedConfig.Model, "config-image-model") + } + if capturedConfig.TextModel != "config-text-model" { + t.Fatalf("NanoBanana TextModel = %q, want %q", capturedConfig.TextModel, "config-text-model") + } + + wordDir := p.findCardDirectory("ябълка") + if wordDir == "" { + t.Fatal("expected word directory to be created") + } + + promptData, err := os.ReadFile(filepath.Join(wordDir, "image_prompt.txt")) + if err != nil { + t.Fatalf("expected prompt file: %v", err) + } + if got := strings.TrimSpace(string(promptData)); got != stubSearcher.GetLastPrompt() { + t.Fatalf("prompt file = %q, want %q", got, stubSearcher.GetLastPrompt()) + } +} + +func TestNewImageSearcherRejectsUnknownConfiguredProvider(t *testing.T) { + originalConfig := viper.New() + *originalConfig = *viper.GetViper() + defer func() { + *viper.GetViper() = *originalConfig + }() + viper.Reset() + viper.Set("image.provider", "not-a-real-provider") + + flags := cli.NewFlags() + flags.ImageAPI = "openai" + flags.ImageAPISpecified = false + p := NewProcessor(flags) + + _, err := p.newImageSearcher() + if err == nil { + t.Fatal("expected error for unknown configured provider") + } + if got := err.Error(); got != "unknown image provider: not-a-real-provider" { + t.Fatalf("newImageSearcher() error = %q, want %q", got, "unknown image provider: not-a-real-provider") + } +} + +func TestNewImageSearcherConfiguredNanoBananaRequiresGoogleAPIKey(t *testing.T) { + t.Setenv("GOOGLE_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "test-openai-key") + + originalConfig := viper.New() + *originalConfig = *viper.GetViper() + defer func() { + *viper.GetViper() = *originalConfig + }() + viper.Reset() + viper.Set("image.provider", "nanobanana") + + flags := cli.NewFlags() + flags.ImageAPI = "openai" + flags.ImageAPISpecified = false + p := NewProcessor(flags) + + _, err := p.newImageSearcher() + if err == nil { + t.Fatal("expected error when Google API key is missing for Nano Banana") + } + if got := err.Error(); got != "Google API key is required for image generation" { + t.Fatalf("newImageSearcher() error = %q, want %q", got, "Google API key is required for image generation") + } +} + func TestProcessSingleWord_InvalidWord(t *testing.T) { flags := cli.NewFlags() flags.OutputDir = t.TempDir() -- cgit v1.2.3