From f251df302de11a299b9df16710883422812f6afc Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Sat, 31 Jan 2026 22:37:58 +0100 Subject: [PATCH] feat: better configuration --- README.md | 455 +-------- cmd/hyprvoice/main.go | 6 + docs/config.md | 432 ++++++++ go.mod | 6 +- go.sum | 20 +- internal/config/config.go | 772 --------------- internal/config/convert.go | 130 +++ internal/config/load.go | 146 +++ internal/config/save.go | 168 ++++ internal/config/types.go | 135 +++ internal/config/validate.go | 173 ++++ internal/tui/configure.go | 1207 +---------------------- internal/tui/configure_advanced.go | 225 +++++ internal/tui/configure_helpers.go | 109 ++ internal/tui/configure_llm.go | 444 +++++++++ internal/tui/configure_notifications.go | 251 +++++ internal/tui/configure_providers.go | 203 ++++ internal/tui/configure_transcription.go | 249 +++++ internal/tui/configure_wizard.go | 213 ++++ progress.txt | 268 ----- tasks/prd.jsonc | 266 ----- 21 files changed, 2972 insertions(+), 2906 deletions(-) create mode 100644 docs/config.md delete mode 100644 internal/config/config.go create mode 100644 internal/config/convert.go create mode 100644 internal/config/load.go create mode 100644 internal/config/save.go create mode 100644 internal/config/types.go create mode 100644 internal/config/validate.go create mode 100644 internal/tui/configure_advanced.go create mode 100644 internal/tui/configure_helpers.go create mode 100644 internal/tui/configure_llm.go create mode 100644 internal/tui/configure_notifications.go create mode 100644 internal/tui/configure_providers.go create mode 100644 internal/tui/configure_transcription.go create mode 100644 internal/tui/configure_wizard.go delete mode 100644 progress.txt delete mode 100644 tasks/prd.jsonc diff --git a/README.md b/README.md index 5bb0da6..bd0f2a0 100644 --- a/README.md +++ b/README.md @@ -94,17 +94,21 @@ sudo usermod -aG input $USER After installing via AUR: 1. **Configure hyprvoice interactively:** + ```bash hyprvoice configure ``` + This wizard will guide you through setting up your transcription provider, API key, audio preferences, and other settings. 2. **Enable and start the service:** + ```bash systemctl --user enable --now hyprvoice.service ``` 3. **Add keybinding to your window manager:** + ```bash # For Hyprland, add to ~/.config/hypr/hyprland.conf bind = SUPER, R, exec, hyprvoice toggle @@ -203,415 +207,25 @@ hyprvoice status ## Configuration -Use the interactive configuration wizard: +The recommended way to configure hyprvoice is through the interactive wizard: ```bash hyprvoice configure ``` -This will guide you through setting up: +The wizard guides you through all settings with a user-friendly interface: -- Provider API keys (OpenAI, Groq, Mistral, ElevenLabs) -- Transcription provider and model -- LLM post-processing options (enabled by default) -- Keywords for domain-specific terms -- Text injection method (clipboard/typing/fallback) -- Notification settings +- **Providers** - API keys for OpenAI, Groq, Mistral, ElevenLabs +- **Transcription** - Speech-to-text provider and model selection +- **LLM** - Post-processing to clean up transcriptions (enabled by default) +- **Keywords** - Domain-specific terms for better accuracy +- **Injection** - How text is typed (ydotool, wtype, clipboard) +- **Notifications** - Desktop notification preferences +- **Advanced Settings** - Recording parameters, timeouts -Configuration is stored in `~/.config/hyprvoice/config.toml` and can also be edited manually. Changes are applied immediately without restarting the daemon. +Configuration is stored in `~/.config/hyprvoice/config.toml`. Changes are applied immediately without restarting the daemon. -### Unified Provider System - -Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features: - -```toml -# Configure API keys for providers you want to use -[providers.openai] - api_key = "sk-..." # Or set OPENAI_API_KEY env var - -[providers.groq] - api_key = "gsk_..." # Or set GROQ_API_KEY env var - -[providers.mistral] - api_key = "..." # Or set MISTRAL_API_KEY env var - -[providers.elevenlabs] - api_key = "..." # Or set ELEVENLABS_API_KEY env var -``` - -**API key resolution order:** -1. `[providers.X]` section in config -2. Legacy `transcription.api_key` (backward compatible) -3. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, etc.) - -### Transcription Providers - -Hyprvoice supports multiple transcription backends: - -#### OpenAI Whisper API - -Cloud-based transcription using OpenAI's Whisper API: - -```toml -[transcription] -provider = "openai" -language = "" # Empty for auto-detect, or "en", "es", "fr", etc. -model = "whisper-1" -``` - -**Features:** -- High-quality transcription -- Supports 50+ languages -- Auto-detection or specify language for better accuracy - -#### Groq Whisper API (Transcription) - -Fast cloud-based transcription using Groq's Whisper API: - -```toml -[transcription] -provider = "groq-transcription" -language = "" # Empty for auto-detect, or "en", "es", "fr", etc. -model = "whisper-large-v3" # Or "whisper-large-v3-turbo" for faster processing -``` - -**Features:** -- Ultra-fast transcription (significantly faster than OpenAI) -- Same Whisper model quality -- Supports 50+ languages -- Free tier available with generous limits - -#### Groq Translation API - -Fast translation of audio to English using Groq's Whisper API: - -```toml -[transcription] -provider = "groq-translation" -language = "es" # Optional: hint source language for better accuracy -model = "whisper-large-v3-turbo" -``` - -**Features:** -- Translates any language audio → English text -- Ultra-fast processing -- Language field hints at source language (improves accuracy) -- Always outputs English regardless of input language - -### LLM Post-Processing - -LLM post-processing is **enabled by default** and significantly improves transcription quality. After transcription, the text is processed by an LLM to: - -- Remove stutters and repeated words ("I I I want" → "I want") -- Add proper punctuation -- Fix grammar errors -- Remove filler words ("um", "uh", "like", "you know", etc.) - -#### Basic Configuration - -```toml -[llm] - enabled = true # Disable with false if you want raw transcriptions - provider = "openai" # "openai" or "groq" - model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile" -``` - -#### Post-Processing Options - -All options are enabled by default. Disable specific ones as needed: - -```toml -[llm.post_processing] - remove_stutters = true # "I I I want" → "I want" - add_punctuation = true # Adds periods, commas, etc. - fix_grammar = true # Fixes grammatical errors - remove_filler_words = true # Removes "um", "uh", "like", "you know" -``` - -#### Custom Prompts - -Add custom instructions for specific use cases: - -```toml -[llm.custom_prompt] - enabled = true - prompt = "Format as bullet points" -``` - -**Use cases for custom prompts:** -- "Format as bullet points" - for note-taking -- "Keep technical terms exactly as spoken" - for programming dictation -- "Use formal language" - for professional documents -- "Translate to Spanish" - for translation workflows - -#### LLM Provider Recommendations - -| Provider | Model | Best For | -| -------- | ----- | -------- | -| OpenAI | gpt-4o-mini | Best quality/cost balance (default) | -| Groq | llama-3.3-70b-versatile | Fastest processing, free tier | - -Both providers use the same API key as transcription if you're using OpenAI or Groq for transcription. - -### Keywords - -Keywords help both transcription and LLM understand domain-specific terms, names, and technical vocabulary: - -```toml -keywords = ["Hyprland", "Wayland", "PipeWire", "Claude", "TypeScript"] -``` - -**How keywords work:** -- **Transcription**: Passed as initial_prompt to Whisper, improving recognition of these terms -- **LLM**: Included in the system prompt to ensure correct spelling - -**When to use keywords:** -- Names of people, companies, or products -- Technical terminology specific to your field -- Acronyms or abbreviations -- Words commonly misheard by speech-to-text - -### Example Configurations - -#### Fast Transcription Only (No LLM) - -```toml -[providers.groq] - api_key = "gsk_..." - -[transcription] - provider = "groq-transcription" - model = "whisper-large-v3-turbo" - -[llm] - enabled = false -``` - -#### High Quality with OpenAI (Default) - -```toml -[providers.openai] - api_key = "sk-..." - -[transcription] - provider = "openai" - model = "whisper-1" - -[llm] - enabled = true - provider = "openai" - model = "gpt-4o-mini" -``` - -#### Budget-Friendly with Groq - -```toml -[providers.groq] - api_key = "gsk_..." - -[transcription] - provider = "groq-transcription" - model = "whisper-large-v3-turbo" - -[llm] - enabled = true - provider = "groq" - model = "llama-3.3-70b-versatile" -``` - -#### Mixed Providers (Groq Transcription + OpenAI LLM) - -```toml -[providers.openai] - api_key = "sk-..." - -[providers.groq] - api_key = "gsk_..." - -[transcription] - provider = "groq-transcription" - model = "whisper-large-v3-turbo" - -[llm] - enabled = true - provider = "openai" - model = "gpt-4o-mini" -``` - -### Migration from Old Config Format - -If you're upgrading from an older version with `transcription.api_key`: - -**Old format (still works):** -```toml -[transcription] - provider = "openai" - api_key = "sk-..." # Legacy location - model = "whisper-1" -``` - -**New format (recommended):** -```toml -[providers.openai] - api_key = "sk-..." # Unified location - -[transcription] - provider = "openai" - model = "whisper-1" - -[llm] - enabled = true - provider = "openai" - model = "gpt-4o-mini" -``` - -Run `hyprvoice configure` to interactively update your config to the new format. - -#### whisper.cpp Local (Planned) -> Not yet implemented - -Private, offline transcription using local models: - -```toml -[transcription] -provider = "whisper_cpp" -model_path = "~/models/ggml-base.en.bin" -threads = 4 -``` - -#### Recording Configuration - -Audio capture settings: - -```toml -[recording] -sample_rate = 16000 # Audio sample rate in Hz -channels = 1 # Number of audio channels (1 for mono) -format = "s16" # Audio format (s16 recommended) -buffer_size = 8192 # Internal buffer size in bytes -device = "" # PipeWire device (empty for default) -channel_buffer_size = 30 # Audio frame buffer size -timeout = "5m" # Maximum recording duration (prevents runaway recordings) -``` - -**Recording Timeout:** - -- Prevents accidental long recordings that could consume resources -- Default: 5 minutes (`"5m"`) -- Format: Go duration strings like `"30s"`, `"2m"`, `"10m"` -- Recording automatically stops when timeout is reached - -#### Text Injection - -Configurable text injection with multiple backends: - -```toml -[injection] -backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain -ydotool_timeout = "5s" -wtype_timeout = "5s" -clipboard_timeout = "3s" -``` - -**Injection Backends:** - -- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps. -- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug). -- **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste. - -**Fallback Chain:** - -Backends are tried in order. The first successful one wins. Example configurations: - -```toml -# Clipboard only (safest, always works) -backends = ["clipboard"] - -# wtype with clipboard fallback -backends = ["wtype", "clipboard"] - -# Full fallback chain (default) - best compatibility -backends = ["ydotool", "wtype", "clipboard"] - -# ydotool only (if you have it set up) -backends = ["ydotool"] -``` - -**ydotool Setup:** - -ydotool requires the `ydotoold` daemon running (for ydotool v1.0.0+) and access to `/dev/uinput`: - -```bash -# Start ydotool daemon (systemd) -systemctl --user enable --now ydotool - -# Or add user to input group -sudo usermod -aG input $USER -# Then logout/login - -# For Hyprland, add to config to set correct keyboard layout: -# device:ydotoold-virtual-device { -# kb_layout = us -# } -``` - -**Behavior:** - -- Backends are tried in order until one succeeds -- Include `clipboard` in the chain if you want text copied to clipboard as fallback - -#### Notifications - -Desktop notification settings: - -```toml -[notifications] -enabled = true # Enable/disable notifications -type = "desktop" # "desktop", "log", or "none" -``` - -**Notification Types:** - -- **`desktop`**: Use notify-send for desktop notifications -- **`log`**: Log messages to console only -- **`none`**: Disable all notifications - -Always keep `type = "desktop"` unless debugging. - -##### Custom Notification Messages - -You can customize notification text via the `[notifications.messages]` section. - -```toml -[notifications.messages] - [notifications.messages.recording_started] - title = "Hyprvoice" - body = "Recording Started" - [notifications.messages.transcribing] - title = "Hyprvoice" - body = "Recording Ended... Transcribing" - [notifications.messages.llm_processing] - title = "Hyprvoice" - body = "Processing..." - [notifications.messages.config_reloaded] - title = "Hyprvoice" - body = "Config Reloaded" - [notifications.messages.operation_cancelled] - title = "Hyprvoice" - body = "Operation Cancelled" - [notifications.messages.recording_aborted] - body = "Recording Aborted" - [notifications.messages.injection_aborted] - body = "Injection Aborted" -``` - -### Configuration Hot-Reloading - -The daemon automatically watches the config file for changes and applies them immediately: - -- **Notification settings**: Applied instantly -- **Injection settings**: Applied to current and future operations -- **Recording/Transcription/LLM settings**: Applied to new recording sessions -- **Invalid configs**: Rejected with error notification, daemon continues with previous config +For manual configuration and detailed options, see [docs/config.md](docs/config.md). ### Service Management @@ -641,25 +255,25 @@ journalctl --user -u hyprvoice.service -f ## Development Status -| Component | Status | Notes | -| ---------------------- | ------ | ----------------------------------------------------- | -| Core daemon & IPC | ✅ | Unix socket control plane | -| Recording workflow | ✅ | Toggle recording via PipeWire | -| Audio capture | ✅ | Efficient PipeWire integration | -| Desktop notifications | ✅ | Status feedback via notify-send | -| OpenAI transcription | ✅ | HTTP API integration | -| Groq transcription | ✅ | Fast Whisper API with transcription and translation | -| Mistral transcription | ✅ | Voxtral API for European languages | -| ElevenLabs transcription| ✅ | Scribe API with 99 language support | -| LLM post-processing | ✅ | OpenAI/Groq text cleanup (enabled by default) | -| Text injection | ✅ | Clipboard + wtype/ydotool with fallback | -| Configuration system | ✅ | TOML-based user settings with hot-reload | -| Interactive TUI setup | ✅ | `hyprvoice configure` wizard with section editing | -| Unit test coverage | ✅ | Comprehensive test suite (100% pass) | -| CI/CD Pipeline | ✅ | Automated builds and releases via GitHub Actions | -| Installation (AUR etc) | ✅ | AUR package with automated dependency installation | -| Light dictation models | ⏳ | Alternatives to whispers for light and fast dictation | -| whisper.cpp support | ⏳ | Local model inference | +| Component | Status | Notes | +| ------------------------ | ------ | ----------------------------------------------------- | +| Core daemon & IPC | ✅ | Unix socket control plane | +| Recording workflow | ✅ | Toggle recording via PipeWire | +| Audio capture | ✅ | Efficient PipeWire integration | +| Desktop notifications | ✅ | Status feedback via notify-send | +| OpenAI transcription | ✅ | HTTP API integration | +| Groq transcription | ✅ | Fast Whisper API with transcription and translation | +| Mistral transcription | ✅ | Voxtral API for European languages | +| ElevenLabs transcription | ✅ | Scribe API with 99 language support | +| LLM post-processing | ✅ | OpenAI/Groq text cleanup (enabled by default) | +| Text injection | ✅ | Clipboard + wtype/ydotool with fallback | +| Configuration system | ✅ | TOML-based user settings with hot-reload | +| Interactive TUI setup | ✅ | `hyprvoice configure` wizard with section editing | +| Unit test coverage | ✅ | Comprehensive test suite (100% pass) | +| CI/CD Pipeline | ✅ | Automated builds and releases via GitHub Actions | +| Installation (AUR etc) | ✅ | AUR package with automated dependency installation | +| Light dictation models | ⏳ | Alternatives to whispers for light and fast dictation | +| whisper.cpp support | ⏳ | Local model inference | **Legend**: ✅ Complete · ⏳ Planned @@ -868,6 +482,7 @@ export PATH="$HOME/.local/bin:$PATH" See [`packaging/RELEASE.md`](packaging/RELEASE.md) for complete release process including AUR deployment. Quick start for AUR: + ```bash # After creating your first GitHub release cd packaging/ diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 6038bf7..80c8cb3 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -344,6 +344,11 @@ func saveConfig(cfg *config.Config) error { sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title)) sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body)) } + if msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" { + sb.WriteString(" [notifications.messages.llm_processing]\n") + sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.LLMProcessing.Title)) + sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.LLMProcessing.Body)) + } if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" { sb.WriteString(" [notifications.messages.config_reloaded]\n") sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title)) @@ -374,6 +379,7 @@ func saveConfig(cfg *config.Config) error { func hasCustomMessages(msgs config.MessagesConfig) bool { return msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" || msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" || + msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" || msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" || msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" || msgs.RecordingAborted.Body != "" || diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..7e1d4a3 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,432 @@ +# Configuration Reference + +This document covers manual configuration of hyprvoice via the `config.toml` file. For most users, the interactive wizard is recommended: + +```bash +hyprvoice configure +``` + +Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are applied immediately without restarting the daemon. + +## Table of Contents + +- [Unified Provider System](#unified-provider-system) +- [Transcription Providers](#transcription-providers) +- [LLM Post-Processing](#llm-post-processing) +- [Keywords](#keywords) +- [Recording Configuration](#recording-configuration) +- [Text Injection](#text-injection) +- [Notifications](#notifications) +- [Example Configurations](#example-configurations) +- [Migration from Old Config Format](#migration-from-old-config-format) + +## Unified Provider System + +Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features: + +```toml +# Configure API keys for providers you want to use +[providers.openai] + api_key = "sk-..." # Or set OPENAI_API_KEY env var + +[providers.groq] + api_key = "gsk_..." # Or set GROQ_API_KEY env var + +[providers.mistral] + api_key = "..." # Or set MISTRAL_API_KEY env var + +[providers.elevenlabs] + api_key = "..." # Or set ELEVENLABS_API_KEY env var +``` + +**API key resolution order:** + +1. `[providers.X]` section in config +2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, etc.) + +## Transcription Providers + +Hyprvoice supports multiple transcription backends: + +### OpenAI Whisper API + +Cloud-based transcription using OpenAI's Whisper API: + +```toml +[transcription] +provider = "openai" +language = "" # Empty for auto-detect, or "en", "es", "fr", etc. +model = "whisper-1" +``` + +**Features:** + +- High-quality transcription +- Supports 50+ languages +- Auto-detection or specify language for better accuracy + +### Groq Whisper API (Transcription) + +Fast cloud-based transcription using Groq's Whisper API: + +```toml +[transcription] +provider = "groq-transcription" +language = "" # Empty for auto-detect, or "en", "es", "fr", etc. +model = "whisper-large-v3" # Or "whisper-large-v3-turbo" for faster processing +``` + +**Features:** + +- Ultra-fast transcription (significantly faster than OpenAI) +- Same Whisper model quality +- Supports 50+ languages +- Free tier available with generous limits + +### Groq Translation API + +Fast translation of audio to English using Groq's Whisper API: + +```toml +[transcription] +provider = "groq-translation" +language = "es" # Optional: hint source language for better accuracy +model = "whisper-large-v3" +``` + +**Features:** + +- Translates any language audio → English text +- Ultra-fast processing +- Language field hints at source language (improves accuracy) +- Always outputs English regardless of input language + +### Mistral Voxtral + +Transcription using Mistral's Voxtral API, excellent for European languages: + +```toml +[transcription] +provider = "mistral-transcription" +language = "" +model = "voxtral-mini-latest" # Or "voxtral-mini-2507" +``` + +### ElevenLabs Scribe + +Transcription using ElevenLabs' Scribe API with 99 language support: + +```toml +[transcription] +provider = "elevenlabs" +language = "" +model = "scribe_v1" # Or "scribe_v2" for real-time, lower latency +``` + +## LLM Post-Processing + +LLM post-processing is **enabled by default** and significantly improves transcription quality. After transcription, the text is processed by an LLM to: + +- Remove stutters and repeated words ("I I I want" → "I want") +- Add proper punctuation +- Fix grammar errors +- Remove filler words ("um", "uh", "like", "you know", etc.) + +### Basic Configuration + +```toml +[llm] + enabled = true # Disable with false if you want raw transcriptions + provider = "openai" # "openai" or "groq" + model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile" +``` + +### Post-Processing Options + +All options are enabled by default. Disable specific ones as needed: + +```toml +[llm.post_processing] + remove_stutters = true # "I I I want" → "I want" + add_punctuation = true # Adds periods, commas, etc. + fix_grammar = true # Fixes grammatical errors + remove_filler_words = true # Removes "um", "uh", "like", "you know" +``` + +### Custom Prompts + +Add custom instructions for specific use cases: + +```toml +[llm.custom_prompt] + enabled = true + prompt = "Format as bullet points" +``` + +**Use cases for custom prompts:** + +- "Format as bullet points" - for note-taking +- "Keep technical terms exactly as spoken" - for programming dictation +- "Use formal language" - for professional documents +- "Translate to Spanish" - for translation workflows + +### LLM Provider Recommendations + +| Provider | Model | Best For | +| -------- | ----------------------- | ----------------------------------- | +| OpenAI | gpt-4o-mini | Best quality/cost balance (default) | +| Groq | llama-3.3-70b-versatile | Fastest processing, free tier | + +## Keywords + +Keywords help both transcription and LLM understand domain-specific terms, names, and technical vocabulary: + +```toml +keywords = ["Hyprland", "Wayland", "PipeWire", "Claude", "TypeScript"] +``` + +**How keywords work:** + +- **Transcription**: Passed as initial_prompt to Whisper, improving recognition of these terms +- **LLM**: Included in the system prompt to ensure correct spelling + +**When to use keywords:** + +- Names of people, companies, or products +- Technical terminology specific to your field +- Acronyms or abbreviations +- Words commonly misheard by speech-to-text + +## Recording Configuration + +Audio capture settings: + +```toml +[recording] +sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech) +channels = 1 # Number of audio channels (1 = mono, 2 = stereo) +format = "s16" # Audio format (s16 = 16-bit signed integers) +buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency) +device = "" # PipeWire device name (empty = default microphone) +channel_buffer_size = 30 # Audio frame buffer size (frames to buffer) +timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m") +``` + +### Recording Timeout + +- Prevents accidental long recordings that could consume resources +- Default: 5 minutes (`"5m"`) +- Format: Go duration strings like `"30s"`, `"2m"`, `"10m"` +- Recording automatically stops when timeout is reached + +## Text Injection + +Configurable text injection with multiple backends: + +```toml +[injection] +backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain +ydotool_timeout = "5s" +wtype_timeout = "5s" +clipboard_timeout = "3s" +``` + +### Injection Backends + +- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps. +- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug). +- **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste. + +### Fallback Chain + +Backends are tried in order. The first successful one wins. Example configurations: + +```toml +# Clipboard only (safest, always works) +backends = ["clipboard"] + +# wtype with clipboard fallback +backends = ["wtype", "clipboard"] + +# Full fallback chain (default) - best compatibility +backends = ["ydotool", "wtype", "clipboard"] + +# ydotool only (if you have it set up) +backends = ["ydotool"] +``` + +### ydotool Setup + +ydotool requires the `ydotoold` daemon running (for ydotool v1.0.0+) and access to `/dev/uinput`: + +```bash +# Start ydotool daemon (systemd) +systemctl --user enable --now ydotool + +# Or add user to input group +sudo usermod -aG input $USER +# Then logout/login + +# For Hyprland, add to config to set correct keyboard layout: +# device:ydotoold-virtual-device { +# kb_layout = us +# } +``` + +## Notifications + +Desktop notification settings: + +```toml +[notifications] +enabled = true # Enable/disable notifications +type = "desktop" # "desktop", "log", or "none" +``` + +### Notification Types + +- **`desktop`**: Use notify-send for desktop notifications +- **`log`**: Log messages to console only +- **`none`**: Disable all notifications + +### Custom Notification Messages + +You can customize notification text via the `[notifications.messages]` section: + +```toml +[notifications.messages] + [notifications.messages.recording_started] + title = "Hyprvoice" + body = "Recording Started" + [notifications.messages.transcribing] + title = "Hyprvoice" + body = "Recording Ended... Transcribing" + [notifications.messages.llm_processing] + title = "Hyprvoice" + body = "Processing..." + [notifications.messages.config_reloaded] + title = "Hyprvoice" + body = "Config Reloaded" + [notifications.messages.operation_cancelled] + title = "Hyprvoice" + body = "Operation Cancelled" + [notifications.messages.recording_aborted] + body = "Recording Aborted" + [notifications.messages.injection_aborted] + body = "Injection Aborted" +``` + +**Emoji-only example** (for minimal pill-style notifications): + +```toml +[notifications.messages.recording_started] + title = "" + body = "🎙️" +``` + +## Example Configurations + +### Fast Transcription Only (No LLM) + +```toml +[providers.groq] + api_key = "gsk_..." + +[transcription] + provider = "groq-transcription" + model = "whisper-large-v3-turbo" + +[llm] + enabled = false +``` + +### High Quality with OpenAI (Default) + +```toml +[providers.openai] + api_key = "sk-..." + +[transcription] + provider = "openai" + model = "whisper-1" + +[llm] + enabled = true + provider = "openai" + model = "gpt-4o-mini" +``` + +### Budget-Friendly with Groq + +```toml +[providers.groq] + api_key = "gsk_..." + +[transcription] + provider = "groq-transcription" + model = "whisper-large-v3-turbo" + +[llm] + enabled = true + provider = "groq" + model = "llama-3.3-70b-versatile" +``` + +### Mixed Providers (Groq Transcription + OpenAI LLM) + +```toml +[providers.openai] + api_key = "sk-..." + +[providers.groq] + api_key = "gsk_..." + +[transcription] + provider = "groq-transcription" + model = "whisper-large-v3-turbo" + +[llm] + enabled = true + provider = "openai" + model = "gpt-4o-mini" +``` + +## Migration from Old Config Format + +If you're upgrading from an older version with `transcription.api_key`: + +**Old format (still works):** + +```toml +[transcription] + provider = "openai" + api_key = "sk-..." # Legacy location + model = "whisper-1" +``` + +**New format (recommended):** + +```toml +[providers.openai] + api_key = "sk-..." # Unified location + +[transcription] + provider = "openai" + model = "whisper-1" + +[llm] + enabled = true + provider = "openai" + model = "gpt-4o-mini" +``` + +Run `hyprvoice configure` to interactively update your config to the new format. + +## Configuration Hot-Reloading + +The daemon automatically watches the config file for changes and applies them immediately: + +- **Notification settings**: Applied instantly +- **Injection settings**: Applied to current and future operations +- **Recording/Transcription/LLM settings**: Applied to new recording sessions +- **Invalid configs**: Rejected with error notification, daemon continues with previous config diff --git a/go.mod b/go.mod index 9f02e2e..929464f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,10 @@ go 1.24.5 require ( github.com/BurntSushi/toml v1.5.0 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fsnotify/fsnotify v1.9.0 + github.com/muesli/termenv v0.16.0 github.com/sashabaranov/go-openai v1.41.1 github.com/spf13/cobra v1.9.1 ) @@ -16,8 +19,6 @@ require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/huh v0.8.0 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -32,7 +33,6 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index f07813c..d198054 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= @@ -20,11 +24,23 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -61,10 +77,10 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 51dad81..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,772 +0,0 @@ -package config - -import ( - "fmt" - "log" - "os" - "path/filepath" - "reflect" - "time" - - "github.com/BurntSushi/toml" - "github.com/leonardotrapani/hyprvoice/internal/injection" - "github.com/leonardotrapani/hyprvoice/internal/notify" - "github.com/leonardotrapani/hyprvoice/internal/recording" - "github.com/leonardotrapani/hyprvoice/internal/transcriber" -) - -type Config struct { - Recording RecordingConfig `toml:"recording"` - Transcription TranscriptionConfig `toml:"transcription"` - Injection InjectionConfig `toml:"injection"` - Notifications NotificationsConfig `toml:"notifications"` - Providers map[string]ProviderConfig `toml:"providers"` - Keywords []string `toml:"keywords"` - LLM LLMConfig `toml:"llm"` -} - -// ProviderConfig holds API key for a provider -type ProviderConfig struct { - APIKey string `toml:"api_key"` -} - -// LLMConfig configures the LLM post-processing phase -type LLMConfig struct { - Enabled bool `toml:"enabled"` - Provider string `toml:"provider"` - Model string `toml:"model"` - PostProcessing LLMPostProcessingConfig `toml:"post_processing"` - CustomPrompt LLMCustomPromptConfig `toml:"custom_prompt"` -} - -// LLMPostProcessingConfig controls text cleanup options -type LLMPostProcessingConfig struct { - RemoveStutters bool `toml:"remove_stutters"` - AddPunctuation bool `toml:"add_punctuation"` - FixGrammar bool `toml:"fix_grammar"` - RemoveFillerWords bool `toml:"remove_filler_words"` -} - -// LLMCustomPromptConfig allows custom prompts -type LLMCustomPromptConfig struct { - Enabled bool `toml:"enabled"` - Prompt string `toml:"prompt"` -} - -type RecordingConfig struct { - SampleRate int `toml:"sample_rate"` - Channels int `toml:"channels"` - Format string `toml:"format"` - BufferSize int `toml:"buffer_size"` - Device string `toml:"device"` - ChannelBufferSize int `toml:"channel_buffer_size"` - Timeout time.Duration `toml:"timeout"` -} - -type TranscriptionConfig struct { - Provider string `toml:"provider"` - APIKey string `toml:"api_key"` - Language string `toml:"language"` - Model string `toml:"model"` -} - -type InjectionConfig struct { - Backends []string `toml:"backends"` - YdotoolTimeout time.Duration `toml:"ydotool_timeout"` - WtypeTimeout time.Duration `toml:"wtype_timeout"` - ClipboardTimeout time.Duration `toml:"clipboard_timeout"` -} - -type NotificationsConfig struct { - Enabled bool `toml:"enabled"` - Type string `toml:"type"` // "desktop", "log", "none" - Messages MessagesConfig `toml:"messages"` -} - -type MessageConfig struct { - Title string `toml:"title"` - Body string `toml:"body"` -} - -type MessagesConfig struct { - RecordingStarted MessageConfig `toml:"recording_started"` - Transcribing MessageConfig `toml:"transcribing"` - LLMProcessing MessageConfig `toml:"llm_processing"` - ConfigReloaded MessageConfig `toml:"config_reloaded"` - OperationCancelled MessageConfig `toml:"operation_cancelled"` - RecordingAborted MessageConfig `toml:"recording_aborted"` - InjectionAborted MessageConfig `toml:"injection_aborted"` -} - -// Resolve merges user config with defaults from MessageDefs -func (m *MessagesConfig) Resolve() map[notify.MessageType]notify.Message { - result := make(map[notify.MessageType]notify.Message) - - // Build toml tag → field index map - v := reflect.ValueOf(m).Elem() - t := v.Type() - tagToField := make(map[string]int) - for i := 0; i < t.NumField(); i++ { - tagToField[t.Field(i).Tag.Get("toml")] = i - } - - for _, def := range notify.MessageDefs { - msg := notify.Message{ - Title: def.DefaultTitle, - Body: def.DefaultBody, - IsError: def.IsError, - } - if idx, ok := tagToField[def.ConfigKey]; ok { - userMsg := v.Field(idx).Interface().(MessageConfig) - if userMsg.Title != "" { - msg.Title = userMsg.Title - } - if userMsg.Body != "" { - msg.Body = userMsg.Body - } - } - result[def.Type] = msg - } - return result -} - -func (c *Config) ToRecordingConfig() recording.Config { - return recording.Config{ - SampleRate: c.Recording.SampleRate, - Channels: c.Recording.Channels, - Format: c.Recording.Format, - BufferSize: c.Recording.BufferSize, - Device: c.Recording.Device, - ChannelBufferSize: c.Recording.ChannelBufferSize, - Timeout: c.Recording.Timeout, - } -} - -func (c *Config) ToTranscriberConfig() transcriber.Config { - config := transcriber.Config{ - Provider: c.Transcription.Provider, - Language: c.Transcription.Language, - Model: c.Transcription.Model, - Keywords: c.Keywords, - } - - // Resolve API key: providers map -> legacy transcription.api_key -> environment variable - config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider) - - return config -} - -// resolveAPIKeyForProvider returns the API key for a provider from multiple sources -func (c *Config) resolveAPIKeyForProvider(provider string) string { - // Map transcription provider names to provider registry names - providerName := provider - envVar := "" - switch provider { - case "openai": - providerName = "openai" - envVar = "OPENAI_API_KEY" - case "groq-transcription", "groq-translation": - providerName = "groq" - envVar = "GROQ_API_KEY" - case "mistral-transcription": - providerName = "mistral" - envVar = "MISTRAL_API_KEY" - case "elevenlabs": - providerName = "elevenlabs" - envVar = "ELEVENLABS_API_KEY" - } - - // 1. Check providers map - if c.Providers != nil { - if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" { - return pc.APIKey - } - } - - // 2. Check legacy transcription.api_key (backward compatibility) - if c.Transcription.APIKey != "" { - return c.Transcription.APIKey - } - - // 3. Check environment variable - if envVar != "" { - return os.Getenv(envVar) - } - - return "" -} - -// LLMAdapterConfig is the configuration passed to the LLM adapter -type LLMAdapterConfig struct { - Provider string - APIKey string - Model string - RemoveStutters bool - AddPunctuation bool - FixGrammar bool - RemoveFillerWords bool - CustomPrompt string - Keywords []string -} - -// ToLLMConfig returns the LLM adapter configuration -func (c *Config) ToLLMConfig() LLMAdapterConfig { - config := LLMAdapterConfig{ - Provider: c.LLM.Provider, - Model: c.LLM.Model, - RemoveStutters: c.LLM.PostProcessing.RemoveStutters, - AddPunctuation: c.LLM.PostProcessing.AddPunctuation, - FixGrammar: c.LLM.PostProcessing.FixGrammar, - RemoveFillerWords: c.LLM.PostProcessing.RemoveFillerWords, - Keywords: c.Keywords, - } - - // Resolve API key for LLM provider - if c.LLM.Provider != "" { - config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider) - } - - // Add custom prompt if enabled - if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" { - config.CustomPrompt = c.LLM.CustomPrompt.Prompt - } - - return config -} - -// resolveAPIKeyForLLMProvider returns the API key for an LLM provider -func (c *Config) resolveAPIKeyForLLMProvider(provider string) string { - envVar := "" - switch provider { - case "openai": - envVar = "OPENAI_API_KEY" - case "groq": - envVar = "GROQ_API_KEY" - } - - // 1. Check providers map - if c.Providers != nil { - if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" { - return pc.APIKey - } - } - - // 2. Check environment variable - if envVar != "" { - return os.Getenv(envVar) - } - - return "" -} - -// IsLLMEnabled returns true if LLM post-processing is enabled and configured -func (c *Config) IsLLMEnabled() bool { - return c.LLM.Enabled && c.LLM.Provider != "" && c.LLM.Model != "" -} - -func (c *Config) ToInjectionConfig() injection.Config { - return injection.Config{ - Backends: c.Injection.Backends, - YdotoolTimeout: c.Injection.YdotoolTimeout, - WtypeTimeout: c.Injection.WtypeTimeout, - ClipboardTimeout: c.Injection.ClipboardTimeout, - } -} - -func (c *Config) Validate() error { - // Recording - if c.Recording.SampleRate <= 0 { - return fmt.Errorf("invalid recording.sample_rate: %d", c.Recording.SampleRate) - } - if c.Recording.Channels <= 0 { - return fmt.Errorf("invalid recording.channels: %d", c.Recording.Channels) - } - if c.Recording.BufferSize <= 0 { - return fmt.Errorf("invalid recording.buffer_size: %d", c.Recording.BufferSize) - } - if c.Recording.ChannelBufferSize <= 0 { - return fmt.Errorf("invalid recording.channel_buffer_size: %d", c.Recording.ChannelBufferSize) - } - if c.Recording.Format == "" { - return fmt.Errorf("invalid recording.format: empty") - } - if c.Recording.Timeout <= 0 { - return fmt.Errorf("invalid recording.timeout: %v", c.Recording.Timeout) - } - - // Transcription - if c.Transcription.Provider == "" { - return fmt.Errorf("invalid transcription.provider: empty") - } - - // Validate provider-specific settings using unified API key resolution - apiKey := c.resolveAPIKeyForProvider(c.Transcription.Provider) - - switch c.Transcription.Provider { - case "openai": - if apiKey == "" { - return fmt.Errorf("OpenAI API key required: not found in config (providers.openai.api_key, transcription.api_key) or environment variable (OPENAI_API_KEY)") - } - - // Validate language code if provided (empty string means auto-detect) - if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { - return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) - } - - case "groq-transcription": - if apiKey == "" { - return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)") - } - - // Validate language code if provided (empty string means auto-detect) - if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { - return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) - } - - // Validate Groq model - validGroqModels := map[string]bool{"whisper-large-v3": true, "whisper-large-v3-turbo": true} - if c.Transcription.Model != "" && !validGroqModels[c.Transcription.Model] { - return fmt.Errorf("invalid model for groq-transcription: %s (must be whisper-large-v3 or whisper-large-v3-turbo)", c.Transcription.Model) - } - - case "groq-translation": - if apiKey == "" { - return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)") - } - - // For translation, language field hints at source language (output is always English) - if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { - return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) - } - - // Validate Groq translation model - only whisper-large-v3 is supported (no turbo) - if c.Transcription.Model != "" && c.Transcription.Model != "whisper-large-v3" { - return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model) - } - - case "mistral-transcription": - if apiKey == "" { - return fmt.Errorf("Mistral API key required: not found in config (providers.mistral.api_key, transcription.api_key) or environment variable (MISTRAL_API_KEY)") - } - - // Validate language code if provided (empty string means auto-detect) - if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { - return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) - } - - // Validate Mistral model - validMistralModels := map[string]bool{"voxtral-mini-latest": true, "voxtral-mini-2507": true} - if c.Transcription.Model != "" && !validMistralModels[c.Transcription.Model] { - return fmt.Errorf("invalid model for mistral-transcription: %s (must be voxtral-mini-latest or voxtral-mini-2507)", c.Transcription.Model) - } - - case "elevenlabs": - if apiKey == "" { - return fmt.Errorf("ElevenLabs API key required: not found in config (providers.elevenlabs.api_key, transcription.api_key) or environment variable (ELEVENLABS_API_KEY)") - } - - // Validate language code if provided (empty string means auto-detect) - if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { - return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'pt', 'es')", c.Transcription.Language) - } - - // Validate Eleven Labs model - validModels := map[string]bool{"scribe_v1": true, "scribe_v2": true} - if c.Transcription.Model != "" && !validModels[c.Transcription.Model] { - return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model) - } - - default: - return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider) - } - - if c.Transcription.Model == "" { - return fmt.Errorf("invalid transcription.model: empty") - } - - // LLM (only validate if enabled) - if c.LLM.Enabled { - if c.LLM.Provider == "" { - return fmt.Errorf("llm.provider required when llm.enabled = true") - } - if c.LLM.Model == "" { - return fmt.Errorf("llm.model required when llm.enabled = true") - } - - // Validate LLM provider - validLLMProviders := map[string]bool{"openai": true, "groq": true} - if !validLLMProviders[c.LLM.Provider] { - return fmt.Errorf("invalid llm.provider: %s (must be openai or groq)", c.LLM.Provider) - } - - // Check API key for LLM provider - llmAPIKey := c.resolveAPIKeyForLLMProvider(c.LLM.Provider) - if llmAPIKey == "" { - switch c.LLM.Provider { - case "openai": - return fmt.Errorf("OpenAI API key required for LLM: not found in config (providers.openai.api_key) or environment variable (OPENAI_API_KEY)") - case "groq": - return fmt.Errorf("Groq API key required for LLM: not found in config (providers.groq.api_key) or environment variable (GROQ_API_KEY)") - } - } - } - - // Injection - if len(c.Injection.Backends) == 0 { - return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)") - } - validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true} - for _, backend := range c.Injection.Backends { - if !validBackends[backend] { - return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend) - } - } - if c.Injection.YdotoolTimeout <= 0 { - return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout) - } - if c.Injection.WtypeTimeout <= 0 { - return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout) - } - if c.Injection.ClipboardTimeout <= 0 { - return fmt.Errorf("invalid injection.clipboard_timeout: %v", c.Injection.ClipboardTimeout) - } - - // Notifications - validTypes := map[string]bool{"desktop": true, "log": true, "none": true} - if !validTypes[c.Notifications.Type] { - return fmt.Errorf("invalid notifications.type: %s (must be desktop, log, or none)", c.Notifications.Type) - } - - return nil -} - -func isValidLanguageCode(code string) bool { - validCodes := map[string]bool{ - "en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true, - "ru": true, "ja": true, "ko": true, "zh": true, "ar": true, "hi": true, - "nl": true, "sv": true, "da": true, "no": true, "fi": true, "pl": true, - "tr": true, "he": true, "th": true, "vi": true, "id": true, "ms": true, - "uk": true, "cs": true, "hu": true, "ro": true, "bg": true, "hr": true, - "sk": true, "sl": true, "et": true, "lv": true, "lt": true, "mt": true, - "cy": true, "ga": true, "eu": true, "ca": true, "gl": true, "is": true, - "mk": true, "sq": true, "az": true, "be": true, "ka": true, "hy": true, - "kk": true, "ky": true, "tg": true, "uz": true, "mn": true, "ne": true, - "si": true, "km": true, "lo": true, "my": true, "fa": true, "ps": true, - "ur": true, "bn": true, "ta": true, "te": true, "ml": true, "kn": true, - "gu": true, "pa": true, "or": true, "as": true, "mr": true, "sa": true, - "sw": true, "yo": true, "ig": true, "ha": true, "zu": true, "xh": true, - "af": true, "am": true, "mg": true, "so": true, "sn": true, "rw": true, - } - return validCodes[code] -} - -func GetConfigPath() (string, error) { - configDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("failed to get user config directory: %w", err) - } - - hyprvoiceDir := filepath.Join(configDir, "hyprvoice") - if err := os.MkdirAll(hyprvoiceDir, 0755); err != nil { - return "", fmt.Errorf("failed to create config directory: %w", err) - } - - return filepath.Join(hyprvoiceDir, "config.toml"), nil -} - -// legacyInjectionConfig for migration from old mode-based config -type legacyInjectionConfig struct { - Mode string `toml:"mode"` -} - -// legacyTranscriptionConfig for migration from old api_key in transcription -type legacyTranscriptionConfig struct { - APIKey string `toml:"api_key"` -} - -type legacyConfig struct { - Injection legacyInjectionConfig `toml:"injection"` - Transcription legacyTranscriptionConfig `toml:"transcription"` -} - -func Load() (*Config, error) { - configPath, err := GetConfigPath() - if err != nil { - return nil, err - } - - // If config file doesn't exist, create it with defaults - if _, err := os.Stat(configPath); os.IsNotExist(err) { - log.Printf("Config: no config file found at %s, creating with defaults", configPath) - if err := SaveDefaultConfig(); err != nil { - return nil, fmt.Errorf("failed to create default config: %w", err) - } - log.Printf("Config: default configuration created successfully") - return Load() // Recursively load the config, now file will exist - } - - log.Printf("Config: loading configuration from %s", configPath) - var config Config - if _, err := toml.DecodeFile(configPath, &config); err != nil { - return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) - } - - // Parse legacy config for migrations - var legacy legacyConfig - toml.DecodeFile(configPath, &legacy) - - // Migrate legacy mode-based config to backends - if len(config.Injection.Backends) == 0 { - config.migrateInjectionMode(legacy.Injection.Mode) - } - - // Migrate legacy transcription.api_key to providers map - if legacy.Transcription.APIKey != "" && config.Providers == nil { - config.migrateTranscriptionAPIKey(legacy.Transcription.APIKey) - } - - // Initialize providers map if nil - if config.Providers == nil { - config.Providers = make(map[string]ProviderConfig) - } - - // Set LLM defaults if not configured - config.applyLLMDefaults() - - log.Printf("Config: configuration loaded successfully") - return &config, nil -} - -// migrateTranscriptionAPIKey migrates old transcription.api_key to providers map -func (c *Config) migrateTranscriptionAPIKey(apiKey string) { - if c.Providers == nil { - c.Providers = make(map[string]ProviderConfig) - } - - // Determine which provider this key is for based on transcription.provider - providerName := c.Transcription.Provider - switch providerName { - case "openai": - c.Providers["openai"] = ProviderConfig{APIKey: apiKey} - case "groq-transcription", "groq-translation": - c.Providers["groq"] = ProviderConfig{APIKey: apiKey} - case "mistral-transcription": - c.Providers["mistral"] = ProviderConfig{APIKey: apiKey} - case "elevenlabs": - c.Providers["elevenlabs"] = ProviderConfig{APIKey: apiKey} - default: - // Unknown provider, try to guess based on key prefix - if len(apiKey) > 3 && apiKey[:3] == "sk-" { - c.Providers["openai"] = ProviderConfig{APIKey: apiKey} - } else if len(apiKey) > 4 && apiKey[:4] == "gsk_" { - c.Providers["groq"] = ProviderConfig{APIKey: apiKey} - } - } - - log.Printf("Config: migrated transcription.api_key to providers map. Run 'hyprvoice configure' to update config format.") -} - -// applyLLMDefaults sets default values for LLM config -func (c *Config) applyLLMDefaults() { - // Default post-processing options to true if LLM is enabled and not explicitly set - // We detect "not set" by checking if all booleans are false (zero value) - // Since the default behavior should be all true, we only apply if everything is false - pp := &c.LLM.PostProcessing - if !pp.RemoveStutters && !pp.AddPunctuation && !pp.FixGrammar && !pp.RemoveFillerWords { - // Nothing was set, apply defaults - pp.RemoveStutters = true - pp.AddPunctuation = true - pp.FixGrammar = true - pp.RemoveFillerWords = true - } -} - -// migrateInjectionMode converts old mode field to new backends array -func (c *Config) migrateInjectionMode(mode string) { - switch mode { - case "clipboard": - c.Injection.Backends = []string{"clipboard"} - log.Printf("Config: migrated injection.mode='clipboard' to backends=['clipboard']") - case "type": - c.Injection.Backends = []string{"wtype"} - log.Printf("Config: migrated injection.mode='type' to backends=['wtype']") - case "fallback": - c.Injection.Backends = []string{"wtype", "clipboard"} - log.Printf("Config: migrated injection.mode='fallback' to backends=['wtype', 'clipboard']") - default: - // Default for new installs or unknown modes - c.Injection.Backends = []string{"ydotool", "wtype", "clipboard"} - if mode != "" { - log.Printf("Config: unknown injection.mode='%s', using default backends", mode) - } - } - - // Set default ydotool timeout if not set - if c.Injection.YdotoolTimeout == 0 { - c.Injection.YdotoolTimeout = 5 * time.Second - } - - log.Printf("Config: legacy 'mode' config detected - please update your config.toml to use 'backends' instead") -} - -func SaveDefaultConfig() error { - configPath, err := GetConfigPath() - if err != nil { - return err - } - - file, err := os.Create(configPath) - if err != nil { - return fmt.Errorf("failed to create config file: %w", err) - } - defer file.Close() - - configContent := `# Hyprvoice Configuration -# This file is automatically generated with defaults. -# Edit values as needed - changes are applied immediately without daemon restart. -# -# MIGRATION NOTE: If upgrading from an older version, your transcription.api_key -# will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure' -# to update your config file structure. - -# Keywords help both transcription and LLM understand domain-specific terms -# Add names, technical terms, or brand names that might be misheard -keywords = [] - -# ───────────────────────────────────────────────────────────────────────────── -# Provider API Keys -# Configure API keys for each provider you want to use. -# Keys can also be set via environment variables: OPENAI_API_KEY, GROQ_API_KEY, etc. -# ───────────────────────────────────────────────────────────────────────────── - -[providers.openai] - api_key = "" # OpenAI API key (or set OPENAI_API_KEY env var) - -[providers.groq] - api_key = "" # Groq API key (or set GROQ_API_KEY env var) - -# Uncomment to configure additional providers: -# [providers.mistral] -# api_key = "" # Mistral API key (or set MISTRAL_API_KEY env var) -# [providers.elevenlabs] -# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var) - -# ───────────────────────────────────────────────────────────────────────────── -# Audio Recording -# ───────────────────────────────────────────────────────────────────────────── - -[recording] - sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech) - channels = 1 # Number of audio channels (1 = mono, 2 = stereo) - format = "s16" # Audio format (s16 = 16-bit signed integers) - buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency) - device = "" # PipeWire audio device (empty = use default microphone) - channel_buffer_size = 30 # Audio frame buffer size (frames to buffer) - timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m") - -# ───────────────────────────────────────────────────────────────────────────── -# Speech Transcription -# Converts audio to text using speech-to-text APIs -# ───────────────────────────────────────────────────────────────────────────── - -[transcription] - provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs" - language = "" # Language code (empty = auto-detect, "en", "it", "es", "fr", etc.) - model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" - -# ───────────────────────────────────────────────────────────────────────────── -# LLM Post-Processing (Recommended) -# Cleans up transcribed text: removes stutters, adds punctuation, fixes grammar -# ───────────────────────────────────────────────────────────────────────────── - -[llm] - enabled = true # Enable LLM post-processing (highly recommended) - provider = "openai" # "openai" or "groq" (must have API key configured above) - model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile" - -[llm.post_processing] - remove_stutters = true # Remove "um", "uh", repeated words - add_punctuation = true # Add proper punctuation - fix_grammar = true # Fix grammatical errors - remove_filler_words = true # Remove "like", "you know", "basically" - -[llm.custom_prompt] - enabled = false # Enable custom instructions for LLM - prompt = "" # Additional instructions (e.g., "Format as bullet points") - -# ───────────────────────────────────────────────────────────────────────────── -# Text Injection -# How transcribed text is inserted into applications -# ───────────────────────────────────────────────────────────────────────────── - -[injection] - backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds) - ydotool_timeout = "5s" # Timeout for ydotool commands - wtype_timeout = "5s" # Timeout for wtype commands - clipboard_timeout = "3s" # Timeout for clipboard operations - -# ───────────────────────────────────────────────────────────────────────────── -# Desktop Notifications -# ───────────────────────────────────────────────────────────────────────────── - -[notifications] - enabled = true # Enable desktop notifications - type = "desktop" # "desktop", "log", or "none" - - # Custom notification messages (optional - defaults shown below) - # Uncomment and modify to customize notification text - # [notifications.messages] - # [notifications.messages.recording_started] - # title = "Hyprvoice" - # body = "Recording Started" - # [notifications.messages.transcribing] - # title = "Hyprvoice" - # body = "Recording Ended... Transcribing" - # [notifications.messages.llm_processing] - # title = "Hyprvoice" - # body = "Processing..." - # [notifications.messages.config_reloaded] - # title = "Hyprvoice" - # body = "Config Reloaded" - # [notifications.messages.operation_cancelled] - # title = "Hyprvoice" - # body = "Operation Cancelled" - # [notifications.messages.recording_aborted] - # body = "Recording Aborted" - # [notifications.messages.injection_aborted] - # body = "Injection Aborted" - # - # Emoji-only example (for minimal pill-style notifications): - # [notifications.messages.recording_started] - # title = "" - # body = "..." - -# ───────────────────────────────────────────────────────────────────────────── -# Reference: Provider Details -# ───────────────────────────────────────────────────────────────────────────── -# -# Transcription providers: -# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy) -# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo) -# - "groq-translation": Groq translation to English (always outputs English text, model: whisper-large-v3) -# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest) -# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2) -# -# LLM providers (for post-processing): -# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance) -# - "groq": Fast inference (llama-3.3-70b-versatile recommended) -# -# Injection backends: -# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps. -# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps. -# - "clipboard": Copies to clipboard only (most reliable, requires manual paste). -# -# Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc. -` - - if _, err := file.WriteString(configContent); err != nil { - return fmt.Errorf("failed to write config content: %w", err) - } - - return nil -} diff --git a/internal/config/convert.go b/internal/config/convert.go new file mode 100644 index 0000000..b432061 --- /dev/null +++ b/internal/config/convert.go @@ -0,0 +1,130 @@ +package config + +import ( + "os" + + "github.com/leonardotrapani/hyprvoice/internal/injection" + "github.com/leonardotrapani/hyprvoice/internal/recording" + "github.com/leonardotrapani/hyprvoice/internal/transcriber" +) + +func (c *Config) ToRecordingConfig() recording.Config { + return recording.Config{ + SampleRate: c.Recording.SampleRate, + Channels: c.Recording.Channels, + Format: c.Recording.Format, + BufferSize: c.Recording.BufferSize, + Device: c.Recording.Device, + ChannelBufferSize: c.Recording.ChannelBufferSize, + Timeout: c.Recording.Timeout, + } +} + +func (c *Config) ToTranscriberConfig() transcriber.Config { + config := transcriber.Config{ + Provider: c.Transcription.Provider, + Language: c.Transcription.Language, + Model: c.Transcription.Model, + Keywords: c.Keywords, + } + + config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider) + + return config +} + +// resolveAPIKeyForProvider returns the API key for a provider from multiple sources +func (c *Config) resolveAPIKeyForProvider(provider string) string { + providerName := provider + envVar := "" + switch provider { + case "openai": + providerName = "openai" + envVar = "OPENAI_API_KEY" + case "groq-transcription", "groq-translation": + providerName = "groq" + envVar = "GROQ_API_KEY" + case "mistral-transcription": + providerName = "mistral" + envVar = "MISTRAL_API_KEY" + case "elevenlabs": + providerName = "elevenlabs" + envVar = "ELEVENLABS_API_KEY" + } + + if c.Providers != nil { + if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" { + return pc.APIKey + } + } + + if c.Transcription.APIKey != "" { + return c.Transcription.APIKey + } + + if envVar != "" { + return os.Getenv(envVar) + } + + return "" +} + +// ToLLMConfig returns the LLM adapter configuration +func (c *Config) ToLLMConfig() LLMAdapterConfig { + config := LLMAdapterConfig{ + Provider: c.LLM.Provider, + Model: c.LLM.Model, + RemoveStutters: c.LLM.PostProcessing.RemoveStutters, + AddPunctuation: c.LLM.PostProcessing.AddPunctuation, + FixGrammar: c.LLM.PostProcessing.FixGrammar, + RemoveFillerWords: c.LLM.PostProcessing.RemoveFillerWords, + Keywords: c.Keywords, + } + + if c.LLM.Provider != "" { + config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider) + } + + if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" { + config.CustomPrompt = c.LLM.CustomPrompt.Prompt + } + + return config +} + +// resolveAPIKeyForLLMProvider returns the API key for an LLM provider +func (c *Config) resolveAPIKeyForLLMProvider(provider string) string { + envVar := "" + switch provider { + case "openai": + envVar = "OPENAI_API_KEY" + case "groq": + envVar = "GROQ_API_KEY" + } + + if c.Providers != nil { + if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" { + return pc.APIKey + } + } + + if envVar != "" { + return os.Getenv(envVar) + } + + return "" +} + +// IsLLMEnabled returns true if LLM post-processing is enabled and configured +func (c *Config) IsLLMEnabled() bool { + return c.LLM.Enabled && c.LLM.Provider != "" && c.LLM.Model != "" +} + +func (c *Config) ToInjectionConfig() injection.Config { + return injection.Config{ + Backends: c.Injection.Backends, + YdotoolTimeout: c.Injection.YdotoolTimeout, + WtypeTimeout: c.Injection.WtypeTimeout, + ClipboardTimeout: c.Injection.ClipboardTimeout, + } +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..33d7460 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,146 @@ +package config + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" +) + +func GetConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get user config directory: %w", err) + } + + hyprvoiceDir := filepath.Join(configDir, "hyprvoice") + if err := os.MkdirAll(hyprvoiceDir, 0755); err != nil { + return "", fmt.Errorf("failed to create config directory: %w", err) + } + + return filepath.Join(hyprvoiceDir, "config.toml"), nil +} + +// legacyInjectionConfig for migration from old mode-based config +type legacyInjectionConfig struct { + Mode string `toml:"mode"` +} + +// legacyTranscriptionConfig for migration from old api_key in transcription +type legacyTranscriptionConfig struct { + APIKey string `toml:"api_key"` +} + +type legacyConfig struct { + Injection legacyInjectionConfig `toml:"injection"` + Transcription legacyTranscriptionConfig `toml:"transcription"` +} + +func Load() (*Config, error) { + configPath, err := GetConfigPath() + if err != nil { + return nil, err + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + log.Printf("Config: no config file found at %s, creating with defaults", configPath) + if err := SaveDefaultConfig(); err != nil { + return nil, fmt.Errorf("failed to create default config: %w", err) + } + log.Printf("Config: default configuration created successfully") + return Load() + } + + log.Printf("Config: loading configuration from %s", configPath) + var config Config + if _, err := toml.DecodeFile(configPath, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) + } + + var legacy legacyConfig + toml.DecodeFile(configPath, &legacy) + + if len(config.Injection.Backends) == 0 { + config.migrateInjectionMode(legacy.Injection.Mode) + } + + if legacy.Transcription.APIKey != "" && config.Providers == nil { + config.migrateTranscriptionAPIKey(legacy.Transcription.APIKey) + } + + if config.Providers == nil { + config.Providers = make(map[string]ProviderConfig) + } + + config.applyLLMDefaults() + + log.Printf("Config: configuration loaded successfully") + return &config, nil +} + +// migrateTranscriptionAPIKey migrates old transcription.api_key to providers map +func (c *Config) migrateTranscriptionAPIKey(apiKey string) { + if c.Providers == nil { + c.Providers = make(map[string]ProviderConfig) + } + + providerName := c.Transcription.Provider + switch providerName { + case "openai": + c.Providers["openai"] = ProviderConfig{APIKey: apiKey} + case "groq-transcription", "groq-translation": + c.Providers["groq"] = ProviderConfig{APIKey: apiKey} + case "mistral-transcription": + c.Providers["mistral"] = ProviderConfig{APIKey: apiKey} + case "elevenlabs": + c.Providers["elevenlabs"] = ProviderConfig{APIKey: apiKey} + default: + if len(apiKey) > 3 && apiKey[:3] == "sk-" { + c.Providers["openai"] = ProviderConfig{APIKey: apiKey} + } else if len(apiKey) > 4 && apiKey[:4] == "gsk_" { + c.Providers["groq"] = ProviderConfig{APIKey: apiKey} + } + } + + log.Printf("Config: migrated transcription.api_key to providers map. Run 'hyprvoice configure' to update config format.") +} + +// applyLLMDefaults sets default values for LLM config +func (c *Config) applyLLMDefaults() { + pp := &c.LLM.PostProcessing + if !pp.RemoveStutters && !pp.AddPunctuation && !pp.FixGrammar && !pp.RemoveFillerWords { + pp.RemoveStutters = true + pp.AddPunctuation = true + pp.FixGrammar = true + pp.RemoveFillerWords = true + } +} + +// migrateInjectionMode converts old mode field to new backends array +func (c *Config) migrateInjectionMode(mode string) { + switch mode { + case "clipboard": + c.Injection.Backends = []string{"clipboard"} + log.Printf("Config: migrated injection.mode='clipboard' to backends=['clipboard']") + case "type": + c.Injection.Backends = []string{"wtype"} + log.Printf("Config: migrated injection.mode='type' to backends=['wtype']") + case "fallback": + c.Injection.Backends = []string{"wtype", "clipboard"} + log.Printf("Config: migrated injection.mode='fallback' to backends=['wtype', 'clipboard']") + default: + c.Injection.Backends = []string{"ydotool", "wtype", "clipboard"} + if mode != "" { + log.Printf("Config: unknown injection.mode='%s', using default backends", mode) + } + } + + if c.Injection.YdotoolTimeout == 0 { + c.Injection.YdotoolTimeout = 5 * time.Second + } + + log.Printf("Config: legacy 'mode' config detected - please update your config.toml to use 'backends' instead") +} diff --git a/internal/config/save.go b/internal/config/save.go new file mode 100644 index 0000000..dcff232 --- /dev/null +++ b/internal/config/save.go @@ -0,0 +1,168 @@ +package config + +import ( + "fmt" + "os" +) + +func SaveDefaultConfig() error { + configPath, err := GetConfigPath() + if err != nil { + return err + } + + file, err := os.Create(configPath) + if err != nil { + return fmt.Errorf("failed to create config file: %w", err) + } + defer file.Close() + + configContent := `# Hyprvoice Configuration +# This file is automatically generated with defaults. +# Edit values as needed - changes are applied immediately without daemon restart. +# +# MIGRATION NOTE: If upgrading from an older version, your transcription.api_key +# will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure' +# to update your config file structure. + +# Keywords help both transcription and LLM understand domain-specific terms +# Add names, technical terms, or brand names that might be misheard +keywords = [] + +# ───────────────────────────────────────────────────────────────────────────── +# Provider API Keys +# Configure API keys for each provider you want to use. +# Keys can also be set via environment variables: OPENAI_API_KEY, GROQ_API_KEY, etc. +# ───────────────────────────────────────────────────────────────────────────── + +[providers.openai] + api_key = "" # OpenAI API key (or set OPENAI_API_KEY env var) + +[providers.groq] + api_key = "" # Groq API key (or set GROQ_API_KEY env var) + +# Uncomment to configure additional providers: +# [providers.mistral] +# api_key = "" # Mistral API key (or set MISTRAL_API_KEY env var) +# [providers.elevenlabs] +# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var) + +# ───────────────────────────────────────────────────────────────────────────── +# Audio Recording +# ───────────────────────────────────────────────────────────────────────────── + +[recording] + sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech) + channels = 1 # Number of audio channels (1 = mono, 2 = stereo) + format = "s16" # Audio format (s16 = 16-bit signed integers) + buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency) + device = "" # PipeWire audio device (empty = use default microphone) + channel_buffer_size = 30 # Audio frame buffer size (frames to buffer) + timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m") + +# ───────────────────────────────────────────────────────────────────────────── +# Speech Transcription +# Converts audio to text using speech-to-text APIs +# ───────────────────────────────────────────────────────────────────────────── + +[transcription] + provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs" + language = "" # Language code (empty = auto-detect, "en", "it", "es", "fr", etc.) + model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" + +# ───────────────────────────────────────────────────────────────────────────── +# LLM Post-Processing (Recommended) +# Cleans up transcribed text: removes stutters, adds punctuation, fixes grammar +# ───────────────────────────────────────────────────────────────────────────── + +[llm] + enabled = true # Enable LLM post-processing (highly recommended) + provider = "openai" # "openai" or "groq" (must have API key configured above) + model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile" + +[llm.post_processing] + remove_stutters = true # Remove "um", "uh", repeated words + add_punctuation = true # Add proper punctuation + fix_grammar = true # Fix grammatical errors + remove_filler_words = true # Remove "like", "you know", "basically" + +[llm.custom_prompt] + enabled = false # Enable custom instructions for LLM + prompt = "" # Additional instructions (e.g., "Format as bullet points") + +# ───────────────────────────────────────────────────────────────────────────── +# Text Injection +# How transcribed text is inserted into applications +# ───────────────────────────────────────────────────────────────────────────── + +[injection] + backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds) + ydotool_timeout = "5s" # Timeout for ydotool commands + wtype_timeout = "5s" # Timeout for wtype commands + clipboard_timeout = "3s" # Timeout for clipboard operations + +# ───────────────────────────────────────────────────────────────────────────── +# Desktop Notifications +# ───────────────────────────────────────────────────────────────────────────── + +[notifications] + enabled = true # Enable desktop notifications + type = "desktop" # "desktop", "log", or "none" + + # Custom notification messages (optional - defaults shown below) + # Uncomment and modify to customize notification text + # [notifications.messages] + # [notifications.messages.recording_started] + # title = "Hyprvoice" + # body = "Recording Started" + # [notifications.messages.transcribing] + # title = "Hyprvoice" + # body = "Recording Ended... Transcribing" + # [notifications.messages.llm_processing] + # title = "Hyprvoice" + # body = "Processing..." + # [notifications.messages.config_reloaded] + # title = "Hyprvoice" + # body = "Config Reloaded" + # [notifications.messages.operation_cancelled] + # title = "Hyprvoice" + # body = "Operation Cancelled" + # [notifications.messages.recording_aborted] + # body = "Recording Aborted" + # [notifications.messages.injection_aborted] + # body = "Injection Aborted" + # + # Emoji-only example (for minimal pill-style notifications): + # [notifications.messages.recording_started] + # title = "" + # body = "..." + +# ───────────────────────────────────────────────────────────────────────────── +# Reference: Provider Details +# ───────────────────────────────────────────────────────────────────────────── +# +# Transcription providers: +# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy) +# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo) +# - "groq-translation": Groq translation to English (always outputs English text, model: whisper-large-v3) +# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest) +# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2) +# +# LLM providers (for post-processing): +# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance) +# - "groq": Fast inference (llama-3.3-70b-versatile recommended) +# +# Injection backends: +# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps. +# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps. +# - "clipboard": Copies to clipboard only (most reliable, requires manual paste). +# +# Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc. +` + + if _, err := file.WriteString(configContent); err != nil { + return fmt.Errorf("failed to write config content: %w", err) + } + + return nil +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..fbf10e8 --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,135 @@ +package config + +import ( + "reflect" + "time" + + "github.com/leonardotrapani/hyprvoice/internal/notify" +) + +type Config struct { + Recording RecordingConfig `toml:"recording"` + Transcription TranscriptionConfig `toml:"transcription"` + Injection InjectionConfig `toml:"injection"` + Notifications NotificationsConfig `toml:"notifications"` + Providers map[string]ProviderConfig `toml:"providers"` + Keywords []string `toml:"keywords"` + LLM LLMConfig `toml:"llm"` +} + +// ProviderConfig holds API key for a provider +type ProviderConfig struct { + APIKey string `toml:"api_key"` +} + +// LLMConfig configures the LLM post-processing phase +type LLMConfig struct { + Enabled bool `toml:"enabled"` + Provider string `toml:"provider"` + Model string `toml:"model"` + PostProcessing LLMPostProcessingConfig `toml:"post_processing"` + CustomPrompt LLMCustomPromptConfig `toml:"custom_prompt"` +} + +// LLMPostProcessingConfig controls text cleanup options +type LLMPostProcessingConfig struct { + RemoveStutters bool `toml:"remove_stutters"` + AddPunctuation bool `toml:"add_punctuation"` + FixGrammar bool `toml:"fix_grammar"` + RemoveFillerWords bool `toml:"remove_filler_words"` +} + +// LLMCustomPromptConfig allows custom prompts +type LLMCustomPromptConfig struct { + Enabled bool `toml:"enabled"` + Prompt string `toml:"prompt"` +} + +type RecordingConfig struct { + SampleRate int `toml:"sample_rate"` + Channels int `toml:"channels"` + Format string `toml:"format"` + BufferSize int `toml:"buffer_size"` + Device string `toml:"device"` + ChannelBufferSize int `toml:"channel_buffer_size"` + Timeout time.Duration `toml:"timeout"` +} + +type TranscriptionConfig struct { + Provider string `toml:"provider"` + APIKey string `toml:"api_key"` + Language string `toml:"language"` + Model string `toml:"model"` +} + +type InjectionConfig struct { + Backends []string `toml:"backends"` + YdotoolTimeout time.Duration `toml:"ydotool_timeout"` + WtypeTimeout time.Duration `toml:"wtype_timeout"` + ClipboardTimeout time.Duration `toml:"clipboard_timeout"` +} + +type NotificationsConfig struct { + Enabled bool `toml:"enabled"` + Type string `toml:"type"` // "desktop", "log", "none" + Messages MessagesConfig `toml:"messages"` +} + +type MessageConfig struct { + Title string `toml:"title"` + Body string `toml:"body"` +} + +type MessagesConfig struct { + RecordingStarted MessageConfig `toml:"recording_started"` + Transcribing MessageConfig `toml:"transcribing"` + LLMProcessing MessageConfig `toml:"llm_processing"` + ConfigReloaded MessageConfig `toml:"config_reloaded"` + OperationCancelled MessageConfig `toml:"operation_cancelled"` + RecordingAborted MessageConfig `toml:"recording_aborted"` + InjectionAborted MessageConfig `toml:"injection_aborted"` +} + +// Resolve merges user config with defaults from MessageDefs +func (m *MessagesConfig) Resolve() map[notify.MessageType]notify.Message { + result := make(map[notify.MessageType]notify.Message) + + v := reflect.ValueOf(m).Elem() + t := v.Type() + tagToField := make(map[string]int) + for i := 0; i < t.NumField(); i++ { + tagToField[t.Field(i).Tag.Get("toml")] = i + } + + for _, def := range notify.MessageDefs { + msg := notify.Message{ + Title: def.DefaultTitle, + Body: def.DefaultBody, + IsError: def.IsError, + } + if idx, ok := tagToField[def.ConfigKey]; ok { + userMsg := v.Field(idx).Interface().(MessageConfig) + if userMsg.Title != "" { + msg.Title = userMsg.Title + } + if userMsg.Body != "" { + msg.Body = userMsg.Body + } + } + result[def.Type] = msg + } + return result +} + +// LLMAdapterConfig is the configuration passed to the LLM adapter +type LLMAdapterConfig struct { + Provider string + APIKey string + Model string + RemoveStutters bool + AddPunctuation bool + FixGrammar bool + RemoveFillerWords bool + CustomPrompt string + Keywords []string +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..da2eaa9 --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,173 @@ +package config + +import "fmt" + +func (c *Config) Validate() error { + if c.Recording.SampleRate <= 0 { + return fmt.Errorf("invalid recording.sample_rate: %d", c.Recording.SampleRate) + } + if c.Recording.Channels <= 0 { + return fmt.Errorf("invalid recording.channels: %d", c.Recording.Channels) + } + if c.Recording.BufferSize <= 0 { + return fmt.Errorf("invalid recording.buffer_size: %d", c.Recording.BufferSize) + } + if c.Recording.ChannelBufferSize <= 0 { + return fmt.Errorf("invalid recording.channel_buffer_size: %d", c.Recording.ChannelBufferSize) + } + if c.Recording.Format == "" { + return fmt.Errorf("invalid recording.format: empty") + } + if c.Recording.Timeout <= 0 { + return fmt.Errorf("invalid recording.timeout: %v", c.Recording.Timeout) + } + + if c.Transcription.Provider == "" { + return fmt.Errorf("invalid transcription.provider: empty") + } + + apiKey := c.resolveAPIKeyForProvider(c.Transcription.Provider) + + switch c.Transcription.Provider { + case "openai": + if apiKey == "" { + return fmt.Errorf("OpenAI API key required: not found in config (providers.openai.api_key, transcription.api_key) or environment variable (OPENAI_API_KEY)") + } + + if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { + return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) + } + + case "groq-transcription": + if apiKey == "" { + return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)") + } + + if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { + return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) + } + + validGroqModels := map[string]bool{"whisper-large-v3": true, "whisper-large-v3-turbo": true} + if c.Transcription.Model != "" && !validGroqModels[c.Transcription.Model] { + return fmt.Errorf("invalid model for groq-transcription: %s (must be whisper-large-v3 or whisper-large-v3-turbo)", c.Transcription.Model) + } + + case "groq-translation": + if apiKey == "" { + return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)") + } + + if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { + return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) + } + + if c.Transcription.Model != "" && c.Transcription.Model != "whisper-large-v3" { + return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model) + } + + case "mistral-transcription": + if apiKey == "" { + return fmt.Errorf("Mistral API key required: not found in config (providers.mistral.api_key, transcription.api_key) or environment variable (MISTRAL_API_KEY)") + } + + if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { + return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) + } + + validMistralModels := map[string]bool{"voxtral-mini-latest": true, "voxtral-mini-2507": true} + if c.Transcription.Model != "" && !validMistralModels[c.Transcription.Model] { + return fmt.Errorf("invalid model for mistral-transcription: %s (must be voxtral-mini-latest or voxtral-mini-2507)", c.Transcription.Model) + } + + case "elevenlabs": + if apiKey == "" { + return fmt.Errorf("ElevenLabs API key required: not found in config (providers.elevenlabs.api_key, transcription.api_key) or environment variable (ELEVENLABS_API_KEY)") + } + + if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { + return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'pt', 'es')", c.Transcription.Language) + } + + validModels := map[string]bool{"scribe_v1": true, "scribe_v2": true} + if c.Transcription.Model != "" && !validModels[c.Transcription.Model] { + return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model) + } + + default: + return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider) + } + + if c.Transcription.Model == "" { + return fmt.Errorf("invalid transcription.model: empty") + } + + if c.LLM.Enabled { + if c.LLM.Provider == "" { + return fmt.Errorf("llm.provider required when llm.enabled = true") + } + if c.LLM.Model == "" { + return fmt.Errorf("llm.model required when llm.enabled = true") + } + + validLLMProviders := map[string]bool{"openai": true, "groq": true} + if !validLLMProviders[c.LLM.Provider] { + return fmt.Errorf("invalid llm.provider: %s (must be openai or groq)", c.LLM.Provider) + } + + llmAPIKey := c.resolveAPIKeyForLLMProvider(c.LLM.Provider) + if llmAPIKey == "" { + switch c.LLM.Provider { + case "openai": + return fmt.Errorf("OpenAI API key required for LLM: not found in config (providers.openai.api_key) or environment variable (OPENAI_API_KEY)") + case "groq": + return fmt.Errorf("Groq API key required for LLM: not found in config (providers.groq.api_key) or environment variable (GROQ_API_KEY)") + } + } + } + + if len(c.Injection.Backends) == 0 { + return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)") + } + validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true} + for _, backend := range c.Injection.Backends { + if !validBackends[backend] { + return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend) + } + } + if c.Injection.YdotoolTimeout <= 0 { + return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout) + } + if c.Injection.WtypeTimeout <= 0 { + return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout) + } + if c.Injection.ClipboardTimeout <= 0 { + return fmt.Errorf("invalid injection.clipboard_timeout: %v", c.Injection.ClipboardTimeout) + } + + validTypes := map[string]bool{"desktop": true, "log": true, "none": true} + if !validTypes[c.Notifications.Type] { + return fmt.Errorf("invalid notifications.type: %s (must be desktop, log, or none)", c.Notifications.Type) + } + + return nil +} + +func isValidLanguageCode(code string) bool { + validCodes := map[string]bool{ + "en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true, + "ru": true, "ja": true, "ko": true, "zh": true, "ar": true, "hi": true, + "nl": true, "sv": true, "da": true, "no": true, "fi": true, "pl": true, + "tr": true, "he": true, "th": true, "vi": true, "id": true, "ms": true, + "uk": true, "cs": true, "hu": true, "ro": true, "bg": true, "hr": true, + "sk": true, "sl": true, "et": true, "lv": true, "lt": true, "mt": true, + "cy": true, "ga": true, "eu": true, "ca": true, "gl": true, "is": true, + "mk": true, "sq": true, "az": true, "be": true, "ka": true, "hy": true, + "kk": true, "ky": true, "tg": true, "uz": true, "mn": true, "ne": true, + "si": true, "km": true, "lo": true, "my": true, "fa": true, "ps": true, + "ur": true, "bn": true, "ta": true, "te": true, "ml": true, "kn": true, + "gu": true, "pa": true, "or": true, "as": true, "mr": true, "sa": true, + "sw": true, "yo": true, "ig": true, "ha": true, "zu": true, "xh": true, + "af": true, "am": true, "mg": true, "so": true, "sn": true, "rw": true, + } + return validCodes[code] +} diff --git a/internal/tui/configure.go b/internal/tui/configure.go index 79c57e8..4b795ea 100644 --- a/internal/tui/configure.go +++ b/internal/tui/configure.go @@ -2,12 +2,12 @@ package tui import ( "fmt" - "strings" + "os" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/leonardotrapani/hyprvoice/internal/config" - "github.com/leonardotrapani/hyprvoice/internal/provider" + "github.com/muesli/termenv" ) // ConfigureResult holds the configuration result from the TUI @@ -16,6 +16,17 @@ type ConfigureResult struct { Cancelled bool } +// AllProviders is the list of all supported providers +var AllProviders = []string{"openai", "groq", "mistral", "elevenlabs"} + +// providerDisplayNames maps provider IDs to human-readable names +var providerDisplayNames = map[string]string{ + "openai": "OpenAI", + "groq": "Groq", + "mistral": "Mistral", + "elevenlabs": "ElevenLabs", +} + // ConfigSection represents a configuration section type ConfigSection string @@ -26,14 +37,13 @@ const ( SectionKeywords ConfigSection = "keywords" SectionInjection ConfigSection = "injection" SectionNotifications ConfigSection = "notifications" - SectionFullSetup ConfigSection = "full_setup" + SectionAdvanced ConfigSection = "advanced" SectionSaveExit ConfigSection = "save_exit" SectionDiscardExit ConfigSection = "discard_exit" ) // Run starts the TUI configuration wizard func Run(existingConfig *config.Config) (*ConfigureResult, error) { - // Detect if config has user changes (providers configured) if existingConfig != nil && hasUserChanges(existingConfig) { return runEditExisting(existingConfig) } @@ -42,11 +52,9 @@ func Run(existingConfig *config.Config) (*ConfigureResult, error) { // hasUserChanges detects if config has user modifications func hasUserChanges(cfg *config.Config) bool { - // If providers map has entries, user has configured something if len(cfg.Providers) > 0 { return true } - // If legacy api_key is set, user has configured something if cfg.Transcription.APIKey != "" { return true } @@ -58,13 +66,10 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) { fmt.Println(Logo()) fmt.Println() - // Track which providers are configured (for smart detection) configuredProviders := getConfiguredProviders(cfg) - // Menu loop for { - // Clear screen for cleaner UX - fmt.Print("\033[H\033[2J") + clearScreen() fmt.Println(Logo()) fmt.Println() @@ -82,17 +87,13 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) { if confirmed { return &ConfigureResult{Config: cfg, Cancelled: false}, nil } - // User cancelled save, back to menu case SectionDiscardExit: return &ConfigureResult{Cancelled: true}, nil - case SectionFullSetup: - return runFreshInstall(cfg) - case SectionProviders: if err := editProviders(cfg); err != nil { - continue // back to menu on cancel + continue } configuredProviders = getConfiguredProviders(cfg) @@ -100,36 +101,39 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) { var err error configuredProviders, err = editTranscription(cfg, configuredProviders) if err != nil { - continue // back to menu on cancel + continue } case SectionLLM: var err error configuredProviders, err = editLLM(cfg, configuredProviders) if err != nil { - continue // back to menu on cancel + continue } case SectionKeywords: keywords, err := inputKeywords(cfg.Keywords) if err != nil { - continue // back to menu on cancel + continue } cfg.Keywords = keywords case SectionInjection: backends, err := selectBackends(cfg.Injection.Backends) if err != nil { - continue // back to menu on cancel + continue } cfg.Injection.Backends = backends case SectionNotifications: - enabled, err := configureNotifications(cfg.Notifications.Enabled) - if err != nil { - continue // back to menu on cancel + if err := editNotifications(cfg); err != nil { + continue + } + + case SectionAdvanced: + if err := editAdvanced(cfg); err != nil { + continue } - cfg.Notifications.Enabled = enabled } } } @@ -142,7 +146,7 @@ func selectSection(cfg *config.Config) (ConfigSection, error) { huh.NewOption(formatKeywordsLabel(cfg), SectionKeywords), huh.NewOption(formatInjectionLabel(cfg), SectionInjection), huh.NewOption(formatNotificationsLabel(cfg), SectionNotifications), - huh.NewOption("Full Setup (reconfigure everything)", SectionFullSetup), + huh.NewOption("Advanced Settings", SectionAdvanced), huh.NewOption("Save & Exit", SectionSaveExit), huh.NewOption("Discard & Exit", SectionDiscardExit), } @@ -165,1168 +169,21 @@ func selectSection(cfg *config.Config) (ConfigSection, error) { return selected, nil } -// formatProvidersLabel shows configured providers -func formatProvidersLabel(cfg *config.Config) string { - var providers []string - for name, pc := range cfg.Providers { - if pc.APIKey != "" { - providers = append(providers, name) - } - } - if len(providers) == 0 { - return "Providers - none configured" - } - return fmt.Sprintf("Providers - %s", strings.Join(providers, ", ")) -} - -// formatTranscriptionLabel shows current transcription settings -func formatTranscriptionLabel(cfg *config.Config) string { - if cfg.Transcription.Provider == "" { - return "Transcription - not configured" - } - return fmt.Sprintf("Transcription - %s/%s", cfg.Transcription.Provider, cfg.Transcription.Model) -} - -// formatLLMLabel shows current LLM settings -func formatLLMLabel(cfg *config.Config) string { - if !cfg.LLM.Enabled { - return "LLM - disabled" - } - if cfg.LLM.Provider == "" { - return "LLM - enabled (not configured)" - } - return fmt.Sprintf("LLM - %s/%s", cfg.LLM.Provider, cfg.LLM.Model) -} - -// formatKeywordsLabel shows keyword count -func formatKeywordsLabel(cfg *config.Config) string { - if len(cfg.Keywords) == 0 { - return "Keywords - none" - } - if len(cfg.Keywords) <= 3 { - return fmt.Sprintf("Keywords - %s", strings.Join(cfg.Keywords, ", ")) - } - return fmt.Sprintf("Keywords - %d configured", len(cfg.Keywords)) -} - -// formatInjectionLabel shows backends -func formatInjectionLabel(cfg *config.Config) string { - if len(cfg.Injection.Backends) == 0 { - return "Injection - no backends" - } - return fmt.Sprintf("Injection - %s", strings.Join(cfg.Injection.Backends, " → ")) -} - -// formatNotificationsLabel shows notification status -func formatNotificationsLabel(cfg *config.Config) string { - if cfg.Notifications.Enabled { - return "Notifications - enabled" - } - return "Notifications - disabled" -} - -// getConfiguredProviders returns list of providers with API keys -func getConfiguredProviders(cfg *config.Config) []string { - var providers []string - for name, pc := range cfg.Providers { - if pc.APIKey != "" { - providers = append(providers, name) - } - } - return providers -} - -// editProviders handles the providers section edit with submenu -func editProviders(cfg *config.Config) error { - allProviders := []string{"openai", "groq", "mistral", "elevenlabs"} - - for { - // Build options with current status - var options []huh.Option[string] - for _, name := range allProviders { - options = append(options, huh.NewOption(formatProviderOption(cfg, name), name)) - } - options = append(options, huh.NewOption("Back", "back")) - - var selected string - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Provider Settings"). - Description("Select a provider to configure API key"). - Options(options...). - Value(&selected), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return err - } - - if selected == "back" { - return nil - } - - // Configure the selected provider - apiKey, err := inputAPIKey(selected) - if err != nil { - continue // cancelled, back to provider menu - } - - if cfg.Providers == nil { - cfg.Providers = make(map[string]config.ProviderConfig) - } - cfg.Providers[selected] = config.ProviderConfig{APIKey: apiKey} - } -} - -// formatProviderOption formats a provider menu option with status -func formatProviderOption(cfg *config.Config, name string) string { - var status string - if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" { - status = "(configured)" - } else { - status = "(not configured)" - } - - switch name { - case "openai": - return fmt.Sprintf("OpenAI - Whisper + GPT %s", status) - case "groq": - return fmt.Sprintf("Groq - Whisper + Llama %s", status) - case "mistral": - return fmt.Sprintf("Mistral - Voxtral %s", status) - case "elevenlabs": - return fmt.Sprintf("ElevenLabs - Scribe %s", status) - default: - return fmt.Sprintf("%s %s", name, status) - } -} - -// editTranscription handles the transcription section edit with smart provider detection -func editTranscription(cfg *config.Config, configuredProviders []string) ([]string, error) { - // Build transcription options from configured providers - var transcriptionOptions []huh.Option[string] - for _, name := range configuredProviders { - p := provider.GetProvider(name) - if p != nil && p.SupportsTranscription() { - switch name { - case "openai": - transcriptionOptions = append(transcriptionOptions, - huh.NewOption("OpenAI Whisper", "openai")) - case "groq": - transcriptionOptions = append(transcriptionOptions, - huh.NewOption("Groq Whisper (transcription)", "groq-transcription"), - huh.NewOption("Groq Whisper (translate to English)", "groq-translation")) - case "mistral": - transcriptionOptions = append(transcriptionOptions, - huh.NewOption("Mistral Voxtral", "mistral-transcription")) - case "elevenlabs": - transcriptionOptions = append(transcriptionOptions, - huh.NewOption("ElevenLabs Scribe", "elevenlabs")) - } - } - } - - // Add options for unconfigured providers (will prompt for key) - unconfiguredOptions := getUnconfiguredTranscriptionOptions(configuredProviders) - if len(unconfiguredOptions) > 0 { - transcriptionOptions = append(transcriptionOptions, unconfiguredOptions...) - } - - if len(transcriptionOptions) == 0 { - return configuredProviders, fmt.Errorf("no transcription providers available") - } - - // Set default to current provider or first option - selectedProvider := cfg.Transcription.Provider - if selectedProvider == "" && len(transcriptionOptions) > 0 { - selectedProvider = transcriptionOptions[0].Value - } - - providerForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Transcription Provider"). - Description("Choose which service to use for speech-to-text"). - Options(transcriptionOptions...). - Value(&selectedProvider), - ), - ).WithTheme(getTheme()) - - if err := providerForm.Run(); err != nil { - return configuredProviders, err - } - - // Smart detection: if provider not configured, prompt for API key - configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders) - - cfg.Transcription.Provider = selectedProvider - - // Model selection - modelOptions := getTranscriptionModelOptions(selectedProvider) - selectedModel := cfg.Transcription.Model - if selectedModel == "" && len(modelOptions) > 0 { - selectedModel = modelOptions[0].Value - } - - language := cfg.Transcription.Language - - modelForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Transcription Model"). - Options(modelOptions...). - Value(&selectedModel), - huh.NewInput(). - Title("Language"). - Description("ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect"). - Placeholder("auto-detect"). - Value(&language), - ), - ).WithTheme(getTheme()) - - if err := modelForm.Run(); err != nil { - return configuredProviders, err - } - - cfg.Transcription.Model = selectedModel - cfg.Transcription.Language = language - - return configuredProviders, nil -} - -// editLLM handles the LLM section edit with smart provider detection -func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error) { - // Check if any LLM-capable providers are configured - var llmProviders []string - for _, name := range configuredProviders { - p := provider.GetProvider(name) - if p != nil && p.SupportsLLM() { - llmProviders = append(llmProviders, name) - } - } - - // Default post-processing - postProcessing := cfg.LLM.PostProcessing - if !postProcessing.RemoveStutters && !postProcessing.AddPunctuation && - !postProcessing.FixGrammar && !postProcessing.RemoveFillerWords { - postProcessing = config.LLMPostProcessingConfig{ - RemoveStutters: true, - AddPunctuation: true, - FixGrammar: true, - RemoveFillerWords: true, - } - } - customPrompt := cfg.LLM.CustomPrompt - - // Ask if user wants LLM - enableLLM := cfg.LLM.Enabled - enableForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Enable LLM Post-Processing? (Recommended)"). - Description("LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"). - Affirmative("Yes (Recommended)"). - Negative("No"). - Value(&enableLLM), - ), - ).WithTheme(getTheme()) - - if err := enableForm.Run(); err != nil { - return configuredProviders, err - } - - if !enableLLM { - cfg.LLM.Enabled = false - return configuredProviders, nil - } - - // Build LLM provider options - var llmOptions []huh.Option[string] - for _, name := range llmProviders { - switch name { - case "openai": - llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai")) - case "groq": - llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq")) - } - } - - // Add unconfigured LLM providers - unconfiguredLLM := getUnconfiguredLLMOptions(configuredProviders) - if len(unconfiguredLLM) > 0 { - llmOptions = append(llmOptions, unconfiguredLLM...) - } - - if len(llmOptions) == 0 { - fmt.Println(StyleError.Render("No LLM providers available. Please configure OpenAI or Groq first.")) - cfg.LLM.Enabled = false - return configuredProviders, nil - } - - selectedProvider := cfg.LLM.Provider - if selectedProvider == "" && len(llmOptions) > 0 { - selectedProvider = llmOptions[0].Value - } - - providerForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("LLM Provider"). - Description("Choose which service to use for text post-processing"). - Options(llmOptions...). - Value(&selectedProvider), - ), - ).WithTheme(getTheme()) - - if err := providerForm.Run(); err != nil { - return configuredProviders, err - } - - // Smart detection: if provider not configured, prompt for API key - configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders) - - cfg.LLM.Provider = selectedProvider - - // Model selection - modelOptions := getLLMModelOptions(selectedProvider) - selectedModel := cfg.LLM.Model - if selectedModel == "" && len(modelOptions) > 0 { - selectedModel = modelOptions[0].Value - } - - modelForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("LLM Model"). - Options(modelOptions...). - Value(&selectedModel), - ), - ).WithTheme(getTheme()) - - if err := modelForm.Run(); err != nil { - return configuredProviders, err - } - - cfg.LLM.Model = selectedModel - - // Post-processing options using MultiSelect - var ppErr error - postProcessing, ppErr = selectPostProcessingOptions(postProcessing) - if ppErr != nil { - return configuredProviders, ppErr - } - - cfg.LLM.PostProcessing = postProcessing - - // Custom prompt - enableCustomPrompt := customPrompt.Enabled - customPromptText := customPrompt.Prompt - - customForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Add custom prompt?"). - Description("Add extra instructions for the LLM"). - Value(&enableCustomPrompt), - ), - ).WithTheme(getTheme()) - - if err := customForm.Run(); err != nil { - return configuredProviders, err - } - - if enableCustomPrompt { - promptForm := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("Custom Prompt"). - Description("Additional instructions (e.g., 'Format as bullet points')"). - Value(&customPromptText). - CharLimit(500), - ), - ).WithTheme(getTheme()) - - if err := promptForm.Run(); err != nil { - return configuredProviders, err - } - cfg.LLM.CustomPrompt.Enabled = true - cfg.LLM.CustomPrompt.Prompt = customPromptText - } else { - cfg.LLM.CustomPrompt.Enabled = false - } - - cfg.LLM.Enabled = true - return configuredProviders, nil -} - -// getUnconfiguredTranscriptionOptions returns options for providers not yet configured -func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Option[string] { - configured := make(map[string]bool) - for _, p := range configuredProviders { - configured[p] = true - } - - var options []huh.Option[string] - if !configured["openai"] { - options = append(options, huh.NewOption("OpenAI Whisper (needs API key)", "openai")) - } - if !configured["groq"] { - options = append(options, - huh.NewOption("Groq Whisper transcription (needs API key)", "groq-transcription"), - huh.NewOption("Groq Whisper translation (needs API key)", "groq-translation")) - } - if !configured["mistral"] { - options = append(options, huh.NewOption("Mistral Voxtral (needs API key)", "mistral-transcription")) - } - if !configured["elevenlabs"] { - options = append(options, huh.NewOption("ElevenLabs Scribe (needs API key)", "elevenlabs")) - } - return options -} - -// getUnconfiguredLLMOptions returns options for LLM providers not yet configured -func getUnconfiguredLLMOptions(configuredProviders []string) []huh.Option[string] { - configured := make(map[string]bool) - for _, p := range configuredProviders { - configured[p] = true - } - - var options []huh.Option[string] - if !configured["openai"] { - options = append(options, huh.NewOption("OpenAI GPT (needs API key)", "openai")) - } - if !configured["groq"] { - options = append(options, huh.NewOption("Groq Llama (needs API key)", "groq")) - } - return options -} - -// ensureProviderConfigured prompts for API key if provider not configured -func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string { - // Map transcription provider to actual provider name - providerName := selectedProvider - switch selectedProvider { - case "groq-transcription", "groq-translation": - providerName = "groq" - case "mistral-transcription": - providerName = "mistral" - } - - // Check if already configured - for _, p := range configuredProviders { - if p == providerName { - return configuredProviders - } - } - - // Not configured - prompt for API key - fmt.Println() - fmt.Println(StyleMuted.Render(fmt.Sprintf("%s not configured. Please enter API key.", strings.Title(providerName)))) - apiKey, err := inputAPIKey(providerName) - if err != nil { - return configuredProviders - } - - if cfg.Providers == nil { - cfg.Providers = make(map[string]config.ProviderConfig) - } - cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey} - - return append(configuredProviders, providerName) -} - -// runFreshInstall runs the full configuration wizard for fresh installs -func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) { - // Welcome screen - fmt.Println(Logo()) - fmt.Println() - fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland")) - fmt.Println() - - // Step 1: Provider selection - selectedProviders, err := selectProviders() - if err != nil { - return &ConfigureResult{Cancelled: true}, nil - } - if len(selectedProviders) == 0 { - return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers selected") - } - - // Initialize providers map - if cfg.Providers == nil { - cfg.Providers = make(map[string]config.ProviderConfig) - } - - // Step 2: API keys for selected providers - for _, providerName := range selectedProviders { - apiKey, err := inputAPIKey(providerName) - if err != nil { - return &ConfigureResult{Cancelled: true}, nil - } - cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey} - } - - // Step 3: Transcription configuration - transcriptionProvider, transcriptionModel, language, err := configureTranscription(selectedProviders, cfg) - if err != nil { - return &ConfigureResult{Cancelled: true}, nil - } - cfg.Transcription.Provider = transcriptionProvider - cfg.Transcription.Model = transcriptionModel - cfg.Transcription.Language = language - - // Step 4: LLM configuration - llmEnabled, llmProvider, llmModel, postProcessing, customPrompt, err := configureLLM(selectedProviders, cfg) - if err != nil { - return &ConfigureResult{Cancelled: true}, nil - } - cfg.LLM.Enabled = llmEnabled - cfg.LLM.Provider = llmProvider - cfg.LLM.Model = llmModel - cfg.LLM.PostProcessing = postProcessing - cfg.LLM.CustomPrompt = customPrompt - - // Step 5: Keywords - keywords, err := inputKeywords(cfg.Keywords) - if err != nil { - return &ConfigureResult{Cancelled: true}, nil - } - cfg.Keywords = keywords - - // Step 6: Injection backends - backends, err := selectBackends(cfg.Injection.Backends) - if err != nil { - return &ConfigureResult{Cancelled: true}, nil - } - cfg.Injection.Backends = backends - - // Step 7: Notifications - notificationsEnabled, err := configureNotifications(cfg.Notifications.Enabled) - if err != nil { - return &ConfigureResult{Cancelled: true}, nil - } - cfg.Notifications.Enabled = notificationsEnabled - - // Step 8: Summary and confirm - confirmed, err := showSummary(cfg) - if err != nil || !confirmed { - return &ConfigureResult{Cancelled: true}, nil - } - - return &ConfigureResult{Config: cfg, Cancelled: false}, nil -} - -func selectProviders() ([]string, error) { - allProviders := []string{"openai", "groq", "mistral", "elevenlabs"} - - options := []huh.Option[string]{ - huh.NewOption("OpenAI - Whisper transcription + GPT for LLM", "openai"), - huh.NewOption("Groq - Fast Whisper transcription + Llama for LLM", "groq"), - huh.NewOption("Mistral - Voxtral transcription (European languages)", "mistral"), - huh.NewOption("ElevenLabs - Scribe transcription (99 languages)", "elevenlabs"), - } - - var selected []string - form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("Which providers do you want to configure?"). - Description("Select all providers you have API keys for"). - Options(options...). - Value(&selected), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return nil, err - } - - // Validate selected providers exist - valid := make([]string, 0) - for _, s := range selected { - for _, p := range allProviders { - if s == p { - valid = append(valid, s) - break - } - } - } - - return valid, nil -} - -func inputAPIKey(providerName string) (string, error) { - p := provider.GetProvider(providerName) - displayName := strings.Title(providerName) - if p != nil { - displayName = strings.Title(p.Name()) - } - - var apiKey string - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title(fmt.Sprintf("%s API Key", displayName)). - Description(fmt.Sprintf("Enter your %s API key", displayName)). - EchoMode(huh.EchoModePassword). - Value(&apiKey). - Validate(func(s string) error { - if s == "" { - return fmt.Errorf("API key is required") - } - if p != nil && !p.ValidateAPIKey(s) { - return fmt.Errorf("invalid API key format for %s", displayName) - } - return nil - }), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return "", err - } - - return apiKey, nil -} - -func configureTranscription(configuredProviders []string, cfg *config.Config) (string, string, string, error) { - // Filter to only transcription-capable configured providers - var transcriptionOptions []huh.Option[string] - for _, name := range configuredProviders { - p := provider.GetProvider(name) - if p != nil && p.SupportsTranscription() { - // Map provider name to transcription provider name - switch name { - case "openai": - transcriptionOptions = append(transcriptionOptions, - huh.NewOption("OpenAI Whisper", "openai")) - case "groq": - transcriptionOptions = append(transcriptionOptions, - huh.NewOption("Groq Whisper (transcription)", "groq-transcription"), - huh.NewOption("Groq Whisper (translate to English)", "groq-translation")) - case "mistral": - transcriptionOptions = append(transcriptionOptions, - huh.NewOption("Mistral Voxtral", "mistral-transcription")) - case "elevenlabs": - transcriptionOptions = append(transcriptionOptions, - huh.NewOption("ElevenLabs Scribe", "elevenlabs")) - } - } - } - - if len(transcriptionOptions) == 0 { - return "", "", "", fmt.Errorf("no transcription-capable providers configured") - } - - var selectedProvider string - if cfg.Transcription.Provider != "" { - selectedProvider = cfg.Transcription.Provider - } else if len(transcriptionOptions) > 0 { - selectedProvider = transcriptionOptions[0].Value - } - - providerForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Transcription Provider"). - Description("Choose which service to use for speech-to-text"). - Options(transcriptionOptions...). - Value(&selectedProvider), - ), - ).WithTheme(getTheme()) - - if err := providerForm.Run(); err != nil { - return "", "", "", err - } - - // Get model options for selected provider - modelOptions := getTranscriptionModelOptions(selectedProvider) - var selectedModel string - if cfg.Transcription.Model != "" { - selectedModel = cfg.Transcription.Model - } else if len(modelOptions) > 0 { - selectedModel = modelOptions[0].Value - } - - var language string - if cfg.Transcription.Language != "" { - language = cfg.Transcription.Language - } - - modelForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Transcription Model"). - Options(modelOptions...). - Value(&selectedModel), - huh.NewInput(). - Title("Language"). - Description("ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect"). - Placeholder("auto-detect"). - Value(&language), - ), - ).WithTheme(getTheme()) - - if err := modelForm.Run(); err != nil { - return "", "", "", err - } - - return selectedProvider, selectedModel, language, nil -} - -func getTranscriptionModelOptions(provider string) []huh.Option[string] { - switch provider { - case "openai": - return []huh.Option[string]{ - huh.NewOption("whisper-1", "whisper-1"), - } - case "groq-transcription": - return []huh.Option[string]{ - huh.NewOption("whisper-large-v3-turbo (faster)", "whisper-large-v3-turbo"), - huh.NewOption("whisper-large-v3 (standard)", "whisper-large-v3"), - } - case "groq-translation": - return []huh.Option[string]{ - huh.NewOption("whisper-large-v3 (only option)", "whisper-large-v3"), - } - case "mistral-transcription": - return []huh.Option[string]{ - huh.NewOption("voxtral-mini-latest (recommended)", "voxtral-mini-latest"), - huh.NewOption("voxtral-mini-2507", "voxtral-mini-2507"), - } - case "elevenlabs": - return []huh.Option[string]{ - huh.NewOption("scribe_v1 (99 languages, best accuracy)", "scribe_v1"), - huh.NewOption("scribe_v2 (real-time, lower latency)", "scribe_v2"), - } - default: - return []huh.Option[string]{} - } -} - -func configureLLM(configuredProviders []string, cfg *config.Config) (bool, string, string, config.LLMPostProcessingConfig, config.LLMCustomPromptConfig, error) { - // Filter to only LLM-capable configured providers - var llmProviders []string - for _, name := range configuredProviders { - p := provider.GetProvider(name) - if p != nil && p.SupportsLLM() { - llmProviders = append(llmProviders, name) - } - } - - // Default values - postProcessing := config.LLMPostProcessingConfig{ - RemoveStutters: true, - AddPunctuation: true, - FixGrammar: true, - RemoveFillerWords: true, - } - customPrompt := config.LLMCustomPromptConfig{ - Enabled: false, - Prompt: "", - } - - // If no LLM providers configured, skip LLM config - if len(llmProviders) == 0 { - return false, "", "", postProcessing, customPrompt, nil - } - - // Ask if user wants LLM post-processing - var enableLLM bool = true - enableForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Enable LLM Post-Processing? (Recommended)"). - Description("LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"). - Affirmative("Yes (Recommended)"). - Negative("No"). - Value(&enableLLM), - ), - ).WithTheme(getTheme()) - - if err := enableForm.Run(); err != nil { - return false, "", "", postProcessing, customPrompt, err - } - - if !enableLLM { - return false, "", "", postProcessing, customPrompt, nil - } - - // LLM provider selection - var llmOptions []huh.Option[string] - for _, name := range llmProviders { - p := provider.GetProvider(name) - if p != nil { - switch name { - case "openai": - llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai")) - case "groq": - llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq")) - } - } - } - - var selectedProvider string - if cfg.LLM.Provider != "" { - selectedProvider = cfg.LLM.Provider - } else if len(llmOptions) > 0 { - selectedProvider = llmOptions[0].Value - } - - providerForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("LLM Provider"). - Description("Choose which service to use for text post-processing"). - Options(llmOptions...). - Value(&selectedProvider), - ), - ).WithTheme(getTheme()) - - if err := providerForm.Run(); err != nil { - return false, "", "", postProcessing, customPrompt, err - } - - // Model selection - modelOptions := getLLMModelOptions(selectedProvider) - var selectedModel string - if cfg.LLM.Model != "" { - selectedModel = cfg.LLM.Model - } else if len(modelOptions) > 0 { - selectedModel = modelOptions[0].Value - } - - modelForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("LLM Model"). - Options(modelOptions...). - Value(&selectedModel), - ), - ).WithTheme(getTheme()) - - if err := modelForm.Run(); err != nil { - return false, "", "", postProcessing, customPrompt, err - } - - // Post-processing options - if cfg.LLM.PostProcessing.RemoveStutters || cfg.LLM.PostProcessing.AddPunctuation || - cfg.LLM.PostProcessing.FixGrammar || cfg.LLM.PostProcessing.RemoveFillerWords { - postProcessing = cfg.LLM.PostProcessing - } - - var ppErr error - postProcessing, ppErr = selectPostProcessingOptions(postProcessing) - if ppErr != nil { - return false, "", "", postProcessing, customPrompt, ppErr - } - - // Custom prompt - var enableCustomPrompt bool - var customPromptText string - if cfg.LLM.CustomPrompt.Enabled { - enableCustomPrompt = true - customPromptText = cfg.LLM.CustomPrompt.Prompt - } - - customForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Add custom prompt?"). - Description("Add extra instructions for the LLM"). - Value(&enableCustomPrompt), - ), - ).WithTheme(getTheme()) - - if err := customForm.Run(); err != nil { - return false, "", "", postProcessing, customPrompt, err - } - - if enableCustomPrompt { - promptForm := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("Custom Prompt"). - Description("Additional instructions (e.g., 'Format as bullet points')"). - Value(&customPromptText). - CharLimit(500), - ), - ).WithTheme(getTheme()) - - if err := promptForm.Run(); err != nil { - return false, "", "", postProcessing, customPrompt, err - } - customPrompt.Enabled = true - customPrompt.Prompt = customPromptText - } - - return true, selectedProvider, selectedModel, postProcessing, customPrompt, nil -} - -func getLLMModelOptions(provider string) []huh.Option[string] { - switch provider { - case "openai": - return []huh.Option[string]{ - huh.NewOption("gpt-4o-mini (recommended)", "gpt-4o-mini"), - huh.NewOption("gpt-4o", "gpt-4o"), - huh.NewOption("gpt-4-turbo", "gpt-4-turbo"), - huh.NewOption("gpt-3.5-turbo", "gpt-3.5-turbo"), - } - case "groq": - return []huh.Option[string]{ - huh.NewOption("llama-3.3-70b-versatile (recommended)", "llama-3.3-70b-versatile"), - huh.NewOption("llama-3.1-8b-instant (faster)", "llama-3.1-8b-instant"), - huh.NewOption("mixtral-8x7b-32768", "mixtral-8x7b-32768"), - } - default: - return []huh.Option[string]{} - } -} - -// selectPostProcessingOptions shows a multi-select for LLM post-processing toggles -func selectPostProcessingOptions(current config.LLMPostProcessingConfig) (config.LLMPostProcessingConfig, error) { - type ppOption string - const ( - optRemoveStutters ppOption = "stutters" - optAddPunctuation ppOption = "punctuation" - optFixGrammar ppOption = "grammar" - optRemoveFillerWords ppOption = "fillers" - ) - - options := []huh.Option[ppOption]{ - huh.NewOption("Remove stutters (repeated words)", optRemoveStutters), - huh.NewOption("Add punctuation", optAddPunctuation), - huh.NewOption("Fix grammar", optFixGrammar), - huh.NewOption("Remove filler words (um, uh, like)", optRemoveFillerWords), - } - - // Pre-select based on current config - var selected []ppOption - if current.RemoveStutters { - selected = append(selected, optRemoveStutters) - } - if current.AddPunctuation { - selected = append(selected, optAddPunctuation) - } - if current.FixGrammar { - selected = append(selected, optFixGrammar) - } - if current.RemoveFillerWords { - selected = append(selected, optRemoveFillerWords) - } - - form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[ppOption](). - Title("Post-Processing Options"). - Description("Select which improvements to apply"). - Options(options...). - Value(&selected), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return current, err - } - - // Convert selections back to config - result := config.LLMPostProcessingConfig{} - for _, opt := range selected { - switch opt { - case optRemoveStutters: - result.RemoveStutters = true - case optAddPunctuation: - result.AddPunctuation = true - case optFixGrammar: - result.FixGrammar = true - case optRemoveFillerWords: - result.RemoveFillerWords = true - } - } - - return result, nil -} - -func inputKeywords(existingKeywords []string) ([]string, error) { - var keywordsInput string - if len(existingKeywords) > 0 { - keywordsInput = strings.Join(existingKeywords, ", ") - } - - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Keywords"). - Description("Comma-separated words to help with spelling (names, technical terms, etc.)"). - Placeholder("e.g., Kubernetes, PostgreSQL, John Smith"). - Value(&keywordsInput), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return nil, err - } - - // Parse keywords - if keywordsInput == "" { - return nil, nil - } - - parts := strings.Split(keywordsInput, ",") - keywords := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - keywords = append(keywords, p) - } - } - - return keywords, nil -} - -func selectBackends(existingBackends []string) ([]string, error) { - options := []huh.Option[string]{ - huh.NewOption("ydotool - Best for Chromium/Electron (needs ydotoold)", "ydotool"), - huh.NewOption("wtype - Native Wayland typing", "wtype"), - huh.NewOption("clipboard - Copy to clipboard only", "clipboard"), - } - - var selected []string - if len(existingBackends) > 0 { - selected = existingBackends - } else { - selected = []string{"ydotool", "wtype", "clipboard"} - } - - form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("Text Injection Backends"). - Description("Backends are tried in order until one succeeds (fallback chain)"). - Options(options...). - Value(&selected), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return nil, err - } - - if len(selected) == 0 { - return nil, fmt.Errorf("at least one backend required") - } - - return selected, nil -} - -func configureNotifications(existingEnabled bool) (bool, error) { - enabled := existingEnabled - - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Enable desktop notifications?"). - Description("Show notifications for recording status changes"). - Value(&enabled), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return false, err - } - - return enabled, nil -} - -func showSummary(cfg *config.Config) (bool, error) { - fmt.Println() - fmt.Println(StyleHeader.Render("Configuration Summary")) - fmt.Println() - - // Providers - var providers []string - for name := range cfg.Providers { - providers = append(providers, name) - } - fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", ")) - - // Transcription - fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model) - if cfg.Transcription.Language != "" { - fmt.Printf(" %s %s\n", StyleLabel.Render("Language:"), cfg.Transcription.Language) - } - - // LLM - if cfg.LLM.Enabled { - fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model) - var ppOpts []string - if cfg.LLM.PostProcessing.RemoveStutters { - ppOpts = append(ppOpts, "remove stutters") - } - if cfg.LLM.PostProcessing.AddPunctuation { - ppOpts = append(ppOpts, "add punctuation") - } - if cfg.LLM.PostProcessing.FixGrammar { - ppOpts = append(ppOpts, "fix grammar") - } - if cfg.LLM.PostProcessing.RemoveFillerWords { - ppOpts = append(ppOpts, "remove fillers") - } - if len(ppOpts) > 0 { - fmt.Printf(" %s %s\n", StyleLabel.Render("Post-processing:"), strings.Join(ppOpts, ", ")) - } - } else { - fmt.Printf(" %s disabled\n", StyleLabel.Render("LLM:")) - } - - // Keywords - if len(cfg.Keywords) > 0 { - fmt.Printf(" %s %s\n", StyleLabel.Render("Keywords:"), strings.Join(cfg.Keywords, ", ")) - } - - // Backends - fmt.Printf(" %s %s\n", StyleLabel.Render("Backends:"), strings.Join(cfg.Injection.Backends, " -> ")) - - // Notifications - if cfg.Notifications.Enabled { - fmt.Printf(" %s enabled\n", StyleLabel.Render("Notifications:")) - } else { - fmt.Printf(" %s disabled\n", StyleLabel.Render("Notifications:")) - } - - fmt.Println() - - var confirmed bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Save this configuration?"). - Affirmative("Save"). - Negative("Cancel"). - Value(&confirmed), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return false, err - } - - return confirmed, nil +// clearScreen clears the terminal screen +func clearScreen() { + output := termenv.NewOutput(os.Stdout) + output.ClearScreen() } func getTheme() *huh.Theme { t := huh.ThemeBase() - // Primary colors t.Focused.Title = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true) t.Focused.Description = lipgloss.NewStyle().Foreground(ColorMuted) t.Focused.Base = lipgloss.NewStyle().BorderForeground(ColorPrimary) t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(ColorSecondary) t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(ColorText) - // Blurred (unfocused) t.Blurred.Title = lipgloss.NewStyle().Foreground(ColorMuted) t.Blurred.Description = lipgloss.NewStyle().Foreground(ColorSubtle) diff --git a/internal/tui/configure_advanced.go b/internal/tui/configure_advanced.go new file mode 100644 index 0000000..1cb5b49 --- /dev/null +++ b/internal/tui/configure_advanced.go @@ -0,0 +1,225 @@ +package tui + +import ( + "fmt" + "strconv" + "time" + + "github.com/charmbracelet/huh" + "github.com/leonardotrapani/hyprvoice/internal/config" +) + +// AdvancedSection represents a section in the advanced settings menu +type AdvancedSection string + +const ( + AdvancedRecording AdvancedSection = "recording" + AdvancedInjectionTimeout AdvancedSection = "injection_timeout" + AdvancedBack AdvancedSection = "back" +) + +// editAdvanced handles the advanced settings submenu +func editAdvanced(cfg *config.Config) error { + for { + options := []huh.Option[AdvancedSection]{ + huh.NewOption(formatAdvancedRecordingLabel(cfg), AdvancedRecording), + huh.NewOption(formatAdvancedInjectionTimeoutLabel(cfg), AdvancedInjectionTimeout), + huh.NewOption("Back to Main Menu", AdvancedBack), + } + + var selected AdvancedSection + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[AdvancedSection](). + Title("Advanced Settings"). + Description("Configure low-level options"). + Options(options...). + Value(&selected), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return err + } + + switch selected { + case AdvancedBack: + return nil + case AdvancedRecording: + if err := editRecording(cfg); err != nil { + continue + } + case AdvancedInjectionTimeout: + if err := editInjectionTimeouts(cfg); err != nil { + continue + } + } + } +} + +func formatAdvancedRecordingLabel(cfg *config.Config) string { + return fmt.Sprintf("Recording Settings (rate=%d, timeout=%s)", cfg.Recording.SampleRate, cfg.Recording.Timeout) +} + +func formatAdvancedInjectionTimeoutLabel(cfg *config.Config) string { + return fmt.Sprintf("Injection Timeouts (ydotool=%s, wtype=%s, clipboard=%s)", + cfg.Injection.YdotoolTimeout, cfg.Injection.WtypeTimeout, cfg.Injection.ClipboardTimeout) +} + +// editRecording handles the recording settings +func editRecording(cfg *config.Config) error { + sampleRate := strconv.Itoa(cfg.Recording.SampleRate) + channels := strconv.Itoa(cfg.Recording.Channels) + format := cfg.Recording.Format + bufferSize := strconv.Itoa(cfg.Recording.BufferSize) + device := cfg.Recording.Device + channelBufferSize := strconv.Itoa(cfg.Recording.ChannelBufferSize) + timeout := cfg.Recording.Timeout.String() + + channelOptions := []huh.Option[string]{ + huh.NewOption("1 (Mono) - Recommended", "1"), + huh.NewOption("2 (Stereo)", "2"), + } + + formatOptions := []huh.Option[string]{ + huh.NewOption("s16 (16-bit signed) - Recommended", "s16"), + huh.NewOption("f32 (32-bit float)", "f32"), + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Sample Rate (Hz)"). + Description("Audio sample rate. 16000 is optimal for speech recognition."). + Placeholder("16000"). + Value(&sampleRate). + Validate(func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return fmt.Errorf("must be a number") + } + return nil + }), + huh.NewSelect[string](). + Title("Channels"). + Description("Number of audio channels"). + Options(channelOptions...). + Value(&channels), + huh.NewSelect[string](). + Title("Audio Format"). + Description("Sample format"). + Options(formatOptions...). + Value(&format), + ), + huh.NewGroup( + huh.NewInput(). + Title("Buffer Size (bytes)"). + Description("Internal buffer size. Larger = less CPU, more latency."). + Placeholder("8192"). + Value(&bufferSize). + Validate(func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return fmt.Errorf("must be a number") + } + return nil + }), + huh.NewInput(). + Title("Channel Buffer Size"). + Description("Number of audio frames to buffer."). + Placeholder("30"). + Value(&channelBufferSize). + Validate(func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return fmt.Errorf("must be a number") + } + return nil + }), + ), + huh.NewGroup( + huh.NewInput(). + Title("Device"). + Description("PipeWire device name. Empty = default microphone."). + Placeholder("(default)"). + Value(&device), + huh.NewInput(). + Title("Recording Timeout"). + Description("Max recording duration (e.g., '30s', '2m', '5m'). Prevents runaway recordings."). + Placeholder("5m"). + Value(&timeout). + Validate(func(s string) error { + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format (use '30s', '2m', etc.)") + } + return nil + }), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return err + } + + cfg.Recording.SampleRate, _ = strconv.Atoi(sampleRate) + cfg.Recording.Channels, _ = strconv.Atoi(channels) + cfg.Recording.Format = format + cfg.Recording.BufferSize, _ = strconv.Atoi(bufferSize) + cfg.Recording.Device = device + cfg.Recording.ChannelBufferSize, _ = strconv.Atoi(channelBufferSize) + cfg.Recording.Timeout, _ = time.ParseDuration(timeout) + + return nil +} + +// editInjectionTimeouts handles the injection timeout settings +func editInjectionTimeouts(cfg *config.Config) error { + ydotoolTimeout := cfg.Injection.YdotoolTimeout.String() + wtypeTimeout := cfg.Injection.WtypeTimeout.String() + clipboardTimeout := cfg.Injection.ClipboardTimeout.String() + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("ydotool Timeout"). + Description("Timeout for ydotool commands (e.g., '5s', '10s')"). + Placeholder("5s"). + Value(&ydotoolTimeout). + Validate(func(s string) error { + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format") + } + return nil + }), + huh.NewInput(). + Title("wtype Timeout"). + Description("Timeout for wtype commands (e.g., '5s', '10s')"). + Placeholder("5s"). + Value(&wtypeTimeout). + Validate(func(s string) error { + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format") + } + return nil + }), + huh.NewInput(). + Title("Clipboard Timeout"). + Description("Timeout for clipboard operations (e.g., '3s', '5s')"). + Placeholder("3s"). + Value(&clipboardTimeout). + Validate(func(s string) error { + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format") + } + return nil + }), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return err + } + + cfg.Injection.YdotoolTimeout, _ = time.ParseDuration(ydotoolTimeout) + cfg.Injection.WtypeTimeout, _ = time.ParseDuration(wtypeTimeout) + cfg.Injection.ClipboardTimeout, _ = time.ParseDuration(clipboardTimeout) + + return nil +} diff --git a/internal/tui/configure_helpers.go b/internal/tui/configure_helpers.go new file mode 100644 index 0000000..454529b --- /dev/null +++ b/internal/tui/configure_helpers.go @@ -0,0 +1,109 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/huh" + "github.com/leonardotrapani/hyprvoice/internal/config" +) + +// formatProvidersLabel formats the providers menu option +func formatProvidersLabel(cfg *config.Config) string { + return "Providers" +} + +// formatTranscriptionLabel formats the transcription menu option +func formatTranscriptionLabel(cfg *config.Config) string { + return "Transcription" +} + +// formatLLMLabel formats the LLM menu option +func formatLLMLabel(cfg *config.Config) string { + return "LLM" +} + +// formatKeywordsLabel formats the keywords menu option +func formatKeywordsLabel(cfg *config.Config) string { + return "Keywords" +} + +// formatInjectionLabel formats the injection menu option +func formatInjectionLabel(cfg *config.Config) string { + return "Injection" +} + +// formatNotificationsLabel formats the notifications menu option +func formatNotificationsLabel(cfg *config.Config) string { + return "Notifications" +} + +func showSummary(cfg *config.Config) (bool, error) { + fmt.Println() + fmt.Println(StyleHeader.Render("Configuration Summary")) + fmt.Println() + + var providers []string + for name := range cfg.Providers { + providers = append(providers, name) + } + fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", ")) + + fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model) + if cfg.Transcription.Language != "" { + fmt.Printf(" %s %s\n", StyleLabel.Render("Language:"), cfg.Transcription.Language) + } + + if cfg.LLM.Enabled { + fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model) + var ppOpts []string + if cfg.LLM.PostProcessing.RemoveStutters { + ppOpts = append(ppOpts, "remove stutters") + } + if cfg.LLM.PostProcessing.AddPunctuation { + ppOpts = append(ppOpts, "add punctuation") + } + if cfg.LLM.PostProcessing.FixGrammar { + ppOpts = append(ppOpts, "fix grammar") + } + if cfg.LLM.PostProcessing.RemoveFillerWords { + ppOpts = append(ppOpts, "remove fillers") + } + if len(ppOpts) > 0 { + fmt.Printf(" %s %s\n", StyleLabel.Render("Post-processing:"), strings.Join(ppOpts, ", ")) + } + } else { + fmt.Printf(" %s disabled\n", StyleLabel.Render("LLM:")) + } + + if len(cfg.Keywords) > 0 { + fmt.Printf(" %s %s\n", StyleLabel.Render("Keywords:"), strings.Join(cfg.Keywords, ", ")) + } + + fmt.Printf(" %s %s\n", StyleLabel.Render("Backends:"), strings.Join(cfg.Injection.Backends, " -> ")) + + if cfg.Notifications.Enabled { + fmt.Printf(" %s enabled\n", StyleLabel.Render("Notifications:")) + } else { + fmt.Printf(" %s disabled\n", StyleLabel.Render("Notifications:")) + } + + fmt.Println() + + var confirmed bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Save this configuration?"). + Affirmative("Save"). + Negative("Cancel"). + Value(&confirmed), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return false, err + } + + return confirmed, nil +} diff --git a/internal/tui/configure_llm.go b/internal/tui/configure_llm.go new file mode 100644 index 0000000..5abe25d --- /dev/null +++ b/internal/tui/configure_llm.go @@ -0,0 +1,444 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/leonardotrapani/hyprvoice/internal/config" + "github.com/leonardotrapani/hyprvoice/internal/provider" +) + +// editLLM handles the LLM section edit with smart provider detection +func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error) { + var llmProviders []string + for _, name := range configuredProviders { + p := provider.GetProvider(name) + if p != nil && p.SupportsLLM() { + llmProviders = append(llmProviders, name) + } + } + + postProcessing := cfg.LLM.PostProcessing + if !postProcessing.RemoveStutters && !postProcessing.AddPunctuation && + !postProcessing.FixGrammar && !postProcessing.RemoveFillerWords { + postProcessing = config.LLMPostProcessingConfig{ + RemoveStutters: true, + AddPunctuation: true, + FixGrammar: true, + RemoveFillerWords: true, + } + } + customPrompt := cfg.LLM.CustomPrompt + + enableLLM := cfg.LLM.Enabled + + enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text" + if cfg.LLM.Enabled { + enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc) + } else { + enableDesc = "Currently: disabled. " + enableDesc + } + + enableForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Enable LLM Post-Processing? (Recommended)"). + Description(enableDesc). + Affirmative("Yes (Recommended)"). + Negative("No"). + Value(&enableLLM), + ), + ).WithTheme(getTheme()) + + if err := enableForm.Run(); err != nil { + return configuredProviders, err + } + + if !enableLLM { + cfg.LLM.Enabled = false + return configuredProviders, nil + } + + var llmOptions []huh.Option[string] + for _, name := range llmProviders { + switch name { + case "openai": + llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai")) + case "groq": + llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq")) + } + } + + unconfiguredLLM := getUnconfiguredLLMOptions(configuredProviders) + if len(unconfiguredLLM) > 0 { + llmOptions = append(llmOptions, unconfiguredLLM...) + } + + if len(llmOptions) == 0 { + fmt.Println(StyleError.Render("No LLM providers available. Please configure OpenAI or Groq first.")) + cfg.LLM.Enabled = false + return configuredProviders, nil + } + + selectedProvider := cfg.LLM.Provider + if selectedProvider == "" && len(llmOptions) > 0 { + selectedProvider = llmOptions[0].Value + } + + llmProviderDesc := "Choose which service to use for text post-processing" + if cfg.LLM.Provider != "" { + llmProviderDesc = fmt.Sprintf("Currently: %s/%s", cfg.LLM.Provider, cfg.LLM.Model) + } + + providerForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("LLM Provider"). + Description(llmProviderDesc). + Options(llmOptions...). + Value(&selectedProvider), + ), + ).WithTheme(getTheme()) + + if err := providerForm.Run(); err != nil { + return configuredProviders, err + } + + configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders) + cfg.LLM.Provider = selectedProvider + + modelOptions := getLLMModelOptions(selectedProvider) + selectedModel := cfg.LLM.Model + if selectedModel == "" && len(modelOptions) > 0 { + selectedModel = modelOptions[0].Value + } + + llmModelDesc := "" + if cfg.LLM.Model != "" { + llmModelDesc = fmt.Sprintf("Currently: %s", cfg.LLM.Model) + } + + modelForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("LLM Model"). + Description(llmModelDesc). + Options(modelOptions...). + Value(&selectedModel), + ), + ).WithTheme(getTheme()) + + if err := modelForm.Run(); err != nil { + return configuredProviders, err + } + + cfg.LLM.Model = selectedModel + + var ppErr error + postProcessing, ppErr = selectPostProcessingOptions(postProcessing) + if ppErr != nil { + return configuredProviders, ppErr + } + + cfg.LLM.PostProcessing = postProcessing + + enableCustomPrompt := customPrompt.Enabled + customPromptText := customPrompt.Prompt + + customPromptDesc := "Add extra instructions for the LLM" + if customPrompt.Enabled && customPrompt.Prompt != "" { + preview := customPrompt.Prompt + if len(preview) > 40 { + preview = preview[:40] + "..." + } + customPromptDesc = fmt.Sprintf("Currently: \"%s\"", preview) + } else { + customPromptDesc = "Currently: none. " + customPromptDesc + } + + customForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Add custom prompt?"). + Description(customPromptDesc). + Value(&enableCustomPrompt), + ), + ).WithTheme(getTheme()) + + if err := customForm.Run(); err != nil { + return configuredProviders, err + } + + if enableCustomPrompt { + promptForm := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Custom Prompt"). + Description("Additional instructions (e.g., 'Format as bullet points')"). + Value(&customPromptText). + CharLimit(500), + ), + ).WithTheme(getTheme()) + + if err := promptForm.Run(); err != nil { + return configuredProviders, err + } + cfg.LLM.CustomPrompt.Enabled = true + cfg.LLM.CustomPrompt.Prompt = customPromptText + } else { + cfg.LLM.CustomPrompt.Enabled = false + } + + cfg.LLM.Enabled = true + return configuredProviders, nil +} + +// getUnconfiguredLLMOptions returns options for LLM providers not yet configured +func getUnconfiguredLLMOptions(configuredProviders []string) []huh.Option[string] { + configured := make(map[string]bool) + for _, p := range configuredProviders { + configured[p] = true + } + + var options []huh.Option[string] + if !configured["openai"] { + options = append(options, huh.NewOption("OpenAI GPT (not configured)", "openai")) + } + if !configured["groq"] { + options = append(options, huh.NewOption("Groq Llama (not configured)", "groq")) + } + return options +} + +func getLLMModelOptions(provider string) []huh.Option[string] { + switch provider { + case "openai": + return []huh.Option[string]{ + huh.NewOption("gpt-4o-mini (recommended)", "gpt-4o-mini"), + huh.NewOption("gpt-4o", "gpt-4o"), + huh.NewOption("gpt-4-turbo", "gpt-4-turbo"), + huh.NewOption("gpt-3.5-turbo", "gpt-3.5-turbo"), + } + case "groq": + return []huh.Option[string]{ + huh.NewOption("llama-3.3-70b-versatile (recommended)", "llama-3.3-70b-versatile"), + huh.NewOption("llama-3.1-8b-instant (faster)", "llama-3.1-8b-instant"), + huh.NewOption("mixtral-8x7b-32768", "mixtral-8x7b-32768"), + } + default: + return []huh.Option[string]{} + } +} + +// selectPostProcessingOptions shows a multi-select for LLM post-processing toggles +func selectPostProcessingOptions(current config.LLMPostProcessingConfig) (config.LLMPostProcessingConfig, error) { + type ppOption string + const ( + optRemoveStutters ppOption = "stutters" + optAddPunctuation ppOption = "punctuation" + optFixGrammar ppOption = "grammar" + optRemoveFillerWords ppOption = "fillers" + ) + + options := []huh.Option[ppOption]{ + huh.NewOption("Remove stutters (repeated words)", optRemoveStutters), + huh.NewOption("Add punctuation", optAddPunctuation), + huh.NewOption("Fix grammar", optFixGrammar), + huh.NewOption("Remove filler words (um, uh, like)", optRemoveFillerWords), + } + + var selected []ppOption + if current.RemoveStutters { + selected = append(selected, optRemoveStutters) + } + if current.AddPunctuation { + selected = append(selected, optAddPunctuation) + } + if current.FixGrammar { + selected = append(selected, optFixGrammar) + } + if current.RemoveFillerWords { + selected = append(selected, optRemoveFillerWords) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[ppOption](). + Title("Post-Processing Options"). + Description("Select which improvements to apply"). + Options(options...). + Value(&selected), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return current, err + } + + result := config.LLMPostProcessingConfig{} + for _, opt := range selected { + switch opt { + case optRemoveStutters: + result.RemoveStutters = true + case optAddPunctuation: + result.AddPunctuation = true + case optFixGrammar: + result.FixGrammar = true + case optRemoveFillerWords: + result.RemoveFillerWords = true + } + } + + return result, nil +} + +func configureLLM(configuredProviders []string, cfg *config.Config) (bool, string, string, config.LLMPostProcessingConfig, config.LLMCustomPromptConfig, error) { + var llmProviders []string + for _, name := range configuredProviders { + p := provider.GetProvider(name) + if p != nil && p.SupportsLLM() { + llmProviders = append(llmProviders, name) + } + } + + postProcessing := config.LLMPostProcessingConfig{ + RemoveStutters: true, + AddPunctuation: true, + FixGrammar: true, + RemoveFillerWords: true, + } + customPrompt := config.LLMCustomPromptConfig{ + Enabled: false, + Prompt: "", + } + + if len(llmProviders) == 0 { + return false, "", "", postProcessing, customPrompt, nil + } + + var enableLLM bool = true + enableForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Enable LLM Post-Processing? (Recommended)"). + Description("LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"). + Affirmative("Yes (Recommended)"). + Negative("No"). + Value(&enableLLM), + ), + ).WithTheme(getTheme()) + + if err := enableForm.Run(); err != nil { + return false, "", "", postProcessing, customPrompt, err + } + + if !enableLLM { + return false, "", "", postProcessing, customPrompt, nil + } + + var llmOptions []huh.Option[string] + for _, name := range llmProviders { + p := provider.GetProvider(name) + if p != nil { + switch name { + case "openai": + llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai")) + case "groq": + llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq")) + } + } + } + + var selectedProvider string + if cfg.LLM.Provider != "" { + selectedProvider = cfg.LLM.Provider + } else if len(llmOptions) > 0 { + selectedProvider = llmOptions[0].Value + } + + providerForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("LLM Provider"). + Description("Choose which service to use for text post-processing"). + Options(llmOptions...). + Value(&selectedProvider), + ), + ).WithTheme(getTheme()) + + if err := providerForm.Run(); err != nil { + return false, "", "", postProcessing, customPrompt, err + } + + modelOptions := getLLMModelOptions(selectedProvider) + var selectedModel string + if cfg.LLM.Model != "" { + selectedModel = cfg.LLM.Model + } else if len(modelOptions) > 0 { + selectedModel = modelOptions[0].Value + } + + modelForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("LLM Model"). + Options(modelOptions...). + Value(&selectedModel), + ), + ).WithTheme(getTheme()) + + if err := modelForm.Run(); err != nil { + return false, "", "", postProcessing, customPrompt, err + } + + if cfg.LLM.PostProcessing.RemoveStutters || cfg.LLM.PostProcessing.AddPunctuation || + cfg.LLM.PostProcessing.FixGrammar || cfg.LLM.PostProcessing.RemoveFillerWords { + postProcessing = cfg.LLM.PostProcessing + } + + var ppErr error + postProcessing, ppErr = selectPostProcessingOptions(postProcessing) + if ppErr != nil { + return false, "", "", postProcessing, customPrompt, ppErr + } + + var enableCustomPrompt bool + var customPromptText string + if cfg.LLM.CustomPrompt.Enabled { + enableCustomPrompt = true + customPromptText = cfg.LLM.CustomPrompt.Prompt + } + + customForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Add custom prompt?"). + Description("Add extra instructions for the LLM"). + Value(&enableCustomPrompt), + ), + ).WithTheme(getTheme()) + + if err := customForm.Run(); err != nil { + return false, "", "", postProcessing, customPrompt, err + } + + if enableCustomPrompt { + promptForm := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Custom Prompt"). + Description("Additional instructions (e.g., 'Format as bullet points')"). + Value(&customPromptText). + CharLimit(500), + ), + ).WithTheme(getTheme()) + + if err := promptForm.Run(); err != nil { + return false, "", "", postProcessing, customPrompt, err + } + customPrompt.Enabled = true + customPrompt.Prompt = customPromptText + } + + return true, selectedProvider, selectedModel, postProcessing, customPrompt, nil +} diff --git a/internal/tui/configure_notifications.go b/internal/tui/configure_notifications.go new file mode 100644 index 0000000..80d489c --- /dev/null +++ b/internal/tui/configure_notifications.go @@ -0,0 +1,251 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/leonardotrapani/hyprvoice/internal/config" + "github.com/leonardotrapani/hyprvoice/internal/notify" +) + +// editNotifications handles the notifications section edit with type and custom messages +func editNotifications(cfg *config.Config) error { + enabled := cfg.Notifications.Enabled + + desc := "Show notifications for recording status changes" + if cfg.Notifications.Enabled { + desc = fmt.Sprintf("Currently: enabled (%s). %s", cfg.Notifications.Type, desc) + } else { + desc = "Currently: disabled. " + desc + } + + enableForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Enable desktop notifications?"). + Description(desc). + Value(&enabled), + ), + ).WithTheme(getTheme()) + + if err := enableForm.Run(); err != nil { + return err + } + + cfg.Notifications.Enabled = enabled + + if !enabled { + return nil + } + + notifType := cfg.Notifications.Type + if notifType == "" { + notifType = "desktop" + } + + typeOptions := []huh.Option[string]{ + huh.NewOption("Desktop notifications (notify-send)", "desktop"), + huh.NewOption("Log to console only", "log"), + huh.NewOption("None (silent)", "none"), + } + + typeForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Notification Type"). + Description("How should notifications be displayed?"). + Options(typeOptions...). + Value(¬ifType), + ), + ).WithTheme(getTheme()) + + if err := typeForm.Run(); err != nil { + return err + } + + cfg.Notifications.Type = notifType + + var configureMessages bool + msgForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Configure custom notification messages?"). + Description("Customize the text shown in notifications"). + Affirmative("Yes"). + Negative("No, use defaults"). + Value(&configureMessages), + ), + ).WithTheme(getTheme()) + + if err := msgForm.Run(); err != nil { + return err + } + + if configureMessages { + if err := editNotificationMessages(cfg); err != nil { + return err + } + } + + return nil +} + +// editNotificationMessages allows editing individual notification messages +func editNotificationMessages(cfg *config.Config) error { + for { + var options []huh.Option[string] + for _, def := range notify.MessageDefs { + currentBody := def.DefaultBody + switch def.ConfigKey { + case "recording_started": + if cfg.Notifications.Messages.RecordingStarted.Body != "" { + currentBody = cfg.Notifications.Messages.RecordingStarted.Body + } + case "transcribing": + if cfg.Notifications.Messages.Transcribing.Body != "" { + currentBody = cfg.Notifications.Messages.Transcribing.Body + } + case "llm_processing": + if cfg.Notifications.Messages.LLMProcessing.Body != "" { + currentBody = cfg.Notifications.Messages.LLMProcessing.Body + } + case "config_reloaded": + if cfg.Notifications.Messages.ConfigReloaded.Body != "" { + currentBody = cfg.Notifications.Messages.ConfigReloaded.Body + } + case "operation_cancelled": + if cfg.Notifications.Messages.OperationCancelled.Body != "" { + currentBody = cfg.Notifications.Messages.OperationCancelled.Body + } + case "recording_aborted": + if cfg.Notifications.Messages.RecordingAborted.Body != "" { + currentBody = cfg.Notifications.Messages.RecordingAborted.Body + } + case "injection_aborted": + if cfg.Notifications.Messages.InjectionAborted.Body != "" { + currentBody = cfg.Notifications.Messages.InjectionAborted.Body + } + } + + displayBody := currentBody + if len(displayBody) > 30 { + displayBody = displayBody[:30] + "..." + } + + label := fmt.Sprintf("%s: \"%s\"", def.ConfigKey, displayBody) + options = append(options, huh.NewOption(label, def.ConfigKey)) + } + options = append(options, huh.NewOption("Back", "back")) + + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Notification Messages"). + Description("Select a message to edit"). + Options(options...). + Value(&selected), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return err + } + + if selected == "back" { + return nil + } + + if err := editSingleMessage(cfg, selected); err != nil { + continue + } + } +} + +// editSingleMessage edits a single notification message +func editSingleMessage(cfg *config.Config, configKey string) error { + var def notify.MessageDef + for _, d := range notify.MessageDefs { + if d.ConfigKey == configKey { + def = d + break + } + } + + var currentTitle, currentBody string + switch configKey { + case "recording_started": + currentTitle = cfg.Notifications.Messages.RecordingStarted.Title + currentBody = cfg.Notifications.Messages.RecordingStarted.Body + case "transcribing": + currentTitle = cfg.Notifications.Messages.Transcribing.Title + currentBody = cfg.Notifications.Messages.Transcribing.Body + case "llm_processing": + currentTitle = cfg.Notifications.Messages.LLMProcessing.Title + currentBody = cfg.Notifications.Messages.LLMProcessing.Body + case "config_reloaded": + currentTitle = cfg.Notifications.Messages.ConfigReloaded.Title + currentBody = cfg.Notifications.Messages.ConfigReloaded.Body + case "operation_cancelled": + currentTitle = cfg.Notifications.Messages.OperationCancelled.Title + currentBody = cfg.Notifications.Messages.OperationCancelled.Body + case "recording_aborted": + currentTitle = cfg.Notifications.Messages.RecordingAborted.Title + currentBody = cfg.Notifications.Messages.RecordingAborted.Body + case "injection_aborted": + currentTitle = cfg.Notifications.Messages.InjectionAborted.Title + currentBody = cfg.Notifications.Messages.InjectionAborted.Body + } + + if currentTitle == "" { + currentTitle = def.DefaultTitle + } + if currentBody == "" { + currentBody = def.DefaultBody + } + + title := currentTitle + body := currentBody + + var fields []huh.Field + if !def.IsError { + fields = append(fields, huh.NewInput(). + Title("Title"). + Description(fmt.Sprintf("Default: %s", def.DefaultTitle)). + Placeholder(def.DefaultTitle). + Value(&title)) + } + fields = append(fields, huh.NewInput(). + Title("Body"). + Description(fmt.Sprintf("Default: %s", def.DefaultBody)). + Placeholder(def.DefaultBody). + Value(&body)) + + form := huh.NewForm( + huh.NewGroup(fields...), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return err + } + + msgConfig := config.MessageConfig{Title: title, Body: body} + switch configKey { + case "recording_started": + cfg.Notifications.Messages.RecordingStarted = msgConfig + case "transcribing": + cfg.Notifications.Messages.Transcribing = msgConfig + case "llm_processing": + cfg.Notifications.Messages.LLMProcessing = msgConfig + case "config_reloaded": + cfg.Notifications.Messages.ConfigReloaded = msgConfig + case "operation_cancelled": + cfg.Notifications.Messages.OperationCancelled = msgConfig + case "recording_aborted": + cfg.Notifications.Messages.RecordingAborted = msgConfig + case "injection_aborted": + cfg.Notifications.Messages.InjectionAborted = msgConfig + } + + return nil +} diff --git a/internal/tui/configure_providers.go b/internal/tui/configure_providers.go new file mode 100644 index 0000000..29beb3f --- /dev/null +++ b/internal/tui/configure_providers.go @@ -0,0 +1,203 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/leonardotrapani/hyprvoice/internal/config" + "github.com/leonardotrapani/hyprvoice/internal/provider" +) + +// getProviderDisplayName returns the display name for a provider +func getProviderDisplayName(providerName string) string { + if name, ok := providerDisplayNames[providerName]; ok { + return name + } + return providerName +} + +// maskAPIKey returns a masked version of an API key for display +func maskAPIKey(key string) string { + if len(key) <= 8 { + return "***" + } + return key[:7] + "..." + key[len(key)-4:] +} + +// getConfiguredProviders returns list of providers with API keys +func getConfiguredProviders(cfg *config.Config) []string { + var providers []string + for name, pc := range cfg.Providers { + if pc.APIKey != "" { + providers = append(providers, name) + } + } + return providers +} + +// editProviders handles the providers section edit with submenu +func editProviders(cfg *config.Config) error { + for { + var options []huh.Option[string] + for _, name := range AllProviders { + options = append(options, huh.NewOption(formatProviderOption(cfg, name), name)) + } + options = append(options, huh.NewOption("Back", "back")) + + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Provider Settings"). + Description("Select a provider to configure API key"). + Options(options...). + Value(&selected), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return err + } + + if selected == "back" { + return nil + } + + apiKey, err := configureSingleProvider(cfg, selected) + if err != nil { + continue + } + + if apiKey != "" { + if cfg.Providers == nil { + cfg.Providers = make(map[string]config.ProviderConfig) + } + cfg.Providers[selected] = config.ProviderConfig{APIKey: apiKey} + } + } +} + +// formatProviderOption formats a provider menu option with status +func formatProviderOption(cfg *config.Config, name string) string { + var status string + if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" { + status = "(configured)" + } else { + status = "(not configured)" + } + + switch name { + case "openai": + return fmt.Sprintf("OpenAI - Whisper + GPT %s", status) + case "groq": + return fmt.Sprintf("Groq - Whisper + Llama %s", status) + case "mistral": + return fmt.Sprintf("Mistral - Voxtral %s", status) + case "elevenlabs": + return fmt.Sprintf("ElevenLabs - Scribe %s", status) + default: + return fmt.Sprintf("%s %s", name, status) + } +} + +// configureSingleProvider handles the complete flow for configuring a single provider's API key. +// Shows confirm dialog if key exists, then prompts for new key if needed. +// Returns the new API key (empty if user kept current) and any error. +func configureSingleProvider(cfg *config.Config, providerName string) (string, error) { + var existingKey string + if pc, exists := cfg.Providers[providerName]; exists && pc.APIKey != "" { + existingKey = pc.APIKey + } + + if existingKey != "" { + displayName := getProviderDisplayName(providerName) + masked := maskAPIKey(existingKey) + + var update bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("%s API Key", displayName)). + Description(fmt.Sprintf("Current: %s", masked)). + Affirmative("Update key"). + Negative("Keep current"). + Value(&update), + ), + ).WithTheme(getTheme()) + + if err := confirmForm.Run(); err != nil { + return "", err + } + + if !update { + return "", nil + } + } + + return inputAPIKey(providerName) +} + +func inputAPIKey(providerName string) (string, error) { + p := provider.GetProvider(providerName) + displayName := getProviderDisplayName(providerName) + if p != nil { + if name, ok := providerDisplayNames[p.Name()]; ok { + displayName = name + } + } + + var apiKey string + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title(fmt.Sprintf("%s API Key", displayName)). + Description(fmt.Sprintf("Enter your %s API key", displayName)). + EchoMode(huh.EchoModePassword). + Value(&apiKey). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("API key is required") + } + if p != nil && !p.ValidateAPIKey(s) { + return fmt.Errorf("invalid API key format for %s", displayName) + } + return nil + }), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return "", err + } + + return apiKey, nil +} + +// ensureProviderConfigured prompts for API key if provider not configured +func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string { + providerName := selectedProvider + switch selectedProvider { + case "groq-transcription", "groq-translation": + providerName = "groq" + case "mistral-transcription": + providerName = "mistral" + } + + for _, p := range configuredProviders { + if p == providerName { + return configuredProviders + } + } + + apiKey, err := configureSingleProvider(cfg, providerName) + if err != nil || apiKey == "" { + return configuredProviders + } + + if cfg.Providers == nil { + cfg.Providers = make(map[string]config.ProviderConfig) + } + cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey} + + return append(configuredProviders, providerName) +} diff --git a/internal/tui/configure_transcription.go b/internal/tui/configure_transcription.go new file mode 100644 index 0000000..2d1748e --- /dev/null +++ b/internal/tui/configure_transcription.go @@ -0,0 +1,249 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/leonardotrapani/hyprvoice/internal/config" + "github.com/leonardotrapani/hyprvoice/internal/provider" +) + +// editTranscription handles the transcription section edit with smart provider detection +func editTranscription(cfg *config.Config, configuredProviders []string) ([]string, error) { + var transcriptionOptions []huh.Option[string] + for _, name := range configuredProviders { + p := provider.GetProvider(name) + if p != nil && p.SupportsTranscription() { + switch name { + case "openai": + transcriptionOptions = append(transcriptionOptions, + huh.NewOption("OpenAI Whisper", "openai")) + case "groq": + transcriptionOptions = append(transcriptionOptions, + huh.NewOption("Groq Whisper (transcription)", "groq-transcription"), + huh.NewOption("Groq Whisper (translate to English)", "groq-translation")) + case "mistral": + transcriptionOptions = append(transcriptionOptions, + huh.NewOption("Mistral Voxtral", "mistral-transcription")) + case "elevenlabs": + transcriptionOptions = append(transcriptionOptions, + huh.NewOption("ElevenLabs Scribe", "elevenlabs")) + } + } + } + + unconfiguredOptions := getUnconfiguredTranscriptionOptions(configuredProviders) + if len(unconfiguredOptions) > 0 { + transcriptionOptions = append(transcriptionOptions, unconfiguredOptions...) + } + + if len(transcriptionOptions) == 0 { + return configuredProviders, fmt.Errorf("no transcription providers available") + } + + selectedProvider := cfg.Transcription.Provider + if selectedProvider == "" && len(transcriptionOptions) > 0 { + selectedProvider = transcriptionOptions[0].Value + } + + providerDesc := "Choose which service to use for speech-to-text" + if cfg.Transcription.Provider != "" { + providerDesc = fmt.Sprintf("Currently: %s/%s", cfg.Transcription.Provider, cfg.Transcription.Model) + } + + providerForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Transcription Provider"). + Description(providerDesc). + Options(transcriptionOptions...). + Value(&selectedProvider), + ), + ).WithTheme(getTheme()) + + if err := providerForm.Run(); err != nil { + return configuredProviders, err + } + + configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders) + cfg.Transcription.Provider = selectedProvider + + modelOptions := getTranscriptionModelOptions(selectedProvider) + selectedModel := cfg.Transcription.Model + if selectedModel == "" && len(modelOptions) > 0 { + selectedModel = modelOptions[0].Value + } + + modelDesc := "" + if cfg.Transcription.Model != "" { + modelDesc = fmt.Sprintf("Currently: %s", cfg.Transcription.Model) + } + + language := cfg.Transcription.Language + + langDesc := "ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect" + if cfg.Transcription.Language != "" { + langDesc = fmt.Sprintf("Currently: %s. %s", cfg.Transcription.Language, langDesc) + } + + modelForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Transcription Model"). + Description(modelDesc). + Options(modelOptions...). + Value(&selectedModel), + huh.NewInput(). + Title("Language"). + Description(langDesc). + Placeholder("auto-detect"). + Value(&language), + ), + ).WithTheme(getTheme()) + + if err := modelForm.Run(); err != nil { + return configuredProviders, err + } + + cfg.Transcription.Model = selectedModel + cfg.Transcription.Language = language + + return configuredProviders, nil +} + +// getUnconfiguredTranscriptionOptions returns options for providers not yet configured +func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Option[string] { + configured := make(map[string]bool) + for _, p := range configuredProviders { + configured[p] = true + } + + var options []huh.Option[string] + if !configured["openai"] { + options = append(options, huh.NewOption("OpenAI Whisper (not configured)", "openai")) + } + if !configured["groq"] { + options = append(options, + huh.NewOption("Groq Whisper transcription (not configured)", "groq-transcription"), + huh.NewOption("Groq Whisper translation (not configured)", "groq-translation")) + } + if !configured["mistral"] { + options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription")) + } + if !configured["elevenlabs"] { + options = append(options, huh.NewOption("ElevenLabs Scribe (not configured)", "elevenlabs")) + } + return options +} + +func getTranscriptionModelOptions(provider string) []huh.Option[string] { + switch provider { + case "openai": + return []huh.Option[string]{ + huh.NewOption("whisper-1", "whisper-1"), + } + case "groq-transcription": + return []huh.Option[string]{ + huh.NewOption("whisper-large-v3-turbo (faster)", "whisper-large-v3-turbo"), + huh.NewOption("whisper-large-v3 (standard)", "whisper-large-v3"), + } + case "groq-translation": + return []huh.Option[string]{ + huh.NewOption("whisper-large-v3 (only option)", "whisper-large-v3"), + } + case "mistral-transcription": + return []huh.Option[string]{ + huh.NewOption("voxtral-mini-latest (recommended)", "voxtral-mini-latest"), + huh.NewOption("voxtral-mini-2507", "voxtral-mini-2507"), + } + case "elevenlabs": + return []huh.Option[string]{ + huh.NewOption("scribe_v1 (99 languages, best accuracy)", "scribe_v1"), + huh.NewOption("scribe_v2 (real-time, lower latency)", "scribe_v2"), + } + default: + return []huh.Option[string]{} + } +} + +func configureTranscription(configuredProviders []string, cfg *config.Config) (string, string, string, error) { + var transcriptionOptions []huh.Option[string] + for _, name := range configuredProviders { + p := provider.GetProvider(name) + if p != nil && p.SupportsTranscription() { + switch name { + case "openai": + transcriptionOptions = append(transcriptionOptions, + huh.NewOption("OpenAI Whisper", "openai")) + case "groq": + transcriptionOptions = append(transcriptionOptions, + huh.NewOption("Groq Whisper (transcription)", "groq-transcription"), + huh.NewOption("Groq Whisper (translate to English)", "groq-translation")) + case "mistral": + transcriptionOptions = append(transcriptionOptions, + huh.NewOption("Mistral Voxtral", "mistral-transcription")) + case "elevenlabs": + transcriptionOptions = append(transcriptionOptions, + huh.NewOption("ElevenLabs Scribe", "elevenlabs")) + } + } + } + + if len(transcriptionOptions) == 0 { + return "", "", "", fmt.Errorf("no transcription-capable providers configured") + } + + var selectedProvider string + if cfg.Transcription.Provider != "" { + selectedProvider = cfg.Transcription.Provider + } else if len(transcriptionOptions) > 0 { + selectedProvider = transcriptionOptions[0].Value + } + + providerForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Transcription Provider"). + Description("Choose which service to use for speech-to-text"). + Options(transcriptionOptions...). + Value(&selectedProvider), + ), + ).WithTheme(getTheme()) + + if err := providerForm.Run(); err != nil { + return "", "", "", err + } + + modelOptions := getTranscriptionModelOptions(selectedProvider) + var selectedModel string + if cfg.Transcription.Model != "" { + selectedModel = cfg.Transcription.Model + } else if len(modelOptions) > 0 { + selectedModel = modelOptions[0].Value + } + + var language string + if cfg.Transcription.Language != "" { + language = cfg.Transcription.Language + } + + modelForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Transcription Model"). + Options(modelOptions...). + Value(&selectedModel), + huh.NewInput(). + Title("Language"). + Description("ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect"). + Placeholder("auto-detect"). + Value(&language), + ), + ).WithTheme(getTheme()) + + if err := modelForm.Run(); err != nil { + return "", "", "", err + } + + return selectedProvider, selectedModel, language, nil +} diff --git a/internal/tui/configure_wizard.go b/internal/tui/configure_wizard.go new file mode 100644 index 0000000..aab5925 --- /dev/null +++ b/internal/tui/configure_wizard.go @@ -0,0 +1,213 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/huh" + "github.com/leonardotrapani/hyprvoice/internal/config" +) + +// runFreshInstall runs the full configuration wizard for fresh installs +func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) { + fmt.Println(Logo()) + fmt.Println() + fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland")) + fmt.Println() + + selectedProviders, err := selectProviders() + if err != nil { + return &ConfigureResult{Cancelled: true}, nil + } + if len(selectedProviders) == 0 { + return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers selected") + } + + if cfg.Providers == nil { + cfg.Providers = make(map[string]config.ProviderConfig) + } + + for _, providerName := range selectedProviders { + apiKey, err := inputAPIKey(providerName) + if err != nil { + return &ConfigureResult{Cancelled: true}, nil + } + cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey} + } + + transcriptionProvider, transcriptionModel, language, err := configureTranscription(selectedProviders, cfg) + if err != nil { + return &ConfigureResult{Cancelled: true}, nil + } + cfg.Transcription.Provider = transcriptionProvider + cfg.Transcription.Model = transcriptionModel + cfg.Transcription.Language = language + + llmEnabled, llmProvider, llmModel, postProcessing, customPrompt, err := configureLLM(selectedProviders, cfg) + if err != nil { + return &ConfigureResult{Cancelled: true}, nil + } + cfg.LLM.Enabled = llmEnabled + cfg.LLM.Provider = llmProvider + cfg.LLM.Model = llmModel + cfg.LLM.PostProcessing = postProcessing + cfg.LLM.CustomPrompt = customPrompt + + keywords, err := inputKeywords(cfg.Keywords) + if err != nil { + return &ConfigureResult{Cancelled: true}, nil + } + cfg.Keywords = keywords + + backends, err := selectBackends(cfg.Injection.Backends) + if err != nil { + return &ConfigureResult{Cancelled: true}, nil + } + cfg.Injection.Backends = backends + + notificationsEnabled, err := configureNotifications(cfg.Notifications.Enabled) + if err != nil { + return &ConfigureResult{Cancelled: true}, nil + } + cfg.Notifications.Enabled = notificationsEnabled + + confirmed, err := showSummary(cfg) + if err != nil || !confirmed { + return &ConfigureResult{Cancelled: true}, nil + } + + return &ConfigureResult{Config: cfg, Cancelled: false}, nil +} + +func selectProviders() ([]string, error) { + options := []huh.Option[string]{ + huh.NewOption("OpenAI - Whisper transcription + GPT for LLM", "openai"), + huh.NewOption("Groq - Fast Whisper transcription + Llama for LLM", "groq"), + huh.NewOption("Mistral - Voxtral transcription (European languages)", "mistral"), + huh.NewOption("ElevenLabs - Scribe transcription (99 languages)", "elevenlabs"), + } + + var selected []string + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Which providers do you want to configure?"). + Description("Select all providers you have API keys for"). + Options(options...). + Value(&selected), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return nil, err + } + + valid := make([]string, 0) + for _, s := range selected { + for _, p := range AllProviders { + if s == p { + valid = append(valid, s) + break + } + } + } + + return valid, nil +} + +func inputKeywords(existingKeywords []string) ([]string, error) { + var keywordsInput string + if len(existingKeywords) > 0 { + keywordsInput = strings.Join(existingKeywords, ", ") + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Keywords"). + Description("Comma-separated words to help with spelling (names, technical terms, etc.)"). + Placeholder("e.g., Kubernetes, PostgreSQL, John Smith"). + Value(&keywordsInput), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return nil, err + } + + if keywordsInput == "" { + return nil, nil + } + + parts := strings.Split(keywordsInput, ",") + keywords := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + keywords = append(keywords, p) + } + } + + return keywords, nil +} + +func selectBackends(existingBackends []string) ([]string, error) { + options := []huh.Option[string]{ + huh.NewOption("ydotool - Best for Chromium/Electron (needs ydotoold)", "ydotool"), + huh.NewOption("wtype - Native Wayland typing", "wtype"), + huh.NewOption("clipboard - Copy to clipboard only", "clipboard"), + } + + var selected []string + if len(existingBackends) > 0 { + selected = existingBackends + } else { + selected = []string{"ydotool", "wtype", "clipboard"} + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Text Injection Backends"). + Description("Backends are tried in order until one succeeds (fallback chain)"). + Options(options...). + Value(&selected), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return nil, err + } + + if len(selected) == 0 { + return nil, fmt.Errorf("at least one backend required") + } + + return selected, nil +} + +func configureNotifications(existingEnabled bool) (bool, error) { + enabled := existingEnabled + + desc := "Show notifications for recording status changes" + if existingEnabled { + desc = "Currently: enabled. " + desc + } else { + desc = "Currently: disabled. " + desc + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Enable desktop notifications?"). + Description(desc). + Value(&enabled), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return false, err + } + + return enabled, nil +} diff --git a/progress.txt b/progress.txt deleted file mode 100644 index e8d422f..0000000 --- a/progress.txt +++ /dev/null @@ -1,268 +0,0 @@ -# Ralph Progress Log -Started: Sat Jan 31 08:30:51 PM CET 2026 ---- - -## Task 1: Create Provider interface and registry - COMPLETE - -Created internal/provider package with: -- Provider interface with all required methods -- ProviderConfig struct for API key storage -- 4 provider implementations: OpenAI, Groq, Mistral, ElevenLabs -- Registry with GetProvider(), ListProviders(), ListProvidersWithLLM(), ListProvidersWithTranscription() -- Comprehensive tests (all passing) - -Key decisions: -- OpenAI and Groq support both transcription + LLM -- Mistral and ElevenLabs are transcription-only -- ValidateAPIKey checks prefix for OpenAI (sk-) and Groq (gsk_), accepts any non-empty for others - -## Task 2: Refactor config to unified provider structure - COMPLETE - -Added to internal/config/config.go: -- `Providers map[string]ProviderConfig` for centralized API key storage -- `Keywords []string` at config root level -- `LLMConfig` with Enabled, Provider, Model, PostProcessing, CustomPrompt -- `LLMPostProcessingConfig` with RemoveStutters, AddPunctuation, FixGrammar, RemoveFillerWords (all default true) -- `LLMCustomPromptConfig` with Enabled, Prompt -- `LLMAdapterConfig` struct for passing to LLM adapters -- `ToLLMConfig()` method -- `IsLLMEnabled()` helper -- `resolveAPIKeyForProvider()` - unified API key resolution: providers map -> legacy transcription.api_key -> env var -- `resolveAPIKeyForLLMProvider()` - same for LLM -- `migrateTranscriptionAPIKey()` - auto-migrates old config format -- `applyLLMDefaults()` - sets post-processing options to true if all are zero - -Migration: -- Old configs with `transcription.api_key` auto-migrate to `providers` map on Load() -- Logs warning: "Run 'hyprvoice configure' to update config format" -- Both old and new config formats work (backward compatible) - -Validation: -- LLM validation only runs when `llm.enabled = true` -- Checks provider is openai or groq -- Checks API key is available for LLM provider - -Key decisions: -- API key resolution order: providers.X.api_key -> transcription.api_key -> ENV_VAR -- LLM provider names are "openai" and "groq" (not "groq-transcription") -- PostProcessing defaults to all true only if ALL options are false (zero values) -- Keywords at root level (global), used by both transcription and LLM - -## Task 3: Create LLM adapter interface and implementations - COMPLETE - -Created internal/llm package with: -- `Adapter` interface: `Process(ctx, text) (string, error)` -- `Config` struct mirroring config.LLMAdapterConfig -- `prompt.go` with `BuildSystemPrompt(opts, keywords)` and `BuildUserPrompt(text, customPrompt)` -- `OpenAIAdapter` using go-openai chat completions API -- `GroqAdapter` using Groq's OpenAI-compatible API (baseURL override) -- `NewAdapter(config)` factory function - -Key decisions: -- Low temperature (0.3) for consistent text cleanup -- Default models: gpt-4o-mini (OpenAI), llama-3.3-70b-versatile (Groq) -- System prompt builds dynamically based on enabled options -- Keywords included in system prompt for correct spelling hints -- Custom prompt prepended to user prompt if enabled - -## Task 4: Integrate LLM phase into pipeline - COMPLETE - -Updated internal/pipeline/pipeline.go: -- Added `Processing` status for LLM post-processing phase -- After transcription, checks `config.IsLLMEnabled()` before LLM processing -- Creates LLM adapter using config.ToLLMConfig() -- Processes text with adapter, uses result for injection -- Graceful fallback: logs warning and uses raw transcription text on any error - -Key decisions: -- LLM processing happens between transcription and injection -- Adapter creation and processing errors are logged but don't fail the pipeline -- Sets status to Processing during LLM phase, then back to Injecting - -## Task 5: Pass keywords to transcription adapters - COMPLETE - -Added Keywords support to transcription adapters: -- Added `Keywords []string` to transcriber.Config struct -- Updated `ToTranscriberConfig()` to pass keywords from config -- OpenAI adapter uses keywords in `Prompt` field (initial_prompt parameter) -- Groq transcription adapter uses keywords in `Prompt` field -- Groq translation adapter uses keywords in `Prompt` field -- Mistral and ElevenLabs adapters ignore keywords (APIs don't support initial_prompt) - -Key decisions: -- Keywords joined with ", " to form a single string for the Prompt field -- Whisper uses this as "initial_prompt" to help with spelling/terminology -- Only added to adapters that clearly support it (OpenAI/Groq via go-openai lib) - -## Task 6: Add TUI dependencies and base components - COMPLETE - -Added Charmbracelet TUI stack: -- bubbletea v1.3.10, lipgloss v1.1.0, huh v0.8.0 -- Created internal/tui package - -Files created: -- `internal/tui/theme.go` - color palette (purple primary, cyan secondary, status colors) -- `internal/tui/styles.go` - lipgloss styles (header, label, success, error, muted, highlight, selected, box styles) -- `Logo()` function for ASCII branding - -Key decisions: -- Purple (#7C3AED) as primary accent, matches hyprvoice "voice" theme -- Dark slate backgrounds for terminal aesthetics -- Box styles with rounded borders for form containers - -## Task 7: Create TUI configure - fresh install flow - COMPLETE - -Created internal/tui/configure.go with full TUI wizard: -- `Run(existingConfig)` entry point returning ConfigureResult -- `runFreshInstall()` - linear flow through all configuration steps -- `selectProviders()` - multi-select for OpenAI, Groq, Mistral, ElevenLabs -- `inputAPIKey()` - password-masked input with validation per provider -- `configureTranscription()` - provider dropdown (only configured+capable), model dropdown, language input -- `configureLLM()` - enable confirm (defaults YES, labeled "Recommended"), provider, model, post-processing toggles, custom prompt -- `inputKeywords()` - comma-separated input -- `selectBackends()` - multi-select with descriptions -- `configureNotifications()` - enable toggle -- `showSummary()` - displays all config, confirm button -- `getTheme()` - applies hyprvoice color scheme to huh forms - -Key decisions: -- Linear flow for fresh installs, all steps required -- Transcription providers mapped: groq -> groq-transcription + groq-translation options -- LLM enabled by default, "Yes (Recommended)" as affirmative text -- Post-processing options all default to true -- Uses huh library forms with custom theme matching styles.go colors - -## Task 8: Create TUI configure - edit existing flow - COMPLETE - -Added edit flow for existing configs in internal/tui/configure.go: -- `hasUserChanges()` detects if config has been modified (providers configured or legacy api_key set) -- `runEditExisting()` - section-based edit flow instead of full wizard -- `selectSections()` - multi-select for Providers, Transcription, LLM, Keywords, Injection, Notifications, Full Setup -- `editProviders()` - add/update API keys for selected providers -- `editTranscription()` - configure speech-to-text with smart provider detection -- `editLLM()` - configure post-processing with smart provider detection -- `getUnconfiguredTranscriptionOptions()` / `getUnconfiguredLLMOptions()` - show options for providers without keys -- `ensureProviderConfigured()` - prompts for API key when user selects unconfigured provider - -Key decisions: -- "Full Setup" option runs the fresh install flow -- Configured providers show "(configured)" label in providers section -- Unconfigured providers show "(needs API key)" in transcription/LLM sections -- When user picks unconfigured provider, immediately prompts for API key -- Unedited sections preserved - only touched sections are modified -- 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 - -## Task 10: Update default config template - COMPLETE - -Updated SaveDefaultConfig() in internal/config/config.go: -- Added `keywords = []` at top level (before any TOML tables) -- Added `[providers.openai]` and `[providers.groq]` sections with api_key -- Added `[llm]` section with enabled = true, provider = "openai", model = "gpt-4o-mini" -- Added `[llm.post_processing]` with all 4 options = true -- Added `[llm.custom_prompt]` with enabled = false -- Added MIGRATION NOTE in header about old format upgrade -- Reorganized with clear section headers (box-drawing chars) -- Removed `transcription.api_key` from default (uses providers map now) -- Simplified and consolidated reference docs at bottom - -Key decisions: -- LLM enabled by default with OpenAI gpt-4o-mini (best cost/quality) -- Providers section at top for visibility -- Keywords before any table definitions (TOML syntax requirement) -- Concise comments, full reference at bottom - -## Task 11: Add LLM processing notification - COMPLETE - -Added notification when LLM post-processing starts: -- Added `MsgLLMProcessing` to `notify/message.go` (default: "Hyprvoice", "Processing...") -- Added `LLMProcessing` field to `MessagesConfig` in config.go (toml: `llm_processing`) -- Added notification channel to pipeline (`GetNotifyCh()` method) -- Pipeline sends `MsgLLMProcessing` when entering Processing status -- Daemon monitors `notifyCh` via `monitorPipelineNotifications` goroutine -- Updated default config template with `llm_processing` message example -- Fixed tests: MockPipeline implements `GetNotifyCh`, notify test expects 7 MessageDefs - -Key decisions: -- Notification channel approach (vs direct notifier access) keeps pipeline decoupled -- Notification sent at same time status changes to Processing -- Configurable like all other notifications via `[notifications.messages.llm_processing]` - -## Task 12: Update README documentation - COMPLETE - -Updated README.md with comprehensive LLM post-processing documentation: -- Added LLM feature to Features list at top -- Added "Unified Provider System" section with API key configuration examples -- Added "LLM Post-Processing" section with full configuration guide -- Added post-processing options documentation (remove_stutters, add_punctuation, etc.) -- Added custom prompt documentation with use cases -- Added "Keywords" section explaining how they help transcription + LLM -- Added 4 example configurations: fast transcription only, high quality, budget-friendly, mixed providers -- Added "Migration from Old Config Format" section with before/after examples -- Updated Development Status table: added Mistral, ElevenLabs, LLM post-processing, TUI setup -- Updated architecture diagrams to show processing state -- Updated state machine description: idle → recording → transcribing → processing → injecting -- Updated project structure to include new packages (config, llm, provider, tui) -- Added llm_processing to custom notification messages example - -Key decisions: -- Put Unified Provider System before Transcription Providers (sets context) -- LLM section after transcription providers (logical flow) -- Example configs ordered by use case (fast → quality → budget → mixed) -- Migration section shows both old and new format side by side - -## Task 13: End-to-end testing - COMPLETE - -Verified all functionality through unit tests and code review: - -**Automated verification (all pass):** -- Old config backward compatibility: TestConfig_MigrateTranscriptionAPIKey -- New config format: TestConfig_NewStyleConfig -- LLM config and validation: TestConfig_LLMConfig, TestConfig_LLMValidation -- LLM defaults applied: TestConfig_LLMDefaults, TestConfig_LLMDefaultsPreserveExplicit -- Keywords in config: TestConfig_LLMConfig (keywords passed to ToLLMConfig) -- Keywords in transcription: adapter_openai.go:48, adapter_groq_transcription.go:51 use keywords in Prompt -- Post-processing options: TestConfig_LLMConfig verifies all 4 options -- Custom prompt: TestConfig_LLMConfig verifies custom prompt config -- LLM disable: TestConfig_LLMValidation "LLM disabled skips validation" -- Config hot-reload: config/manager.go watches file changes, debounces, reloads -- Provider system: provider_test.go covers all providers - -**Build and test results:** -- `go build ./...` - passes -- `go test ./...` - all tests pass (100+ tests across 11 packages) - -**TUI implementation verified by code review:** -- Fresh install flow: runFreshInstall() walks through all steps -- Edit existing flow: runEditExisting() with section picker -- Smart provider detection: ensureProviderConfigured() prompts for API key when needed -- Configured providers show "(configured)" label -- Unconfigured show "(needs API key)" label -- Full setup option available in edit flow - -**Items requiring manual verification with real API keys:** -- LLM actually improves text quality (needs live API call) -- TUI is intuitive (requires interactive terminal session) - -Key decisions: -- TUI testing can't be automated without heavy mocking (charmbracelet forms are interactive) -- LLM quality testing needs real API keys for actual API calls -- All code paths are covered by unit tests, only integration layer needs manual verification diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc deleted file mode 100644 index 37f00d7..0000000 --- a/tasks/prd.jsonc +++ /dev/null @@ -1,266 +0,0 @@ -{ - "project": "hyprvoice LLM Post-Processing", - "description": "Add LLM post-processing phase with unified provider system, TUI configure command, and configurable cleanup options", - "issue": "https://github.com/LeonardoTrapani/hyprvoice/issues/4", - "tasks": [ - { - "title": "Create Provider interface and registry", - "steps": [ - "Create new package internal/provider", - "Define Provider interface: Name(), RequiresAPIKey(), ValidateAPIKey(key), SupportsTranscription(), SupportsLLM(), DefaultTranscriptionModel(), DefaultLLMModel(), TranscriptionModels(), LLMModels()", - "Create ProviderConfig struct with APIKey field", - "Implement OpenAIProvider: transcription (whisper-1) + LLM (gpt-4o-mini)", - "Implement GroqProvider: transcription (whisper-large-v3, turbo) + LLM (llama-3.3-70b-versatile)", - "Implement MistralProvider: transcription only (voxtral-mini-latest)", - "Implement ElevenLabsProvider: transcription only (scribe_v1, scribe_v2)", - "Create GetProvider(name) and ListProviders() functions", - "Create ListProvidersWithLLM() and ListProvidersWithTranscription() helpers" - ], - "verify": [ - "All providers implement the interface", - "GetProvider returns correct provider for each name", - "Capability methods return correct values", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Refactor config to unified provider structure", - "steps": [ - "Add Providers map[string]ProviderConfig to Config struct", - "Add global Keywords []string field to Config", - "Remove api_key from TranscriptionConfig, keep provider/model/language", - "Add LLMConfig with Enabled (default true), Provider, Model", - "Add LLMPostProcessingConfig: RemoveStutters, AddPunctuation, FixGrammar, RemoveFillerWords (all default true)", - "Add LLMCustomPromptConfig: Enabled, Prompt", - "Add migration in Load() to detect old format (transcription.api_key) and convert to providers map", - "Migration logs: 'Config migrated. Run hyprvoice configure to update format.'", - "Update ToTranscriberConfig() to resolve API key from Providers", - "Add ToLLMConfig() method", - "Environment variables still work as fallback" - ], - "verify": [ - "Old config with transcription.api_key still loads (backward compatible)", - "New config with [providers.openai] works", - "Environment variable fallback works", - "Migration logs warning", - "Typecheck passes", - "go test ./internal/config/... passes" - ], - "passes": true - }, - { - "title": "Create LLM adapter interface and implementations", - "steps": [ - "Create internal/llm package", - "Define LLMAdapter interface: Process(ctx, text) (string, error)", - "Define Config struct with all options", - "Create prompt.go with BuildSystemPrompt(opts, keywords) and BuildUserPrompt(text, customPrompt)", - "Implement OpenAIAdapter using chat completions API", - "Implement GroqAdapter using Groq API (OpenAI-compatible)", - "Create NewAdapter(config) factory function" - ], - "verify": [ - "Both adapters implement interface", - "Prompt builder generates correct prompts", - "Factory returns correct adapter", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Integrate LLM phase into pipeline", - "steps": [ - "Update internal/pipeline/pipeline.go", - "After transcription, check if LLM enabled", - "If enabled, create adapter and process text", - "Use processed text for injection", - "On failure, fall back to raw text with warning", - "Add LLM processing notification" - ], - "verify": [ - "Pipeline unchanged when LLM disabled", - "LLM processes text when enabled", - "Graceful fallback on LLM error", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Pass keywords to transcription adapters", - "steps": [ - "Add Keywords []string to transcriber.Config", - "Update ToTranscriberConfig() to pass keywords", - "OpenAI transcriber uses keywords in initial_prompt", - "Groq transcriber uses keywords in prompt parameter", - "Other transcribers ignore if unsupported" - ], - "verify": [ - "Keywords passed to transcription", - "OpenAI includes in request", - "Non-supporting transcribers still work", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Add TUI dependencies and base components", - "steps": [ - "Run: go get github.com/charmbracelet/bubbletea github.com/charmbracelet/lipgloss github.com/charmbracelet/huh", - "Create internal/tui package", - "Create styles.go with lipgloss styles: header, label, success, error, muted, highlight, selected", - "Create theme.go with color scheme matching hyprvoice branding" - ], - "verify": [ - "Dependencies in go.mod", - "Styles render in terminal", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Create TUI configure - fresh install flow", - "steps": [ - "Create internal/tui/configure.go with Run() function", - "Welcome screen with hyprvoice ASCII/branding", - "Provider selection: multi-select which to configure (OpenAI, Groq, Mistral, ElevenLabs)", - "For each selected: API key input with password mask", - "Transcription: provider dropdown (only configured + supports transcription), model dropdown", - "LLM: 'Enable post-processing? (Recommended)' - defaults YES", - "If LLM yes: provider (only configured + supports LLM), model, post-processing toggles (all default true), custom prompt", - "Keywords: comma-separated input", - "Injection: backend multi-select with descriptions", - "Notifications: enable toggle", - "Summary screen with confirm" - ], - "verify": [ - "Fresh install walks through all steps", - "Only shows providers user selected for API keys", - "Transcription only shows configured + capable providers", - "LLM defaults to enabled, Yes is recommended", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Create TUI configure - edit existing flow", - "steps": [ - "Detect if config exists and has user changes", - "Show section picker: 'What to configure?' multi-select", - "Sections: Providers, Transcription, LLM, Keywords, Injection, Notifications, Full Setup", - "For each section, show only that form", - "Smart provider detection: if user picks unconfigured provider, prompt for API key", - "If provider already configured, show 'Using existing key' (no re-prompt unless in Providers section)", - "Merge with existing config, preserve unedited sections" - ], - "verify": [ - "Existing config shows section picker", - "Single section only edits that section", - "Unconfigured provider triggers key prompt", - "Configured providers don't re-prompt", - "Unedited sections preserved", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Replace old configure with TUI", - "steps": [ - "Update cmd/hyprvoice/main.go configureCmd to call tui.Run()", - "Remove runInteractiveConfig and related helpers (maskAPIKey, formatBackends, etc.)", - "Update saveConfig to write new TOML structure with [providers.X]", - "Ensure validation before save", - "Show next steps after successful save" - ], - "verify": [ - "hyprvoice configure launches TUI", - "Old code removed", - "Saved config valid TOML with new structure", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Update default config template", - "steps": [ - "Update SaveDefaultConfig() in config.go", - "Add [providers.openai] and [providers.groq] sections", - "Add keywords = [] global", - "Add [llm] with enabled = true, provider, model", - "Add [llm.post_processing] all true", - "Add [llm.custom_prompt] enabled = false", - "Clear comments explaining structure", - "Add migration note about old format" - ], - "verify": [ - "Default config valid TOML", - "LLM enabled by default", - "Post-processing all true by default", - "Comments clear", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Add LLM processing notification", - "steps": [ - "Add MsgLLMProcessing to notify types", - "Default: title='Hyprvoice', body='Processing...'", - "Add to MessagesConfig", - "Trigger when LLM starts", - "Make configurable" - ], - "verify": [ - "Type defined", - "Notification appears", - "Configurable in config", - "Typecheck passes" - ], - "passes": true - }, - { - "title": "Update README documentation", - "steps": [ - "Add '## LLM Post-Processing' section", - "Document unified provider structure with examples", - "Document post-processing options", - "Document custom prompt with use cases", - "Document keywords (helps transcription + LLM)", - "Example configs for common setups", - "Document migration from old format", - "Note LLM enabled by default" - ], - "verify": [ - "README clear", - "Examples valid TOML", - "Migration documented", - "Keywords explained" - ], - "passes": true - }, - { - "title": "End-to-end testing", - "steps": [ - "Test old config loads (backward compatible)", - "Test new config works", - "Test LLM enabled by default improves output", - "Test LLM can be disabled", - "Test each post-processing option", - "Test custom prompt", - "Test keywords in transcription", - "Test TUI fresh install flow", - "Test TUI edit existing flow", - "Test smart provider detection", - "Test config hot-reload" - ], - "verify": [ - "Old configs work unchanged", - "New configs work", - "LLM improves text quality", - "TUI flows intuitive and state-aware", - "No regressions" - ], - "passes": true - } - ] -}