From 796cd0627915d3f28fdb342ccb9260b73c60ff05 Mon Sep 17 00:00:00 2001 From: Eve Date: Tue, 17 Mar 2026 22:37:03 +1100 Subject: [PATCH 1/5] feat: Add OpenAI-compatible backend for AI enhancement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new backend option for AI text enhancement that works with any OpenAI-compatible API server (oMLX, LM Studio, LocalAI, etc). - New OpenAiCompatClient with retry logic, exponential backoff, timeout - Config: backend selector (ollama/openai_compat), base_url, api_key fields - Settings UI: Cloud/Local tab switcher with endpoint + model configuration - Tauri commands: list_openai_models, enhance_openai for frontend integration - All fields use serde(default) for backward compatibility with existing configs - Comprehensive test coverage for client construction and API key handling Ollama functionality unchanged — this is purely additive. --- src-tauri/src/config.rs | 25 +- src-tauri/src/enhancement/mod.rs | 173 ++++++-- src-tauri/src/enhancement/openai_compat.rs | 417 ++++++++++++++++++ src-tauri/src/lib.rs | 8 + .../components/AIEnhancementSettings.svelte | 97 +++- src/lib/stores/config.svelte.ts | 19 +- 6 files changed, 692 insertions(+), 47 deletions(-) create mode 100644 src-tauri/src/enhancement/openai_compat.rs diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 102bf9e..26e909c 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -143,12 +143,16 @@ impl Default for ShortcutConfig { pub struct EnhancementConfig { /// Whether AI enhancement is enabled pub enabled: bool, - /// Ollama model to use for enhancement + /// Model to use for enhancement pub model: String, /// Selected prompt template ID pub prompt_id: String, - /// Ollama server URL + /// Server URL (used for both Ollama and OpenAI-compatible backends) pub ollama_url: String, + /// Backend type: "ollama" or "openai_compat" + pub backend: String, + /// Optional API key for OpenAI-compatible backends + pub api_key: Option, } impl Default for EnhancementConfig { @@ -158,6 +162,8 @@ impl Default for EnhancementConfig { model: "llama3.2".to_string(), prompt_id: "fix-grammar".to_string(), ollama_url: "http://localhost:11434".to_string(), + backend: "ollama".to_string(), + api_key: None, } } } @@ -612,6 +618,8 @@ mod tests { assert_eq!(enhancement.model, "llama3.2"); assert_eq!(enhancement.prompt_id, "fix-grammar"); assert_eq!(enhancement.ollama_url, "http://localhost:11434"); + assert_eq!(enhancement.backend, "ollama"); + assert_eq!(enhancement.api_key, None); } #[test] @@ -740,6 +748,8 @@ mod tests { model: "mistral".to_string(), prompt_id: "custom".to_string(), ollama_url: "http://custom:8080".to_string(), + backend: "openai_compat".to_string(), + api_key: Some("sk-test".to_string()), }, general: GeneralConfig { launch_at_login: true, @@ -828,9 +838,20 @@ mod tests { model: "custom-model".to_string(), prompt_id: "summarise".to_string(), ollama_url: "http://192.168.1.100:11434".to_string(), + backend: "ollama".to_string(), + api_key: None, }; assert!(enhancement.enabled); assert_eq!(enhancement.ollama_url, "http://192.168.1.100:11434"); } + + #[test] + fn test_enhancement_config_backward_compat() { + // Old config without backend/api_key fields should deserialise with defaults + let json = r#"{"enabled": true, "model": "llama3.2", "prompt_id": "fix-grammar", "ollama_url": "http://localhost:11434"}"#; + let enhancement: EnhancementConfig = serde_json::from_str(json).unwrap(); + assert_eq!(enhancement.backend, "ollama"); + assert_eq!(enhancement.api_key, None); + } } diff --git a/src-tauri/src/enhancement/mod.rs b/src-tauri/src/enhancement/mod.rs index dbbb576..59b0bca 100644 --- a/src-tauri/src/enhancement/mod.rs +++ b/src-tauri/src/enhancement/mod.rs @@ -1,16 +1,19 @@ //! AI text enhancement subsystem //! -//! Provides AI-powered text enhancement using local Ollama models, -//! with context capture support for clipboard and selected text. +//! Provides AI-powered text enhancement using local Ollama models or any +//! OpenAI-compatible server, with context capture support for clipboard and +//! selected text. pub mod context; pub mod ollama; +pub mod openai_compat; pub mod prompts; pub use context::{ build_context, build_enhancement_context, get_clipboard_context, ContextCapture, }; pub use ollama::OllamaClient; +pub use openai_compat::OpenAiCompatClient; pub use prompts::{ delete_custom_prompt_cmd, get_all_prompts, get_builtin_prompts_cmd, get_custom_prompts_cmd, get_prompt_by_id, save_custom_prompt_cmd, PromptTemplate, @@ -19,34 +22,103 @@ pub use prompts::{ use parking_lot::Mutex; use std::sync::OnceLock; -/// Global Ollama client instance -static OLLAMA_CLIENT: OnceLock> = OnceLock::new(); +/// Which AI backend to use for enhancement +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendType { + Ollama, + OpenAiCompat, +} + +impl BackendType { + pub fn from_str(s: &str) -> Self { + match s { + "openai_compat" => BackendType::OpenAiCompat, + _ => BackendType::Ollama, + } + } +} + +/// Holds the active backend configuration +#[derive(Debug, Clone)] +struct EnhancementBackend { + backend_type: BackendType, + ollama: OllamaClient, + openai_compat: Option, +} + +impl Default for EnhancementBackend { + fn default() -> Self { + Self { + backend_type: BackendType::Ollama, + ollama: OllamaClient::new(), + openai_compat: None, + } + } +} + +/// Global backend instance +static BACKEND: OnceLock> = OnceLock::new(); + +fn get_backend() -> &'static Mutex { + BACKEND.get_or_init(|| Mutex::new(EnhancementBackend::default())) +} -fn get_client() -> &'static Mutex { - OLLAMA_CLIENT.get_or_init(|| Mutex::new(OllamaClient::new())) +/// Configure the enhancement backend. Called when config is applied. +pub fn configure_backend(backend: &str, base_url: &str, api_key: Option<&str>) { + let backend_type = BackendType::from_str(backend); + let mut state = get_backend().lock(); + + state.backend_type = backend_type; + + match backend_type { + BackendType::Ollama => { + state.ollama = OllamaClient::with_base_url(base_url.to_string()); + } + BackendType::OpenAiCompat => { + state.openai_compat = Some(OpenAiCompatClient::new( + base_url, + api_key.map(|k| k.to_string()), + )); + } + } + + tracing::info!("Enhancement backend configured: {:?}", backend_type); } -/// Check if Ollama server is available +/// Check if the AI server is available #[tauri::command] pub async fn check_ollama_available() -> bool { - let client = get_client().lock().clone(); - client.is_available().await + let state = get_backend().lock().clone(); + match state.backend_type { + BackendType::Ollama => state.ollama.is_available().await, + BackendType::OpenAiCompat => match &state.openai_compat { + Some(client) => client.is_available().await, + None => false, + }, + } } -/// List available Ollama models +/// List available models from the active backend #[tauri::command] pub async fn list_ollama_models() -> Result, String> { - let client = get_client().lock().clone(); - - client.list_models().await.map_err(|e| { - tracing::error!("Failed to list Ollama models: {}", e); - format!("Failed to list models: {}", e) - }) + let state = get_backend().lock().clone(); + + match state.backend_type { + BackendType::Ollama => state.ollama.list_models().await.map_err(|e| { + tracing::error!("Failed to list Ollama models: {}", e); + format!("Failed to list models: {}", e) + }), + BackendType::OpenAiCompat => match &state.openai_compat { + Some(client) => client.list_models().await.map_err(|e| { + tracing::error!("Failed to list OpenAI-compat models: {}", e); + format!("Failed to list models: {}", e) + }), + None => Err("OpenAI-compatible backend not configured".to_string()), + }, + } } -/// Enhance text using Ollama -/// -/// The prompt should contain `{text}` which will be replaced with the input text. +/// Enhance text using the active backend #[tauri::command] pub async fn enhance_text(text: String, model: String, prompt: String) -> Result { if text.is_empty() { @@ -57,21 +129,35 @@ pub async fn enhance_text(text: String, model: String, prompt: String) -> Result return Err("Model cannot be empty".to_string()); } - let client = get_client().lock().clone(); + let state = get_backend().lock().clone(); tracing::info!( - "Enhancing text with model '{}' ({} characters)", + "Enhancing text with model '{}' ({} characters, backend: {:?})", model, - text.len() + text.len(), + state.backend_type ); - let result = client - .enhance_text(&text, &model, &prompt) - .await - .map_err(|e| { - tracing::error!("Enhancement failed: {}", e); - format!("Enhancement failed: {}", e) - })?; + let result = match state.backend_type { + BackendType::Ollama => state + .ollama + .enhance_text(&text, &model, &prompt) + .await + .map_err(|e| { + tracing::error!("Enhancement failed: {}", e); + format!("Enhancement failed: {}", e) + })?, + BackendType::OpenAiCompat => match &state.openai_compat { + Some(client) => client + .enhance_text(&text, &model, &prompt) + .await + .map_err(|e| { + tracing::error!("Enhancement failed: {}", e); + format!("Enhancement failed: {}", e) + })?, + None => return Err("OpenAI-compatible backend not configured".to_string()), + }, + }; tracing::info!( "Enhancement complete ({} -> {} characters)", @@ -82,14 +168,35 @@ pub async fn enhance_text(text: String, model: String, prompt: String) -> Result Ok(result) } +/// Set the enhancement backend from the frontend +#[tauri::command] +pub fn set_enhancement_backend( + backend: String, + base_url: String, + api_key: Option, +) -> Result<(), String> { + configure_backend(&backend, &base_url, api_key.as_deref()); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_client_initialisation() { - let client = get_client(); - let _guard = client.lock(); - // Client should be initialised without panicking + fn test_backend_type_from_str() { + assert_eq!(BackendType::from_str("ollama"), BackendType::Ollama); + assert_eq!( + BackendType::from_str("openai_compat"), + BackendType::OpenAiCompat + ); + assert_eq!(BackendType::from_str("unknown"), BackendType::Ollama); + } + + #[test] + fn test_default_backend() { + let backend = EnhancementBackend::default(); + assert_eq!(backend.backend_type, BackendType::Ollama); + assert!(backend.openai_compat.is_none()); } } diff --git a/src-tauri/src/enhancement/openai_compat.rs b/src-tauri/src/enhancement/openai_compat.rs new file mode 100644 index 0000000..9c7f321 --- /dev/null +++ b/src-tauri/src/enhancement/openai_compat.rs @@ -0,0 +1,417 @@ +//! OpenAI-compatible HTTP client for AI text enhancement +//! +//! Provides AI enhancement via any OpenAI-compatible API server (oMLX, LM Studio, +//! LocalAI, Ollama OpenAI-compat mode, etc.) using the `/v1/chat/completions` +//! endpoint. Supports optional Bearer token authentication and retry with +//! exponential backoff. + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tokio::time::sleep; + +/// Default timeout for API requests in seconds +const DEFAULT_TIMEOUT_SECS: u64 = 30; + +/// Maximum number of retry attempts +const MAX_RETRY_ATTEMPTS: u32 = 3; + +/// Base delay for exponential backoff in milliseconds +const BASE_RETRY_DELAY_MS: u64 = 100; + +// ── Request / response types ──────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct ChatMessage { + role: String, + content: String, +} + +#[derive(Debug, Serialize)] +struct ChatCompletionRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + stream: bool, +} + +#[derive(Debug, Deserialize)] +struct ChatCompletionResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct ChatChoice { + message: ChatChoiceMessage, +} + +#[derive(Debug, Deserialize)] +struct ChatChoiceMessage { + content: String, +} + +#[derive(Debug, Deserialize)] +struct ModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + id: String, +} + +// ── Error types ───────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum OpenAiCompatError { + #[error("Connection failed: {0}")] + ConnectionFailed(String), + + #[error("Request timeout after {0} seconds")] + Timeout(u64), + + #[error("Server error ({status}): {message}")] + ServerError { status: u16, message: String }, + + #[error("Failed to parse response: {0}")] + ParseError(String), + + #[error("All {attempts} retry attempts failed: {last_error}")] + RetriesExhausted { attempts: u32, last_error: String }, +} + +// ── Client ────────────────────────────────────────────────────────────────── + +/// OpenAI-compatible HTTP client for AI text enhancement +#[derive(Debug, Clone)] +pub struct OpenAiCompatClient { + base_url: String, + api_key: Option, + client: reqwest::Client, + timeout: Duration, +} + +impl OpenAiCompatClient { + /// Create a new client with the given base URL and optional API key. + pub fn new(base_url: &str, api_key: Option) -> Self { + Self::with_timeout(base_url, api_key, DEFAULT_TIMEOUT_SECS) + } + + /// Create a new client with a custom timeout. + pub fn with_timeout(base_url: &str, api_key: Option, timeout_secs: u64) -> Self { + let timeout = Duration::from_secs(timeout_secs); + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("Failed to create HTTP client"); + + Self { + base_url: base_url.trim_end_matches('/').to_string(), + api_key, + client, + timeout, + } + } + + /// Check if the server is available by hitting `/v1/models`. + pub async fn is_available(&self) -> bool { + let url = format!("{}/v1/models", self.base_url); + let mut req = self.client.get(&url); + if let Some(key) = self.effective_api_key() { + req = req.header("Authorization", format!("Bearer {}", key)); + } + match req.send().await { + Ok(response) => response.status().is_success(), + Err(e) => { + tracing::debug!("OpenAI-compat server not available: {}", e); + false + } + } + } + + /// List available models via `/v1/models`. + pub async fn list_models(&self) -> Result> { + let url = format!("{}/v1/models", self.base_url); + let mut req = self.client.get(&url); + if let Some(key) = self.effective_api_key() { + req = req.header("Authorization", format!("Bearer {}", key)); + } + + let response = req + .send() + .await + .map_err(|e| anyhow!("Failed to connect to OpenAI-compat server: {}", e))?; + + if !response.status().is_success() { + return Err(anyhow!( + "Server returned error status: {}", + response.status() + )); + } + + let models: ModelsResponse = response + .json() + .await + .map_err(|e| anyhow!("Failed to parse models response: {}", e))?; + + let ids: Vec = models.data.into_iter().map(|m| m.id).collect(); + tracing::debug!("Found {} models via OpenAI-compat API", ids.len()); + Ok(ids) + } + + /// Enhance text using a prompt template containing `{text}`. + pub async fn enhance_text( + &self, + text: &str, + model: &str, + prompt_template: &str, + ) -> Result { + let full_prompt = prompt_template.replace("{text}", text); + self.chat(model, &full_prompt, None, None).await + } + + /// Enhance text with a system prompt (wraps text in TRANSCRIPT tags). + pub async fn enhance_with_system( + &self, + text: &str, + model: &str, + system_prompt: &str, + ) -> Result { + let user_msg = format!("\n{}\n", text); + self.chat(model, &user_msg, Some(system_prompt), Some(0.3)) + .await + } + + // ── Internal helpers ──────────────────────────────────────────────────── + + /// Send a chat completion request with retry logic. + async fn chat( + &self, + model: &str, + user_message: &str, + system_prompt: Option<&str>, + temperature: Option, + ) -> Result { + let mut messages = Vec::new(); + if let Some(sys) = system_prompt { + messages.push(ChatMessage { + role: "system".to_string(), + content: sys.to_string(), + }); + } + messages.push(ChatMessage { + role: "user".to_string(), + content: user_message.to_string(), + }); + + let request = ChatCompletionRequest { + model: model.to_string(), + messages, + temperature, + stream: false, + }; + + tracing::debug!( + "Sending chat completion request with model: {} (system prompt: {})", + model, + system_prompt.is_some() + ); + + let mut last_error: Option = None; + + for attempt in 0..MAX_RETRY_ATTEMPTS { + match self.send_chat_request(&request).await { + Ok(response) => { + if attempt > 0 { + tracing::debug!("Request succeeded on attempt {}", attempt + 1); + } + return Ok(response); + } + Err(e) => { + let is_retryable = match &e { + OpenAiCompatError::ConnectionFailed(_) + | OpenAiCompatError::Timeout(_) => true, + OpenAiCompatError::ServerError { status, .. } => *status >= 500, + _ => false, + }; + + if !is_retryable || attempt == MAX_RETRY_ATTEMPTS - 1 { + tracing::error!( + "OpenAI-compat request failed (attempt {}): {}", + attempt + 1, + e + ); + last_error = Some(e); + break; + } + + let delay_ms = BASE_RETRY_DELAY_MS * 2u64.pow(attempt); + tracing::warn!( + "OpenAI-compat request failed (attempt {}), retrying in {}ms: {}", + attempt + 1, + delay_ms, + e + ); + last_error = Some(e); + sleep(Duration::from_millis(delay_ms)).await; + } + } + } + + Err(anyhow!(OpenAiCompatError::RetriesExhausted { + attempts: MAX_RETRY_ATTEMPTS, + last_error: last_error + .map(|e| e.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + })) + } + + /// Send a single chat completion request. + async fn send_chat_request( + &self, + request: &ChatCompletionRequest, + ) -> Result { + let url = format!("{}/v1/chat/completions", self.base_url); + + let mut req = self.client.post(&url).json(request); + if let Some(key) = self.effective_api_key() { + req = req.header("Authorization", format!("Bearer {}", key)); + } + + let response = req.send().await.map_err(|e| { + if e.is_timeout() { + OpenAiCompatError::Timeout(self.timeout.as_secs()) + } else { + OpenAiCompatError::ConnectionFailed(e.to_string()) + } + })?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response + .text() + .await + .unwrap_or_else(|_| "unknown error".to_string()); + return Err(OpenAiCompatError::ServerError { status, message }); + } + + let chat_response: ChatCompletionResponse = response + .json() + .await + .map_err(|e| OpenAiCompatError::ParseError(e.to_string()))?; + + chat_response + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .ok_or_else(|| OpenAiCompatError::ParseError("No choices in response".to_string())) + } + + /// Return the API key only if it is non-empty. + fn effective_api_key(&self) -> Option<&str> { + self.api_key + .as_deref() + .filter(|k| !k.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_creation() { + let client = OpenAiCompatClient::new("http://localhost:8080", None); + assert_eq!(client.base_url, "http://localhost:8080"); + assert!(client.api_key.is_none()); + } + + #[test] + fn test_client_strips_trailing_slash() { + let client = OpenAiCompatClient::new("http://localhost:8080/", None); + assert_eq!(client.base_url, "http://localhost:8080"); + } + + #[test] + fn test_client_with_api_key() { + let client = + OpenAiCompatClient::new("http://localhost:8080", Some("sk-test123".to_string())); + assert_eq!(client.api_key, Some("sk-test123".to_string())); + } + + #[test] + fn test_effective_api_key_none() { + let client = OpenAiCompatClient::new("http://localhost:8080", None); + assert!(client.effective_api_key().is_none()); + } + + #[test] + fn test_effective_api_key_empty() { + let client = OpenAiCompatClient::new("http://localhost:8080", Some("".to_string())); + assert!(client.effective_api_key().is_none()); + } + + #[test] + fn test_effective_api_key_present() { + let client = + OpenAiCompatClient::new("http://localhost:8080", Some("sk-test".to_string())); + assert_eq!(client.effective_api_key(), Some("sk-test")); + } + + #[test] + fn test_chat_request_serialisation() { + let request = ChatCompletionRequest { + model: "gpt-4".to_string(), + messages: vec![ChatMessage { + role: "user".to_string(), + content: "Hello".to_string(), + }], + temperature: Some(0.3), + stream: false, + }; + + let json = serde_json::to_string(&request).expect("Failed to serialise"); + assert!(json.contains("\"model\":\"gpt-4\"")); + assert!(json.contains("\"stream\":false")); + assert!(json.contains("\"temperature\":0.3")); + } + + #[test] + fn test_chat_request_no_temperature() { + let request = ChatCompletionRequest { + model: "gpt-4".to_string(), + messages: vec![ChatMessage { + role: "user".to_string(), + content: "Hello".to_string(), + }], + temperature: None, + stream: false, + }; + + let json = serde_json::to_string(&request).expect("Failed to serialise"); + assert!(!json.contains("\"temperature\"")); + } + + #[test] + fn test_error_display() { + let err = OpenAiCompatError::ConnectionFailed("connection refused".to_string()); + assert_eq!(err.to_string(), "Connection failed: connection refused"); + + let err = OpenAiCompatError::Timeout(30); + assert_eq!(err.to_string(), "Request timeout after 30 seconds"); + + let err = OpenAiCompatError::ServerError { + status: 401, + message: "Unauthorized".to_string(), + }; + assert_eq!(err.to_string(), "Server error (401): Unauthorized"); + + let err = OpenAiCompatError::RetriesExhausted { + attempts: 3, + last_error: "timeout".to_string(), + }; + assert_eq!(err.to_string(), "All 3 retry attempts failed: timeout"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbc0969..8baa14c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -211,6 +211,13 @@ pub fn run() { // Load config and register shortcuts if let Ok(cfg) = config::get_config() { + // Configure enhancement backend from saved config + enhancement::configure_backend( + &cfg.enhancement.backend, + &cfg.enhancement.ollama_url, + cfg.enhancement.api_key.as_deref(), + ); + // Register shortcuts from config let app_handle = app.handle().clone(); register_shortcuts_from_config(&app_handle, &cfg); @@ -373,6 +380,7 @@ pub fn run() { enhancement::check_ollama_available, enhancement::list_ollama_models, enhancement::enhance_text, + enhancement::set_enhancement_backend, enhancement::context::get_clipboard_context, enhancement::context::build_enhancement_context, // Prompt Templates diff --git a/src/lib/components/AIEnhancementSettings.svelte b/src/lib/components/AIEnhancementSettings.svelte index 7639b83..14652b4 100644 --- a/src/lib/components/AIEnhancementSettings.svelte +++ b/src/lib/components/AIEnhancementSettings.svelte @@ -8,7 +8,7 @@ import { invoke } from '@tauri-apps/api/core'; import { onMount } from 'svelte'; - import { configStore } from '../stores/config.svelte'; + import { configStore, type EnhancementBackend } from '../stores/config.svelte'; import { toastStore } from '../stores/toast.svelte'; /** Prompt template matching Rust PromptTemplate struct */ @@ -120,12 +120,48 @@ configStore.updateEnhancement('ollamaUrl', input.value); } - /** Handle Ollama URL blur (save and re-check) */ + /** Handle URL blur (save, update backend, and re-check) */ async function handleUrlBlur(): Promise { await saveSettings(); + await applyBackend(); await checkOllama(); } + /** Handle backend selection change */ + async function handleBackendChange(event: Event): Promise { + const select = event.target as HTMLSelectElement; + configStore.updateEnhancement('backend', select.value as EnhancementBackend); + await saveSettings(); + await applyBackend(); + await checkOllama(); + } + + /** Handle API key change */ + function handleApiKeyChange(event: Event): void { + const input = event.target as HTMLInputElement; + configStore.updateEnhancement('apiKey', input.value); + } + + /** Handle API key blur (save and re-check) */ + async function handleApiKeyBlur(): Promise { + await saveSettings(); + await applyBackend(); + await checkOllama(); + } + + /** Notify the backend of the current enhancement backend config */ + async function applyBackend(): Promise { + try { + await invoke('set_enhancement_backend', { + backend: configStore.config.enhancement.backend, + baseUrl: configStore.config.enhancement.ollamaUrl, + apiKey: configStore.config.enhancement.apiKey || null, + }); + } catch (e) { + console.error('Failed to set enhancement backend:', e); + } + } + /** Start creating a new custom prompt */ function startNewPrompt(): void { isEditing = true; @@ -247,7 +283,7 @@
Enable AI enhancement - Use Ollama to enhance transcriptions with grammar correction, formatting, and more + Use a local AI server to enhance transcriptions with grammar correction, formatting, and more