diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-19 22:43:42 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-19 22:43:42 +0300 |
| commit | 06a11a3aabec600a3388b7e818434052474ea341 (patch) | |
| tree | 80821c7c19d98883dc7d9e4e03d793b3a6f9c06e /internal/gui/generator.go | |
| parent | 44cf5eee8fba096496f0704cec44fd436ecb5c2e (diff) | |
feat: add context cancellation for card operations
- Add per-card context tracking to cancel ongoing operations
- Cancel audio/image generation when card is deleted
- Keep delete button enabled during generation for cancellation
- Add cleanup hook to remove recreated directories after deletion
This allows users to cancel in-progress card generation by deleting
the card, preventing orphaned API calls and file creation.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal/gui/generator.go')
| -rw-r--r-- | internal/gui/generator.go | 109 |
1 files changed, 55 insertions, 54 deletions
diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 08785ed..f7bed2f 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -1,6 +1,7 @@ package gui import ( + "context" "fmt" "math/rand" "os" @@ -10,7 +11,7 @@ import ( "fyne.io/fyne/v2" "github.com/sashabaranov/go-openai" - + "codeberg.org/snonux/totalrecall/internal" "codeberg.org/snonux/totalrecall/internal/audio" "codeberg.org/snonux/totalrecall/internal/image" @@ -21,9 +22,9 @@ func (a *Application) translateWord(word string) (string, error) { if a.config.OpenAIKey == "" { return "", fmt.Errorf("OpenAI API key not configured") } - + client := openai.NewClient(a.config.OpenAIKey) - + req := openai.ChatCompletionRequest{ Model: openai.GPT4oMini, Messages: []openai.ChatCompletionMessage{ @@ -35,16 +36,16 @@ func (a *Application) translateWord(word string) (string, error) { MaxTokens: 50, Temperature: 0.3, } - + resp, err := client.CreateChatCompletion(a.ctx, req) if err != nil { return "", fmt.Errorf("OpenAI API error: %w", err) } - + if len(resp.Choices) == 0 { return "", fmt.Errorf("no translation returned") } - + translation := strings.TrimSpace(resp.Choices[0].Message.Content) return translation, nil } @@ -54,9 +55,9 @@ func (a *Application) translateEnglishToBulgarian(word string) (string, error) { if a.config.OpenAIKey == "" { return "", fmt.Errorf("OpenAI API key not configured") } - + client := openai.NewClient(a.config.OpenAIKey) - + req := openai.ChatCompletionRequest{ Model: openai.GPT4oMini, Messages: []openai.ChatCompletionMessage{ @@ -68,22 +69,22 @@ func (a *Application) translateEnglishToBulgarian(word string) (string, error) { MaxTokens: 50, Temperature: 0.3, } - + resp, err := client.CreateChatCompletion(a.ctx, req) if err != nil { return "", fmt.Errorf("OpenAI API error: %w", err) } - + if len(resp.Choices) == 0 { return "", fmt.Errorf("no translation returned") } - + translation := strings.TrimSpace(resp.Choices[0].Message.Content) return translation, nil } // generateAudio generates audio for a word -func (a *Application) generateAudio(word string) (string, error) { +func (a *Application) generateAudio(ctx context.Context, word string) (string, error) { // Check if this is a regeneration by looking for existing audio file wordDir := a.findCardDirectory(word) isRegeneration := false @@ -93,37 +94,38 @@ func (a *Application) generateAudio(word string) (string, error) { isRegeneration = true } } - + // For regeneration, use random voice and speed; otherwise use defaults var voice string var speed float64 - + if isRegeneration { // Get available voices allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} - + // Select a random voice rand.Seed(time.Now().UnixNano()) voice = allVoices[rand.Intn(len(allVoices))] - + // Generate random speed between 0.90 and 1.00 speed = 0.90 + rand.Float64()*0.10 } else { // Use defaults for first generation voice = "alloy" - speed = 0.98 + speed = 1.0 + // speed = 0.98 } - + // Update audio config with selected voice and speed a.audioConfig.OpenAIVoice = voice a.audioConfig.OpenAISpeed = speed - + // Create audio provider provider, err := audio.NewProvider(a.audioConfig) if err != nil { return "", err } - + // Find existing card directory or create new one again after provider creation wordDir = a.findCardDirectory(word) if wordDir == "" { @@ -133,69 +135,69 @@ func (a *Application) generateAudio(word string) (string, error) { if err := os.MkdirAll(wordDir, 0755); err != nil { return "", fmt.Errorf("failed to create word directory: %w", err) } - + // Save the original Bulgarian word in a metadata file metadataFile := filepath.Join(wordDir, "word.txt") if err := os.WriteFile(metadataFile, []byte(word), 0644); err != nil { return "", fmt.Errorf("failed to save word metadata: %w", err) } } - + // Generate filename in subdirectory outputFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) - + // Generate audio - err = provider.GenerateAudio(a.ctx, word, outputFile) + err = provider.GenerateAudio(ctx, word, outputFile) if err != nil { return "", err } - + // Save audio attribution if err := a.saveAudioAttribution(word, outputFile, voice, speed); err != nil { // Non-fatal error, just log it fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) } - + // Save voice metadata for GUI display metadataFile := filepath.Join(wordDir, "audio_metadata.txt") metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\n", voice, speed) if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil { fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) } - + return outputFile, nil } // generateImages downloads images for a word -func (a *Application) generateImages(word string) (string, error) { - return a.generateImagesWithPrompt(word, "", "") +func (a *Application) generateImages(ctx context.Context, word string) (string, error) { + return a.generateImagesWithPrompt(ctx, word, "", "") } // generateImagesWithPrompt downloads a single image for a word with optional custom prompt and translation -func (a *Application) generateImagesWithPrompt(word string, customPrompt string, translation string) (string, error) { +func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, customPrompt string, translation 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", + APIKey: a.config.OpenAIKey, + Model: "dall-e-2", // DALL-E 2 supports 512x512 + Size: "512x512", // Half of 1024x1024 + Quality: "standard", + Style: "natural", } - + searcher = image.NewOpenAIClient(openaiConfig) 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) } - + // Find existing card directory or create new one wordDir := a.findCardDirectory(word) if wordDir == "" { @@ -205,14 +207,14 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, if err := os.MkdirAll(wordDir, 0755); err != nil { return "", fmt.Errorf("failed to create word directory: %w", err) } - + // Save the original Bulgarian word in a metadata file metadataFile := filepath.Join(wordDir, "word.txt") if err := os.WriteFile(metadataFile, []byte(word), 0644); err != nil { return "", fmt.Errorf("failed to save word metadata: %w", err) } } - + // Create downloader downloadOpts := &image.DownloadOptions{ OutputDir: wordDir, @@ -221,9 +223,9 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, FileNamePattern: "image", MaxSizeBytes: 5 * 1024 * 1024, // 5MB } - + downloader := image.NewDownloader(searcher, downloadOpts) - + // Create search options with custom prompt and translation if provided searchOpts := image.DefaultSearchOptions(word) if customPrompt != "" { @@ -232,13 +234,13 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, if translation != "" { searchOpts.Translation = translation } - + // Download single image - _, path, err := downloader.DownloadBestMatchWithOptions(a.ctx, searchOpts) + _, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts) if err != nil { return "", err } - + // If using OpenAI, get the last used prompt if a.config.ImageProvider == "openai" { if openaiClient, ok := searcher.(*image.OpenAIClient); ok { @@ -247,12 +249,12 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, // Save the prompt to disk immediately for this word promptFile := filepath.Join(wordDir, "image_prompt.txt") os.WriteFile(promptFile, []byte(usedPrompt), 0644) - + // 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(usedPrompt) @@ -261,7 +263,7 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, } } } - + return path, nil } @@ -272,19 +274,18 @@ func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed attribution += fmt.Sprintf("Model: %s\n", a.audioConfig.OpenAIModel) attribution += fmt.Sprintf("Voice: %s\n", voice) attribution += fmt.Sprintf("Speed: %.2f\n", speed) - + if a.audioConfig.OpenAIInstruction != "" { attribution += fmt.Sprintf("\nVoice instructions:\n%s\n", a.audioConfig.OpenAIInstruction) } - + attribution += fmt.Sprintf("\nGenerated at: %s\n", time.Now().Format("2006-01-02 15:04:05")) - + // Save to file attrPath := strings.TrimSuffix(audioFile, filepath.Ext(audioFile)) + "_attribution.txt" if err := os.WriteFile(attrPath, []byte(attribution), 0644); err != nil { return fmt.Errorf("failed to write audio attribution file: %w", err) } - + return nil } - |
