From f101b021334613fe792da7e408f279a6a3dda99f Mon Sep 17 00:00:00 2001 From: avijitbhuin21 Date: Tue, 16 Dec 2025 05:58:16 +0530 Subject: [PATCH 01/11] ui work --- src-tauri/src/settings.rs | 27 +++ src/bindings.ts | 8 +- src/components/Sidebar.tsx | 14 +- .../settings/UseOnlineProviderToggle.tsx | 31 +++ .../settings/general/GeneralSettings.tsx | 2 + src/components/settings/index.ts | 2 + .../OnlineProviderSettings.tsx | 229 ++++++++++++++++++ src/components/ui/SettingContainer.tsx | 4 +- src/i18n/locales/en/translation.json | 30 ++- 9 files changed, 335 insertions(+), 12 deletions(-) create mode 100644 src/components/settings/UseOnlineProviderToggle.tsx create mode 100644 src/components/settings/online-providers/OnlineProviderSettings.tsx diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index c2b98bd3e..20eab5a67 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -291,6 +291,15 @@ pub struct AppSettings { pub mute_while_recording: bool, #[serde(default)] pub append_trailing_space: bool, + // Online provider settings + #[serde(default)] + pub use_online_provider: bool, + #[serde(default = "default_online_provider_id")] + pub online_provider_id: String, + #[serde(default = "default_online_provider_api_keys")] + pub online_provider_api_keys: HashMap, + #[serde(default)] + pub online_provider_custom_prompt: Option, } fn default_model() -> String { @@ -360,6 +369,19 @@ fn default_post_process_enabled() -> bool { false } +fn default_online_provider_id() -> String { + "openai".to_string() +} + +fn default_online_provider_api_keys() -> HashMap { + let mut map = HashMap::new(); + map.insert("openai".to_string(), String::new()); + map.insert("groq".to_string(), String::new()); + map.insert("gemini".to_string(), String::new()); + map.insert("sambanova".to_string(), String::new()); + map +} + fn default_post_process_provider_id() -> String { "openai".to_string() } @@ -554,6 +576,11 @@ pub fn get_default_settings() -> AppSettings { post_process_selected_prompt_id: None, mute_while_recording: false, append_trailing_space: false, + // Online provider defaults + use_online_provider: false, + online_provider_id: default_online_provider_id(), + online_provider_api_keys: default_online_provider_api_keys(), + online_provider_custom_prompt: None, } } diff --git a/src/bindings.ts b/src/bindings.ts index cf72c49a2..635fb4c5d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -588,10 +588,8 @@ async updateRecordingRetentionPeriod(period: string) : Promise> { try { @@ -613,7 +611,7 @@ async isLaptop() : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; use_online_provider?: boolean; online_provider_id?: string; online_provider_api_keys?: Partial<{ [key in string]: string }>; online_provider_custom_prompt?: string | null } export type AudioDevice = { index: string; name: string; is_default: boolean } export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2d42cb6b7..7d74d517b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react"; +import { Cog, FlaskConical, History, Info, Sparkles, Cloud } from "lucide-react"; import HandyTextLogo from "./icons/HandyTextLogo"; import HandyHand from "./icons/HandyHand"; import { useSettings } from "../hooks/useSettings"; @@ -11,6 +11,7 @@ import { DebugSettings, AboutSettings, PostProcessingSettings, + OnlineProviderSettings, } from "./settings"; export type SidebarSection = keyof typeof SECTIONS_CONFIG; @@ -43,6 +44,12 @@ export const SECTIONS_CONFIG = { component: AdvancedSettings, enabled: () => true, }, + providers: { + labelKey: "sidebar.providers", + icon: Cloud, + component: OnlineProviderSettings, + enabled: (settings) => settings?.use_online_provider ?? false, + }, postprocessing: { labelKey: "sidebar.postProcessing", icon: Sparkles, @@ -96,11 +103,10 @@ export const Sidebar: React.FC = ({ return (
onSectionChange(section.id)} > diff --git a/src/components/settings/UseOnlineProviderToggle.tsx b/src/components/settings/UseOnlineProviderToggle.tsx new file mode 100644 index 000000000..47d076fa0 --- /dev/null +++ b/src/components/settings/UseOnlineProviderToggle.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { useSettings } from "../../hooks/useSettings"; + +interface UseOnlineProviderToggleProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const UseOnlineProviderToggle: React.FC = + React.memo(({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating } = useSettings(); + + const enabled = getSetting("use_online_provider") || false; + + return ( + updateSetting("use_online_provider", enabled)} + isUpdating={isUpdating("use_online_provider")} + label={t("settings.general.useOnlineProvider.label")} + description={t("settings.general.useOnlineProvider.description")} + descriptionMode={descriptionMode} + grouped={grouped} + /> + ); + }); + +UseOnlineProviderToggle.displayName = "UseOnlineProviderToggle"; diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index b3a0ad678..fa98c5ed9 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -9,6 +9,7 @@ import { PushToTalk } from "../PushToTalk"; import { AudioFeedback } from "../AudioFeedback"; import { useSettings } from "../../../hooks/useSettings"; import { VolumeSlider } from "../VolumeSlider"; +import { UseOnlineProviderToggle } from "../UseOnlineProviderToggle"; export const GeneralSettings: React.FC = () => { const { t } = useTranslation(); @@ -19,6 +20,7 @@ export const GeneralSettings: React.FC = () => { + diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index b01bcd202..9ec3ed76b 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -5,6 +5,7 @@ export { DebugSettings } from "./debug/DebugSettings"; export { HistorySettings } from "./history/HistorySettings"; export { AboutSettings } from "./about/AboutSettings"; export { PostProcessingSettings } from "./post-processing/PostProcessingSettings"; +export { OnlineProviderSettings } from "./online-providers/OnlineProviderSettings"; // Individual setting components export { MicrophoneSelector } from "./MicrophoneSelector"; @@ -27,3 +28,4 @@ export { HistoryLimit } from "./HistoryLimit"; export { RecordingRetentionPeriodSelector } from "./RecordingRetentionPeriod"; export { AutostartToggle } from "./AutostartToggle"; export { UpdateChecksToggle } from "./UpdateChecksToggle"; +export { UseOnlineProviderToggle } from "./UseOnlineProviderToggle"; diff --git a/src/components/settings/online-providers/OnlineProviderSettings.tsx b/src/components/settings/online-providers/OnlineProviderSettings.tsx new file mode 100644 index 000000000..62f814d12 --- /dev/null +++ b/src/components/settings/online-providers/OnlineProviderSettings.tsx @@ -0,0 +1,229 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Check } from "lucide-react"; + +import { SettingsGroup } from "../../ui/SettingsGroup"; +import { SettingContainer } from "../../ui/SettingContainer"; +import { Input } from "../../ui/Input"; +import { Dropdown } from "../../ui/Dropdown"; +import { Textarea } from "../../ui/Textarea"; +import { Button } from "../../ui/Button"; +import { useSettings } from "../../../hooks/useSettings"; + +const ONLINE_PROVIDERS = [ + { value: "openai", label: "OpenAI" }, + { value: "groq", label: "Groq" }, + { value: "gemini", label: "Gemini" }, + { value: "sambanova", label: "SambaNova" }, +]; + +const PROVIDER_MODELS: Record = { + openai: [ + { value: "whisper", label: "Whisper" }, + ], + groq: [ + { value: "whisper-large-v3", label: "Whisper Large V3" }, + { value: "whisper-large-v3-turbo", label: "Whisper Large V3 Turbo" }, + ], + gemini: [ + { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + { value: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, + { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + { value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, + { value: "other", label: "Other (Custom)" }, + ], + sambanova: [ + { value: "whisper-large-v3", label: "Whisper Large V3" }, + ], +}; + +const DisabledNotice: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( +
+

{children}

+
+); + +export const OnlineProviderSettings: React.FC = () => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating } = useSettings(); + + const enabled = getSetting("use_online_provider") || false; + const selectedProviderId = getSetting("online_provider_id") || "openai"; + const apiKeys = getSetting("online_provider_api_keys") || {}; + const customPrompt = getSetting("online_provider_custom_prompt") || ""; + + const [localApiKey, setLocalApiKey] = useState(apiKeys[selectedProviderId] || ""); + const [localCustomPrompt, setLocalCustomPrompt] = useState(customPrompt || ""); + const [selectedModel, setSelectedModel] = useState(""); + const [customModelId, setCustomModelId] = useState(""); + + const modelOptions = PROVIDER_MODELS[selectedProviderId] || []; + const isOtherSelected = selectedModel === "other"; + + // Set default model when provider changes + useEffect(() => { + if (modelOptions.length > 0) { + setSelectedModel(modelOptions[0].value); + setCustomModelId(""); + } + }, [selectedProviderId, modelOptions]); + + const isApiKeyDirty = localApiKey !== (apiKeys[selectedProviderId] || ""); + const isApiKeySaving = isUpdating("online_provider_api_keys"); + + const handleProviderChange = (providerId: string | null) => { + if (!providerId) return; + updateSetting("online_provider_id", providerId); + // Update local API key to show the key for the new provider + setLocalApiKey(apiKeys[providerId] || ""); + }; + + const handleModelChange = (modelId: string | null) => { + if (!modelId) return; + setSelectedModel(modelId); + if (modelId !== "other") { + setCustomModelId(""); + } + }; + + const handleSaveApiKey = () => { + if (isApiKeyDirty) { + const updatedKeys = { ...apiKeys, [selectedProviderId]: localApiKey }; + updateSetting("online_provider_api_keys", updatedKeys); + } + }; + + const handleCustomPromptBlur = () => { + if (localCustomPrompt !== (customPrompt || "")) { + updateSetting("online_provider_custom_prompt", localCustomPrompt || null); + } + }; + + if (!enabled) { + return ( +
+ + + {t("settings.onlineProviders.disabledNotice")} + + +
+ ); + } + + return ( +
+ + +
+ +
+
+ + +
+ + {isOtherSelected && ( + setCustomModelId(e.target.value)} + placeholder="Enter model ID" + className="min-w-[180px]" + variant="compact" + /> + )} +
+
+ + +
+ setLocalApiKey(e.target.value)} + placeholder={t("settings.onlineProviders.apiKey.placeholder")} + className="min-w-[280px]" + variant="compact" + /> + {isApiKeyDirty && ( + + )} +
+
+ + +
+