diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index ca14c257e..8f1b306f9 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -8,10 +8,6 @@ use crate::settings::{get_settings, AppSettings, APPLE_INTELLIGENCE_PROVIDER_ID} use crate::shortcut; use crate::tray::{change_tray_icon, TrayIconState}; use crate::utils::{self, show_recording_overlay, show_transcribing_overlay}; -use async_openai::types::{ - ChatCompletionRequestMessage, ChatCompletionRequestUserMessageArgs, - CreateChatCompletionRequestArgs, -}; use ferrous_opencc::{config::BuiltinConfig, OpenCC}; use log::{debug, error}; use once_cell::sync::Lazy; @@ -30,6 +26,8 @@ pub trait ShortcutAction: Send + Sync { // Transcribe Action struct TranscribeAction; + + async fn maybe_post_process_transcription( settings: &AppSettings, transcription: &str, @@ -148,45 +146,20 @@ async fn maybe_post_process_transcription( } }; - // Build the chat completion request - let message = match ChatCompletionRequestUserMessageArgs::default() - .content(processed_prompt) - .build() - { - Ok(msg) => ChatCompletionRequestMessage::User(msg), - Err(e) => { - error!("Failed to build chat message: {}", e); - return None; - } - }; - - let request = match CreateChatCompletionRequestArgs::default() - .model(&model) - .messages(vec![message]) - .build() - { - Ok(req) => req, - Err(e) => { - error!("Failed to build chat completion request: {}", e); - return None; - } - }; - - // Send the request - match client.chat().create(request).await { - Ok(response) => { - if let Some(choice) = response.choices.first() { - if let Some(content) = &choice.message.content { - debug!( - "LLM post-processing succeeded for provider '{}'. Output length: {} chars", - provider.id, - content.len() - ); - return Some(content.clone()); - } + // Send the chat completion request using our custom client + match client.chat_completion(&model, &processed_prompt).await { + Ok(content) => { + if content.trim().is_empty() { + error!("LLM API response has empty content"); + None + } else { + debug!( + "LLM post-processing succeeded for provider '{}'. Output length: {} chars", + provider.id, + content.len() + ); + Some(content) } - error!("LLM API response has no content"); - None } Err(e) => { error!( @@ -351,9 +324,16 @@ impl ShortcutAction for TranscribeAction { samples.len() ); + let settings = get_settings(&ah); + let transcription_time = Instant::now(); let samples_clone = samples.clone(); // Clone for history saving - match tm.transcribe(samples) { + + // Use local transcription + debug!("Using local model for transcription"); + let transcription_result: Result = tm.transcribe(samples).map_err(|e| e.to_string()); + + match transcription_result { Ok(transcription) => { debug!( "Transcription completed in {:?}: '{}'", @@ -361,7 +341,6 @@ impl ShortcutAction for TranscribeAction { transcription ); if !transcription.is_empty() { - let settings = get_settings(&ah); let mut final_text = transcription.clone(); let mut post_processed_text: Option = None; let mut post_process_prompt: Option = None; @@ -435,7 +414,7 @@ impl ShortcutAction for TranscribeAction { } } Err(err) => { - debug!("Global Shortcut Transcription error: {}", err); + error!("Transcription error: {}", err); utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e39c792b6..2aabd3f03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod commands; mod helpers; mod input; mod llm_client; +mod llm_types; mod managers; mod overlay; mod settings; diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 2177ea652..70cf07ef9 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -1,33 +1,101 @@ +use crate::llm_types::ChatCompletionResponse; use crate::settings::PostProcessProvider; -use async_openai::{config::OpenAIConfig, Client}; +use reqwest::Client; +use serde::Serialize; -/// Create an OpenAI-compatible client configured for the given provider +#[derive(Serialize)] +struct ChatCompletionRequest { + model: String, + messages: Vec, +} + +#[derive(Serialize)] +struct ChatMessage { + role: String, + content: String, +} + +/// LLM client for making chat completion requests to OpenAI-compatible APIs +pub struct LlmClient { + http_client: Client, + base_url: String, + api_key: String, +} + +impl LlmClient { + /// Send a chat completion request and return the response content + pub async fn chat_completion( + &self, + model: &str, + user_message: &str, + ) -> Result { + let request = ChatCompletionRequest { + model: model.to_string(), + messages: vec![ChatMessage { + role: "user".to_string(), + content: user_message.to_string(), + }], + }; + + let url = format!("{}/chat/completions", self.base_url); + + let response = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("API request failed with status {}: {}", status, body)); + } + + let body = response + .text() + .await + .map_err(|e| format!("Failed to read response body: {}", e))?; + + let parsed: ChatCompletionResponse = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse response: {} - body: {}", e, body))?; + + parsed + .choices + .first() + .and_then(|c| c.message.content.clone()) + .ok_or_else(|| "No content in response".to_string()) + } +} + +/// Create an LLM client configured for the given provider pub fn create_client( provider: &PostProcessProvider, api_key: String, -) -> Result, String> { - let base_url = provider.base_url.trim_end_matches('/'); - let config = OpenAIConfig::new() - .with_api_base(base_url) - .with_api_key(api_key); - - // Create client with Anthropic-specific header if needed - let client = if provider.id == "anthropic" { - let mut headers = reqwest::header::HeaderMap::new(); +) -> Result { + let base_url = provider.base_url.trim_end_matches('/').to_string(); + + let mut headers = reqwest::header::HeaderMap::new(); + + // Add provider-specific headers + if provider.id == "anthropic" { headers.insert( "anthropic-version", reqwest::header::HeaderValue::from_static("2023-06-01"), ); + } - let http_client = reqwest::Client::builder() - .default_headers(headers) - .build() - .map_err(|e| format!("Failed to build HTTP client: {}", e))?; - - Client::with_config(config).with_http_client(http_client) - } else { - Client::with_config(config) - }; + let http_client = reqwest::Client::builder() + .default_headers(headers) + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e))?; - Ok(client) + Ok(LlmClient { + http_client, + base_url, + api_key, + }) } diff --git a/src-tauri/src/llm_types.rs b/src-tauri/src/llm_types.rs new file mode 100644 index 000000000..2ebc8fd84 --- /dev/null +++ b/src-tauri/src/llm_types.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; + +/// Custom response types for OpenAI-compatible APIs that may have +/// non-standard fields (like Groq's `service_tier: "on_demand"`) + +#[derive(Debug, Deserialize)] +pub struct ChatCompletionResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, + #[serde(skip)] + pub usage: Option, + #[serde(skip)] + pub service_tier: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChatChoice { + pub index: u32, + pub message: ChatMessage, + pub finish_reason: Option, + #[serde(skip)] + pub logprobs: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: Option, +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index cc98d9891..7408df1a4 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -362,6 +362,7 @@ fn default_post_process_enabled() -> bool { false } + fn default_app_language() -> String { "en".to_string() } @@ -371,6 +372,7 @@ fn default_post_process_provider_id() -> String { } fn default_post_process_providers() -> Vec { + #[allow(unused_mut)] let mut providers = vec![ PostProcessProvider { id: "openai".to_string(), @@ -387,9 +389,23 @@ fn default_post_process_providers() -> Vec { models_endpoint: Some("/models".to_string()), }, PostProcessProvider { - id: "anthropic".to_string(), - label: "Anthropic".to_string(), - base_url: "https://api.anthropic.com/v1".to_string(), + id: "gemini".to_string(), + label: "Gemini".to_string(), + base_url: "https://generativelanguage.googleapis.com/v1beta/openai".to_string(), + allow_base_url_edit: false, + models_endpoint: Some("/models".to_string()), + }, + PostProcessProvider { + id: "groq".to_string(), + label: "Groq".to_string(), + base_url: "https://api.groq.com/openai/v1".to_string(), + allow_base_url_edit: false, + models_endpoint: Some("/models".to_string()), + }, + PostProcessProvider { + id: "cerebras".to_string(), + label: "Cerebras".to_string(), + base_url: "https://api.cerebras.ai/v1".to_string(), allow_base_url_edit: false, models_endpoint: Some("/models".to_string()), }, diff --git a/src-tauri/src/signal_handle.rs b/src-tauri/src/signal_handle.rs index c4445c99e..8d8c80943 100644 --- a/src-tauri/src/signal_handle.rs +++ b/src-tauri/src/signal_handle.rs @@ -1,7 +1,12 @@ +#[cfg(unix)] use crate::actions::ACTION_MAP; +#[cfg(unix)] use crate::ManagedToggleState; +#[cfg(unix)] use log::{debug, info, warn}; +#[cfg(unix)] use std::thread; +#[cfg(unix)] use tauri::{AppHandle, Manager}; #[cfg(unix)] diff --git a/src/App.css b/src/App.css index 8a8e66fb9..09ae142fc 100644 --- a/src/App.css +++ b/src/App.css @@ -35,6 +35,16 @@ background-color: var(--color-background); } +/* Hide scrollbars while keeping scroll functionality */ +* { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ +} + +*::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ +} + .container { margin: 0; padding-top: 10vh; diff --git a/src/bindings.ts b/src/bindings.ts index 7e271a462..c781fa111 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -698,4 +698,4 @@ function __makeEvents__>( }, }, ); -} +} \ No newline at end of file diff --git a/src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx b/src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx index 804c1f204..689b43c00 100644 --- a/src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx +++ b/src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx @@ -1,4 +1,5 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; +import { Eye, EyeOff } from "lucide-react"; import { Input } from "../../ui/Input"; interface ApiKeyFieldProps { @@ -7,28 +8,45 @@ interface ApiKeyFieldProps { disabled: boolean; placeholder?: string; className?: string; + providerId?: string; // Used to reset local state when provider changes } export const ApiKeyField: React.FC = React.memo( - ({ value, onBlur, disabled, placeholder, className = "" }) => { + ({ value, onBlur, disabled, placeholder, className = "", providerId }) => { const [localValue, setLocalValue] = useState(value); + const [showPassword, setShowPassword] = useState(false); - // Sync with prop changes - React.useEffect(() => { + // Reset local state when provider changes or value changes + useEffect(() => { setLocalValue(value); - }, [value]); + setShowPassword(false); // Hide password when provider changes + }, [value, providerId]); return ( - setLocalValue(event.target.value)} - onBlur={() => onBlur(localValue)} - placeholder={placeholder} - variant="compact" - disabled={disabled} - className={`flex-1 min-w-[320px] ${className}`} - /> +
+ setLocalValue(event.target.value)} + onBlur={() => onBlur(localValue)} + placeholder={placeholder} + variant="compact" + disabled={disabled} + className="flex-1 min-w-[320px] pr-10" + /> + +
); }, ); diff --git a/src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx b/src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx index 1c1cf13c4..339787c9d 100644 --- a/src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx +++ b/src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx @@ -31,8 +31,8 @@ export const BaseUrlField: React.FC = React.memo( placeholder={placeholder} variant="compact" disabled={disabled} - className={`flex-1 min-w-[360px] ${className}`} - title={disabledMessage} + className={`w-[220px] truncate ${className}`} + title={localValue || disabledMessage} /> ); }, diff --git a/src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx b/src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx index 8a05ed0f1..68a98f848 100644 --- a/src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx +++ b/src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx @@ -1,4 +1,5 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; +import { RefreshCcw } from "lucide-react"; import type { ModelOption } from "./types"; import { Select } from "../../ui/Select"; @@ -11,7 +12,10 @@ type ModelSelectProps = { onSelect: (value: string) => void; onCreate: (value: string) => void; onBlur: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; className?: string; + providerId?: string; // Track provider changes to reset menu state }; export const ModelSelect: React.FC = React.memo( @@ -24,32 +28,82 @@ export const ModelSelect: React.FC = React.memo( onSelect, onCreate, onBlur, + onRefresh, + isRefreshing, className = "flex-1 min-w-[360px]", + providerId, }) => { + // Track if menu should be open - starts open if no value selected + const [isMenuOpen, setIsMenuOpen] = useState(!value); + + // Reset menu state when provider changes - open if no model for new provider + useEffect(() => { + if (!value) { + setIsMenuOpen(true); + } + }, [providerId, value]); + const handleCreate = (inputValue: string) => { const trimmed = inputValue.trim(); if (!trimmed) return; onCreate(trimmed); + setIsMenuOpen(false); + }; + + const handleSelect = (selected: string | null) => { + onSelect(selected ?? ""); + setIsMenuOpen(false); + }; + + const handleRefreshClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (onRefresh && !isRefreshing && !disabled) { + onRefresh(); + } }; const computedClassName = `text-sm ${className}`; + // Custom dropdown indicator with refresh icon + const customComponents = { + DropdownIndicator: () => ( + + ), + }; + return ( setSearchQuery(e.target.value)} + placeholder={t("settings.postProcessing.prompts.searchPlaceholder")} + className="w-full pl-9" + variant="compact" + /> - {!isCreating && hasPrompts && selectedPrompt && ( -
-
- - setDraftName(e.target.value)} - placeholder={t( - "settings.postProcessing.prompts.promptLabelPlaceholder", - )} - variant="compact" - /> -
- -
- -