replace old configure command with TUI wizard
This commit is contained in:
+150
-529
@@ -1,18 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/bus"
|
"github.com/leonardotrapani/hyprvoice/internal/bus"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/daemon"
|
"github.com/leonardotrapani/hyprvoice/internal/daemon"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
"github.com/leonardotrapani/hyprvoice/internal/tui"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,443 +129,56 @@ func configureCmd() *cobra.Command {
|
|||||||
Short: "Interactive configuration setup",
|
Short: "Interactive configuration setup",
|
||||||
Long: `Interactive configuration wizard for hyprvoice.
|
Long: `Interactive configuration wizard for hyprvoice.
|
||||||
This will guide you through setting up:
|
This will guide you through setting up:
|
||||||
- Transcription provider (OpenAI, Groq, or Mistral)
|
- Provider API keys (OpenAI, Groq, Mistral, ElevenLabs)
|
||||||
- API keys and model selection
|
- Transcription settings
|
||||||
- Audio and text injection preferences
|
- LLM post-processing
|
||||||
- Notification settings`,
|
- Text injection and notification preferences`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runInteractiveConfig()
|
return runConfigure()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInteractiveConfig() error {
|
func runConfigure() error {
|
||||||
fmt.Println("🎤 Hyprvoice Configuration Wizard")
|
|
||||||
fmt.Println("==================================")
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Load existing config or create default
|
// Load existing config or create default
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
// Run TUI wizard
|
||||||
|
result, err := tui.Run(cfg)
|
||||||
// Configure transcription
|
if err != nil {
|
||||||
fmt.Println("📝 Transcription Configuration")
|
return fmt.Errorf("configuration wizard error: %w", err)
|
||||||
fmt.Println("------------------------------")
|
|
||||||
|
|
||||||
// Provider selection
|
|
||||||
for {
|
|
||||||
fmt.Println("Select transcription provider:")
|
|
||||||
fmt.Println(" 1. openai - OpenAI Whisper API (cloud-based)")
|
|
||||||
fmt.Println(" 2. groq-transcription - Groq Whisper API (fast transcription)")
|
|
||||||
fmt.Println(" 3. groq-translation - Groq Whisper API (translate to English)")
|
|
||||||
fmt.Println(" 4. mistral-transcription - Mistral Voxtral API (excellent for European languages)")
|
|
||||||
fmt.Println(" 5. elevenlabs - ElevenLabs Scribe API (99 languages, excellent accuracy)")
|
|
||||||
fmt.Printf("Provider [1-5] (current: %s): ", cfg.Transcription.Provider)
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
if input == "" {
|
|
||||||
break // keep current
|
|
||||||
}
|
|
||||||
switch input {
|
|
||||||
case "1":
|
|
||||||
cfg.Transcription.Provider = "openai"
|
|
||||||
case "2":
|
|
||||||
cfg.Transcription.Provider = "groq-transcription"
|
|
||||||
case "3":
|
|
||||||
cfg.Transcription.Provider = "groq-translation"
|
|
||||||
case "4":
|
|
||||||
cfg.Transcription.Provider = "mistral-transcription"
|
|
||||||
case "5":
|
|
||||||
cfg.Transcription.Provider = "elevenlabs"
|
|
||||||
case "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs":
|
|
||||||
cfg.Transcription.Provider = input
|
|
||||||
default:
|
|
||||||
fmt.Println("❌ Error: invalid provider. Please enter 1-5 or provider name.")
|
|
||||||
fmt.Println()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model selection based on provider
|
if result.Cancelled {
|
||||||
switch cfg.Transcription.Provider {
|
fmt.Println("Configuration cancelled.")
|
||||||
case "openai":
|
return nil
|
||||||
fmt.Println("\nOpenAI Model:")
|
|
||||||
fmt.Printf("Model (current: %s): ", cfg.Transcription.Model)
|
|
||||||
if scanner.Scan() {
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
if input != "" {
|
|
||||||
cfg.Transcription.Model = input
|
|
||||||
} else if cfg.Transcription.Model == "" {
|
|
||||||
cfg.Transcription.Model = "whisper-1"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
case "groq-transcription":
|
|
||||||
for {
|
|
||||||
fmt.Println("\nGroq Transcription Model:")
|
|
||||||
fmt.Println(" 1. whisper-large-v3 - Standard model")
|
|
||||||
fmt.Println(" 2. whisper-large-v3-turbo - Faster model")
|
|
||||||
fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model)
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
switch input {
|
|
||||||
case "1":
|
|
||||||
cfg.Transcription.Model = "whisper-large-v3"
|
|
||||||
case "2":
|
|
||||||
cfg.Transcription.Model = "whisper-large-v3-turbo"
|
|
||||||
case "whisper-large-v3", "whisper-large-v3-turbo":
|
|
||||||
cfg.Transcription.Model = input
|
|
||||||
case "":
|
|
||||||
if cfg.Transcription.Model == "" {
|
|
||||||
cfg.Transcription.Model = "whisper-large-v3-turbo"
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "groq-translation":
|
|
||||||
for {
|
|
||||||
fmt.Println("\nGroq Translation Model:")
|
|
||||||
fmt.Println(" Note: Translation only supports whisper-large-v3 (turbo not available)")
|
|
||||||
fmt.Printf("Model (current: %s, press Enter for whisper-large-v3): ", cfg.Transcription.Model)
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
if input == "" || input == "whisper-large-v3" || input == "1" {
|
|
||||||
cfg.Transcription.Model = "whisper-large-v3"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
fmt.Println("❌ Error: only whisper-large-v3 is supported for translation.")
|
|
||||||
}
|
|
||||||
case "mistral-transcription":
|
|
||||||
for {
|
|
||||||
fmt.Println("\nMistral Voxtral Model:")
|
|
||||||
fmt.Println(" 1. voxtral-mini-latest - Recommended (latest version)")
|
|
||||||
fmt.Println(" 2. voxtral-mini-2507 - Pinned version")
|
|
||||||
fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model)
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
switch input {
|
|
||||||
case "1":
|
|
||||||
cfg.Transcription.Model = "voxtral-mini-latest"
|
|
||||||
case "2":
|
|
||||||
cfg.Transcription.Model = "voxtral-mini-2507"
|
|
||||||
case "voxtral-mini-latest", "voxtral-mini-2507":
|
|
||||||
cfg.Transcription.Model = input
|
|
||||||
case "":
|
|
||||||
if cfg.Transcription.Model == "" || !strings.HasPrefix(cfg.Transcription.Model, "voxtral") {
|
|
||||||
cfg.Transcription.Model = "voxtral-mini-latest"
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "elevenlabs":
|
|
||||||
for {
|
|
||||||
fmt.Println("\nElevenLabs Scribe Model:")
|
|
||||||
fmt.Println(" Language Support:")
|
|
||||||
fmt.Println(" scribe_v1: 99 languages (96.7% accuracy for English, ≤5% WER for Portuguese)")
|
|
||||||
fmt.Println(" scribe_v2: 90 languages (real-time optimized, lower latency)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println(" Available Models:")
|
|
||||||
fmt.Println(" 1. scribe_v1 - Best accuracy, full timestamps (recommended)")
|
|
||||||
fmt.Println(" 2. scribe_v2 - Real-time streaming, lower latency")
|
|
||||||
fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model)
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
switch input {
|
|
||||||
case "1":
|
|
||||||
cfg.Transcription.Model = "scribe_v1"
|
|
||||||
case "2":
|
|
||||||
cfg.Transcription.Model = "scribe_v2"
|
|
||||||
case "scribe_v1", "scribe_v2":
|
|
||||||
cfg.Transcription.Model = input
|
|
||||||
case "":
|
|
||||||
if cfg.Transcription.Model == "" {
|
|
||||||
cfg.Transcription.Model = "scribe_v1"
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Key (provider-aware)
|
|
||||||
var envVarName string
|
|
||||||
switch cfg.Transcription.Provider {
|
|
||||||
case "openai":
|
|
||||||
envVarName = "OPENAI_API_KEY"
|
|
||||||
case "mistral-transcription":
|
|
||||||
envVarName = "MISTRAL_API_KEY"
|
|
||||||
case "elevenlabs":
|
|
||||||
envVarName = "ELEVENLABS_API_KEY"
|
|
||||||
default:
|
|
||||||
envVarName = "GROQ_API_KEY"
|
|
||||||
}
|
|
||||||
fmt.Printf("\nAPI Key (current: %s, leave empty to use %s env var): ", maskAPIKey(cfg.Transcription.APIKey), envVarName)
|
|
||||||
if scanner.Scan() {
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
if input != "" {
|
|
||||||
cfg.Transcription.APIKey = input
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language
|
|
||||||
if cfg.Transcription.Provider == "groq-translation" {
|
|
||||||
fmt.Printf("\nSource language hint (empty for auto-detect, current: %s): ", cfg.Transcription.Language)
|
|
||||||
fmt.Println("\n Note: Translation always outputs English. Language hints at source audio language.")
|
|
||||||
} else if cfg.Transcription.Provider == "elevenlabs" {
|
|
||||||
fmt.Println("\nLanguage Performance:")
|
|
||||||
fmt.Println(" Excellent (≤5% WER): English, Portuguese, +25 languages")
|
|
||||||
fmt.Println(" High (5-10% WER): French, German, Spanish, Italian, etc.")
|
|
||||||
fmt.Println(" Good (10-20% WER): Most supported languages")
|
|
||||||
fmt.Println(" Leave empty for auto-detection (recommended)")
|
|
||||||
fmt.Printf("Language (current: %s): ", cfg.Transcription.Language)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("\nLanguage (empty for auto-detect, current: %s): ", cfg.Transcription.Language)
|
|
||||||
}
|
|
||||||
if scanner.Scan() {
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
cfg.Transcription.Language = input
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Configure injection
|
|
||||||
for {
|
|
||||||
fmt.Println("⌨️ Text Injection Configuration")
|
|
||||||
fmt.Println("--------------------------------")
|
|
||||||
fmt.Println("Backends are tried in order until one succeeds (fallback chain):")
|
|
||||||
fmt.Println(" - ydotool: Best for Chromium/Electron apps (requires ydotoold daemon for ydotool v1.0.0+)")
|
|
||||||
fmt.Println(" - wtype: Native Wayland typing (may fail on some Chromium apps)")
|
|
||||||
fmt.Println(" - clipboard: Copies to clipboard only (most reliable, needs manual paste)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Recommended: ydotool,wtype,clipboard (full fallback chain)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("Backends (comma-separated) (current: %s): ", strings.Join(cfg.Injection.Backends, ","))
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
if input == "" {
|
|
||||||
break // keep current
|
|
||||||
}
|
|
||||||
backends := strings.Split(input, ",")
|
|
||||||
validBackends := make([]string, 0)
|
|
||||||
invalidBackends := make([]string, 0)
|
|
||||||
for _, b := range backends {
|
|
||||||
b = strings.TrimSpace(b)
|
|
||||||
if b == "ydotool" || b == "wtype" || b == "clipboard" {
|
|
||||||
validBackends = append(validBackends, b)
|
|
||||||
} else if b != "" {
|
|
||||||
invalidBackends = append(invalidBackends, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(invalidBackends) > 0 {
|
|
||||||
fmt.Printf("❌ Error: invalid backend(s): %s. Valid: ydotool, wtype, clipboard.\n", strings.Join(invalidBackends, ", "))
|
|
||||||
fmt.Println()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(validBackends) == 0 {
|
|
||||||
fmt.Println("❌ Error: at least one backend required.")
|
|
||||||
fmt.Println()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cfg.Injection.Backends = validBackends
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ydotool is selected and warn about daemon requirement
|
|
||||||
for _, b := range cfg.Injection.Backends {
|
|
||||||
if b == "ydotool" {
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("⚠️ ydotool requires the ydotoold daemon to be running! make sure it works")
|
|
||||||
fmt.Println()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Configure notifications
|
|
||||||
for {
|
|
||||||
fmt.Println("🔔 Notification Configuration")
|
|
||||||
fmt.Println("-----------------------------")
|
|
||||||
fmt.Printf("Enable notifications [y/n] (current: %v): ", cfg.Notifications.Enabled)
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
|
||||||
switch input {
|
|
||||||
case "y", "yes":
|
|
||||||
cfg.Notifications.Enabled = true
|
|
||||||
case "n", "no":
|
|
||||||
cfg.Notifications.Enabled = false
|
|
||||||
case "":
|
|
||||||
// keep current
|
|
||||||
default:
|
|
||||||
fmt.Println("❌ Error: please enter y or n.")
|
|
||||||
fmt.Println()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask if user wants to customize notification messages
|
|
||||||
fmt.Print("Customize notification messages? [y/n] (default: n): ")
|
|
||||||
if scanner.Scan() {
|
|
||||||
input := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
|
||||||
if input == "y" || input == "yes" {
|
|
||||||
fmt.Println()
|
|
||||||
// Get resolved values (user config merged with defaults)
|
|
||||||
msgs := cfg.Notifications.Messages.Resolve()
|
|
||||||
|
|
||||||
// Recording Started
|
|
||||||
fmt.Println(" Recording Started notification:")
|
|
||||||
fmt.Printf(" Title (current: %s): ", msgs[notify.MsgRecordingStarted].Title)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if t := strings.TrimSpace(scanner.Text()); t != "" {
|
|
||||||
cfg.Notifications.Messages.RecordingStarted.Title = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgRecordingStarted].Body)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if b := strings.TrimSpace(scanner.Text()); b != "" {
|
|
||||||
cfg.Notifications.Messages.RecordingStarted.Body = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Transcribing
|
|
||||||
fmt.Println(" Transcribing notification:")
|
|
||||||
fmt.Printf(" Title (current: %s): ", msgs[notify.MsgTranscribing].Title)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if t := strings.TrimSpace(scanner.Text()); t != "" {
|
|
||||||
cfg.Notifications.Messages.Transcribing.Title = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgTranscribing].Body)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if b := strings.TrimSpace(scanner.Text()); b != "" {
|
|
||||||
cfg.Notifications.Messages.Transcribing.Body = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Config Reloaded
|
|
||||||
fmt.Println(" Config Reloaded notification:")
|
|
||||||
fmt.Printf(" Title (current: %s): ", msgs[notify.MsgConfigReloaded].Title)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if t := strings.TrimSpace(scanner.Text()); t != "" {
|
|
||||||
cfg.Notifications.Messages.ConfigReloaded.Title = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgConfigReloaded].Body)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if b := strings.TrimSpace(scanner.Text()); b != "" {
|
|
||||||
cfg.Notifications.Messages.ConfigReloaded.Body = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Operation Cancelled
|
|
||||||
fmt.Println(" Operation Cancelled notification:")
|
|
||||||
fmt.Printf(" Title (current: %s): ", msgs[notify.MsgOperationCancelled].Title)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if t := strings.TrimSpace(scanner.Text()); t != "" {
|
|
||||||
cfg.Notifications.Messages.OperationCancelled.Title = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgOperationCancelled].Body)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if b := strings.TrimSpace(scanner.Text()); b != "" {
|
|
||||||
cfg.Notifications.Messages.OperationCancelled.Body = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Recording Aborted (body only)
|
|
||||||
fmt.Println(" Recording Aborted notification:")
|
|
||||||
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgRecordingAborted].Body)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if b := strings.TrimSpace(scanner.Text()); b != "" {
|
|
||||||
cfg.Notifications.Messages.RecordingAborted.Body = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Injection Aborted (body only)
|
|
||||||
fmt.Println(" Injection Aborted notification:")
|
|
||||||
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgInjectionAborted].Body)
|
|
||||||
if scanner.Scan() {
|
|
||||||
if b := strings.TrimSpace(scanner.Text()); b != "" {
|
|
||||||
cfg.Notifications.Messages.InjectionAborted.Body = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Configure recording timeout
|
|
||||||
for {
|
|
||||||
fmt.Println("⏱️ Recording Configuration")
|
|
||||||
fmt.Println("---------------------------")
|
|
||||||
fmt.Printf("Recording timeout in minutes (current: %.0f): ", cfg.Recording.Timeout.Minutes())
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
if input == "" {
|
|
||||||
break // keep current
|
|
||||||
}
|
|
||||||
minutes, err := strconv.Atoi(input)
|
|
||||||
if err != nil || minutes <= 0 {
|
|
||||||
fmt.Println("❌ Error: please enter a positive number.")
|
|
||||||
fmt.Println()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cfg.Recording.Timeout = time.Duration(minutes) * time.Minute
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := result.Config.Validate(); err != nil {
|
||||||
fmt.Printf("❌ Configuration validation failed: %v\n", err)
|
fmt.Printf("Configuration validation failed: %v\n", err)
|
||||||
fmt.Println("Please check your inputs and try again.")
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save configuration
|
// Save configuration
|
||||||
fmt.Println("💾 Saving configuration...")
|
if err := saveConfig(result.Config); err != nil {
|
||||||
if err := saveConfig(cfg); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("✅ Configuration saved successfully!")
|
fmt.Println()
|
||||||
|
fmt.Println("Configuration saved successfully!")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
|
// Show next steps
|
||||||
|
showNextSteps(result.Config)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func showNextSteps(cfg *config.Config) {
|
||||||
// Check if service is running
|
// Check if service is running
|
||||||
serviceRunning := false
|
serviceRunning := false
|
||||||
if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil {
|
if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil {
|
||||||
@@ -584,8 +194,7 @@ func runInteractiveConfig() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show next steps
|
fmt.Println("Next Steps:")
|
||||||
fmt.Println("🚀 Next Steps:")
|
|
||||||
step := 1
|
step := 1
|
||||||
if hasYdotool {
|
if hasYdotool {
|
||||||
fmt.Printf("%d. Ensure ydotoold is running\n", step)
|
fmt.Printf("%d. Ensure ydotoold is running\n", step)
|
||||||
@@ -597,31 +206,11 @@ func runInteractiveConfig() error {
|
|||||||
fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step)
|
fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step)
|
||||||
}
|
}
|
||||||
step++
|
step++
|
||||||
fmt.Printf("%d. Test voice input: hyprvoice toggle (or use keybind you configured in hyprland config)\n", step)
|
fmt.Printf("%d. Test voice input: hyprvoice toggle\n", step)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
configPath, _ := config.GetConfigPath()
|
configPath, _ := config.GetConfigPath()
|
||||||
fmt.Printf("📁 Config file location: %s\n", configPath)
|
fmt.Printf("Config file location: %s\n", configPath)
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatBackends(backends []string) string {
|
|
||||||
quoted := make([]string, len(backends))
|
|
||||||
for i, b := range backends {
|
|
||||||
quoted[i] = fmt.Sprintf(`"%s"`, b)
|
|
||||||
}
|
|
||||||
return strings.Join(quoted, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func maskAPIKey(key string) string {
|
|
||||||
if key == "" {
|
|
||||||
return "<not set>"
|
|
||||||
}
|
|
||||||
if len(key) <= 8 {
|
|
||||||
return "****"
|
|
||||||
}
|
|
||||||
return key[:4] + "****" + key[len(key)-4:]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveConfig(cfg *config.Config) error {
|
func saveConfig(cfg *config.Config) error {
|
||||||
@@ -636,115 +225,147 @@ func saveConfig(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
configContent := fmt.Sprintf(`# Hyprvoice Configuration
|
var sb strings.Builder
|
||||||
# This file is automatically generated with defaults.
|
|
||||||
# Edit values as needed - changes are applied immediately without daemon restart.
|
|
||||||
|
|
||||||
# Audio Recording Configuration
|
// Header
|
||||||
[recording]
|
sb.WriteString(`# Hyprvoice Configuration
|
||||||
sample_rate = %d # Audio sample rate in Hz (16000 recommended for speech)
|
# Generated by hyprvoice configure
|
||||||
channels = %d # Number of audio channels (1 = mono, 2 = stereo)
|
# Changes are applied immediately without daemon restart.
|
||||||
format = "%s" # Audio format (s16 = 16-bit signed integers)
|
|
||||||
buffer_size = %d # Internal buffer size in bytes (larger = less CPU, more latency)
|
|
||||||
device = "%s" # PipeWire audio device (empty = use default microphone)
|
|
||||||
channel_buffer_size = %d # Audio frame buffer size (frames to buffer)
|
|
||||||
timeout = "%s" # Maximum recording duration (e.g., "30s", "2m", "5m")
|
|
||||||
|
|
||||||
# Speech Transcription Configuration
|
`)
|
||||||
[transcription]
|
|
||||||
provider = "%s" # Transcription service: "openai", "groq-transcription", "groq-translation", "mistral-transcription", or "elevenlabs"
|
|
||||||
api_key = "%s" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY/ELEVENLABS_API_KEY environment variable)
|
|
||||||
language = "%s" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.)
|
|
||||||
model = "%s" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" or "scribe_v2"
|
|
||||||
|
|
||||||
# Text Injection Configuration
|
// Keywords (must be before any table definitions)
|
||||||
[injection]
|
if len(cfg.Keywords) > 0 {
|
||||||
backends = [%s] # Ordered fallback chain (tries each until one succeeds)
|
sb.WriteString("# Keywords help transcription and LLM spell names/terms correctly\n")
|
||||||
ydotool_timeout = "%s" # Timeout for ydotool commands
|
sb.WriteString("keywords = [")
|
||||||
wtype_timeout = "%s" # Timeout for wtype commands
|
for i, kw := range cfg.Keywords {
|
||||||
clipboard_timeout = "%s" # Timeout for clipboard operations
|
if i > 0 {
|
||||||
|
sb.WriteString(", ")
|
||||||
# Backend explanations:
|
}
|
||||||
# - "ydotool": Uses ydotool (requires ydotoold daemon running for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
|
sb.WriteString(fmt.Sprintf("%q", kw))
|
||||||
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps.
|
}
|
||||||
# - "clipboard": Copies text to clipboard only (most reliable, but requires manual paste).
|
sb.WriteString("]\n\n")
|
||||||
#
|
|
||||||
# The backends are tried in order. First successful one wins.
|
|
||||||
#
|
|
||||||
# Provider explanations:
|
|
||||||
# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY)
|
|
||||||
# - "groq-transcription": Groq Whisper API for transcription (fast, requires GROQ_API_KEY)
|
|
||||||
# Models: whisper-large-v3 or whisper-large-v3-turbo
|
|
||||||
# - "groq-translation": Groq Whisper API for translation to English (always outputs English text)
|
|
||||||
# Models: whisper-large-v3 only (turbo not supported for translation)
|
|
||||||
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, requires MISTRAL_API_KEY)
|
|
||||||
# Models: voxtral-mini-latest or voxtral-mini-2507
|
|
||||||
# - "elevenlabs": ElevenLabs Scribe API (excellent accuracy, 99 languages, requires ELEVENLABS_API_KEY)
|
|
||||||
# Models: scribe_v1 (99 languages, best accuracy) or scribe_v2 (90 languages, real-time)
|
|
||||||
#
|
|
||||||
# Language codes: Use empty string ("") for automatic detection, or specific codes like:
|
|
||||||
# "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc.
|
|
||||||
# For groq-translation, the language field hints at the source audio language for better accuracy.
|
|
||||||
|
|
||||||
# Desktop Notification Configuration
|
|
||||||
[notifications]
|
|
||||||
enabled = %v # Enable desktop notifications
|
|
||||||
type = "%s" # Notification type ("desktop", "log", "none")
|
|
||||||
`,
|
|
||||||
cfg.Recording.SampleRate,
|
|
||||||
cfg.Recording.Channels,
|
|
||||||
cfg.Recording.Format,
|
|
||||||
cfg.Recording.BufferSize,
|
|
||||||
cfg.Recording.Device,
|
|
||||||
cfg.Recording.ChannelBufferSize,
|
|
||||||
cfg.Recording.Timeout,
|
|
||||||
cfg.Transcription.Provider,
|
|
||||||
cfg.Transcription.APIKey,
|
|
||||||
cfg.Transcription.Language,
|
|
||||||
cfg.Transcription.Model,
|
|
||||||
formatBackends(cfg.Injection.Backends),
|
|
||||||
cfg.Injection.YdotoolTimeout,
|
|
||||||
cfg.Injection.WtypeTimeout,
|
|
||||||
cfg.Injection.ClipboardTimeout,
|
|
||||||
cfg.Notifications.Enabled,
|
|
||||||
cfg.Notifications.Type,
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := file.WriteString(configContent); err != nil {
|
|
||||||
return fmt.Errorf("failed to write config content: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write notification messages if any are configured
|
// Providers section
|
||||||
|
if len(cfg.Providers) > 0 {
|
||||||
|
sb.WriteString("# API Keys for providers\n")
|
||||||
|
for name, pc := range cfg.Providers {
|
||||||
|
sb.WriteString(fmt.Sprintf("[providers.%s]\n", name))
|
||||||
|
sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recording
|
||||||
|
sb.WriteString(`# Audio Recording Configuration
|
||||||
|
[recording]
|
||||||
|
`)
|
||||||
|
sb.WriteString(fmt.Sprintf(" sample_rate = %d\n", cfg.Recording.SampleRate))
|
||||||
|
sb.WriteString(fmt.Sprintf(" channels = %d\n", cfg.Recording.Channels))
|
||||||
|
sb.WriteString(fmt.Sprintf(" format = %q\n", cfg.Recording.Format))
|
||||||
|
sb.WriteString(fmt.Sprintf(" buffer_size = %d\n", cfg.Recording.BufferSize))
|
||||||
|
sb.WriteString(fmt.Sprintf(" device = %q\n", cfg.Recording.Device))
|
||||||
|
sb.WriteString(fmt.Sprintf(" channel_buffer_size = %d\n", cfg.Recording.ChannelBufferSize))
|
||||||
|
sb.WriteString(fmt.Sprintf(" timeout = %q\n", cfg.Recording.Timeout.String()))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Transcription
|
||||||
|
sb.WriteString(`# Speech Transcription Configuration
|
||||||
|
[transcription]
|
||||||
|
`)
|
||||||
|
sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.Transcription.Provider))
|
||||||
|
sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.Transcription.Language))
|
||||||
|
sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.Transcription.Model))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// LLM
|
||||||
|
sb.WriteString(`# LLM Post-Processing Configuration
|
||||||
|
[llm]
|
||||||
|
`)
|
||||||
|
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.Enabled))
|
||||||
|
if cfg.LLM.Provider != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.LLM.Provider))
|
||||||
|
}
|
||||||
|
if cfg.LLM.Model != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.LLM.Model))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
sb.WriteString(" [llm.post_processing]\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(" remove_stutters = %v\n", cfg.LLM.PostProcessing.RemoveStutters))
|
||||||
|
sb.WriteString(fmt.Sprintf(" add_punctuation = %v\n", cfg.LLM.PostProcessing.AddPunctuation))
|
||||||
|
sb.WriteString(fmt.Sprintf(" fix_grammar = %v\n", cfg.LLM.PostProcessing.FixGrammar))
|
||||||
|
sb.WriteString(fmt.Sprintf(" remove_filler_words = %v\n", cfg.LLM.PostProcessing.RemoveFillerWords))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
sb.WriteString(" [llm.custom_prompt]\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.CustomPrompt.Enabled))
|
||||||
|
if cfg.LLM.CustomPrompt.Prompt != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" prompt = %q\n", cfg.LLM.CustomPrompt.Prompt))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Injection
|
||||||
|
sb.WriteString(`# Text Injection Configuration
|
||||||
|
[injection]
|
||||||
|
`)
|
||||||
|
sb.WriteString(" backends = [")
|
||||||
|
for i, b := range cfg.Injection.Backends {
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString(", ")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%q", b))
|
||||||
|
}
|
||||||
|
sb.WriteString("]\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String()))
|
||||||
|
sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String()))
|
||||||
|
sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String()))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
sb.WriteString(`# Desktop Notification Configuration
|
||||||
|
[notifications]
|
||||||
|
`)
|
||||||
|
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.Notifications.Enabled))
|
||||||
|
sb.WriteString(fmt.Sprintf(" type = %q\n", cfg.Notifications.Type))
|
||||||
|
|
||||||
|
// Write custom messages if any
|
||||||
msgs := cfg.Notifications.Messages
|
msgs := cfg.Notifications.Messages
|
||||||
if hasCustomMessages(msgs) {
|
if hasCustomMessages(msgs) {
|
||||||
messagesContent := "\n [notifications.messages]\n"
|
sb.WriteString("\n [notifications.messages]\n")
|
||||||
if msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" {
|
if msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" {
|
||||||
messagesContent += fmt.Sprintf(" [notifications.messages.recording_started]\n title = %q\n body = %q\n",
|
sb.WriteString(" [notifications.messages.recording_started]\n")
|
||||||
msgs.RecordingStarted.Title, msgs.RecordingStarted.Body)
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.RecordingStarted.Title))
|
||||||
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingStarted.Body))
|
||||||
}
|
}
|
||||||
if msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" {
|
if msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" {
|
||||||
messagesContent += fmt.Sprintf(" [notifications.messages.transcribing]\n title = %q\n body = %q\n",
|
sb.WriteString(" [notifications.messages.transcribing]\n")
|
||||||
msgs.Transcribing.Title, msgs.Transcribing.Body)
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title))
|
||||||
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body))
|
||||||
}
|
}
|
||||||
if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" {
|
if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" {
|
||||||
messagesContent += fmt.Sprintf(" [notifications.messages.config_reloaded]\n title = %q\n body = %q\n",
|
sb.WriteString(" [notifications.messages.config_reloaded]\n")
|
||||||
msgs.ConfigReloaded.Title, msgs.ConfigReloaded.Body)
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title))
|
||||||
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.ConfigReloaded.Body))
|
||||||
}
|
}
|
||||||
if msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" {
|
if msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" {
|
||||||
messagesContent += fmt.Sprintf(" [notifications.messages.operation_cancelled]\n title = %q\n body = %q\n",
|
sb.WriteString(" [notifications.messages.operation_cancelled]\n")
|
||||||
msgs.OperationCancelled.Title, msgs.OperationCancelled.Body)
|
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.OperationCancelled.Title))
|
||||||
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.OperationCancelled.Body))
|
||||||
}
|
}
|
||||||
if msgs.RecordingAborted.Body != "" {
|
if msgs.RecordingAborted.Body != "" {
|
||||||
messagesContent += fmt.Sprintf(" [notifications.messages.recording_aborted]\n body = %q\n",
|
sb.WriteString(" [notifications.messages.recording_aborted]\n")
|
||||||
msgs.RecordingAborted.Body)
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingAborted.Body))
|
||||||
}
|
}
|
||||||
if msgs.InjectionAborted.Body != "" {
|
if msgs.InjectionAborted.Body != "" {
|
||||||
messagesContent += fmt.Sprintf(" [notifications.messages.injection_aborted]\n body = %q\n",
|
sb.WriteString(" [notifications.messages.injection_aborted]\n")
|
||||||
msgs.InjectionAborted.Body)
|
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.InjectionAborted.Body))
|
||||||
}
|
}
|
||||||
if _, err := file.WriteString(messagesContent); err != nil {
|
|
||||||
return fmt.Errorf("failed to write messages config: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := file.WriteString(sb.String()); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -151,3 +151,22 @@ Key decisions:
|
|||||||
- When user picks unconfigured provider, immediately prompts for API key
|
- When user picks unconfigured provider, immediately prompts for API key
|
||||||
- Unedited sections preserved - only touched sections are modified
|
- Unedited sections preserved - only touched sections are modified
|
||||||
- Config struct passed by reference, changes accumulate
|
- Config struct passed by reference, changes accumulate
|
||||||
|
|
||||||
|
## Task 9: Replace old configure with TUI - COMPLETE
|
||||||
|
|
||||||
|
Replaced old interactive config in cmd/hyprvoice/main.go:
|
||||||
|
- `configureCmd` now calls `tui.Run()` instead of `runInteractiveConfig()`
|
||||||
|
- Removed all old functions: `runInteractiveConfig`, `maskAPIKey`, `formatBackends`, old `saveConfig`
|
||||||
|
- New `saveConfig()` writes proper TOML with new structure:
|
||||||
|
- `keywords = [...]` at top (before any tables)
|
||||||
|
- `[providers.X]` sections with `api_key`
|
||||||
|
- `[llm]` with `[llm.post_processing]` and `[llm.custom_prompt]` subsections
|
||||||
|
- No more `transcription.api_key` in saved configs
|
||||||
|
- Added `showNextSteps()` helper for post-save instructions
|
||||||
|
- Added `runConfigure()` that wraps TUI flow with validation and save
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
- Keywords written before any TOML table definitions (TOML requirement)
|
||||||
|
- Config saved only if user confirms in TUI summary
|
||||||
|
- Validation runs before save, errors displayed cleanly
|
||||||
|
- Next steps shown after successful save
|
||||||
|
|||||||
+1
-1
@@ -178,7 +178,7 @@
|
|||||||
"Saved config valid TOML with new structure",
|
"Saved config valid TOML with new structure",
|
||||||
"Typecheck passes"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"passes": false
|
"passes": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Update default config template",
|
"title": "Update default config template",
|
||||||
|
|||||||
Reference in New Issue
Block a user