From 0effef4c3e50edd48bc024652610e756a340c96a Mon Sep 17 00:00:00 2001 From: Hoss Date: Sun, 18 Jan 2026 11:58:34 +0800 Subject: [PATCH 01/11] feat(models): auto-discover custom Whisper models in models directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable automatic discovery of custom GGML-format Whisper models (.bin files) placed in the models directory, so users don't need to modify source code to use their own fine-tuned models. Backend changes: - Add discover_custom_whisper_models() to scan for .bin files - Generate display names from filenames (e.g., "my-model" → "My Model") - Skip predefined model filenames to avoid duplicates - Add 3 unit tests for discovery logic Frontend changes: - Split model dropdown into 3 sections: Custom, Downloaded, Downloadable - Add collapsible section for downloadable models to reduce clutter - Add max-height with scroll for long model lists - Add "Custom" badge for user-provided models --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/managers/model.rs | 219 +++++++++++- .../model-selector/ModelDropdown.tsx | 316 ++++++++++-------- src/i18n/locales/en/translation.json | 3 + 5 files changed, 405 insertions(+), 137 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e5c502166..e9ed2bb72 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2417,6 +2417,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-specta", + "tempfile", "tokio", "transcribe-rs", "vad-rs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e2f26501c..7482e7f15 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -96,6 +96,9 @@ windows = { version = "0.61.3", features = [ [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } +[dev-dependencies] +tempfile = "3" + [profile.release] lto = true codegen-units = 1 diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index d64dab519..e4c31650f 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -5,7 +5,7 @@ use futures_util::StreamExt; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use specta::Type; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; use std::io::Write; @@ -203,6 +203,11 @@ impl ModelManager { }, ); + // Auto-discover custom Whisper models (.bin files) in the models directory + if let Err(e) = Self::discover_custom_whisper_models(&models_dir, &mut available_models) { + warn!("Failed to discover custom models: {}", e); + } + let manager = Self { app_handle: app_handle.clone(), models_dir, @@ -331,6 +336,121 @@ impl ModelManager { Ok(()) } + /// Discover custom Whisper models (.bin files) in the models directory. + /// Skips files that match predefined model filenames. + fn discover_custom_whisper_models( + models_dir: &PathBuf, + available_models: &mut HashMap, + ) -> Result<()> { + if !models_dir.exists() { + return Ok(()); + } + + // Collect filenames of predefined Whisper file-based models to skip + let predefined_filenames: HashSet = available_models + .values() + .filter(|m| matches!(m.engine_type, EngineType::Whisper) && !m.is_directory) + .map(|m| m.filename.clone()) + .collect(); + + // Scan models directory for .bin files + for entry in fs::read_dir(models_dir)? { + let entry = match entry { + Ok(e) => e, + Err(e) => { + warn!("Failed to read directory entry: {}", e); + continue; + } + }; + + let path = entry.path(); + + // Only process .bin files (not directories) + if !path.is_file() { + continue; + } + + let filename = match path.file_name().and_then(|s| s.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + // Skip hidden files + if filename.starts_with('.') { + continue; + } + + // Only process .bin files (Whisper GGML format). + // This also excludes .partial downloads (e.g., "model.bin.partial"). + // If we add discovery for other formats, add a .partial check before this filter. + if !filename.ends_with(".bin") { + continue; + } + + // Skip predefined model files + if predefined_filenames.contains(&filename) { + continue; + } + + // Generate model ID from filename (remove .bin extension) + let model_id = filename.trim_end_matches(".bin").to_string(); + + // Skip if model ID already exists (shouldn't happen, but be safe) + if available_models.contains_key(&model_id) { + continue; + } + + // Generate display name: replace - and _ with space, capitalize words + let display_name = model_id + .replace(['-', '_'], " ") + .split_whitespace() + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect::>() + .join(" "); + + // Get file size in MB + let size_mb = match path.metadata() { + Ok(meta) => meta.len() / (1024 * 1024), + Err(e) => { + warn!("Failed to get metadata for {}: {}", filename, e); + 0 + } + }; + + info!( + "Discovered custom Whisper model: {} ({}, {} MB)", + model_id, filename, size_mb + ); + + available_models.insert( + model_id.clone(), + ModelInfo { + id: model_id, + name: display_name, + description: "Custom Whisper model".to_string(), + filename, + url: None, // Custom models have no download URL + size_mb, + is_downloaded: true, // Already present on disk + is_downloading: false, + partial_size: 0, + is_directory: false, + engine_type: EngineType::Whisper, + accuracy_score: 0.75, + speed_score: 0.75, + }, + ); + } + + Ok(()) + } + pub async fn download_model(&self, model_id: &str) -> Result<()> { let model_info = { let models = self.available_models.lock().unwrap(); @@ -741,3 +861,100 @@ impl ModelManager { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + #[test] + fn test_discover_custom_whisper_models() { + let temp_dir = TempDir::new().unwrap(); + let models_dir = temp_dir.path().to_path_buf(); + + // Create test .bin files + let mut custom_file = File::create(models_dir.join("my-custom-model.bin")).unwrap(); + custom_file.write_all(b"fake model data").unwrap(); + + let mut another_file = File::create(models_dir.join("whisper_medical_v2.bin")).unwrap(); + another_file.write_all(b"another fake model").unwrap(); + + // Create files that should be ignored + File::create(models_dir.join(".hidden-model.bin")).unwrap(); // Hidden file + File::create(models_dir.join("readme.txt")).unwrap(); // Non-.bin file + File::create(models_dir.join("ggml-small.bin")).unwrap(); // Predefined filename + fs::create_dir(models_dir.join("some-directory.bin")).unwrap(); // Directory + + // Set up available_models with a predefined Whisper model + let mut models = HashMap::new(); + models.insert( + "small".to_string(), + ModelInfo { + id: "small".to_string(), + name: "Whisper Small".to_string(), + description: "Test".to_string(), + filename: "ggml-small.bin".to_string(), + url: Some("https://example.com".to_string()), + size_mb: 100, + is_downloaded: false, + is_downloading: false, + partial_size: 0, + is_directory: false, + engine_type: EngineType::Whisper, + accuracy_score: 0.5, + speed_score: 0.5, + }, + ); + + // Discover custom models + ModelManager::discover_custom_whisper_models(&models_dir, &mut models).unwrap(); + + // Should have discovered 2 custom models (my-custom-model and whisper_medical_v2) + assert!(models.contains_key("my-custom-model")); + assert!(models.contains_key("whisper_medical_v2")); + + // Verify custom model properties + let custom = models.get("my-custom-model").unwrap(); + assert_eq!(custom.name, "My Custom Model"); + assert_eq!(custom.filename, "my-custom-model.bin"); + assert!(custom.url.is_none()); // Custom models have no URL + assert!(custom.is_downloaded); + + // Verify underscore handling + let medical = models.get("whisper_medical_v2").unwrap(); + assert_eq!(medical.name, "Whisper Medical V2"); + + // Should NOT have discovered hidden, non-.bin, predefined, or directories + assert!(!models.contains_key(".hidden-model")); + assert!(!models.contains_key("readme")); + assert!(!models.contains_key("some-directory")); + } + + #[test] + fn test_discover_custom_models_empty_dir() { + let temp_dir = TempDir::new().unwrap(); + let models_dir = temp_dir.path().to_path_buf(); + + let mut models = HashMap::new(); + let count_before = models.len(); + + ModelManager::discover_custom_whisper_models(&models_dir, &mut models).unwrap(); + + // No new models should be added + assert_eq!(models.len(), count_before); + } + + #[test] + fn test_discover_custom_models_nonexistent_dir() { + let models_dir = PathBuf::from("/nonexistent/path/that/does/not/exist"); + + let mut models = HashMap::new(); + let count_before = models.len(); + + // Should not error, just return Ok + let result = ModelManager::discover_custom_whisper_models(&models_dir, &mut models); + assert!(result.is_ok()); + assert_eq!(models.len(), count_before); + } +} diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index 8f22fe5ab..f5e501c1e 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; import type { ModelInfo } from "@/bindings"; import { formatModelSize } from "../../lib/utils/format"; import { @@ -35,9 +36,22 @@ const ModelDropdown: React.FC = ({ onError, }) => { const { t } = useTranslation(); - const availableModels = models.filter((m) => m.is_downloaded); - const downloadableModels = models.filter((m) => !m.is_downloaded); - const isFirstRun = availableModels.length === 0 && models.length > 0; + + // Split models into three groups: downloaded (with URL), custom (no URL), downloadable + const downloadedModels = models.filter( + (m) => m.is_downloaded && m.url !== null, + ); + const customModels = models.filter((m) => m.url === null); + const downloadableModels = models.filter( + (m) => !m.is_downloaded && m.url !== null, + ); + + const hasDownloadedModels = + downloadedModels.length > 0 || customModels.length > 0; + const isFirstRun = !hasDownloadedModels && models.length > 0; + + // Collapse downloadable section by default when there are downloaded models + const [downloadableExpanded, setDownloadableExpanded] = useState(isFirstRun); const handleDeleteClick = async (e: React.MouseEvent, modelId: string) => { e.preventDefault(); @@ -65,8 +79,69 @@ const ModelDropdown: React.FC = ({ onModelDownload(modelId); }; + // Reusable model item renderer for downloaded/custom models + const renderModelItem = (model: ModelInfo, isCustom = false) => ( +
handleModelClick(model.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleModelClick(model.id); + } + }} + tabIndex={0} + role="button" + className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ + currentModelId === model.id + ? "bg-logo-primary/10 text-logo-primary" + : "" + }`} + > +
+
+
+ {getTranslatedModelName(model, t)} + {isCustom && ( + + {t("modelSelector.custom")} + + )} +
+
+ {getTranslatedModelDescription(model, t)} +
+
+
+ {currentModelId === model.id && ( +
+ {t("modelSelector.active")} +
+ )} + {currentModelId !== model.id && ( + + )} +
+
+
+ ); + return ( -
+
{/* First Run Welcome */} {isFirstRun && (
@@ -79,154 +154,123 @@ const ModelDropdown: React.FC = ({
)} - {/* Available Models */} - {availableModels.length > 0 && ( + {/* Custom Models */} + {customModels.length > 0 && (
-
- {t("modelSelector.availableModels")} +
+ {t("modelSelector.customModels")} ({customModels.length})
- {availableModels.map((model) => ( -
handleModelClick(model.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleModelClick(model.id); - } - }} - tabIndex={0} - role="button" - className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ - currentModelId === model.id - ? "bg-logo-primary/10 text-logo-primary" - : "" - }`} - > -
-
-
- {getTranslatedModelName(model, t)} -
-
- {getTranslatedModelDescription(model, t)} -
-
-
- {currentModelId === model.id && ( -
- {t("modelSelector.active")} -
- )} - {currentModelId !== model.id && ( - - )} -
-
-
- ))} + {customModels.map((model) => renderModelItem(model, true))}
)} - {/* Downloadable Models */} - {downloadableModels.length > 0 && ( + {/* Downloaded Models */} + {downloadedModels.length > 0 && (
- {(availableModels.length > 0 || isFirstRun) && ( + {customModels.length > 0 && (
)} -
- {isFirstRun - ? t("modelSelector.chooseModel") - : t("modelSelector.downloadModels")} +
+ {t("modelSelector.downloadedModels")} ({downloadedModels.length})
- {downloadableModels.map((model) => { - const isDownloading = model.id in downloadProgress; - const progress = downloadProgress[model.id]; - - return ( -
handleDownloadClick(model.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleDownloadClick(model.id); - } - }} - tabIndex={0} - role="button" - aria-disabled={isDownloading} - className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ - isDownloading - ? "opacity-50 cursor-not-allowed hover:bg-transparent" - : "" - }`} - > -
-
-
- {getTranslatedModelName(model, t)} - {model.id === "parakeet-tdt-0.6b-v3" && isFirstRun && ( - - {t("onboarding.recommended")} - - )} -
-
- {getTranslatedModelDescription(model, t)} + {downloadedModels.map((model) => renderModelItem(model, false))} +
+ )} + + {/* Downloadable Models - Collapsible */} + {downloadableModels.length > 0 && ( +
+ {(hasDownloadedModels || isFirstRun) && ( +
+ )} + + {downloadableExpanded && + downloadableModels.map((model) => { + const isDownloading = model.id in downloadProgress; + const progress = downloadProgress[model.id]; + + return ( +
handleDownloadClick(model.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleDownloadClick(model.id); + } + }} + tabIndex={0} + role="button" + aria-disabled={isDownloading} + className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ + isDownloading + ? "opacity-50 cursor-not-allowed hover:bg-transparent" + : "" + }`} + > +
+
+
+ {getTranslatedModelName(model, t)} + {model.id === "parakeet-tdt-0.6b-v3" && isFirstRun && ( + + {t("onboarding.recommended")} + + )} +
+
+ {getTranslatedModelDescription(model, t)} +
+
+ {t("modelSelector.downloadSize")} ·{" "} + {formatModelSize(Number(model.size_mb))} +
-
- {t("modelSelector.downloadSize")} ·{" "} - {formatModelSize(Number(model.size_mb))} +
+ {isDownloading && progress + ? `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%` + : t("modelSelector.download")}
-
- {isDownloading && progress - ? `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%` - : t("modelSelector.download")} -
-
- {isDownloading && progress && ( -
- -
- )} -
- ); - })} + {isDownloading && progress && ( +
+ +
+ )} +
+ ); + })}
)} {/* No Models Available */} - {availableModels.length === 0 && downloadableModels.length === 0 && ( + {!hasDownloadedModels && downloadableModels.length === 0 && (
{t("modelSelector.noModelsAvailable")}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 059bec64a..718226af1 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -82,6 +82,9 @@ "welcome": "Welcome to Handy!", "downloadPrompt": "Download a model below to get started with transcription.", "availableModels": "Available Models", + "downloadedModels": "Downloaded", + "customModels": "Custom Models", + "custom": "Custom", "downloadModels": "Download Models", "chooseModel": "Choose a Model", "active": "Active", From 294f455834a57333600b5a1e4453b281799c0718 Mon Sep 17 00:00:00 2001 From: Hoss Date: Mon, 19 Jan 2026 13:48:59 +0800 Subject: [PATCH 02/11] feat(models): make custom Whisper model discovery opt-in [why] Address maintainer concern about support burden from community models. Custom models should be a power-user feature, not enabled by default. [how] Add custom_models_enabled setting (default: false) in Debug settings. Discovery only runs when enabled. Models show "Not officially supported" messaging. Documentation updated with enable steps. --- README.md | 19 ++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/managers/model.rs | 12 +++++--- src-tauri/src/settings.rs | 3 ++ src-tauri/src/shortcut/mod.rs | 10 +++++++ src/bindings.ts | 10 ++++++- .../settings/CustomModelsToggle.tsx | 30 +++++++++++++++++++ .../settings/debug/DebugSettings.tsx | 2 ++ src/i18n/locales/en/translation.json | 5 ++++ src/lib/utils/modelTranslation.ts | 4 +++ src/stores/settingsStore.ts | 2 ++ 11 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 src/components/settings/CustomModelsToggle.tsx diff --git a/README.md b/README.md index d4f57f046..dbf96c5af 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,25 @@ Final structure should look like: 3. Your manually installed models should now appear as "Downloaded" 4. Select the model you want to use and test transcription +### Custom Whisper Models + +Handy can auto-discover custom Whisper GGML models placed in the `models` directory. This is useful for users who want to use fine-tuned or community models not included in the default model list. + +**How to use:** + +1. Enable Debug mode (`Cmd+Shift+D` on macOS, `Ctrl+Shift+D` on Windows/Linux) +2. Go to Debug settings and enable "Custom Models" +3. Obtain a Whisper model in GGML `.bin` format (e.g., from [Hugging Face](https://huggingface.co/models?search=whisper%20ggml)) +4. Place the `.bin` file in your `models` directory (see paths above) +5. Restart Handy +6. The model will appear in the "Custom Models" section of the model selector + +**Important:** + +- Community models are user-provided and may not receive troubleshooting assistance +- The model must be a valid Whisper GGML format (`.bin` file) +- Model name is derived from the filename (e.g., `my-custom-model.bin` → "My Custom Model") + ### How to Contribute 1. **Check existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 556dd3a12..f04008d0e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -268,6 +268,7 @@ pub fn run() { shortcut::change_mute_while_recording_setting, shortcut::change_append_trailing_space_setting, shortcut::change_app_language_setting, + shortcut::change_custom_models_enabled_setting, shortcut::change_update_checks_setting, shortcut::change_keyboard_implementation_setting, shortcut::get_keyboard_implementation, diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index e4c31650f..1c0b12fd3 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -203,9 +203,13 @@ impl ModelManager { }, ); - // Auto-discover custom Whisper models (.bin files) in the models directory - if let Err(e) = Self::discover_custom_whisper_models(&models_dir, &mut available_models) { - warn!("Failed to discover custom models: {}", e); + // Auto-discover custom Whisper models if enabled in settings + let settings = get_settings(app_handle); + if settings.custom_models_enabled { + if let Err(e) = Self::discover_custom_whisper_models(&models_dir, &mut available_models) + { + warn!("Failed to discover custom models: {}", e); + } } let manager = Self { @@ -433,7 +437,7 @@ impl ModelManager { ModelInfo { id: model_id, name: display_name, - description: "Custom Whisper model".to_string(), + description: "Not officially supported".to_string(), filename, url: None, // Custom models have no download URL size_mb, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8feb07f4d..3c4db1fca 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -309,6 +309,8 @@ pub struct AppSettings { pub mute_while_recording: bool, #[serde(default)] pub append_trailing_space: bool, + #[serde(default)] + pub custom_models_enabled: bool, #[serde(default = "default_app_language")] pub app_language: String, #[serde(default)] @@ -602,6 +604,7 @@ pub fn get_default_settings() -> AppSettings { post_process_selected_prompt_id: None, mute_while_recording: false, append_trailing_space: false, + custom_models_enabled: false, app_language: default_app_language(), experimental_enabled: false, keyboard_implementation: KeyboardImplementation::default(), diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index da0e16091..abe10cdb3 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -941,3 +941,13 @@ pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<( Ok(()) } + +#[tauri::command] +#[specta::specta] +pub fn change_custom_models_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.custom_models_enabled = enabled; + settings::write_settings(&app, settings); + + Ok(()) +} diff --git a/src/bindings.ts b/src/bindings.ts index 6b50126ab..2af28cbc0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -268,6 +268,14 @@ async changeAppLanguageSetting(language: string) : Promise> else return { status: "error", error: e as any }; } }, +async changeCustomModelsEnabledSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_custom_models_enabled_setting", { enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changeUpdateChecksSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) }; @@ -690,7 +698,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?: number; 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; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation } +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?: number; 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; custom_models_enabled?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation } 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/settings/CustomModelsToggle.tsx b/src/components/settings/CustomModelsToggle.tsx new file mode 100644 index 000000000..a2aa20469 --- /dev/null +++ b/src/components/settings/CustomModelsToggle.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { useSettings } from "../../hooks/useSettings"; + +interface CustomModelsToggleProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const CustomModelsToggle: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating } = useSettings(); + const customModelsEnabled = getSetting("custom_models_enabled") ?? false; + + return ( + updateSetting("custom_models_enabled", enabled)} + isUpdating={isUpdating("custom_models_enabled")} + label={t("settings.debug.customModels.label")} + description={t("settings.debug.customModels.description")} + descriptionMode={descriptionMode} + grouped={grouped} + /> + ); +}; diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index 60102b0c0..c5a4bea70 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -6,6 +6,7 @@ import { LogLevelSelector } from "./LogLevelSelector"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone"; import { SoundPicker } from "../SoundPicker"; +import { CustomModelsToggle } from "../CustomModelsToggle"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; import { ShortcutInput } from "../ShortcutInput"; import { UpdateChecksToggle } from "../UpdateChecksToggle"; @@ -29,6 +30,7 @@ export const DebugSettings: React.FC = () => { + {/* Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration */} {!isLinux && ( commands.changeAppLanguageSetting(value as string), experimental_enabled: (value) => commands.changeExperimentalEnabledSetting(value as boolean), + custom_models_enabled: (value) => + commands.changeCustomModelsEnabledSetting(value as boolean), }; export const useSettingsStore = create()( From 105375277fcee733624618ebbfa9717917f993ac Mon Sep 17 00:00:00 2001 From: Hoss Date: Sun, 8 Feb 2026 18:01:11 +0800 Subject: [PATCH 03/11] feat(models): add is_custom field and integrate custom models with new UI [why] PR review requested an explicit is_custom field instead of inferring custom status from url === null. Custom models also need proper integration with the new models settings page from PR #478. [how] - Add is_custom: bool to ModelInfo struct, set true on discovered models - Change discover_custom_whisper_models signature from &PathBuf to &Path - Use 0.0 sentinel scores so UI hides score bars for custom models - Add "Custom Models" section to ModelsSettings with 3-way model split - Show "Custom" badge in ModelCard and ModelDropdown - Hide language/translation tags when supported_languages is empty - Remove custom models from available_models on delete instead of just marking as not downloaded (they have no re-download URL) - Update tests with new fields and assertions --- src-tauri/src/managers/model.rs | 43 +++++++++-- src/bindings.ts | 12 ++- .../model-selector/ModelDropdown.tsx | 5 ++ src/components/onboarding/ModelCard.tsx | 73 ++++++++++--------- .../settings/models/ModelsSettings.tsx | 45 ++++++++++-- src/lib/utils/modelTranslation.ts | 4 +- 6 files changed, 131 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index b716d3610..f31a39d6b 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -41,6 +41,7 @@ pub struct ModelInfo { pub supports_translation: bool, // Whether the model supports translating to English pub is_recommended: bool, // Whether this is the recommended model for new users pub supported_languages: Vec, // Languages this model can transcribe + pub is_custom: bool, // Whether this is a user-provided custom model } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -110,6 +111,7 @@ impl ModelManager { supports_translation: true, is_recommended: false, supported_languages: whisper_languages.clone(), + is_custom: false, }, ); @@ -133,6 +135,7 @@ impl ModelManager { supports_translation: true, is_recommended: false, supported_languages: whisper_languages.clone(), + is_custom: false, }, ); @@ -155,6 +158,7 @@ impl ModelManager { supports_translation: false, // Turbo doesn't support translation is_recommended: false, supported_languages: whisper_languages.clone(), + is_custom: false, }, ); @@ -177,6 +181,7 @@ impl ModelManager { supports_translation: true, is_recommended: false, supported_languages: whisper_languages.clone(), + is_custom: false, }, ); @@ -200,6 +205,7 @@ impl ModelManager { supports_translation: false, is_recommended: false, supported_languages: whisper_languages, + is_custom: false, }, ); @@ -223,6 +229,7 @@ impl ModelManager { supports_translation: false, is_recommended: false, supported_languages: vec!["en".to_string()], + is_custom: false, }, ); @@ -255,6 +262,7 @@ impl ModelManager { supports_translation: false, is_recommended: true, supported_languages: parakeet_v3_languages, + is_custom: false, }, ); @@ -277,6 +285,7 @@ impl ModelManager { supports_translation: false, is_recommended: false, supported_languages: vec!["en".to_string()], + is_custom: false, }, ); @@ -427,7 +436,7 @@ impl ModelManager { /// Discover custom Whisper models (.bin files) in the models directory. /// Skips files that match predefined model filenames. fn discover_custom_whisper_models( - models_dir: &PathBuf, + models_dir: &Path, available_models: &mut HashMap, ) -> Result<()> { if !models_dir.exists() { @@ -530,8 +539,12 @@ impl ModelManager { partial_size: 0, is_directory: false, engine_type: EngineType::Whisper, - accuracy_score: 0.75, - speed_score: 0.75, + accuracy_score: 0.0, // Sentinel: UI hides score bars when both are 0 + speed_score: 0.0, + supports_translation: false, + is_recommended: false, + supported_languages: vec![], + is_custom: true, }, ); } @@ -941,9 +954,17 @@ impl ModelManager { return Err(anyhow::anyhow!("No model files found to delete")); } - // Update download status - self.update_download_status()?; - debug!("ModelManager: download status updated"); + // Custom models should be removed from the list entirely since they + // have no download URL and can't be re-downloaded + if model_info.is_custom { + let mut models = self.available_models.lock().unwrap(); + models.remove(model_id); + debug!("ModelManager: removed custom model from available models"); + } else { + // Update download status (marks predefined models as not downloaded) + self.update_download_status()?; + debug!("ModelManager: download status updated"); + } // Emit event to notify UI let _ = self.app_handle.emit("model-deleted", model_id); @@ -1071,6 +1092,10 @@ mod tests { engine_type: EngineType::Whisper, accuracy_score: 0.5, speed_score: 0.5, + supports_translation: true, + is_recommended: false, + supported_languages: vec!["en".to_string()], + is_custom: false, }, ); @@ -1087,6 +1112,10 @@ mod tests { assert_eq!(custom.filename, "my-custom-model.bin"); assert!(custom.url.is_none()); // Custom models have no URL assert!(custom.is_downloaded); + assert!(custom.is_custom); + assert_eq!(custom.accuracy_score, 0.0); + assert_eq!(custom.speed_score, 0.0); + assert!(custom.supported_languages.is_empty()); // Verify underscore handling let medical = models.get("whisper_medical_v2").unwrap(); diff --git a/src/bindings.ts b/src/bindings.ts index ff3846d32..503d60f0d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -268,6 +268,14 @@ async changeAppLanguageSetting(language: string) : Promise> else return { status: "error", error: e as any }; } }, +async changeCustomModelsEnabledSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_custom_models_enabled_setting", { enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changeUpdateChecksSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) }; @@ -695,7 +703,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?: number; 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; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; paste_delay_ms?: number } +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?: number; 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; custom_models_enabled?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; paste_delay_ms?: number } 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" @@ -713,7 +721,7 @@ reset_bindings: string[] } export type KeyboardImplementation = "tauri" | "handy_keys" export type LLMPrompt = { id: string; name: string; prompt: string } export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" -export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[] } +export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[]; is_custom: boolean } export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null } export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5" export type OverlayPosition = "none" | "top" | "bottom" diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index f2d57017c..17abc15d5 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -50,6 +50,11 @@ const ModelDropdown: React.FC = ({
{getTranslatedModelName(model, t)} + {model.is_custom && ( + + {t("modelSelector.custom")} + + )}
{getTranslatedModelDescription(model, t)} diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index f28f52cef..9ae5fdf8c 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -146,6 +146,9 @@ const ModelCard: React.FC = ({ {t("modelSelector.active")} )} + {model.is_custom && ( + {t("modelSelector.custom")} + )} {status === "switching" && ( @@ -157,49 +160,53 @@ const ModelCard: React.FC = ({ {displayDescription}

-
-
-
-

- {t("onboarding.modelCard.accuracy")} -

-
-
+ {(model.accuracy_score > 0 || model.speed_score > 0) && ( +
+
+
+

+ {t("onboarding.modelCard.accuracy")} +

+
+
+
-
-
-

- {t("onboarding.modelCard.speed")} -

-
-
+
+

+ {t("onboarding.modelCard.speed")} +

+
+
+
-
+ )}

{/* Bottom row: tags + action buttons (full width) */}
-
- - {getLanguageDisplayText(model.supported_languages, t)} -
+ {model.supported_languages.length > 0 && ( +
+ + {getLanguageDisplayText(model.supported_languages, t)} +
+ )} {model.supports_translation && (
{ }); }, [models, languageFilter]); - // Split filtered models into downloaded and available sections - const { downloadedModels, availableModels } = useMemo(() => { + // Split filtered models into downloaded, custom, and available sections + const { downloadedModels, customModels, availableModels } = useMemo(() => { const downloaded: ModelInfo[] = []; + const custom: ModelInfo[] = []; const available: ModelInfo[] = []; for (const model of filteredModels) { - const isDownloaded = + if (model.is_custom) { + custom.push(model); + } else if ( model.is_downloaded || model.id in downloadingModels || - model.id in extractingModels; - if (isDownloaded) { + model.id in extractingModels + ) { downloaded.push(model); } else { available.push(model); } } - // Sort downloaded models so the active model is always first + // Sort active model first in each section downloaded.sort((a, b) => { if (a.id === currentModel) return -1; if (b.id === currentModel) return 1; return 0; }); + custom.sort((a, b) => { + if (a.id === currentModel) return -1; + if (b.id === currentModel) return 1; + return 0; + }); - return { downloadedModels: downloaded, availableModels: available }; + return { + downloadedModels: downloaded, + customModels: custom, + availableModels: available, + }; }, [filteredModels, downloadingModels, extractingModels, currentModel]); if (loading) { @@ -327,6 +339,25 @@ export const ModelsSettings: React.FC = () => {
)} + {/* Custom Models Section */} + {customModels.length > 0 && ( +
+

+ {t("settings.models.customModels")} +

+ {customModels.map((model: ModelInfo) => ( + + ))} +
+ )} + {/* Available Models Section */} {availableModels.length > 0 && (
diff --git a/src/lib/utils/modelTranslation.ts b/src/lib/utils/modelTranslation.ts index 84b1729b8..0a417b37f 100644 --- a/src/lib/utils/modelTranslation.ts +++ b/src/lib/utils/modelTranslation.ts @@ -23,8 +23,8 @@ export function getTranslatedModelDescription( model: ModelInfo, t: TFunction, ): string { - // Custom models (no download URL) use a generic translation key - if (model.url === null) { + // Custom models use a generic translation key + if (model.is_custom) { return t("onboarding.customModelDescription"); } const translationKey = `onboarding.models.${model.id}.description`; From cd25f140495171ed72d5b2af1720a3dfd275e89f Mon Sep 17 00:00:00 2001 From: Hoss Date: Sun, 8 Feb 2026 18:01:17 +0800 Subject: [PATCH 04/11] chore(i18n): add custom model translation keys to all locales [why] Custom model feature introduces 5 new translation keys that need to be present in all 15 non-English locales for CI to pass. [how] Add English placeholder values for: customModelDescription, modelSelector.custom, settings.models.customModels, settings.debug.customModels.label, settings.debug.customModels.description --- src/i18n/locales/ar/translation.json | 9 ++++++++- src/i18n/locales/cs/translation.json | 9 ++++++++- src/i18n/locales/de/translation.json | 9 ++++++++- src/i18n/locales/es/translation.json | 9 ++++++++- src/i18n/locales/fr/translation.json | 9 ++++++++- src/i18n/locales/it/translation.json | 9 ++++++++- src/i18n/locales/ja/translation.json | 9 ++++++++- src/i18n/locales/ko/translation.json | 9 ++++++++- src/i18n/locales/pl/translation.json | 9 ++++++++- src/i18n/locales/pt/translation.json | 9 ++++++++- src/i18n/locales/ru/translation.json | 9 ++++++++- src/i18n/locales/tr/translation.json | 9 ++++++++- src/i18n/locales/uk/translation.json | 9 ++++++++- src/i18n/locales/vi/translation.json | 9 ++++++++- src/i18n/locales/zh/translation.json | 9 ++++++++- 15 files changed, 120 insertions(+), 15 deletions(-) diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 92ab7ccab..8b5f5a926 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -20,6 +20,7 @@ "recommended": "موصى به", "download": "تنزيل", "downloading": "...جاري التنزيل", + "customModelDescription": "غير مدعوم رسميًا", "downloadFailed": "فشل التنزيل. يرجى المحاولة مرة أخرى.", "modelCard": { "accuracy": "الدقة", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "مخصص", "active": "نشط", "noModelsAvailable": "لا توجد نماذج متاحة", "extracting": "...جاري استخراج {{modelName}}", @@ -383,6 +385,10 @@ "description": ".اختر الواجهة الخلفية لاختصارات لوحة المفاتيح", "bindingsReset": "كانت اختصارات لوحة المفاتيح غير متوافقة وتمت إعادة تعيينها إلى القيم الافتراضية" }, + "customModels": { + "label": "نماذج مخصصة", + "description": "السماح بتحميل نماذج Whisper المخصصة من مجلد النماذج. يتطلب إعادة التشغيل. النماذج المجتمعية غير مدعومة رسميًا." + }, "paths": { "appData": "بيانات التطبيق:", "models": "النماذج:", @@ -442,7 +448,8 @@ "translation": "ترجمة", "allLanguages": "جميع اللغات" }, - "noModelsMatch": "لا توجد نماذج مطابقة لهذا الفلتر." + "noModelsMatch": "لا توجد نماذج مطابقة لهذا الفلتر.", + "customModels": "نماذج مخصصة" } }, "footer": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 239974fa3..3073886c7 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -20,6 +20,7 @@ "recommended": "Doporučeno", "download": "Stáhnout", "downloading": "Stahování...", + "customModelDescription": "Oficiálně nepodporováno", "downloadFailed": "Stahování se nezdařilo. Zkuste to prosím znovu.", "modelCard": { "accuracy": "přesnost", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Vlastní", "active": "Aktivní", "noModelsAvailable": "Nejsou dostupné žádné modely", "extracting": "Rozbaluji {{modelName}}...", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Tomuto filtru neodpovídají žádné modely.", "yourModels": "Stažené modely", - "availableModels": "Dostupné ke stažení" + "availableModels": "Dostupné ke stažení", + "customModels": "Vlastní modely" }, "general": { "title": "Obecné", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "Vlastní modely", + "description": "Povolit načítání vlastních modelů Whisper ze složky modelů. Vyžaduje restart. Komunitní modely nejsou oficiálně podporovány." + }, "paths": { "appData": "Data aplikace:", "models": "Modely:", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index b51d98287..a882f2a96 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -20,6 +20,7 @@ "recommended": "Empfohlen", "download": "Herunterladen", "downloading": "Wird heruntergeladen...", + "customModelDescription": "Nicht offiziell unterstützt", "downloadFailed": "Download fehlgeschlagen. Bitte erneut versuchen.", "modelCard": { "accuracy": "Genauigkeit", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Benutzerdefiniert", "active": "Aktiv", "switching": "Wechseln...", "noModelsAvailable": "Keine Modelle verfügbar", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Keine Modelle entsprechen diesem Filter.", "yourModels": "Heruntergeladene Modelle", - "availableModels": "Zum Download verfügbar" + "availableModels": "Zum Download verfügbar", + "customModels": "Benutzerdefinierte Modelle" }, "general": { "title": "Allgemein", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "Benutzerdefinierte Modelle", + "description": "Laden von benutzerdefinierten Whisper-Modellen aus dem Modellordner erlauben. Neustart erforderlich. Community-Modelle werden nicht offiziell unterstützt." + }, "paths": { "appData": "App-Daten:", "models": "Modelle:", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 353fe6373..2cd7cc7ec 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -20,6 +20,7 @@ "recommended": "Recomendado", "download": "Descargar", "downloading": "Descargando...", + "customModelDescription": "Sin soporte oficial", "downloadFailed": "La descarga falló. Por favor, inténtalo de nuevo.", "modelCard": { "accuracy": "precisión", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Personalizado", "active": "Activo", "switching": "Cambiando...", "noModelsAvailable": "No hay modelos disponibles", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Ningún modelo coincide con este filtro.", "yourModels": "Modelos descargados", - "availableModels": "Disponibles para descargar" + "availableModels": "Disponibles para descargar", + "customModels": "Modelos personalizados" }, "general": { "title": "General", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "Modelos personalizados", + "description": "Permitir la carga de modelos Whisper personalizados desde la carpeta de modelos. Requiere reinicio. Los modelos de la comunidad no son compatibles oficialmente." + }, "paths": { "appData": "Datos de la Aplicación:", "models": "Modelos:", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index c21a05beb..66b01ea16 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -20,6 +20,7 @@ "recommended": "Recommandé", "download": "Télécharger", "downloading": "Téléchargement...", + "customModelDescription": "Non officiellement pris en charge", "downloadFailed": "Échec du téléchargement. Veuillez réessayer.", "modelCard": { "accuracy": "précision", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Personnalisé", "active": "Actif", "switching": "Changement...", "noModelsAvailable": "Aucun modèle disponible", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Aucun modèle ne correspond à ce filtre.", "yourModels": "Modèles téléchargés", - "availableModels": "Disponibles au téléchargement" + "availableModels": "Disponibles au téléchargement", + "customModels": "Modèles personnalisés" }, "general": { "title": "Général", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "Modèles personnalisés", + "description": "Autoriser le chargement de modèles Whisper personnalisés depuis le dossier des modèles. Redémarrage requis. Les modèles communautaires ne sont pas officiellement pris en charge." + }, "paths": { "appData": "Données de l'application :", "models": "Modèles :", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index c7885a506..278e41870 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -20,6 +20,7 @@ "recommended": "Raccomandato", "download": "Scaricare", "downloading": "Download in corso...", + "customModelDescription": "Non ufficialmente supportato", "downloadFailed": "Download fallito. Riprova.", "modelCard": { "accuracy": "accuratezza", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Personalizzato", "active": "Attivo", "switching": "Cambio...", "noModelsAvailable": "Nessun modello disponibile", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Nessun modello corrisponde a questo filtro.", "yourModels": "Modelli scaricati", - "availableModels": "Disponibili per il download" + "availableModels": "Disponibili per il download", + "customModels": "Modelli personalizzati" }, "general": { "title": "Generale", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "Modelli personalizzati", + "description": "Consenti il caricamento di modelli Whisper personalizzati dalla cartella dei modelli. Riavvio richiesto. I modelli della comunità non sono ufficialmente supportati." + }, "paths": { "appData": "Dati App:", "models": "Modelli:", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 592115901..906454c54 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -20,6 +20,7 @@ "recommended": "おすすめ", "download": "ダウンロード", "downloading": "ダウンロード中...", + "customModelDescription": "公式サポート対象外", "downloadFailed": "ダウンロードに失敗しました。もう一度お試しください。", "modelCard": { "accuracy": "精度", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "カスタム", "active": "アクティブ", "switching": "切替中...", "noModelsAvailable": "利用可能なモデルがありません", @@ -129,7 +131,8 @@ }, "noModelsMatch": "このフィルターに一致するモデルがありません。", "yourModels": "ダウンロード済みモデル", - "availableModels": "ダウンロード可能" + "availableModels": "ダウンロード可能", + "customModels": "カスタムモデル" }, "general": { "title": "一般", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "カスタムモデル", + "description": "モデルフォルダからカスタムWhisperモデルの読み込みを許可します。再起動が必要です。コミュニティモデルは公式にはサポートされていません。" + }, "paths": { "appData": "アプリデータ:", "models": "モデル:", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 73397c834..ece7ab344 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -20,6 +20,7 @@ "recommended": "추천", "download": "다운로드", "downloading": "다운로드 중...", + "customModelDescription": "공식 지원되지 않음", "downloadFailed": "다운로드에 실패했습니다. 다시 시도해주세요.", "modelCard": { "accuracy": "정확도", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "사용자 정의", "active": "활성", "switching": "전환 중...", "noModelsAvailable": "사용 가능한 모델이 없습니다", @@ -194,7 +196,8 @@ "translation": "번역", "allLanguages": "모든 언어" }, - "noModelsMatch": "이 필터에 맞는 모델이 없습니다." + "noModelsMatch": "이 필터에 맞는 모델이 없습니다.", + "customModels": "사용자 정의 모델" }, "advanced": { "title": "고급", @@ -405,6 +408,10 @@ "description": "키보드 단축키 백엔드를 선택하세요.", "bindingsReset": "키보드 단축키가 호환되지 않아 기본값으로 재설정되었습니다" }, + "customModels": { + "label": "사용자 정의 모델", + "description": "모델 폴더에서 사용자 정의 Whisper 모델 로드를 허용합니다. 재시작이 필요합니다. 커뮤니티 모델은 공식적으로 지원되지 않습니다." + }, "pasteDelay": { "title": "붙여넣기 지연", "description": "붙여넣기 키 입력을 보내기 전 지연 시간(밀리초). 잘못된 텍스트가 붙여넣어지면 늘리세요." diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 9c5405209..56d3e988e 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -20,6 +20,7 @@ "recommended": "Polecane", "download": "Pobierz", "downloading": "Pobieranie...", + "customModelDescription": "Nieoficjalnie wspierane", "downloadFailed": "Pobieranie nie powiodło się. Spróbuj ponownie.", "modelCard": { "accuracy": "dokładność", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Własny", "active": "Aktywny", "switching": "Przełączanie...", "noModelsAvailable": "Brak dostępnych modeli", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Żadne modele nie pasują do tego filtra.", "yourModels": "Pobrane modele", - "availableModels": "Dostępne do pobrania" + "availableModels": "Dostępne do pobrania", + "customModels": "Niestandardowe modele" }, "general": { "title": "Ogólne", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "Niestandardowe modele", + "description": "Zezwól na ładowanie niestandardowych modeli Whisper z folderu modeli. Wymagany restart. Modele społeczności nie są oficjalnie wspierane." + }, "paths": { "appData": "Dane aplikacji:", "models": "Modele:", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 950f6ab93..df57680a1 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -20,6 +20,7 @@ "recommended": "Recomendado", "download": "Baixar", "downloading": "Baixando...", + "customModelDescription": "Não suportado oficialmente", "downloadFailed": "Falha no download. Por favor, tente novamente.", "modelCard": { "accuracy": "precisão", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Personalizado", "active": "Ativo", "noModelsAvailable": "Nenhum modelo disponível", "extracting": "Extraindo {{modelName}}...", @@ -171,7 +173,8 @@ }, "noModelsMatch": "Nenhum modelo corresponde a este filtro.", "yourModels": "Modelos baixados", - "availableModels": "Disponíveis para download" + "availableModels": "Disponíveis para download", + "customModels": "Modelos personalizados" }, "sound": { "title": "Som", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "Modelos personalizados", + "description": "Permitir o carregamento de modelos Whisper personalizados a partir da pasta de modelos. Reinicialização necessária. Os modelos da comunidade não são oficialmente suportados." + }, "paths": { "appData": "Dados do App:", "models": "Modelos:", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 9132e9bd9..b5a90edac 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -20,6 +20,7 @@ "recommended": "Рекомендуется", "download": "Скачать", "downloading": "Загрузка...", + "customModelDescription": "Официально не поддерживается", "downloadFailed": "Загрузка не удалась. Пожалуйста, попробуйте снова.", "modelCard": { "accuracy": "точность", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Пользовательская", "active": "Активный", "switching": "Переключение...", "noModelsAvailable": "Нет доступных моделей", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Нет моделей, соответствующих этому фильтру.", "yourModels": "Загруженные модели", - "availableModels": "Доступны для загрузки" + "availableModels": "Доступны для загрузки", + "customModels": "Пользовательские модели" }, "general": { "title": "Общие", @@ -405,6 +408,10 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, + "customModels": { + "label": "Пользовательские модели", + "description": "Разрешить загрузку пользовательских моделей Whisper из папки моделей. Требуется перезапуск. Модели сообщества официально не поддерживаются." + }, "paths": { "appData": "Данные приложения:", "models": "Модели:", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index d0d587cfc..eaadd9034 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -20,6 +20,7 @@ "recommended": "Önerilen", "download": "İndir", "downloading": "İndiriliyor...", + "customModelDescription": "Resmi olarak desteklenmiyor", "downloadFailed": "İndirme başarısız oldu. Lütfen tekrar deneyin.", "modelCard": { "accuracy": "doğruluk", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Özel", "active": "Aktif", "noModelsAvailable": "Kullanılabilir model yok", "extracting": "{{modelName}} çıkarılıyor...", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Bu filtreyle eşleşen model yok.", "yourModels": "İndirilen modeller", - "availableModels": "İndirilebilir" + "availableModels": "İndirilebilir", + "customModels": "Özel Modeller" }, "general": { "title": "Genel", @@ -400,6 +403,10 @@ "label": "Sonuna Boşluk Ekle", "description": "Yapıştırılan transkripsiyondan sonra boşluk ekler" }, + "customModels": { + "label": "Özel Modeller", + "description": "Model klasöründen özel Whisper modellerinin yüklenmesine izin ver. Yeniden başlatma gerekli. Topluluk modelleri resmi olarak desteklenmemektedir." + }, "keyboardImplementation": { "title": "Keyboard Implementation", "description": "Choose the keyboard shortcut backend.", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 0af4137a5..e02ff423e 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -20,6 +20,7 @@ "recommended": "Рекомендовано", "download": "Завантажити", "downloading": "Завантаження...", + "customModelDescription": "Офіційно не підтримується", "downloadFailed": "Завантаження не вдалося. Будь ласка, спробуйте ще раз.", "modelCard": { "accuracy": "точність", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Користувацька", "active": "Активна", "noModelsAvailable": "Немає доступних моделей", "extracting": "Розпакування {{modelName}}...", @@ -171,7 +173,8 @@ }, "noModelsMatch": "Жодна модель не відповідає цьому фільтру.", "yourModels": "Завантажені моделі", - "availableModels": "Доступні для завантаження" + "availableModels": "Доступні для завантаження", + "customModels": "Користувацькі моделі" }, "sound": { "title": "Звук", @@ -400,6 +403,10 @@ "label": "Додавати пробіл в кінці", "description": "Додавати пробіл після вставленої транскрипції" }, + "customModels": { + "label": "Користувацькі моделі", + "description": "Дозволити завантаження користувацьких моделей Whisper з папки моделей. Потрібен перезапуск. Моделі спільноти офіційно не підтримуються." + }, "keyboardImplementation": { "title": "Keyboard Implementation", "description": "Choose the keyboard shortcut backend.", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 81f077755..318c12c46 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -20,6 +20,7 @@ "recommended": "Đề xuất", "download": "Tải xuống", "downloading": "Đang tải xuống...", + "customModelDescription": "Không được hỗ trợ chính thức", "downloadFailed": "Tải xuống thất bại. Vui lòng thử lại.", "modelCard": { "accuracy": "độ chính xác", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "Tùy chỉnh", "active": "Đang hoạt động", "switching": "Đang chuyển...", "noModelsAvailable": "Không có mô hình nào", @@ -129,7 +131,8 @@ }, "noModelsMatch": "Không có mô hình nào khớp với bộ lọc này.", "yourModels": "Mô hình đã tải", - "availableModels": "Có sẵn để tải xuống" + "availableModels": "Có sẵn để tải xuống", + "customModels": "Mô hình tùy chỉnh" }, "general": { "title": "Chung", @@ -400,6 +403,10 @@ "label": "Thêm dấu cách cuối", "description": "Thêm một dấu cách sau bản ghi đã dán" }, + "customModels": { + "label": "Mô hình tùy chỉnh", + "description": "Cho phép tải các mô hình Whisper tùy chỉnh từ thư mục mô hình. Cần khởi động lại. Các mô hình cộng đồng không được hỗ trợ chính thức." + }, "keyboardImplementation": { "title": "Keyboard Implementation", "description": "Choose the keyboard shortcut backend.", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 5c16a4ad9..2f11389eb 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -20,6 +20,7 @@ "recommended": "推荐", "download": "下载", "downloading": "下载中...", + "customModelDescription": "非官方支持", "downloadFailed": "下载失败。请重试。", "modelCard": { "accuracy": "准确度", @@ -81,6 +82,7 @@ } }, "modelSelector": { + "custom": "自定义", "active": "活跃", "switching": "切换中...", "noModelsAvailable": "没有可用的模型", @@ -129,7 +131,8 @@ }, "noModelsMatch": "没有符合此筛选条件的模型。", "yourModels": "已下载的模型", - "availableModels": "可供下载" + "availableModels": "可供下载", + "customModels": "自定义模型" }, "general": { "title": "通用", @@ -400,6 +403,10 @@ "label": "追加尾部空格", "description": "在粘贴的转录后添加空格" }, + "customModels": { + "label": "自定义模型", + "description": "允许从模型文件夹加载自定义 Whisper 模型。需要重启。社区模型未获官方支持。" + }, "keyboardImplementation": { "title": "Keyboard Implementation", "description": "Choose the keyboard shortcut backend.", From 32dbc70251b5f82843ab781f3c6996eb41f10c85 Mon Sep 17 00:00:00 2001 From: Hoss Date: Sun, 8 Feb 2026 18:09:56 +0800 Subject: [PATCH 05/11] fix(models): keep language filter visible when no models match [why] The language filter was inside a conditionally rendered section that disappeared when no downloaded models matched the selected language, leaving the user stuck with no way to change the filter. [how] Always render the "Your Models" header row with the language filter, only conditionally render the model cards below it. --- .../settings/models/ModelsSettings.tsx | 198 +++++++++--------- 1 file changed, 97 insertions(+), 101 deletions(-) diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 4ca36fa1b..15af9df16 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -220,124 +220,120 @@ export const ModelsSettings: React.FC = () => {
{filteredModels.length > 0 ? (
- {/* Downloaded Models Section */} - {downloadedModels.length > 0 && ( -
-
-

- {t("settings.models.yourModels")} -

- {/* Language filter dropdown */} -
- + /> + - {languageDropdownOpen && ( -
-
- setLanguageSearch(e.target.value)} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - filteredLanguages.length > 0 - ) { - setLanguageFilter(filteredLanguages[0].value); - setLanguageDropdownOpen(false); - setLanguageSearch(""); - } else if (e.key === "Escape") { - setLanguageDropdownOpen(false); - setLanguageSearch(""); - } - }} - placeholder={t( - "settings.general.language.searchPlaceholder", - )} - className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary" - /> -
-
+ {languageDropdownOpen && ( +
+
+ setLanguageSearch(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + filteredLanguages.length > 0 + ) { + setLanguageFilter(filteredLanguages[0].value); + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } else if (e.key === "Escape") { + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } + }} + placeholder={t( + "settings.general.language.searchPlaceholder", + )} + className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary" + /> +
+
+ + {filteredLanguages.map((lang) => ( - {filteredLanguages.map((lang) => ( - - ))} - {filteredLanguages.length === 0 && ( -
- {t("settings.general.language.noResults")} -
- )} -
+ ))} + {filteredLanguages.length === 0 && ( +
+ {t("settings.general.language.noResults")} +
+ )}
- )} -
+
+ )}
- {downloadedModels.map((model: ModelInfo) => ( - - ))}
- )} + {downloadedModels.map((model: ModelInfo) => ( + + ))} +
{/* Custom Models Section */} {customModels.length > 0 && ( From 23a035358366f344a9d147dabd7616a50b8dc384 Mon Sep 17 00:00:00 2001 From: Hoss Date: Sun, 8 Feb 2026 18:47:46 +0800 Subject: [PATCH 06/11] docs: fix custom models section reference in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model selector → Models settings page. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ffce641f3..41aa20441 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ Handy can auto-discover custom Whisper GGML models placed in the `models` direct 3. Obtain a Whisper model in GGML `.bin` format (e.g., from [Hugging Face](https://huggingface.co/models?search=whisper%20ggml)) 4. Place the `.bin` file in your `models` directory (see paths above) 5. Restart Handy -6. The model will appear in the "Custom Models" section of the model selector +6. The model will appear in the "Custom Models" section of the Models settings page **Important:** From 1c32426812282bd3c8f2315c1d471cd45a356f27 Mon Sep 17 00:00:00 2001 From: Hoss Date: Mon, 9 Feb 2026 01:21:31 +0800 Subject: [PATCH 07/11] fix(models): apply custom models toggle immediately without restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [why] Two bugs reported in PR review: (1) app breaks on restart when a custom model was selected and the toggle is disabled — selected_model still points to the missing model, causing "Model not found" error. (2) Custom models remain visible in the UI after toggle-off because the in-memory model list is never updated and no event is emitted. [how] - Add remove_custom_models() and add_custom_models() to ModelManager for runtime mutation of the available_models mutex - On disable: reset selected_model to empty if it's a custom model, then remove custom models from the in-memory list - On enable: run discover_custom_whisper_models against the mutex - Emit model-state-changed event so the frontend refreshes immediately --- src-tauri/src/managers/model.rs | 12 ++++++++++++ src-tauri/src/shortcut/mod.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index f31a39d6b..5fc7c8e8c 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -328,6 +328,18 @@ impl ModelManager { models.get(model_id).cloned() } + /// Remove all custom models from the in-memory available models list. + pub fn remove_custom_models(&self) { + let mut models = self.available_models.lock().unwrap(); + models.retain(|_, m| !m.is_custom); + } + + /// Discover custom models and add them to the in-memory available models list. + pub fn add_custom_models(&self) -> Result<()> { + let mut models = self.available_models.lock().unwrap(); + Self::discover_custom_whisper_models(&self.models_dir, &mut models) + } + fn migrate_bundled_models(&self) -> Result<()> { // Check for bundled models and copy them to user directory let bundled_models = ["ggml-small.bin"]; // Add other bundled models here if any diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 43636cf6e..d03595493 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -16,9 +16,11 @@ mod tauri_impl; use log::{error, info, warn}; use serde::Serialize; use specta::Type; +use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_autostart::ManagerExt; +use crate::managers::model::ModelManager; use crate::settings::{ self, get_settings, ClipboardHandling, KeyboardImplementation, LLMPrompt, OverlayPosition, PasteMethod, ShortcutBinding, SoundTheme, APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, @@ -984,7 +986,31 @@ pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<( pub fn change_custom_models_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.custom_models_enabled = enabled; + + let model_manager = app.state::>(); + + if enabled { + // Discover and add custom models so they appear immediately + if let Err(e) = model_manager.add_custom_models() { + warn!("Failed to discover custom models: {}", e); + } + } else { + // If the currently selected model is custom, reset to empty so + // auto_select_model_if_needed picks a valid default on next startup + if let Some(model) = model_manager.get_model_info(&settings.selected_model) { + if model.is_custom { + settings.selected_model = String::new(); + } + } + + // Remove custom models from the in-memory list so the UI updates immediately + model_manager.remove_custom_models(); + } + settings::write_settings(&app, settings); + // Notify the frontend so it refreshes the model list + let _ = app.emit("model-state-changed", ()); + Ok(()) } From e023fc889648c618587d977f9e4ea77b6c56aebc Mon Sep 17 00:00:00 2001 From: Hoss Date: Mon, 9 Feb 2026 01:21:40 +0800 Subject: [PATCH 08/11] chore(i18n): remove "restart required" from custom model descriptions Toggle now takes effect immediately, so the restart sentence is inaccurate. Updated all 16 locales and README instructions. --- README.md | 2 +- src/i18n/locales/ar/translation.json | 2 +- src/i18n/locales/cs/translation.json | 2 +- src/i18n/locales/de/translation.json | 2 +- src/i18n/locales/en/translation.json | 2 +- src/i18n/locales/es/translation.json | 2 +- src/i18n/locales/fr/translation.json | 2 +- src/i18n/locales/it/translation.json | 2 +- src/i18n/locales/ja/translation.json | 2 +- src/i18n/locales/ko/translation.json | 2 +- src/i18n/locales/pl/translation.json | 2 +- src/i18n/locales/pt/translation.json | 2 +- src/i18n/locales/ru/translation.json | 2 +- src/i18n/locales/tr/translation.json | 2 +- src/i18n/locales/uk/translation.json | 2 +- src/i18n/locales/vi/translation.json | 2 +- src/i18n/locales/zh/translation.json | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 41aa20441..1497beb97 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ Handy can auto-discover custom Whisper GGML models placed in the `models` direct 2. Go to Debug settings and enable "Custom Models" 3. Obtain a Whisper model in GGML `.bin` format (e.g., from [Hugging Face](https://huggingface.co/models?search=whisper%20ggml)) 4. Place the `.bin` file in your `models` directory (see paths above) -5. Restart Handy +5. Toggle "Custom Models" off and on again (or restart Handy) to discover the new model 6. The model will appear in the "Custom Models" section of the Models settings page **Important:** diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 8b5f5a926..1983672b1 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -387,7 +387,7 @@ }, "customModels": { "label": "نماذج مخصصة", - "description": "السماح بتحميل نماذج Whisper المخصصة من مجلد النماذج. يتطلب إعادة التشغيل. النماذج المجتمعية غير مدعومة رسميًا." + "description": "السماح بتحميل نماذج Whisper المخصصة من مجلد النماذج. النماذج المجتمعية غير مدعومة رسميًا." }, "paths": { "appData": "بيانات التطبيق:", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 3073886c7..03da12045 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Vlastní modely", - "description": "Povolit načítání vlastních modelů Whisper ze složky modelů. Vyžaduje restart. Komunitní modely nejsou oficiálně podporovány." + "description": "Povolit načítání vlastních modelů Whisper ze složky modelů. Komunitní modely nejsou oficiálně podporovány." }, "paths": { "appData": "Data aplikace:", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index a882f2a96..fcb4a1d99 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Benutzerdefinierte Modelle", - "description": "Laden von benutzerdefinierten Whisper-Modellen aus dem Modellordner erlauben. Neustart erforderlich. Community-Modelle werden nicht offiziell unterstützt." + "description": "Laden von benutzerdefinierten Whisper-Modellen aus dem Modellordner erlauben. Community-Modelle werden nicht offiziell unterstützt." }, "paths": { "appData": "App-Daten:", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index c75effde8..b39d51dee 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Custom Models", - "description": "Allow loading custom Whisper models from the models folder. Restart required. Community models are not officially supported." + "description": "Allow loading custom Whisper models from the models folder. Community models are not officially supported." }, "paths": { "appData": "App Data:", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 2cd7cc7ec..84486c086 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Modelos personalizados", - "description": "Permitir la carga de modelos Whisper personalizados desde la carpeta de modelos. Requiere reinicio. Los modelos de la comunidad no son compatibles oficialmente." + "description": "Permitir la carga de modelos Whisper personalizados desde la carpeta de modelos. Los modelos de la comunidad no son compatibles oficialmente." }, "paths": { "appData": "Datos de la Aplicación:", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 66b01ea16..b38fe3450 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Modèles personnalisés", - "description": "Autoriser le chargement de modèles Whisper personnalisés depuis le dossier des modèles. Redémarrage requis. Les modèles communautaires ne sont pas officiellement pris en charge." + "description": "Autoriser le chargement de modèles Whisper personnalisés depuis le dossier des modèles. Les modèles communautaires ne sont pas officiellement pris en charge." }, "paths": { "appData": "Données de l'application :", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 278e41870..e905f9672 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Modelli personalizzati", - "description": "Consenti il caricamento di modelli Whisper personalizzati dalla cartella dei modelli. Riavvio richiesto. I modelli della comunità non sono ufficialmente supportati." + "description": "Consenti il caricamento di modelli Whisper personalizzati dalla cartella dei modelli. I modelli della comunità non sono ufficialmente supportati." }, "paths": { "appData": "Dati App:", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 906454c54..80b76df7f 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "カスタムモデル", - "description": "モデルフォルダからカスタムWhisperモデルの読み込みを許可します。再起動が必要です。コミュニティモデルは公式にはサポートされていません。" + "description": "モデルフォルダからカスタムWhisperモデルの読み込みを許可します。コミュニティモデルは公式にはサポートされていません。" }, "paths": { "appData": "アプリデータ:", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index ece7ab344..461dafecf 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "사용자 정의 모델", - "description": "모델 폴더에서 사용자 정의 Whisper 모델 로드를 허용합니다. 재시작이 필요합니다. 커뮤니티 모델은 공식적으로 지원되지 않습니다." + "description": "모델 폴더에서 사용자 정의 Whisper 모델 로드를 허용합니다. 커뮤니티 모델은 공식적으로 지원되지 않습니다." }, "pasteDelay": { "title": "붙여넣기 지연", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 56d3e988e..b1a1f270e 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Niestandardowe modele", - "description": "Zezwól na ładowanie niestandardowych modeli Whisper z folderu modeli. Wymagany restart. Modele społeczności nie są oficjalnie wspierane." + "description": "Zezwól na ładowanie niestandardowych modeli Whisper z folderu modeli. Modele społeczności nie są oficjalnie wspierane." }, "paths": { "appData": "Dane aplikacji:", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index df57680a1..681184a35 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Modelos personalizados", - "description": "Permitir o carregamento de modelos Whisper personalizados a partir da pasta de modelos. Reinicialização necessária. Os modelos da comunidade não são oficialmente suportados." + "description": "Permitir o carregamento de modelos Whisper personalizados a partir da pasta de modelos. Os modelos da comunidade não são oficialmente suportados." }, "paths": { "appData": "Dados do App:", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index b5a90edac..c8c22b396 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -410,7 +410,7 @@ }, "customModels": { "label": "Пользовательские модели", - "description": "Разрешить загрузку пользовательских моделей Whisper из папки моделей. Требуется перезапуск. Модели сообщества официально не поддерживаются." + "description": "Разрешить загрузку пользовательских моделей Whisper из папки моделей. Модели сообщества официально не поддерживаются." }, "paths": { "appData": "Данные приложения:", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index eaadd9034..0299d3d40 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -405,7 +405,7 @@ }, "customModels": { "label": "Özel Modeller", - "description": "Model klasöründen özel Whisper modellerinin yüklenmesine izin ver. Yeniden başlatma gerekli. Topluluk modelleri resmi olarak desteklenmemektedir." + "description": "Model klasöründen özel Whisper modellerinin yüklenmesine izin ver. Topluluk modelleri resmi olarak desteklenmemektedir." }, "keyboardImplementation": { "title": "Keyboard Implementation", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index e02ff423e..75e5c403a 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -405,7 +405,7 @@ }, "customModels": { "label": "Користувацькі моделі", - "description": "Дозволити завантаження користувацьких моделей Whisper з папки моделей. Потрібен перезапуск. Моделі спільноти офіційно не підтримуються." + "description": "Дозволити завантаження користувацьких моделей Whisper з папки моделей. Моделі спільноти офіційно не підтримуються." }, "keyboardImplementation": { "title": "Keyboard Implementation", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 318c12c46..ad6f6a1b2 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -405,7 +405,7 @@ }, "customModels": { "label": "Mô hình tùy chỉnh", - "description": "Cho phép tải các mô hình Whisper tùy chỉnh từ thư mục mô hình. Cần khởi động lại. Các mô hình cộng đồng không được hỗ trợ chính thức." + "description": "Cho phép tải các mô hình Whisper tùy chỉnh từ thư mục mô hình. Các mô hình cộng đồng không được hỗ trợ chính thức." }, "keyboardImplementation": { "title": "Keyboard Implementation", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 2f11389eb..f344a0138 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -405,7 +405,7 @@ }, "customModels": { "label": "自定义模型", - "description": "允许从模型文件夹加载自定义 Whisper 模型。需要重启。社区模型未获官方支持。" + "description": "允许从模型文件夹加载自定义 Whisper 模型。社区模型未获官方支持。" }, "keyboardImplementation": { "title": "Keyboard Implementation", From 536f1eeb6e017bdec8eb72d662f9a94a4cfc71ee Mon Sep 17 00:00:00 2001 From: Hoss Date: Mon, 9 Feb 2026 01:33:49 +0800 Subject: [PATCH 09/11] fix(models): clear stale model selection on startup [why] If a custom model file is deleted from disk while it's the selected model, the app gets stuck on "Loading..." forever on next launch because the model ID is not in available_models but auto_select_model_if_needed only checked for empty string. [how] Validate that selected_model exists in available_models before accepting it. If not found, clear the selection so auto-select picks a valid downloaded model. --- src-tauri/src/managers/model.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 5fc7c8e8c..199dcf7b0 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -420,10 +420,26 @@ impl ModelManager { } fn auto_select_model_if_needed(&self) -> Result<()> { - // Check if we have a selected model in settings - let settings = get_settings(&self.app_handle); + let mut settings = get_settings(&self.app_handle); - // If no model is selected or selected model is empty + // Clear stale selection: selected model is set but doesn't exist + // in available_models (e.g. deleted custom model file) + if !settings.selected_model.is_empty() { + let models = self.available_models.lock().unwrap(); + let exists = models.contains_key(&settings.selected_model); + drop(models); + + if !exists { + info!( + "Selected model '{}' not found in available models, clearing selection", + settings.selected_model + ); + settings.selected_model = String::new(); + write_settings(&self.app_handle, settings.clone()); + } + } + + // If no model is selected, pick the first downloaded one if settings.selected_model.is_empty() { // Find the first available (downloaded) model let models = self.available_models.lock().unwrap(); From 8ec52fcd8a2b15f970be4209198ae8a38afc1a90 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Mon, 9 Feb 2026 10:10:49 +0800 Subject: [PATCH 10/11] remove toggle and clean up --- README.md | 10 ++--- src-tauri/src/lib.rs | 1 - src-tauri/src/managers/model.rs | 22 ++--------- src-tauri/src/settings.rs | 3 -- src-tauri/src/shortcut/mod.rs | 35 ----------------- src/bindings.ts | 10 +---- .../settings/CustomModelsToggle.tsx | 30 --------------- .../settings/debug/DebugSettings.tsx | 2 - .../settings/models/ModelsSettings.tsx | 38 +++---------------- src/i18n/locales/ar/translation.json | 7 +--- src/i18n/locales/cs/translation.json | 7 +--- src/i18n/locales/de/translation.json | 7 +--- src/i18n/locales/en/translation.json | 7 +--- src/i18n/locales/es/translation.json | 7 +--- src/i18n/locales/fr/translation.json | 7 +--- src/i18n/locales/it/translation.json | 7 +--- src/i18n/locales/ja/translation.json | 7 +--- src/i18n/locales/ko/translation.json | 7 +--- src/i18n/locales/pl/translation.json | 7 +--- src/i18n/locales/pt/translation.json | 7 +--- src/i18n/locales/ru/translation.json | 7 +--- src/i18n/locales/tr/translation.json | 7 +--- src/i18n/locales/uk/translation.json | 7 +--- src/i18n/locales/vi/translation.json | 7 +--- src/i18n/locales/zh/translation.json | 7 +--- src/stores/settingsStore.ts | 2 - 26 files changed, 30 insertions(+), 235 deletions(-) delete mode 100644 src/components/settings/CustomModelsToggle.tsx diff --git a/README.md b/README.md index 1497beb97..264d9ded1 100644 --- a/README.md +++ b/README.md @@ -270,12 +270,10 @@ Handy can auto-discover custom Whisper GGML models placed in the `models` direct **How to use:** -1. Enable Debug mode (`Cmd+Shift+D` on macOS, `Ctrl+Shift+D` on Windows/Linux) -2. Go to Debug settings and enable "Custom Models" -3. Obtain a Whisper model in GGML `.bin` format (e.g., from [Hugging Face](https://huggingface.co/models?search=whisper%20ggml)) -4. Place the `.bin` file in your `models` directory (see paths above) -5. Toggle "Custom Models" off and on again (or restart Handy) to discover the new model -6. The model will appear in the "Custom Models" section of the Models settings page +1. Obtain a Whisper model in GGML `.bin` format (e.g., from [Hugging Face](https://huggingface.co/models?search=whisper%20ggml)) +2. Place the `.bin` file in your `models` directory (see paths above) +3. Restart Handy to discover the new model +4. The model will appear in the "Custom Models" section of the Models settings page **Important:** diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aabe0e237..c45adf9fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -270,7 +270,6 @@ pub fn run() { shortcut::change_mute_while_recording_setting, shortcut::change_append_trailing_space_setting, shortcut::change_app_language_setting, - shortcut::change_custom_models_enabled_setting, shortcut::change_update_checks_setting, shortcut::change_keyboard_implementation_setting, shortcut::get_keyboard_implementation, diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 199dcf7b0..28fbcfa22 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -289,13 +289,9 @@ impl ModelManager { }, ); - // Auto-discover custom Whisper models if enabled in settings - let settings = get_settings(app_handle); - if settings.custom_models_enabled { - if let Err(e) = Self::discover_custom_whisper_models(&models_dir, &mut available_models) - { - warn!("Failed to discover custom models: {}", e); - } + // Auto-discover custom Whisper models (.bin files) in the models directory + if let Err(e) = Self::discover_custom_whisper_models(&models_dir, &mut available_models) { + warn!("Failed to discover custom models: {}", e); } let manager = Self { @@ -328,18 +324,6 @@ impl ModelManager { models.get(model_id).cloned() } - /// Remove all custom models from the in-memory available models list. - pub fn remove_custom_models(&self) { - let mut models = self.available_models.lock().unwrap(); - models.retain(|_, m| !m.is_custom); - } - - /// Discover custom models and add them to the in-memory available models list. - pub fn add_custom_models(&self) -> Result<()> { - let mut models = self.available_models.lock().unwrap(); - Self::discover_custom_whisper_models(&self.models_dir, &mut models) - } - fn migrate_bundled_models(&self) -> Result<()> { // Check for bundled models and copy them to user directory let bundled_models = ["ggml-small.bin"]; // Add other bundled models here if any diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 07a5cf7a5..44402bc16 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -309,8 +309,6 @@ pub struct AppSettings { pub mute_while_recording: bool, #[serde(default)] pub append_trailing_space: bool, - #[serde(default)] - pub custom_models_enabled: bool, #[serde(default = "default_app_language")] pub app_language: String, #[serde(default)] @@ -630,7 +628,6 @@ pub fn get_default_settings() -> AppSettings { post_process_selected_prompt_id: None, mute_while_recording: false, append_trailing_space: false, - custom_models_enabled: false, app_language: default_app_language(), experimental_enabled: false, keyboard_implementation: KeyboardImplementation::default(), diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index d03595493..b13993a69 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -16,11 +16,9 @@ mod tauri_impl; use log::{error, info, warn}; use serde::Serialize; use specta::Type; -use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_autostart::ManagerExt; -use crate::managers::model::ModelManager; use crate::settings::{ self, get_settings, ClipboardHandling, KeyboardImplementation, LLMPrompt, OverlayPosition, PasteMethod, ShortcutBinding, SoundTheme, APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, @@ -981,36 +979,3 @@ pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<( Ok(()) } -#[tauri::command] -#[specta::specta] -pub fn change_custom_models_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> { - let mut settings = settings::get_settings(&app); - settings.custom_models_enabled = enabled; - - let model_manager = app.state::>(); - - if enabled { - // Discover and add custom models so they appear immediately - if let Err(e) = model_manager.add_custom_models() { - warn!("Failed to discover custom models: {}", e); - } - } else { - // If the currently selected model is custom, reset to empty so - // auto_select_model_if_needed picks a valid default on next startup - if let Some(model) = model_manager.get_model_info(&settings.selected_model) { - if model.is_custom { - settings.selected_model = String::new(); - } - } - - // Remove custom models from the in-memory list so the UI updates immediately - model_manager.remove_custom_models(); - } - - settings::write_settings(&app, settings); - - // Notify the frontend so it refreshes the model list - let _ = app.emit("model-state-changed", ()); - - Ok(()) -} diff --git a/src/bindings.ts b/src/bindings.ts index 503d60f0d..cc6a33682 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -268,14 +268,6 @@ async changeAppLanguageSetting(language: string) : Promise> else return { status: "error", error: e as any }; } }, -async changeCustomModelsEnabledSetting(enabled: boolean) : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("change_custom_models_enabled_setting", { enabled }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, async changeUpdateChecksSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) }; @@ -703,7 +695,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?: number; 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; custom_models_enabled?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; paste_delay_ms?: number } +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?: number; 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; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; paste_delay_ms?: number } 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/settings/CustomModelsToggle.tsx b/src/components/settings/CustomModelsToggle.tsx deleted file mode 100644 index a2aa20469..000000000 --- a/src/components/settings/CustomModelsToggle.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { ToggleSwitch } from "../ui/ToggleSwitch"; -import { useSettings } from "../../hooks/useSettings"; - -interface CustomModelsToggleProps { - descriptionMode?: "inline" | "tooltip"; - grouped?: boolean; -} - -export const CustomModelsToggle: React.FC = ({ - descriptionMode = "tooltip", - grouped = false, -}) => { - const { t } = useTranslation(); - const { getSetting, updateSetting, isUpdating } = useSettings(); - const customModelsEnabled = getSetting("custom_models_enabled") ?? false; - - return ( - updateSetting("custom_models_enabled", enabled)} - isUpdating={isUpdating("custom_models_enabled")} - label={t("settings.debug.customModels.label")} - description={t("settings.debug.customModels.description")} - descriptionMode={descriptionMode} - grouped={grouped} - /> - ); -}; diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index 03db899c2..02ad5273f 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -7,7 +7,6 @@ import { PasteDelay } from "./PasteDelay"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone"; import { SoundPicker } from "../SoundPicker"; -import { CustomModelsToggle } from "../CustomModelsToggle"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; import { ShortcutInput } from "../ShortcutInput"; import { UpdateChecksToggle } from "../UpdateChecksToggle"; @@ -32,7 +31,6 @@ export const DebugSettings: React.FC = () => { - {/* Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration */} {!isLinux && ( { }); }, [models, languageFilter]); - // Split filtered models into downloaded, custom, and available sections - const { downloadedModels, customModels, availableModels } = useMemo(() => { + // Split filtered models into downloaded (including custom) and available sections + const { downloadedModels, availableModels } = useMemo(() => { const downloaded: ModelInfo[] = []; - const custom: ModelInfo[] = []; const available: ModelInfo[] = []; for (const model of filteredModels) { - if (model.is_custom) { - custom.push(model); - } else if ( + if ( + model.is_custom || model.is_downloaded || model.id in downloadingModels || model.id in extractingModels @@ -179,21 +177,16 @@ export const ModelsSettings: React.FC = () => { } } - // Sort active model first in each section + // Sort: active model first, then non-custom, then custom at the bottom downloaded.sort((a, b) => { if (a.id === currentModel) return -1; if (b.id === currentModel) return 1; - return 0; - }); - custom.sort((a, b) => { - if (a.id === currentModel) return -1; - if (b.id === currentModel) return 1; + if (a.is_custom !== b.is_custom) return a.is_custom ? 1 : -1; return 0; }); return { downloadedModels: downloaded, - customModels: custom, availableModels: available, }; }, [filteredModels, downloadingModels, extractingModels, currentModel]); @@ -335,25 +328,6 @@ export const ModelsSettings: React.FC = () => { ))}
- {/* Custom Models Section */} - {customModels.length > 0 && ( -
-

- {t("settings.models.customModels")} -

- {customModels.map((model: ModelInfo) => ( - - ))} -
- )} - {/* Available Models Section */} {availableModels.length > 0 && (
diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 1983672b1..18bbe145a 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -385,10 +385,6 @@ "description": ".اختر الواجهة الخلفية لاختصارات لوحة المفاتيح", "bindingsReset": "كانت اختصارات لوحة المفاتيح غير متوافقة وتمت إعادة تعيينها إلى القيم الافتراضية" }, - "customModels": { - "label": "نماذج مخصصة", - "description": "السماح بتحميل نماذج Whisper المخصصة من مجلد النماذج. النماذج المجتمعية غير مدعومة رسميًا." - }, "paths": { "appData": "بيانات التطبيق:", "models": "النماذج:", @@ -448,8 +444,7 @@ "translation": "ترجمة", "allLanguages": "جميع اللغات" }, - "noModelsMatch": "لا توجد نماذج مطابقة لهذا الفلتر.", - "customModels": "نماذج مخصصة" + "noModelsMatch": "لا توجد نماذج مطابقة لهذا الفلتر." } }, "footer": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 03da12045..4f671296a 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Tomuto filtru neodpovídají žádné modely.", "yourModels": "Stažené modely", - "availableModels": "Dostupné ke stažení", - "customModels": "Vlastní modely" + "availableModels": "Dostupné ke stažení" }, "general": { "title": "Obecné", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Vlastní modely", - "description": "Povolit načítání vlastních modelů Whisper ze složky modelů. Komunitní modely nejsou oficiálně podporovány." - }, "paths": { "appData": "Data aplikace:", "models": "Modely:", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index fcb4a1d99..4085e5c3b 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Keine Modelle entsprechen diesem Filter.", "yourModels": "Heruntergeladene Modelle", - "availableModels": "Zum Download verfügbar", - "customModels": "Benutzerdefinierte Modelle" + "availableModels": "Zum Download verfügbar" }, "general": { "title": "Allgemein", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Benutzerdefinierte Modelle", - "description": "Laden von benutzerdefinierten Whisper-Modellen aus dem Modellordner erlauben. Community-Modelle werden nicht offiziell unterstützt." - }, "paths": { "appData": "App-Daten:", "models": "Modelle:", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index b39d51dee..58d842d16 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -173,8 +173,7 @@ "translation": "Translation", "allLanguages": "All Languages" }, - "noModelsMatch": "No models match this filter.", - "customModels": "Custom Models" + "noModelsMatch": "No models match this filter." }, "sound": { "title": "Sound", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Custom Models", - "description": "Allow loading custom Whisper models from the models folder. Community models are not officially supported." - }, "paths": { "appData": "App Data:", "models": "Models:", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 84486c086..6ae8f86b0 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Ningún modelo coincide con este filtro.", "yourModels": "Modelos descargados", - "availableModels": "Disponibles para descargar", - "customModels": "Modelos personalizados" + "availableModels": "Disponibles para descargar" }, "general": { "title": "General", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Modelos personalizados", - "description": "Permitir la carga de modelos Whisper personalizados desde la carpeta de modelos. Los modelos de la comunidad no son compatibles oficialmente." - }, "paths": { "appData": "Datos de la Aplicación:", "models": "Modelos:", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index b38fe3450..826412d5d 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Aucun modèle ne correspond à ce filtre.", "yourModels": "Modèles téléchargés", - "availableModels": "Disponibles au téléchargement", - "customModels": "Modèles personnalisés" + "availableModels": "Disponibles au téléchargement" }, "general": { "title": "Général", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Modèles personnalisés", - "description": "Autoriser le chargement de modèles Whisper personnalisés depuis le dossier des modèles. Les modèles communautaires ne sont pas officiellement pris en charge." - }, "paths": { "appData": "Données de l'application :", "models": "Modèles :", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index e905f9672..ae8fae495 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Nessun modello corrisponde a questo filtro.", "yourModels": "Modelli scaricati", - "availableModels": "Disponibili per il download", - "customModels": "Modelli personalizzati" + "availableModels": "Disponibili per il download" }, "general": { "title": "Generale", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Modelli personalizzati", - "description": "Consenti il caricamento di modelli Whisper personalizzati dalla cartella dei modelli. I modelli della comunità non sono ufficialmente supportati." - }, "paths": { "appData": "Dati App:", "models": "Modelli:", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 80b76df7f..503b77baf 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "このフィルターに一致するモデルがありません。", "yourModels": "ダウンロード済みモデル", - "availableModels": "ダウンロード可能", - "customModels": "カスタムモデル" + "availableModels": "ダウンロード可能" }, "general": { "title": "一般", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "カスタムモデル", - "description": "モデルフォルダからカスタムWhisperモデルの読み込みを許可します。コミュニティモデルは公式にはサポートされていません。" - }, "paths": { "appData": "アプリデータ:", "models": "モデル:", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 461dafecf..aa18e95f4 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -196,8 +196,7 @@ "translation": "번역", "allLanguages": "모든 언어" }, - "noModelsMatch": "이 필터에 맞는 모델이 없습니다.", - "customModels": "사용자 정의 모델" + "noModelsMatch": "이 필터에 맞는 모델이 없습니다." }, "advanced": { "title": "고급", @@ -408,10 +407,6 @@ "description": "키보드 단축키 백엔드를 선택하세요.", "bindingsReset": "키보드 단축키가 호환되지 않아 기본값으로 재설정되었습니다" }, - "customModels": { - "label": "사용자 정의 모델", - "description": "모델 폴더에서 사용자 정의 Whisper 모델 로드를 허용합니다. 커뮤니티 모델은 공식적으로 지원되지 않습니다." - }, "pasteDelay": { "title": "붙여넣기 지연", "description": "붙여넣기 키 입력을 보내기 전 지연 시간(밀리초). 잘못된 텍스트가 붙여넣어지면 늘리세요." diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index b1a1f270e..8c89e4be9 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Żadne modele nie pasują do tego filtra.", "yourModels": "Pobrane modele", - "availableModels": "Dostępne do pobrania", - "customModels": "Niestandardowe modele" + "availableModels": "Dostępne do pobrania" }, "general": { "title": "Ogólne", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Niestandardowe modele", - "description": "Zezwól na ładowanie niestandardowych modeli Whisper z folderu modeli. Modele społeczności nie są oficjalnie wspierane." - }, "paths": { "appData": "Dane aplikacji:", "models": "Modele:", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 681184a35..887917f93 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -173,8 +173,7 @@ }, "noModelsMatch": "Nenhum modelo corresponde a este filtro.", "yourModels": "Modelos baixados", - "availableModels": "Disponíveis para download", - "customModels": "Modelos personalizados" + "availableModels": "Disponíveis para download" }, "sound": { "title": "Som", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Modelos personalizados", - "description": "Permitir o carregamento de modelos Whisper personalizados a partir da pasta de modelos. Os modelos da comunidade não são oficialmente suportados." - }, "paths": { "appData": "Dados do App:", "models": "Modelos:", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index c8c22b396..ec71884d4 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Нет моделей, соответствующих этому фильтру.", "yourModels": "Загруженные модели", - "availableModels": "Доступны для загрузки", - "customModels": "Пользовательские модели" + "availableModels": "Доступны для загрузки" }, "general": { "title": "Общие", @@ -408,10 +407,6 @@ "description": "Choose the keyboard shortcut backend.", "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" }, - "customModels": { - "label": "Пользовательские модели", - "description": "Разрешить загрузку пользовательских моделей Whisper из папки моделей. Модели сообщества официально не поддерживаются." - }, "paths": { "appData": "Данные приложения:", "models": "Модели:", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 0299d3d40..192745fb6 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Bu filtreyle eşleşen model yok.", "yourModels": "İndirilen modeller", - "availableModels": "İndirilebilir", - "customModels": "Özel Modeller" + "availableModels": "İndirilebilir" }, "general": { "title": "Genel", @@ -403,10 +402,6 @@ "label": "Sonuna Boşluk Ekle", "description": "Yapıştırılan transkripsiyondan sonra boşluk ekler" }, - "customModels": { - "label": "Özel Modeller", - "description": "Model klasöründen özel Whisper modellerinin yüklenmesine izin ver. Topluluk modelleri resmi olarak desteklenmemektedir." - }, "keyboardImplementation": { "title": "Keyboard Implementation", "description": "Choose the keyboard shortcut backend.", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 75e5c403a..ad040e0bb 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -173,8 +173,7 @@ }, "noModelsMatch": "Жодна модель не відповідає цьому фільтру.", "yourModels": "Завантажені моделі", - "availableModels": "Доступні для завантаження", - "customModels": "Користувацькі моделі" + "availableModels": "Доступні для завантаження" }, "sound": { "title": "Звук", @@ -403,10 +402,6 @@ "label": "Додавати пробіл в кінці", "description": "Додавати пробіл після вставленої транскрипції" }, - "customModels": { - "label": "Користувацькі моделі", - "description": "Дозволити завантаження користувацьких моделей Whisper з папки моделей. Моделі спільноти офіційно не підтримуються." - }, "keyboardImplementation": { "title": "Keyboard Implementation", "description": "Choose the keyboard shortcut backend.", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index ad6f6a1b2..c2a969960 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "Không có mô hình nào khớp với bộ lọc này.", "yourModels": "Mô hình đã tải", - "availableModels": "Có sẵn để tải xuống", - "customModels": "Mô hình tùy chỉnh" + "availableModels": "Có sẵn để tải xuống" }, "general": { "title": "Chung", @@ -403,10 +402,6 @@ "label": "Thêm dấu cách cuối", "description": "Thêm một dấu cách sau bản ghi đã dán" }, - "customModels": { - "label": "Mô hình tùy chỉnh", - "description": "Cho phép tải các mô hình Whisper tùy chỉnh từ thư mục mô hình. Các mô hình cộng đồng không được hỗ trợ chính thức." - }, "keyboardImplementation": { "title": "Keyboard Implementation", "description": "Choose the keyboard shortcut backend.", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index f344a0138..a30babdc7 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -131,8 +131,7 @@ }, "noModelsMatch": "没有符合此筛选条件的模型。", "yourModels": "已下载的模型", - "availableModels": "可供下载", - "customModels": "自定义模型" + "availableModels": "可供下载" }, "general": { "title": "通用", @@ -403,10 +402,6 @@ "label": "追加尾部空格", "description": "在粘贴的转录后添加空格" }, - "customModels": { - "label": "自定义模型", - "description": "允许从模型文件夹加载自定义 Whisper 模型。社区模型未获官方支持。" - }, "keyboardImplementation": { "title": "Keyboard Implementation", "description": "Choose the keyboard shortcut backend.", diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index f264fc2c9..620ab7053 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -127,8 +127,6 @@ const settingUpdaters: { app_language: (value) => commands.changeAppLanguageSetting(value as string), experimental_enabled: (value) => commands.changeExperimentalEnabledSetting(value as boolean), - custom_models_enabled: (value) => - commands.changeCustomModelsEnabledSetting(value as boolean), }; export const useSettingsStore = create()( From 251bbcb908bae101ecc6675388125b2f5222bf0c Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Mon, 9 Feb 2026 10:12:57 +0800 Subject: [PATCH 11/11] format --- src-tauri/src/shortcut/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index b13993a69..6ff5a04b7 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -978,4 +978,3 @@ pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<( Ok(()) } -