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:
parent
3eb370df80
commit
a4999164ae
25 changed files with 480 additions and 152 deletions
17
README.md
17
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)
|
||||
|
|
|
|||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -2490,6 +2490,7 @@ dependencies = [
|
|||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"transcribe-rs",
|
||||
"vad-rs",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"recommended": "موصى به",
|
||||
"download": "تنزيل",
|
||||
"downloading": "...جاري التنزيل",
|
||||
"customModelDescription": "غير مدعوم رسميًا",
|
||||
"downloadFailed": "فشل التنزيل. يرجى المحاولة مرة أخرى.",
|
||||
"modelCard": {
|
||||
"accuracy": "الدقة",
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
"custom": "مخصص",
|
||||
"active": "نشط",
|
||||
"noModelsAvailable": "لا توجد نماذج متاحة",
|
||||
"extracting": "...جاري استخراج {{modelName}}",
|
||||
|
|
|
|||
|
|
@ -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}}...",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"recommended": "おすすめ",
|
||||
"download": "ダウンロード",
|
||||
"downloading": "ダウンロード中...",
|
||||
"customModelDescription": "公式サポート対象外",
|
||||
"downloadFailed": "ダウンロードに失敗しました。もう一度お試しください。",
|
||||
"modelCard": {
|
||||
"accuracy": "精度",
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
"custom": "カスタム",
|
||||
"active": "アクティブ",
|
||||
"switching": "切替中...",
|
||||
"noModelsAvailable": "利用可能なモデルがありません",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"recommended": "추천",
|
||||
"download": "다운로드",
|
||||
"downloading": "다운로드 중...",
|
||||
"customModelDescription": "공식 지원되지 않음",
|
||||
"downloadFailed": "다운로드에 실패했습니다. 다시 시도해주세요.",
|
||||
"modelCard": {
|
||||
"accuracy": "정확도",
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
"custom": "사용자 정의",
|
||||
"active": "활성",
|
||||
"switching": "전환 중...",
|
||||
"noModelsAvailable": "사용 가능한 모델이 없습니다",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}...",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"recommended": "Рекомендуется",
|
||||
"download": "Скачать",
|
||||
"downloading": "Загрузка...",
|
||||
"customModelDescription": "Официально не поддерживается",
|
||||
"downloadFailed": "Загрузка не удалась. Пожалуйста, попробуйте снова.",
|
||||
"modelCard": {
|
||||
"accuracy": "точность",
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
"custom": "Пользовательская",
|
||||
"active": "Активный",
|
||||
"switching": "Переключение...",
|
||||
"noModelsAvailable": "Нет доступных моделей",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"recommended": "Рекомендовано",
|
||||
"download": "Завантажити",
|
||||
"downloading": "Завантаження...",
|
||||
"customModelDescription": "Офіційно не підтримується",
|
||||
"downloadFailed": "Завантаження не вдалося. Будь ласка, спробуйте ще раз.",
|
||||
"modelCard": {
|
||||
"accuracy": "точність",
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
"custom": "Користувацька",
|
||||
"active": "Активна",
|
||||
"noModelsAvailable": "Немає доступних моделей",
|
||||
"extracting": "Розпакування {{modelName}}...",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"recommended": "推荐",
|
||||
"download": "下载",
|
||||
"downloading": "下载中...",
|
||||
"customModelDescription": "非官方支持",
|
||||
"downloadFailed": "下载失败。请重试。",
|
||||
"modelCard": {
|
||||
"accuracy": "准确度",
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
"custom": "自定义",
|
||||
"active": "活跃",
|
||||
"switching": "切换中...",
|
||||
"noModelsAvailable": "没有可用的模型",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue