summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-01 20:07:10 +0300
committerPaul Buetow <paul@buetow.org>2026-04-01 20:07:10 +0300
commit3ed1c1ba41709c0de75512643c50b064f7dce650 (patch)
tree6706c2217de844d5d2aa72d4d604af72468f6d40
parentd59813eb7ec44e8f57f75ece502a2360ab939816 (diff)
z8: finish Nano Banana CLI wiring
-rw-r--r--README.md9
-rw-r--r--assets/config.yaml.example6
-rw-r--r--internal/cli/command.go10
-rw-r--r--internal/cli/command_test.go16
-rw-r--r--internal/processor/processor.go18
-rw-r--r--internal/processor/processor_test.go108
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.
[<img src="assets/totalrecall.png" alt="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()