From 4426af63cbb5665d11814df57fd40da3e8c33c06 Mon Sep 17 00:00:00 2001 From: thread Date: Tue, 1 Sep 2026 01:09:59 -0400 Subject: [PATCH] LLMIFY THIS MOFO --- docs/config.md | 35 +++++- internal/config/config_test.go | 25 +++++ internal/config/convert.go | 15 ++- internal/config/defaults.go | 9 +- internal/config/save.go | 24 ++++- internal/config/types.go | 13 ++- internal/config/validate.go | 90 +++++++++++----- internal/injection/clipboard_paste.go | 141 +++++++++++++++++++++++++ internal/injection/injection.go | 11 +- internal/injection/injection_test.go | 14 ++- internal/injection/ydotool.go | 4 + internal/llm/adapter_openai.go | 6 ++ internal/llm/llm.go | 9 ++ internal/pipeline/pipeline.go | 6 +- internal/provider/llama_swap.go | 14 +++ internal/provider/names.go | 4 + internal/provider/provider.go | 1 + internal/transcriber/adapter_openai.go | 2 +- internal/transcriber/transcriber.go | 21 +++- 19 files changed, 388 insertions(+), 56 deletions(-) create mode 100644 internal/injection/clipboard_paste.go create mode 100644 internal/provider/llama_swap.go diff --git a/docs/config.md b/docs/config.md index 1a848e3..fb434c6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -91,12 +91,16 @@ Hyprvoice uses a unified provider system where API keys are configured once and [providers.deepgram] api_key = "..." # Or set DEEPGRAM_API_KEY env var + +[providers.llama-swap] + api_key = "..." # Or set LLAMA_SWAP_API_KEY + base_url = "http://llama-swap.example:8080" # Do not include /v1 ``` **API key resolution order:** 1. `[providers.X]` section in config -2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, etc.) +2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, `LLAMA_SWAP_API_KEY`, etc.) ## Transcription Providers @@ -104,6 +108,29 @@ Hyprvoice supports multiple transcription backends. See [docs/providers.md](./pr ### Cloud Providers +### LlamaSwap (network-hosted OpenAI-compatible models) + +LlamaSwap proxies OpenAI-compatible endpoints, including `/v1/audio/transcriptions` and `/v1/chat/completions`. Configure its host once, then set the model IDs exactly as they appear in LlamaSwap's `/v1/models` response: + +```toml +[providers.llama-swap] +api_key = "your-llama-swap-api-key" +base_url = "http://192.168.1.50:8080" # No trailing /v1 + +[transcription] +provider = "llama-swap" +model = "whisper-large-v3-turbo" +language = "" +streaming = false + +[llm] +enabled = true +provider = "llama-swap" +model = "your-chat-model-id" +``` + +The transcription and chat model IDs are deliberately not restricted by Hyprvoice: LlamaSwap selects from the models configured on your server. Hyprvoice submits transcription after recording ends, so set `streaming = false`. + ### OpenAI Whisper API Cloud-based transcription using OpenAI's Whisper API: @@ -455,15 +482,19 @@ Configurable text injection with multiple backends: ```toml [injection] -backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain +backends = ["clipboard-paste", "ydotool", "wtype", "clipboard"] # Ordered fallback chain ydotool_timeout = "5s" wtype_timeout = "5s" clipboard_timeout = "3s" +ctrl_shift_v_classes = ["ghostty"] # Window-class substrings that paste with Ctrl+Shift+V ``` ### Injection Backends - **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps. +- **`clipboard-paste`**: Temporarily copies text to the regular clipboard, sends Ctrl+V through wtype, then restores the prior text clipboard. This works in browsers and Electron apps and is layout-independent; recommended for Dvorak/Colemak users. Requires `wl-clipboard` and `wtype`. + +`ctrl_shift_v_classes` selects applications that need Ctrl+Shift+V instead of Ctrl+V. Matching is case-insensitive against Hyprland's active-window data, and the default `"ghostty"` handles Ghostty. Add a terminal's class substring to this list when needed. - **`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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fcbb3b5..81c8a10 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -696,6 +696,31 @@ func TestConfig_ConversionMethods(t *testing.T) { }) } +func TestConfig_LlamaSwap(t *testing.T) { + cfg := createTestConfig() + cfg.Transcription.Provider = "llama-swap" + cfg.Transcription.Model = "whisper-large-v3-turbo" + cfg.Providers = map[string]ProviderConfig{ + "llama-swap": {APIKey: "test-key", BaseURL: "http://192.168.1.50:8080"}, + } + cfg.LLM = LLMConfig{Enabled: true, Provider: "llama-swap", Model: "qwen3"} + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if got := cfg.ToTranscriberConfig().BaseURL; got != "http://192.168.1.50:8080" { + t.Errorf("transcriber BaseURL = %q", got) + } + if got := cfg.ToLLMConfig().BaseURL; got != "http://192.168.1.50:8080" { + t.Errorf("LLM BaseURL = %q", got) + } + + cfg.Providers["llama-swap"] = ProviderConfig{APIKey: "test-key", BaseURL: "http://192.168.1.50:8080/v1"} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "omit the /v1 suffix") { + t.Errorf("Validate() error = %v, want /v1 validation error", err) + } +} + func TestValidateModelLanguageCompatibility(t *testing.T) { tests := []struct { name string diff --git a/internal/config/convert.go b/internal/config/convert.go index 37a07db..6aa02a3 100644 --- a/internal/config/convert.go +++ b/internal/config/convert.go @@ -32,6 +32,9 @@ func (c *Config) ToTranscriberConfig() transcriber.Config { } config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider) + if c.Transcription.Provider == provider.ProviderLlamaSwap { + config.BaseURL = c.Providers[provider.ProviderLlamaSwap].BaseURL + } return config } @@ -74,6 +77,9 @@ func (c *Config) ToLLMConfig() LLMAdapterConfig { if c.LLM.Provider != "" { config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider) } + if c.LLM.Provider == provider.ProviderLlamaSwap { + config.BaseURL = c.Providers[provider.ProviderLlamaSwap].BaseURL + } if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" { config.CustomPrompt = c.LLM.CustomPrompt.Prompt @@ -106,9 +112,10 @@ func (c *Config) IsLLMEnabled() bool { 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, + Backends: c.Injection.Backends, + YdotoolTimeout: c.Injection.YdotoolTimeout, + WtypeTimeout: c.Injection.WtypeTimeout, + ClipboardTimeout: c.Injection.ClipboardTimeout, + CtrlShiftVClasses: c.Injection.CtrlShiftVClasses, } } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index bc6410f..8e3efef 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -20,10 +20,11 @@ func DefaultConfig() *Config { Threads: 0, }, Injection: InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, - YdotoolTimeout: 5 * time.Second, - WtypeTimeout: 5 * time.Second, - ClipboardTimeout: 3 * time.Second, + Backends: []string{"ydotool", "wtype", "clipboard"}, + YdotoolTimeout: 5 * time.Second, + WtypeTimeout: 5 * time.Second, + ClipboardTimeout: 3 * time.Second, + CtrlShiftVClasses: []string{"ghostty"}, }, Notifications: NotificationsConfig{ Enabled: false, diff --git a/internal/config/save.go b/internal/config/save.go index 82af52a..b0a0df3 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -47,6 +47,9 @@ func Save(cfg *Config) error { for name, pc := range cfg.Providers { sb.WriteString(fmt.Sprintf("[providers.%s]\n", name)) sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey)) + if pc.BaseURL != "" { + sb.WriteString(fmt.Sprintf(" base_url = %q\n", pc.BaseURL)) + } sb.WriteString("\n") } } @@ -117,6 +120,14 @@ func Save(cfg *Config) error { sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String())) sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String())) sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String())) + sb.WriteString(" ctrl_shift_v_classes = [") + for i, class := range cfg.Injection.CtrlShiftVClasses { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%q", class)) + } + sb.WriteString("]\n") sb.WriteString("\n") // Notifications @@ -221,6 +232,9 @@ keywords = [] # api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var) # [providers.deepgram] # api_key = "" # Deepgram API key (or set DEEPGRAM_API_KEY env var) +# [providers.llama-swap] +# api_key = "" # Or set LLAMA_SWAP_API_KEY +# base_url = "http://llama-swap.example:8080" # No /v1 suffix # ───────────────────────────────────────────────────────────────────────────── # Audio Recording @@ -241,7 +255,7 @@ keywords = [] # ───────────────────────────────────────────────────────────────────────────── [transcription] - provider = "openai" # "openai", "groq-transcription", "mistral-transcription", "elevenlabs", "whisper-cpp" + provider = "openai" # Also "llama-swap" for a remote OpenAI-compatible LlamaSwap server model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect. threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1) @@ -253,7 +267,7 @@ keywords = [] [llm] enabled = true # Enable LLM post-processing (highly recommended) - provider = "openai" # "openai" or "groq" (must have API key configured above) + provider = "openai" # "openai", "groq", or "llama-swap" (must have API key configured above) model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile" [llm.post_processing] @@ -272,7 +286,7 @@ keywords = [] # ───────────────────────────────────────────────────────────────────────────── [injection] - backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds) + backends = ["clipboard-paste", "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 @@ -322,12 +336,16 @@ keywords = [] # - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo) # - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest) # - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2, scribe_v2_realtime) +# - "llama-swap": Remote OpenAI-compatible LlamaSwap (any configured transcription model) # # 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) +# - "llama-swap": Any chat model configured in your LlamaSwap server # # Injection backends: +# - "clipboard-paste": Temporarily uses the regular clipboard + wtype Ctrl+V, then restores prior text clipboard. +# - ctrl_shift_v_classes: Window-class substrings that use Ctrl+Shift+V instead (default: ["ghostty"]). # - "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). diff --git a/internal/config/types.go b/internal/config/types.go index 898009a..aed1316 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -25,7 +25,8 @@ type Config struct { // ProviderConfig holds API key for a provider type ProviderConfig struct { - APIKey string `toml:"api_key"` + APIKey string `toml:"api_key"` + BaseURL string `toml:"base_url"` // OpenAI-compatible base URL, without /v1 } // LLMConfig configures the LLM post-processing phase @@ -70,10 +71,11 @@ type TranscriptionConfig struct { } 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"` + Backends []string `toml:"backends"` + YdotoolTimeout time.Duration `toml:"ydotool_timeout"` + WtypeTimeout time.Duration `toml:"wtype_timeout"` + ClipboardTimeout time.Duration `toml:"clipboard_timeout"` + CtrlShiftVClasses []string `toml:"ctrl_shift_v_classes"` } type NotificationsConfig struct { @@ -139,4 +141,5 @@ type LLMAdapterConfig struct { RemoveFillerWords bool CustomPrompt string Keywords []string + BaseURL string } diff --git a/internal/config/validate.go b/internal/config/validate.go index 1354dc6..01218bf 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/url" "strings" "github.com/leonardotrapani/hyprvoice/internal/provider" @@ -34,6 +35,8 @@ func envVarForProvider(registryName string) string { return "ELEVENLABS_API_KEY" case "deepgram": return "DEEPGRAM_API_KEY" + case "llama-swap": + return "LLAMA_SWAP_API_KEY" default: return "" } @@ -82,27 +85,40 @@ func (c *Config) Validate() error { strings.Title(registryName), registryName, envVar) } } + if registryName == provider.ProviderLlamaSwap { + if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil { + return err + } + } // validate model exists if c.Transcription.Model == "" { return fmt.Errorf("invalid transcription.model: empty") } - // validate model exists in provider - _, err := provider.GetModel(registryName, c.Transcription.Model) - if err != nil { - models := provider.ModelsOfType(p, provider.Transcription) - modelIDs := make([]string, len(models)) - for i, m := range models { - modelIDs[i] = m.ID + // LlamaSwap is an OpenAI-compatible router: its model IDs are defined by the + // remote server, so they cannot be validated against Hyprvoice's static registry. + if registryName == provider.ProviderLlamaSwap { + if c.Transcription.Streaming { + return fmt.Errorf("llama-swap transcription supports batch mode only (set transcription.streaming = false)") + } + } else { + // validate model exists in provider + _, err := provider.GetModel(registryName, c.Transcription.Model) + if err != nil { + models := provider.ModelsOfType(p, provider.Transcription) + modelIDs := make([]string, len(models)) + for i, m := range models { + modelIDs[i] = m.ID + } + return fmt.Errorf("invalid model for %s: %s (available: %s)", c.Transcription.Provider, c.Transcription.Model, strings.Join(modelIDs, ", ")) } - return fmt.Errorf("invalid model for %s: %s (available: %s)", c.Transcription.Provider, c.Transcription.Model, strings.Join(modelIDs, ", ")) - } - // validate language-model compatibility using effective language (transcription overrides general) - effectiveLanguage := c.resolveEffectiveLanguage() - if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil { - return err + // validate language-model compatibility using effective language (transcription overrides general) + effectiveLanguage := c.resolveEffectiveLanguage() + if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil { + return err + } } // LLM validation @@ -121,20 +137,26 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid llm.provider: %s (available: %s)", c.LLM.Provider, strings.Join(providers, ", ")) } - // validate LLM model exists - llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model) - if err != nil { - models := provider.ModelsOfType(llmProvider, provider.LLM) - modelIDs := make([]string, len(models)) - for i, m := range models { - modelIDs[i] = m.ID + if c.LLM.Provider == provider.ProviderLlamaSwap { + if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil { + return err + } + } else { + // validate LLM model exists + llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model) + if err != nil { + models := provider.ModelsOfType(llmProvider, provider.LLM) + modelIDs := make([]string, len(models)) + for i, m := range models { + modelIDs[i] = m.ID + } + return fmt.Errorf("invalid llm.model: %s (available for %s: %s)", c.LLM.Model, c.LLM.Provider, strings.Join(modelIDs, ", ")) } - return fmt.Errorf("invalid llm.model: %s (available for %s: %s)", c.LLM.Model, c.LLM.Provider, strings.Join(modelIDs, ", ")) - } - // verify model is actually an LLM - if llmModel.Type != provider.LLM { - return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model) + // verify model is actually an LLM + if llmModel.Type != provider.LLM { + return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model) + } } // validate LLM API key @@ -151,10 +173,10 @@ func (c *Config) Validate() error { 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} + validBackends := map[string]bool{"clipboard-paste": true, "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) + return fmt.Errorf("invalid injection.backends: unknown backend %q (must be clipboard-paste, ydotool, wtype, or clipboard)", backend) } } if c.Injection.YdotoolTimeout <= 0 { @@ -175,6 +197,20 @@ func (c *Config) Validate() error { return nil } +func validateLlamaSwapBaseURL(baseURL string) error { + if baseURL == "" { + return fmt.Errorf("llama-swap base_url required in providers.llama-swap.base_url") + } + u, err := url.Parse(baseURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("invalid llama-swap base_url: %q (expected http://host:port, without /v1)", baseURL) + } + if strings.TrimRight(u.Path, "/") == "/v1" { + return fmt.Errorf("invalid llama-swap base_url: omit the /v1 suffix") + } + return nil +} + // ValidateModelLanguageCompatibility validates that a model supports the given language. // Returns error if the language is not supported, nil if supported or if langCode is empty (auto). func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode string) error { diff --git a/internal/injection/clipboard_paste.go b/internal/injection/clipboard_paste.go new file mode 100644 index 0000000..d142028 --- /dev/null +++ b/internal/injection/clipboard_paste.go @@ -0,0 +1,141 @@ +package injection + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +// clipboardPasteBackend temporarily puts text on the regular clipboard, then +// uses wtype to issue Ctrl+V. This works in applications that do not support +// the primary selection, such as browsers and Electron apps. The previous +// text clipboard is restored after the paste has been delivered. +type clipboardPasteBackend struct { + clipboard *clipboardBackend + wtype *wtypeBackend + ctrlShiftVClasses []string +} + +func NewClipboardPasteBackend(ctrlShiftVClasses []string) Backend { + return &clipboardPasteBackend{ + clipboard: NewClipboardBackend().(*clipboardBackend), + wtype: NewWtypeBackend().(*wtypeBackend), + ctrlShiftVClasses: ctrlShiftVClasses, + } +} + +func (c *clipboardPasteBackend) Name() string { + return "clipboard-paste" +} + +func (c *clipboardPasteBackend) Available() error { + if err := c.clipboard.Available(); err != nil { + return err + } + if err := c.wtype.Available(); err != nil { + return err + } + return nil +} + +func (c *clipboardPasteBackend) Inject(ctx context.Context, text string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := c.Available(); err != nil { + return err + } + // wl-paste only needs to keep the clipboard owner alive while it reads the + // selection, so this command returns with the exact existing text content. + // --no-newline prevents wl-paste from adding a terminal-friendly newline. + previousClipboard, restore, err := c.readClipboard(ctx) + if err != nil { + return err + } + + copyText := exec.CommandContext(ctx, "wl-copy") + copyText.Stdin = strings.NewReader(text) + if err := copyText.Run(); err != nil { + return fmt.Errorf("copy transcription to clipboard: %w", err) + } + // wl-copy returns before every client has observed the new selection. Give + // the compositor a short chance to publish it; this is still perceived as + // an immediate paste and avoids inserting the previous clipboard contents. + select { + case <-time.After(150 * time.Millisecond): + case <-ctx.Done(): + return ctx.Err() + } + + // wtype resolves the keysym itself, avoiding ydotool's physical-keycode + // layout problem on Dvorak and Colemak setups. Terminal emulators typically + // reserve Ctrl+V, so configured window classes use Ctrl+Shift+V instead. + args := []string{"-M", "ctrl"} + if c.shouldUseCtrlShiftV(ctx) { + args = append(args, "-M", "shift", "-k", "v", "-m", "shift") + } else { + args = append(args, "-k", "v") + } + args = append(args, "-m", "ctrl") + cmd := exec.CommandContext(ctx, "wtype", args...) + if err := cmd.Run(); err != nil { + return fmt.Errorf("paste clipboard with wtype: %w", err) + } + + if !restore { + return nil + } + // Receiving a Wayland paste is asynchronous. Leave the transcription + // available briefly, then put the user's prior clipboard back. + select { + case <-time.After(300 * time.Millisecond): + case <-ctx.Done(): + return ctx.Err() + } + restoreClipboard := exec.CommandContext(ctx, "wl-copy") + restoreClipboard.Stdin = strings.NewReader(previousClipboard) + if err := restoreClipboard.Run(); err != nil { + return fmt.Errorf("restore previous clipboard: %w", err) + } + return nil +} + +func (c *clipboardPasteBackend) shouldUseCtrlShiftV(ctx context.Context) bool { + if len(c.ctrlShiftVClasses) == 0 { + return false + } + cmd := exec.CommandContext(ctx, "hyprctl", "activewindow", "-j") + output, err := cmd.Output() + if err != nil { + return false + } + var activeWindow struct { + Class string `json:"class"` + } + if err := json.Unmarshal(output, &activeWindow); err != nil { + return false + } + class := strings.ToLower(activeWindow.Class) + for _, match := range c.ctrlShiftVClasses { + if match = strings.TrimSpace(strings.ToLower(match)); match != "" && strings.Contains(class, match) { + return true + } + } + return false +} + +func (c *clipboardPasteBackend) readClipboard(ctx context.Context) (text string, restore bool, err error) { + cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline") + output, err := cmd.Output() + if err != nil { + // No clipboard owner is normal. There is simply nothing to restore. + if _, ok := err.(*exec.ExitError); ok { + return "", false, nil + } + return "", false, fmt.Errorf("read existing clipboard: %w", err) + } + return string(output), true, nil +} diff --git a/internal/injection/injection.go b/internal/injection/injection.go index b0540ea..bc25672 100644 --- a/internal/injection/injection.go +++ b/internal/injection/injection.go @@ -12,10 +12,11 @@ type Injector interface { } type Config struct { - Backends []string // Ordered list: "ydotool", "wtype", "clipboard" - YdotoolTimeout time.Duration // Timeout for ydotool commands - WtypeTimeout time.Duration // Timeout for wtype commands - ClipboardTimeout time.Duration // Timeout for clipboard operations + Backends []string // Ordered list: "clipboard-paste", "ydotool", "wtype", "clipboard" + YdotoolTimeout time.Duration // Timeout for ydotool commands + WtypeTimeout time.Duration // Timeout for wtype commands + ClipboardTimeout time.Duration // Timeout for clipboard operations + CtrlShiftVClasses []string // Hyprland window-class substrings that paste with Ctrl+Shift+V } type injector struct { @@ -34,6 +35,8 @@ func NewInjector(config Config) Injector { backends = append(backends, NewWtypeBackend()) case "clipboard": backends = append(backends, NewClipboardBackend()) + case "clipboard-paste": + backends = append(backends, NewClipboardPasteBackend(config.CtrlShiftVClasses)) default: log.Printf("Injection: unknown backend %q, skipping", name) } diff --git a/internal/injection/injection_test.go b/internal/injection/injection_test.go index 7303b0f..ea84076 100644 --- a/internal/injection/injection_test.go +++ b/internal/injection/injection_test.go @@ -66,9 +66,10 @@ func TestNewInjector_IgnoresUnknownBackends(t *testing.T) { } func TestInjector_Inject(t *testing.T) { - // Skip integration tests in CI environments - if os.Getenv("CI") == "true" { - t.Skip("Skipping integration test in CI environment") + // This test invokes real Wayland input tools and must run in a graphical + // session, not merely outside CI. + if os.Getenv("CI") == "true" || os.Getenv("WAYLAND_DISPLAY") == "" { + t.Skip("Skipping integration test outside a Wayland session") } tests := []struct { @@ -202,6 +203,13 @@ func TestClipboardBackend(t *testing.T) { t.Logf("clipboard is available") } +func TestClipboardPasteBackend(t *testing.T) { + backend := NewClipboardPasteBackend([]string{"ghostty"}) + if backend.Name() != "clipboard-paste" { + t.Errorf("Name() = %s, want clipboard-paste", backend.Name()) + } +} + // TestInjector_ClipboardMode tests clipboard-only injection func TestInjector_ClipboardMode(t *testing.T) { config := Config{ diff --git a/internal/injection/ydotool.go b/internal/injection/ydotool.go index 2df993f..93db50f 100644 --- a/internal/injection/ydotool.go +++ b/internal/injection/ydotool.go @@ -85,9 +85,13 @@ func (y *ydotoolBackend) Inject(ctx context.Context, text string, timeout time.D if err := y.Available(); err != nil { return err } + socketPath := y.getSocketPath() // ydotool type -- "text" cmd := exec.CommandContext(ctx, "ydotool", "type", "--", text) + if socketPath != "" { + cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath) + } if err := cmd.Run(); err != nil { return fmt.Errorf("ydotool failed: %w", err) } diff --git a/internal/llm/adapter_openai.go b/internal/llm/adapter_openai.go index 03b1ab5..c1a4409 100644 --- a/internal/llm/adapter_openai.go +++ b/internal/llm/adapter_openai.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "strings" "time" "github.com/sashabaranov/go-openai" @@ -17,6 +18,11 @@ type OpenAIAdapter struct { // NewOpenAIAdapter creates a new OpenAI LLM adapter func NewOpenAIAdapter(cfg Config) *OpenAIAdapter { + if cfg.BaseURL != "" { + clientConfig := openai.DefaultConfig(cfg.APIKey) + clientConfig.BaseURL = strings.TrimRight(cfg.BaseURL, "/") + "/v1" + return &OpenAIAdapter{client: openai.NewClientWithConfig(clientConfig), config: cfg} + } return &OpenAIAdapter{ client: openai.NewClient(cfg.APIKey), config: cfg, diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 0e8cef2..63a1fac 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -21,6 +21,7 @@ type Config struct { RemoveFillerWords bool CustomPrompt string Keywords []string + BaseURL string } // NewAdapter creates an LLM adapter based on the provider @@ -36,6 +37,14 @@ func NewAdapter(cfg Config) (Adapter, error) { return nil, fmt.Errorf("Groq API key required") } return NewGroqAdapter(cfg), nil + case "llama-swap": + if cfg.APIKey == "" { + return nil, fmt.Errorf("LlamaSwap API key required") + } + if cfg.BaseURL == "" { + return nil, fmt.Errorf("LlamaSwap base_url required") + } + return NewOpenAIAdapter(cfg), nil default: return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider) } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index fba8668..13e44a5 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -317,6 +317,7 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re RemoveFillerWords: llmCfg.RemoveFillerWords, CustomPrompt: llmCfg.CustomPrompt, Keywords: llmCfg.Keywords, + BaseURL: llmCfg.BaseURL, }) if err != nil { log.Printf("Pipeline: Failed to create LLM adapter: %v, using raw transcription", err) @@ -343,7 +344,10 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re } return r }, textToInject) - + // Speech APIs occasionally include leading or trailing whitespace (for + // example Whisper commonly returns a leading space). It is transport + // formatting rather than dictated content, so never inject it. + textToInject = strings.TrimSpace(textToInject) injector := p.injectorFactory(p.config.ToInjectionConfig()) if err := injector.Inject(ctx, textToInject); err != nil { diff --git a/internal/provider/llama_swap.go b/internal/provider/llama_swap.go new file mode 100644 index 0000000..08ee692 --- /dev/null +++ b/internal/provider/llama_swap.go @@ -0,0 +1,14 @@ +package provider + +// LlamaSwapProvider represents LlamaSwap's authenticated OpenAI-compatible +// proxy. The concrete model IDs are supplied in config because LlamaSwap can +// route to any model configured by its operator. +type LlamaSwapProvider struct{} + +func (p *LlamaSwapProvider) Name() string { return ProviderLlamaSwap } +func (p *LlamaSwapProvider) RequiresAPIKey() bool { return true } +func (p *LlamaSwapProvider) ValidateAPIKey(key string) bool { return key != "" } +func (p *LlamaSwapProvider) APIKeyURL() string { return "" } +func (p *LlamaSwapProvider) IsLocal() bool { return false } +func (p *LlamaSwapProvider) Models() []Model { return nil } +func (p *LlamaSwapProvider) DefaultModel(ModelType) string { return "" } diff --git a/internal/provider/names.go b/internal/provider/names.go index 1fd1f88..3ad0036 100644 --- a/internal/provider/names.go +++ b/internal/provider/names.go @@ -8,6 +8,7 @@ const ( ProviderElevenLabs = "elevenlabs" ProviderDeepgram = "deepgram" ProviderWhisperCpp = "whisper-cpp" + ProviderLlamaSwap = "llama-swap" ) // Config provider names (used in config file transcription.provider) @@ -27,6 +28,7 @@ const ( EnvMistralKey = "MISTRAL_API_KEY" EnvElevenLabsKey = "ELEVENLABS_API_KEY" EnvDeepgramKey = "DEEPGRAM_API_KEY" + EnvLlamaSwapKey = "LLAMA_SWAP_API_KEY" ) // Adapter type constants for transcription backends @@ -66,6 +68,8 @@ func EnvVarForProvider(provider string) string { return EnvElevenLabsKey case ProviderDeepgram: return EnvDeepgramKey + case ProviderLlamaSwap: + return EnvLlamaSwapKey default: return "" } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index fb1013d..db1a2a9 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -31,6 +31,7 @@ func init() { Register(&ElevenLabsProvider{}) Register(&WhisperCppProvider{}) Register(&DeepgramProvider{}) + Register(&LlamaSwapProvider{}) } // Register adds a provider to the registry diff --git a/internal/transcriber/adapter_openai.go b/internal/transcriber/adapter_openai.go index 47e8460..6a30d0f 100644 --- a/internal/transcriber/adapter_openai.go +++ b/internal/transcriber/adapter_openai.go @@ -35,7 +35,7 @@ func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang str if endpoint != nil && endpoint.BaseURL != "" { // use custom endpoint clientConfig := openai.DefaultConfig(apiKey) - clientConfig.BaseURL = endpoint.BaseURL + "/v1" + clientConfig.BaseURL = strings.TrimRight(endpoint.BaseURL, "/") + "/v1" client = openai.NewClientWithConfig(clientConfig) } else { // default to OpenAI diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index d5dd458..ef15351 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -31,8 +31,9 @@ type Config struct { Language string Model string Keywords []string - Threads int // CPU threads for local transcription (0 = auto) - Streaming bool // use streaming mode if model supports it + Threads int // CPU threads for local transcription (0 = auto) + Streaming bool // use streaming mode if model supports it + BaseURL string // OpenAI-compatible base URL, without /v1 } // NewTranscriber creates a new transcriber based on model metadata @@ -55,6 +56,22 @@ func NewTranscriber(config Config) (Transcriber, error) { return nil, fmt.Errorf("%s API key required", cases.Title(language.English).String(registryProvider)) } + // llama-swap proxies arbitrary OpenAI-compatible model IDs, so models are + // intentionally configured by the user rather than limited to this registry. + if registryProvider == provider.ProviderLlamaSwap { + if config.Model == "" { + return nil, fmt.Errorf("model is required for llama-swap") + } + if config.BaseURL == "" { + return nil, fmt.Errorf("llama-swap base_url required") + } + if config.Streaming { + return nil, fmt.Errorf("llama-swap transcription currently supports batch mode only (set streaming = false)") + } + endpoint := &provider.EndpointConfig{BaseURL: config.BaseURL} + return NewSimpleTranscriber(config, NewOpenAIAdapter(endpoint, config.APIKey, config.Model, config.Language, config.Keywords, registryProvider)), nil + } + // lookup model from provider model, err := provider.GetModel(registryProvider, config.Model) if err != nil {