diff --git a/README.md b/README.md index 992ccddf..264d9ded 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d4427aff..c7c52801 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2490,6 +2490,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 39bbcf53..db7f7473 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 852b341f..28fbcfa2 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,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); + + // 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 or selected model is empty + // 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, + ) -> 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: "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); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index ff3846d3..cc6a3368 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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" diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index f2d57017..17abc15d 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 f28f52ce..9ae5fdf8 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 + // 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 = () => {
{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) => ( + + ))} +
{/* Available Models Section */} {availableModels.length > 0 && ( diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 92ab7cca..18bbe145 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}}", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 239974fa..4f671296 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}}...", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index b51d9828..4085e5c3 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", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 1a1a10c5..58d842d1 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -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:", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 353fe637..6ae8f86b 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", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index c21a05be..826412d5 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", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index c7885a50..ae8fae49 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", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 59211590..503b77ba 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": "利用可能なモデルがありません", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 73397c83..aa18e95f 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": "사용 가능한 모델이 없습니다", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 9c540520..8c89e4be 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", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 950f6ab9..887917f9 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}}...", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 9132e9bd..ec71884d 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": "Нет доступных моделей", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index d0d587cf..192745fb 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...", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 0af4137a..ad040e0b 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}}...", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 81f07775..c2a96996 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", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 5c16a4ad..a30babdc 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": "没有可用的模型", diff --git a/src/lib/utils/modelTranslation.ts b/src/lib/utils/modelTranslation.ts index e5536315..0a417b37 100644 --- a/src/lib/utils/modelTranslation.ts +++ b/src/lib/utils/modelTranslation.ts @@ -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;