feat(models): add opt-in custom Whisper model discovery (#622)

* feat(models): auto-discover custom Whisper models in models directory

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

* 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.

* 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

* 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

* 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.

* docs: fix custom models section reference in README

Model selector → Models settings page.

* fix(models): apply custom models toggle immediately without restart

[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

* 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.

* 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.

* remove toggle and clean up

* format

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
Hoss 2026-02-09 10:15:45 +08:00 committed by GitHub
parent 3eb370df80
commit a4999164ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 480 additions and 152 deletions

View file

@ -264,6 +264,23 @@ Final structure should look like:
3. Your manually installed models should now appear as "Downloaded" 3. Your manually installed models should now appear as "Downloaded"
4. Select the model you want to use and test transcription 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. 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:**
- 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 ### How to Contribute
1. **Check existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues) 1. **Check existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues)

1
src-tauri/Cargo.lock generated
View file

@ -2490,6 +2490,7 @@ dependencies = [
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-specta", "tauri-specta",
"tempfile",
"tokio", "tokio",
"transcribe-rs", "transcribe-rs",
"vad-rs", "vad-rs",

View file

@ -101,6 +101,9 @@ tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.
gtk-layer-shell = { version = "0.8", features = ["v0_6"] } gtk-layer-shell = { version = "0.8", features = ["v0_6"] }
gtk = "0.18" gtk = "0.18"
[dev-dependencies]
tempfile = "3"
[profile.release] [profile.release]
lto = true lto = true
codegen-units = 1 codegen-units = 1

View file

@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -41,6 +41,7 @@ pub struct ModelInfo {
pub supports_translation: bool, // Whether the model supports translating to English 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 is_recommended: bool, // Whether this is the recommended model for new users
pub supported_languages: Vec<String>, // Languages this model can transcribe pub supported_languages: Vec<String>, // Languages this model can transcribe
pub is_custom: bool, // Whether this is a user-provided custom model
} }
#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[derive(Debug, Clone, Serialize, Deserialize, Type)]
@ -110,6 +111,7 @@ impl ModelManager {
supports_translation: true, supports_translation: true,
is_recommended: false, is_recommended: false,
supported_languages: whisper_languages.clone(), supported_languages: whisper_languages.clone(),
is_custom: false,
}, },
); );
@ -133,6 +135,7 @@ impl ModelManager {
supports_translation: true, supports_translation: true,
is_recommended: false, is_recommended: false,
supported_languages: whisper_languages.clone(), supported_languages: whisper_languages.clone(),
is_custom: false,
}, },
); );
@ -155,6 +158,7 @@ impl ModelManager {
supports_translation: false, // Turbo doesn't support translation supports_translation: false, // Turbo doesn't support translation
is_recommended: false, is_recommended: false,
supported_languages: whisper_languages.clone(), supported_languages: whisper_languages.clone(),
is_custom: false,
}, },
); );
@ -177,6 +181,7 @@ impl ModelManager {
supports_translation: true, supports_translation: true,
is_recommended: false, is_recommended: false,
supported_languages: whisper_languages.clone(), supported_languages: whisper_languages.clone(),
is_custom: false,
}, },
); );
@ -200,6 +205,7 @@ impl ModelManager {
supports_translation: false, supports_translation: false,
is_recommended: false, is_recommended: false,
supported_languages: whisper_languages, supported_languages: whisper_languages,
is_custom: false,
}, },
); );
@ -223,6 +229,7 @@ impl ModelManager {
supports_translation: false, supports_translation: false,
is_recommended: false, is_recommended: false,
supported_languages: vec!["en".to_string()], supported_languages: vec!["en".to_string()],
is_custom: false,
}, },
); );
@ -255,6 +262,7 @@ impl ModelManager {
supports_translation: false, supports_translation: false,
is_recommended: true, is_recommended: true,
supported_languages: parakeet_v3_languages, supported_languages: parakeet_v3_languages,
is_custom: false,
}, },
); );
@ -277,9 +285,15 @@ impl ModelManager {
supports_translation: false, supports_translation: false,
is_recommended: false, is_recommended: false,
supported_languages: vec!["en".to_string()], supported_languages: vec!["en".to_string()],
is_custom: false,
}, },
); );
// 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 { let manager = Self {
app_handle: app_handle.clone(), app_handle: app_handle.clone(),
models_dir, models_dir,
@ -390,10 +404,26 @@ impl ModelManager {
} }
fn auto_select_model_if_needed(&self) -> Result<()> { fn auto_select_model_if_needed(&self) -> Result<()> {
// Check if we have a selected model in settings let mut settings = get_settings(&self.app_handle);
let 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() { if settings.selected_model.is_empty() {
// Find the first available (downloaded) model // Find the first available (downloaded) model
let models = self.available_models.lock().unwrap(); let models = self.available_models.lock().unwrap();
@ -415,6 +445,125 @@ impl ModelManager {
Ok(()) 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: &Path,
available_models: &mut HashMap<String, ModelInfo>,
) -> Result<()> {
if !models_dir.exists() {
return Ok(());
}
// Collect filenames of predefined Whisper file-based models to skip
let predefined_filenames: HashSet<String> = 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::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.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: "Not officially supported".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.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,
},
);
}
Ok(())
}
pub async fn download_model(&self, model_id: &str) -> Result<()> { pub async fn download_model(&self, model_id: &str) -> Result<()> {
let model_info = { let model_info = {
let models = self.available_models.lock().unwrap(); let models = self.available_models.lock().unwrap();
@ -817,9 +966,17 @@ impl ModelManager {
return Err(anyhow::anyhow!("No model files found to delete")); return Err(anyhow::anyhow!("No model files found to delete"));
} }
// Update download status // Custom models should be removed from the list entirely since they
self.update_download_status()?; // have no download URL and can't be re-downloaded
debug!("ModelManager: download status updated"); 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 // Emit event to notify UI
let _ = self.app_handle.emit("model-deleted", model_id); let _ = self.app_handle.emit("model-deleted", model_id);
@ -904,3 +1061,108 @@ impl ModelManager {
Ok(()) 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,
supports_translation: true,
is_recommended: false,
supported_languages: vec!["en".to_string()],
is_custom: false,
},
);
// 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);
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();
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);
}
}

View file

@ -713,7 +713,7 @@ reset_bindings: string[] }
export type KeyboardImplementation = "tauri" | "handy_keys" export type KeyboardImplementation = "tauri" | "handy_keys"
export type LLMPrompt = { id: string; name: string; prompt: string } export type LLMPrompt = { id: string; name: string; prompt: string }
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" 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 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 ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5"
export type OverlayPosition = "none" | "top" | "bottom" export type OverlayPosition = "none" | "top" | "bottom"

View file

@ -50,6 +50,11 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
<div> <div>
<div className="text-sm text-text/80"> <div className="text-sm text-text/80">
{getTranslatedModelName(model, t)} {getTranslatedModelName(model, t)}
{model.is_custom && (
<span className="ms-1.5 text-[10px] font-medium text-text/40 uppercase">
{t("modelSelector.custom")}
</span>
)}
</div> </div>
<div className="text-xs text-text/40 italic pe-4"> <div className="text-xs text-text/40 italic pe-4">
{getTranslatedModelDescription(model, t)} {getTranslatedModelDescription(model, t)}

View file

@ -146,6 +146,9 @@ const ModelCard: React.FC<ModelCardProps> = ({
{t("modelSelector.active")} {t("modelSelector.active")}
</Badge> </Badge>
)} )}
{model.is_custom && (
<Badge variant="secondary">{t("modelSelector.custom")}</Badge>
)}
{status === "switching" && ( {status === "switching" && (
<Badge variant="secondary"> <Badge variant="secondary">
<Loader2 className="w-3 h-3 mr-1 animate-spin" /> <Loader2 className="w-3 h-3 mr-1 animate-spin" />
@ -157,49 +160,53 @@ const ModelCard: React.FC<ModelCardProps> = ({
{displayDescription} {displayDescription}
</p> </p>
</div> </div>
<div className="hidden sm:flex items-center ml-4"> {(model.accuracy_score > 0 || model.speed_score > 0) && (
<div className="space-y-1"> <div className="hidden sm:flex items-center ml-4">
<div className="flex items-center gap-2"> <div className="space-y-1">
<p className="text-xs text-text/60 w-14 text-right"> <div className="flex items-center gap-2">
{t("onboarding.modelCard.accuracy")} <p className="text-xs text-text/60 w-14 text-right">
</p> {t("onboarding.modelCard.accuracy")}
<div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden"> </p>
<div <div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
className="h-full bg-logo-primary rounded-full" <div
style={{ width: `${model.accuracy_score * 100}%` }} className="h-full bg-logo-primary rounded-full"
/> style={{ width: `${model.accuracy_score * 100}%` }}
/>
</div>
</div> </div>
</div> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <p className="text-xs text-text/60 w-14 text-right">
<p className="text-xs text-text/60 w-14 text-right"> {t("onboarding.modelCard.speed")}
{t("onboarding.modelCard.speed")} </p>
</p> <div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
<div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden"> <div
<div className="h-full bg-logo-primary rounded-full"
className="h-full bg-logo-primary rounded-full" style={{ width: `${model.speed_score * 100}%` }}
style={{ width: `${model.speed_score * 100}%` }} />
/> </div>
</div> </div>
</div> </div>
</div> </div>
</div> )}
</div> </div>
<hr className="w-full border-mid-gray/20" /> <hr className="w-full border-mid-gray/20" />
{/* Bottom row: tags + action buttons (full width) */} {/* Bottom row: tags + action buttons (full width) */}
<div className="flex items-center gap-3 w-full -mb-0.5 mt-0.5 h-5"> <div className="flex items-center gap-3 w-full -mb-0.5 mt-0.5 h-5">
<div {model.supported_languages.length > 0 && (
className="flex items-center gap-1 text-xs text-text/50" <div
title={ className="flex items-center gap-1 text-xs text-text/50"
model.supported_languages.length === 1 title={
? t("modelSelector.capabilities.singleLanguage") model.supported_languages.length === 1
: t("modelSelector.capabilities.languageSelection") ? t("modelSelector.capabilities.singleLanguage")
} : t("modelSelector.capabilities.languageSelection")
> }
<Globe className="w-3.5 h-3.5" /> >
<span>{getLanguageDisplayText(model.supported_languages, t)}</span> <Globe className="w-3.5 h-3.5" />
</div> <span>{getLanguageDisplayText(model.supported_languages, t)}</span>
</div>
)}
{model.supports_translation && ( {model.supports_translation && (
<div <div
className="flex items-center gap-1 text-xs text-text/50" className="flex items-center gap-1 text-xs text-text/50"

View file

@ -159,31 +159,36 @@ export const ModelsSettings: React.FC = () => {
}); });
}, [models, languageFilter]); }, [models, languageFilter]);
// Split filtered models into downloaded and available sections // Split filtered models into downloaded (including custom) and available sections
const { downloadedModels, availableModels } = useMemo(() => { const { downloadedModels, availableModels } = useMemo(() => {
const downloaded: ModelInfo[] = []; const downloaded: ModelInfo[] = [];
const available: ModelInfo[] = []; const available: ModelInfo[] = [];
for (const model of filteredModels) { for (const model of filteredModels) {
const isDownloaded = if (
model.is_custom ||
model.is_downloaded || model.is_downloaded ||
model.id in downloadingModels || model.id in downloadingModels ||
model.id in extractingModels; model.id in extractingModels
if (isDownloaded) { ) {
downloaded.push(model); downloaded.push(model);
} else { } else {
available.push(model); available.push(model);
} }
} }
// Sort downloaded models so the active model is always first // Sort: active model first, then non-custom, then custom at the bottom
downloaded.sort((a, b) => { downloaded.sort((a, b) => {
if (a.id === currentModel) return -1; if (a.id === currentModel) return -1;
if (b.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 0;
}); });
return { downloadedModels: downloaded, availableModels: available }; return {
downloadedModels: downloaded,
availableModels: available,
};
}, [filteredModels, downloadingModels, extractingModels, currentModel]); }, [filteredModels, downloadingModels, extractingModels, currentModel]);
if (loading) { if (loading) {
@ -208,124 +213,120 @@ export const ModelsSettings: React.FC = () => {
</div> </div>
{filteredModels.length > 0 ? ( {filteredModels.length > 0 ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Downloaded Models Section */} {/* Downloaded Models Section — header always visible so filter stays accessible */}
{downloadedModels.length > 0 && ( <div className="space-y-3">
<div className="space-y-3"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <h2 className="text-sm font-medium text-text/60">
<h2 className="text-sm font-medium text-text/60"> {t("settings.models.yourModels")}
{t("settings.models.yourModels")} </h2>
</h2> {/* Language filter dropdown */}
{/* Language filter dropdown */} <div className="relative" ref={languageDropdownRef}>
<div className="relative" ref={languageDropdownRef}> <button
<button type="button"
type="button" onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
onClick={() => className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
setLanguageDropdownOpen(!languageDropdownOpen) languageFilter !== "all"
} ? "bg-logo-primary/20 text-logo-primary"
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${ : "bg-mid-gray/10 text-text/60 hover:bg-mid-gray/20"
languageFilter !== "all" }`}
? "bg-logo-primary/20 text-logo-primary" >
: "bg-mid-gray/10 text-text/60 hover:bg-mid-gray/20" <Globe className="w-3.5 h-3.5" />
<span className="max-w-[120px] truncate">
{selectedLanguageLabel}
</span>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${
languageDropdownOpen ? "rotate-180" : ""
}`} }`}
> />
<Globe className="w-3.5 h-3.5" /> </button>
<span className="max-w-[120px] truncate">
{selectedLanguageLabel}
</span>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${
languageDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{languageDropdownOpen && ( {languageDropdownOpen && (
<div className="absolute top-full right-0 mt-1 w-56 bg-background border border-mid-gray/80 rounded-lg shadow-lg z-50 overflow-hidden"> <div className="absolute top-full right-0 mt-1 w-56 bg-background border border-mid-gray/80 rounded-lg shadow-lg z-50 overflow-hidden">
<div className="p-2 border-b border-mid-gray/40"> <div className="p-2 border-b border-mid-gray/40">
<input <input
ref={languageSearchInputRef} ref={languageSearchInputRef}
type="text" type="text"
value={languageSearch} value={languageSearch}
onChange={(e) => setLanguageSearch(e.target.value)} onChange={(e) => setLanguageSearch(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if ( if (
e.key === "Enter" && e.key === "Enter" &&
filteredLanguages.length > 0 filteredLanguages.length > 0
) { ) {
setLanguageFilter(filteredLanguages[0].value); setLanguageFilter(filteredLanguages[0].value);
setLanguageDropdownOpen(false); setLanguageDropdownOpen(false);
setLanguageSearch(""); setLanguageSearch("");
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
setLanguageDropdownOpen(false); setLanguageDropdownOpen(false);
setLanguageSearch(""); setLanguageSearch("");
} }
}} }}
placeholder={t( placeholder={t(
"settings.general.language.searchPlaceholder", "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" 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"
/> />
</div> </div>
<div className="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setLanguageFilter("all");
setLanguageDropdownOpen(false);
setLanguageSearch("");
}}
className={`w-full px-3 py-1.5 text-sm text-left transition-colors ${
languageFilter === "all"
? "bg-logo-primary/20 text-logo-primary font-semibold"
: "hover:bg-mid-gray/10"
}`}
>
{t("settings.models.filters.allLanguages")}
</button>
{filteredLanguages.map((lang) => (
<button <button
key={lang.value}
type="button" type="button"
onClick={() => { onClick={() => {
setLanguageFilter("all"); setLanguageFilter(lang.value);
setLanguageDropdownOpen(false); setLanguageDropdownOpen(false);
setLanguageSearch(""); setLanguageSearch("");
}} }}
className={`w-full px-3 py-1.5 text-sm text-left transition-colors ${ className={`w-full px-3 py-1.5 text-sm text-left transition-colors ${
languageFilter === "all" languageFilter === lang.value
? "bg-logo-primary/20 text-logo-primary font-semibold" ? "bg-logo-primary/20 text-logo-primary font-semibold"
: "hover:bg-mid-gray/10" : "hover:bg-mid-gray/10"
}`} }`}
> >
{t("settings.models.filters.allLanguages")} {lang.label}
</button> </button>
{filteredLanguages.map((lang) => ( ))}
<button {filteredLanguages.length === 0 && (
key={lang.value} <div className="px-3 py-2 text-sm text-text/50 text-center">
type="button" {t("settings.general.language.noResults")}
onClick={() => { </div>
setLanguageFilter(lang.value); )}
setLanguageDropdownOpen(false);
setLanguageSearch("");
}}
className={`w-full px-3 py-1.5 text-sm text-left transition-colors ${
languageFilter === lang.value
? "bg-logo-primary/20 text-logo-primary font-semibold"
: "hover:bg-mid-gray/10"
}`}
>
{lang.label}
</button>
))}
{filteredLanguages.length === 0 && (
<div className="px-3 py-2 text-sm text-text/50 text-center">
{t("settings.general.language.noResults")}
</div>
)}
</div>
</div> </div>
)} </div>
</div> )}
</div> </div>
{downloadedModels.map((model: ModelInfo) => (
<ModelCard
key={model.id}
model={model}
status={getModelStatus(model.id)}
onSelect={handleModelSelect}
onDownload={handleModelDownload}
onDelete={handleModelDelete}
onCancel={handleModelCancel}
downloadProgress={getDownloadProgress(model.id)}
downloadSpeed={getDownloadSpeed(model.id)}
showRecommended={false}
/>
))}
</div> </div>
)} {downloadedModels.map((model: ModelInfo) => (
<ModelCard
key={model.id}
model={model}
status={getModelStatus(model.id)}
onSelect={handleModelSelect}
onDownload={handleModelDownload}
onDelete={handleModelDelete}
onCancel={handleModelCancel}
downloadProgress={getDownloadProgress(model.id)}
downloadSpeed={getDownloadSpeed(model.id)}
showRecommended={false}
/>
))}
</div>
{/* Available Models Section */} {/* Available Models Section */}
{availableModels.length > 0 && ( {availableModels.length > 0 && (

View file

@ -20,6 +20,7 @@
"recommended": "موصى به", "recommended": "موصى به",
"download": "تنزيل", "download": "تنزيل",
"downloading": "...جاري التنزيل", "downloading": "...جاري التنزيل",
"customModelDescription": "غير مدعوم رسميًا",
"downloadFailed": "فشل التنزيل. يرجى المحاولة مرة أخرى.", "downloadFailed": "فشل التنزيل. يرجى المحاولة مرة أخرى.",
"modelCard": { "modelCard": {
"accuracy": "الدقة", "accuracy": "الدقة",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "مخصص",
"active": "نشط", "active": "نشط",
"noModelsAvailable": "لا توجد نماذج متاحة", "noModelsAvailable": "لا توجد نماذج متاحة",
"extracting": "...جاري استخراج {{modelName}}", "extracting": "...جاري استخراج {{modelName}}",

View file

@ -20,6 +20,7 @@
"recommended": "Doporučeno", "recommended": "Doporučeno",
"download": "Stáhnout", "download": "Stáhnout",
"downloading": "Stahování...", "downloading": "Stahování...",
"customModelDescription": "Oficiálně nepodporováno",
"downloadFailed": "Stahování se nezdařilo. Zkuste to prosím znovu.", "downloadFailed": "Stahování se nezdařilo. Zkuste to prosím znovu.",
"modelCard": { "modelCard": {
"accuracy": "přesnost", "accuracy": "přesnost",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Vlastní",
"active": "Aktivní", "active": "Aktivní",
"noModelsAvailable": "Nejsou dostupné žádné modely", "noModelsAvailable": "Nejsou dostupné žádné modely",
"extracting": "Rozbaluji {{modelName}}...", "extracting": "Rozbaluji {{modelName}}...",

View file

@ -20,6 +20,7 @@
"recommended": "Empfohlen", "recommended": "Empfohlen",
"download": "Herunterladen", "download": "Herunterladen",
"downloading": "Wird heruntergeladen...", "downloading": "Wird heruntergeladen...",
"customModelDescription": "Nicht offiziell unterstützt",
"downloadFailed": "Download fehlgeschlagen. Bitte erneut versuchen.", "downloadFailed": "Download fehlgeschlagen. Bitte erneut versuchen.",
"modelCard": { "modelCard": {
"accuracy": "Genauigkeit", "accuracy": "Genauigkeit",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Benutzerdefiniert",
"active": "Aktiv", "active": "Aktiv",
"switching": "Wechseln...", "switching": "Wechseln...",
"noModelsAvailable": "Keine Modelle verfügbar", "noModelsAvailable": "Keine Modelle verfügbar",

View file

@ -20,6 +20,7 @@
"recommended": "Recommended", "recommended": "Recommended",
"download": "Download", "download": "Download",
"downloading": "Downloading...", "downloading": "Downloading...",
"customModelDescription": "Not officially supported",
"downloadFailed": "Download failed. Please try again.", "downloadFailed": "Download failed. Please try again.",
"modelCard": { "modelCard": {
"accuracy": "accuracy", "accuracy": "accuracy",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Custom",
"active": "Active", "active": "Active",
"switching": "Switching...", "switching": "Switching...",
"noModelsAvailable": "No models available", "noModelsAvailable": "No models available",
@ -405,10 +407,6 @@
"description": "Choose the keyboard shortcut backend.", "description": "Choose the keyboard shortcut backend.",
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
}, },
"pasteDelay": {
"title": "Paste Delay",
"description": "Delay before sending paste keystroke (in milliseconds). Increase if wrong text is being pasted."
},
"paths": { "paths": {
"appData": "App Data:", "appData": "App Data:",
"models": "Models:", "models": "Models:",

View file

@ -20,6 +20,7 @@
"recommended": "Recomendado", "recommended": "Recomendado",
"download": "Descargar", "download": "Descargar",
"downloading": "Descargando...", "downloading": "Descargando...",
"customModelDescription": "Sin soporte oficial",
"downloadFailed": "La descarga falló. Por favor, inténtalo de nuevo.", "downloadFailed": "La descarga falló. Por favor, inténtalo de nuevo.",
"modelCard": { "modelCard": {
"accuracy": "precisión", "accuracy": "precisión",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Personalizado",
"active": "Activo", "active": "Activo",
"switching": "Cambiando...", "switching": "Cambiando...",
"noModelsAvailable": "No hay modelos disponibles", "noModelsAvailable": "No hay modelos disponibles",

View file

@ -20,6 +20,7 @@
"recommended": "Recommandé", "recommended": "Recommandé",
"download": "Télécharger", "download": "Télécharger",
"downloading": "Téléchargement...", "downloading": "Téléchargement...",
"customModelDescription": "Non officiellement pris en charge",
"downloadFailed": "Échec du téléchargement. Veuillez réessayer.", "downloadFailed": "Échec du téléchargement. Veuillez réessayer.",
"modelCard": { "modelCard": {
"accuracy": "précision", "accuracy": "précision",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Personnalisé",
"active": "Actif", "active": "Actif",
"switching": "Changement...", "switching": "Changement...",
"noModelsAvailable": "Aucun modèle disponible", "noModelsAvailable": "Aucun modèle disponible",

View file

@ -20,6 +20,7 @@
"recommended": "Raccomandato", "recommended": "Raccomandato",
"download": "Scaricare", "download": "Scaricare",
"downloading": "Download in corso...", "downloading": "Download in corso...",
"customModelDescription": "Non ufficialmente supportato",
"downloadFailed": "Download fallito. Riprova.", "downloadFailed": "Download fallito. Riprova.",
"modelCard": { "modelCard": {
"accuracy": "accuratezza", "accuracy": "accuratezza",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Personalizzato",
"active": "Attivo", "active": "Attivo",
"switching": "Cambio...", "switching": "Cambio...",
"noModelsAvailable": "Nessun modello disponibile", "noModelsAvailable": "Nessun modello disponibile",

View file

@ -20,6 +20,7 @@
"recommended": "おすすめ", "recommended": "おすすめ",
"download": "ダウンロード", "download": "ダウンロード",
"downloading": "ダウンロード中...", "downloading": "ダウンロード中...",
"customModelDescription": "公式サポート対象外",
"downloadFailed": "ダウンロードに失敗しました。もう一度お試しください。", "downloadFailed": "ダウンロードに失敗しました。もう一度お試しください。",
"modelCard": { "modelCard": {
"accuracy": "精度", "accuracy": "精度",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "カスタム",
"active": "アクティブ", "active": "アクティブ",
"switching": "切替中...", "switching": "切替中...",
"noModelsAvailable": "利用可能なモデルがありません", "noModelsAvailable": "利用可能なモデルがありません",

View file

@ -20,6 +20,7 @@
"recommended": "추천", "recommended": "추천",
"download": "다운로드", "download": "다운로드",
"downloading": "다운로드 중...", "downloading": "다운로드 중...",
"customModelDescription": "공식 지원되지 않음",
"downloadFailed": "다운로드에 실패했습니다. 다시 시도해주세요.", "downloadFailed": "다운로드에 실패했습니다. 다시 시도해주세요.",
"modelCard": { "modelCard": {
"accuracy": "정확도", "accuracy": "정확도",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "사용자 정의",
"active": "활성", "active": "활성",
"switching": "전환 중...", "switching": "전환 중...",
"noModelsAvailable": "사용 가능한 모델이 없습니다", "noModelsAvailable": "사용 가능한 모델이 없습니다",

View file

@ -20,6 +20,7 @@
"recommended": "Polecane", "recommended": "Polecane",
"download": "Pobierz", "download": "Pobierz",
"downloading": "Pobieranie...", "downloading": "Pobieranie...",
"customModelDescription": "Nieoficjalnie wspierane",
"downloadFailed": "Pobieranie nie powiodło się. Spróbuj ponownie.", "downloadFailed": "Pobieranie nie powiodło się. Spróbuj ponownie.",
"modelCard": { "modelCard": {
"accuracy": "dokładność", "accuracy": "dokładność",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Własny",
"active": "Aktywny", "active": "Aktywny",
"switching": "Przełączanie...", "switching": "Przełączanie...",
"noModelsAvailable": "Brak dostępnych modeli", "noModelsAvailable": "Brak dostępnych modeli",

View file

@ -20,6 +20,7 @@
"recommended": "Recomendado", "recommended": "Recomendado",
"download": "Baixar", "download": "Baixar",
"downloading": "Baixando...", "downloading": "Baixando...",
"customModelDescription": "Não suportado oficialmente",
"downloadFailed": "Falha no download. Por favor, tente novamente.", "downloadFailed": "Falha no download. Por favor, tente novamente.",
"modelCard": { "modelCard": {
"accuracy": "precisão", "accuracy": "precisão",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Personalizado",
"active": "Ativo", "active": "Ativo",
"noModelsAvailable": "Nenhum modelo disponível", "noModelsAvailable": "Nenhum modelo disponível",
"extracting": "Extraindo {{modelName}}...", "extracting": "Extraindo {{modelName}}...",

View file

@ -20,6 +20,7 @@
"recommended": "Рекомендуется", "recommended": "Рекомендуется",
"download": "Скачать", "download": "Скачать",
"downloading": "Загрузка...", "downloading": "Загрузка...",
"customModelDescription": "Официально не поддерживается",
"downloadFailed": "Загрузка не удалась. Пожалуйста, попробуйте снова.", "downloadFailed": "Загрузка не удалась. Пожалуйста, попробуйте снова.",
"modelCard": { "modelCard": {
"accuracy": "точность", "accuracy": "точность",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Пользовательская",
"active": "Активный", "active": "Активный",
"switching": "Переключение...", "switching": "Переключение...",
"noModelsAvailable": "Нет доступных моделей", "noModelsAvailable": "Нет доступных моделей",

View file

@ -20,6 +20,7 @@
"recommended": "Önerilen", "recommended": "Önerilen",
"download": "İndir", "download": "İndir",
"downloading": "İndiriliyor...", "downloading": "İndiriliyor...",
"customModelDescription": "Resmi olarak desteklenmiyor",
"downloadFailed": "İndirme başarısız oldu. Lütfen tekrar deneyin.", "downloadFailed": "İndirme başarısız oldu. Lütfen tekrar deneyin.",
"modelCard": { "modelCard": {
"accuracy": "doğruluk", "accuracy": "doğruluk",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Özel",
"active": "Aktif", "active": "Aktif",
"noModelsAvailable": "Kullanılabilir model yok", "noModelsAvailable": "Kullanılabilir model yok",
"extracting": "{{modelName}} çıkarılıyor...", "extracting": "{{modelName}} çıkarılıyor...",

View file

@ -20,6 +20,7 @@
"recommended": "Рекомендовано", "recommended": "Рекомендовано",
"download": "Завантажити", "download": "Завантажити",
"downloading": "Завантаження...", "downloading": "Завантаження...",
"customModelDescription": "Офіційно не підтримується",
"downloadFailed": "Завантаження не вдалося. Будь ласка, спробуйте ще раз.", "downloadFailed": "Завантаження не вдалося. Будь ласка, спробуйте ще раз.",
"modelCard": { "modelCard": {
"accuracy": "точність", "accuracy": "точність",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Користувацька",
"active": "Активна", "active": "Активна",
"noModelsAvailable": "Немає доступних моделей", "noModelsAvailable": "Немає доступних моделей",
"extracting": "Розпакування {{modelName}}...", "extracting": "Розпакування {{modelName}}...",

View file

@ -20,6 +20,7 @@
"recommended": "Đề xuất", "recommended": "Đề xuất",
"download": "Tải xuống", "download": "Tải xuống",
"downloading": "Đang 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.", "downloadFailed": "Tải xuống thất bại. Vui lòng thử lại.",
"modelCard": { "modelCard": {
"accuracy": "độ chính xác", "accuracy": "độ chính xác",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "Tùy chỉnh",
"active": "Đang hoạt động", "active": "Đang hoạt động",
"switching": "Đang chuyển...", "switching": "Đang chuyển...",
"noModelsAvailable": "Không có mô hình nào", "noModelsAvailable": "Không có mô hình nào",

View file

@ -20,6 +20,7 @@
"recommended": "推荐", "recommended": "推荐",
"download": "下载", "download": "下载",
"downloading": "下载中...", "downloading": "下载中...",
"customModelDescription": "非官方支持",
"downloadFailed": "下载失败。请重试。", "downloadFailed": "下载失败。请重试。",
"modelCard": { "modelCard": {
"accuracy": "准确度", "accuracy": "准确度",
@ -81,6 +82,7 @@
} }
}, },
"modelSelector": { "modelSelector": {
"custom": "自定义",
"active": "活跃", "active": "活跃",
"switching": "切换中...", "switching": "切换中...",
"noModelsAvailable": "没有可用的模型", "noModelsAvailable": "没有可用的模型",

View file

@ -23,6 +23,10 @@ export function getTranslatedModelDescription(
model: ModelInfo, model: ModelInfo,
t: TFunction, t: TFunction,
): string { ): string {
// Custom models use a generic translation key
if (model.is_custom) {
return t("onboarding.customModelDescription");
}
const translationKey = `onboarding.models.${model.id}.description`; const translationKey = `onboarding.models.${model.id}.description`;
const translated = t(translationKey, { defaultValue: "" }); const translated = t(translationKey, { defaultValue: "" });
return translated !== "" ? translated : model.description; return translated !== "" ? translated : model.description;