summaryrefslogtreecommitdiff
path: root/internal/gui/generator.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-19 22:43:42 +0300
committerPaul Buetow <paul@buetow.org>2025-07-19 22:43:42 +0300
commit06a11a3aabec600a3388b7e818434052474ea341 (patch)
tree80821c7c19d98883dc7d9e4e03d793b3a6f9c06e /internal/gui/generator.go
parent44cf5eee8fba096496f0704cec44fd436ecb5c2e (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.go109
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
}
-