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"
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
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-updater",
"tauri-specta",
"tempfile",
"tokio",
"transcribe-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 = "0.18"
[dev-dependencies]
tempfile = "3"
[profile.release]
lto = true
codegen-units = 1

View file

@ -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<String>, // 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,9 +285,15 @@ impl ModelManager {
supports_translation: false,
is_recommended: false,
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 {
app_handle: app_handle.clone(),
models_dir,
@ -390,10 +404,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();
@ -415,6 +445,125 @@ 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: &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<()> {
let model_info = {
let models = self.available_models.lock().unwrap();
@ -817,9 +966,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);
@ -904,3 +1061,108 @@ 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,
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 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"

View file

@ -50,6 +50,11 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
<div>
<div className="text-sm text-text/80">
{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 className="text-xs text-text/40 italic pe-4">
{getTranslatedModelDescription(model, t)}

View file

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

View file

@ -159,31 +159,36 @@ export const ModelsSettings: React.FC = () => {
});
}, [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 downloaded: ModelInfo[] = [];
const available: ModelInfo[] = [];
for (const model of filteredModels) {
const isDownloaded =
if (
model.is_custom ||
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, then non-custom, then custom at the bottom
downloaded.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, availableModels: available };
return {
downloadedModels: downloaded,
availableModels: available,
};
}, [filteredModels, downloadingModels, extractingModels, currentModel]);
if (loading) {
@ -208,124 +213,120 @@ export const ModelsSettings: React.FC = () => {
</div>
{filteredModels.length > 0 ? (
<div className="space-y-6">
{/* Downloaded Models Section */}
{downloadedModels.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-text/60">
{t("settings.models.yourModels")}
</h2>
{/* Language filter dropdown */}
<div className="relative" ref={languageDropdownRef}>
<button
type="button"
onClick={() =>
setLanguageDropdownOpen(!languageDropdownOpen)
}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
languageFilter !== "all"
? "bg-logo-primary/20 text-logo-primary"
: "bg-mid-gray/10 text-text/60 hover:bg-mid-gray/20"
{/* Downloaded Models Section — header always visible so filter stays accessible */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-text/60">
{t("settings.models.yourModels")}
</h2>
{/* Language filter dropdown */}
<div className="relative" ref={languageDropdownRef}>
<button
type="button"
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
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" />
<span className="max-w-[120px] truncate">
{selectedLanguageLabel}
</span>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${
languageDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
/>
</button>
{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="p-2 border-b border-mid-gray/40">
<input
ref={languageSearchInputRef}
type="text"
value={languageSearch}
onChange={(e) => 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"
/>
</div>
<div className="max-h-48 overflow-y-auto">
{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="p-2 border-b border-mid-gray/40">
<input
ref={languageSearchInputRef}
type="text"
value={languageSearch}
onChange={(e) => 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"
/>
</div>
<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
key={lang.value}
type="button"
onClick={() => {
setLanguageFilter("all");
setLanguageFilter(lang.value);
setLanguageDropdownOpen(false);
setLanguageSearch("");
}}
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"
: "hover:bg-mid-gray/10"
}`}
>
{t("settings.models.filters.allLanguages")}
{lang.label}
</button>
{filteredLanguages.map((lang) => (
<button
key={lang.value}
type="button"
onClick={() => {
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>
))}
{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>
{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>
)}
{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 */}
{availableModels.length > 0 && (

View file

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

View file

@ -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}}...",

View file

@ -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",

View file

@ -20,6 +20,7 @@
"recommended": "Recommended",
"download": "Download",
"downloading": "Downloading...",
"customModelDescription": "Not officially supported",
"downloadFailed": "Download failed. Please try again.",
"modelCard": {
"accuracy": "accuracy",
@ -81,6 +82,7 @@
}
},
"modelSelector": {
"custom": "Custom",
"active": "Active",
"switching": "Switching...",
"noModelsAvailable": "No models available",
@ -405,10 +407,6 @@
"description": "Choose the keyboard shortcut backend.",
"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": {
"appData": "App Data:",
"models": "Models:",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

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

View file

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

View file

@ -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",

View file

@ -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}}...",

View file

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

View file

@ -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...",

View file

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

View file

@ -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",

View file

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

View file

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