From eda1545c7ff97607eb34c65e58ae209e12e7b51c Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sat, 20 Dec 2025 17:18:00 +0530 Subject: [PATCH 01/26] feat: add models settings page with filtering and management - add dedicated models settings page in sidebar - reuse ModelCard component from onboarding for consistent UI - add filter buttons for all/multi-language/translation models - add model deletion with native confirmation dialog (tauri-plugin-dialog) - consolidate model event listeners into useModels hook (Zustand store) - remove duplicate event listeners from ModelSelector, LanguageSelector, TranslateToEnglish - gate AccessibilityPermissions to macOS only - add is_recommended field to model registry for featured models - remove unused get_recommended_first_model command - add translations for all 9 supported languages --- bun.lock | 3 + package.json | 5 +- src-tauri/Cargo.lock | 89 ++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/commands/models.rs | 7 - src-tauri/src/lib.rs | 49 +-- src-tauri/src/managers/model.rs | 24 ++ src/bindings.ts | 10 +- src/components/AccessibilityPermissions.tsx | 13 +- src/components/Sidebar.tsx | 9 +- .../model-selector/ModelDropdown.tsx | 18 +- src/components/onboarding/ModelCard.tsx | 250 ++++++++++++---- src/components/onboarding/Onboarding.tsx | 54 ++-- src/components/onboarding/index.ts | 2 + .../settings/advanced/AdvancedSettings.tsx | 2 +- .../settings/general/GeneralSettings.tsx | 2 + .../settings/general/ModelSettingsCard.tsx | 46 +++ src/components/settings/index.ts | 1 + .../settings/models/ModelsSettings.tsx | 186 ++++++++++++ src/components/settings/models/index.ts | 1 + src/components/ui/Badge.tsx | 4 +- src/i18n/locales/de/translation.json | 29 +- src/i18n/locales/en/translation.json | 29 +- src/i18n/locales/es/translation.json | 29 +- src/i18n/locales/fr/translation.json | 29 +- src/i18n/locales/it/translation.json | 29 +- src/i18n/locales/ja/translation.json | 29 +- src/i18n/locales/pl/translation.json | 29 +- src/i18n/locales/ru/translation.json | 29 +- src/i18n/locales/vi/translation.json | 29 +- src/i18n/locales/zh/translation.json | 29 +- src/stores/modelStore.ts | 282 +++++++++++------- 33 files changed, 1090 insertions(+), 259 deletions(-) create mode 100644 src/components/settings/general/ModelSettingsCard.tsx create mode 100644 src/components/settings/models/ModelsSettings.tsx create mode 100644 src/components/settings/models/index.ts diff --git a/bun.lock b/bun.lock index c0f3202d2..c45196b0f 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@tauri-apps/api": "^2.9.0", "@tauri-apps/plugin-autostart": "~2.5.1", "@tauri-apps/plugin-clipboard-manager": "~2.3.2", + "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-fs": "~2.4.4", "@tauri-apps/plugin-global-shortcut": "~2.3.1", "@tauri-apps/plugin-opener": "^2.5.2", @@ -313,6 +314,8 @@ "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="], + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ=="], "@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g=="], diff --git a/package.json b/package.json index f039567ab..55359d18c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@tauri-apps/api": "^2.9.0", "@tauri-apps/plugin-autostart": "~2.5.1", "@tauri-apps/plugin-clipboard-manager": "~2.3.2", + "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-fs": "~2.4.4", "@tauri-apps/plugin-global-shortcut": "~2.3.1", "@tauri-apps/plugin-opener": "^2.5.2", @@ -30,16 +31,16 @@ "@tauri-apps/plugin-sql": "~2.3.1", "@tauri-apps/plugin-store": "~2.4.1", "@tauri-apps/plugin-updater": "~2.9.0", - "react-select": "^5.8.0", - "tauri-plugin-macos-permissions-api": "2.3.0", "i18next": "^25.7.2", "immer": "^11.1.3", "lucide-react": "^0.542.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.4.1", + "react-select": "^5.8.0", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", + "tauri-plugin-macos-permissions-api": "2.3.0", "zod": "^3.25.76", "zustand": "^5.0.8" }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f372c45a..881ae30c8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -189,6 +189,27 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1401,6 +1422,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2 0.6.2", + "libc", "objc2 0.6.3", ] @@ -1415,6 +1438,15 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading 0.8.9", +] + [[package]] name = "dlopen2" version = "0.8.0" @@ -2446,6 +2478,7 @@ dependencies = [ "tauri-nspanel", "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-global-shortcut", "tauri-plugin-log", @@ -4991,6 +5024,31 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -5299,6 +5357,12 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -6308,6 +6372,24 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.4" @@ -6778,8 +6860,10 @@ dependencies = [ "libc", "mio 1.1.0", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -7467,6 +7551,7 @@ dependencies = [ "cc", "downcast-rs", "rustix 1.1.2", + "scoped-tls", "smallvec 1.15.1", "wayland-sys", ] @@ -7525,6 +7610,8 @@ version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ + "dlib", + "log", "pkg-config", ] @@ -8530,6 +8617,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -8683,6 +8771,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.13", "zvariant_derive", "zvariant_utils", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0b7bb0cd2..a638de72d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,6 +74,7 @@ ferrous-opencc = "0.2.3" specta = "=2.0.0-rc.22" specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } +tauri-plugin-dialog = "2" [target.'cfg(unix)'.dependencies] signal-hook = "0.3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 84974d5c3..d051e5470 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "store:default", "updater:default", "process:default", + "dialog:default", "global-shortcut:allow-is-registered", "global-shortcut:allow-register", "global-shortcut:allow-unregister", diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index b2e30b658..26fc00a42 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -128,10 +128,3 @@ pub async fn cancel_download( .cancel_download(&model_id) .map_err(|e| e.to_string()) } - -#[tauri::command] -#[specta::specta] -pub async fn get_recommended_first_model() -> Result { - // Recommend Parakeet V3 model for first-time users - fastest and most accurate - Ok("parakeet-tdt-0.6b-v3".to_string()) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c80a8b98f..c45adf9fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -299,7 +299,6 @@ pub fn run() { commands::models::is_model_loading, commands::models::has_any_models_available, commands::models::has_any_models_or_downloads, - commands::models::get_recommended_first_model, commands::audio::update_microphone_mode, commands::audio::get_microphone_mode, commands::audio::get_available_microphones, @@ -333,29 +332,31 @@ pub fn run() { ) .expect("Failed to export typescript bindings"); - let mut builder = tauri::Builder::default().plugin( - LogBuilder::new() - .level(log::LevelFilter::Trace) // Set to most verbose level globally - .max_file_size(500_000) - .rotation_strategy(RotationStrategy::KeepOne) - .clear_targets() - .targets([ - // Console output respects RUST_LOG environment variable - Target::new(TargetKind::Stdout).filter({ - let console_filter = console_filter.clone(); - move |metadata| console_filter.enabled(metadata) - }), - // File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic) - Target::new(TargetKind::LogDir { - file_name: Some("handy".into()), - }) - .filter(|metadata| { - let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed); - metadata.level() <= level_filter_from_u8(file_level) - }), - ]) - .build(), - ); + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin( + LogBuilder::new() + .level(log::LevelFilter::Trace) // Set to most verbose level globally + .max_file_size(500_000) + .rotation_strategy(RotationStrategy::KeepOne) + .clear_targets() + .targets([ + // Console output respects RUST_LOG environment variable + Target::new(TargetKind::Stdout).filter({ + let console_filter = console_filter.clone(); + move |metadata| console_filter.enabled(metadata) + }), + // File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic) + Target::new(TargetKind::LogDir { + file_name: Some("handy".into()), + }) + .filter(|metadata| { + let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed); + metadata.level() <= level_filter_from_u8(file_level) + }), + ]) + .build(), + ); #[cfg(target_os = "macos")] { diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index d64dab519..ef90b59a9 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -36,6 +36,9 @@ pub struct ModelInfo { pub engine_type: EngineType, pub accuracy_score: f32, // 0.0 to 1.0, higher is more accurate pub speed_score: f32, // 0.0 to 1.0, higher is faster + pub supports_language_selection: bool, // Whether the model supports selecting input language + pub supports_translation: bool, // Whether the model supports translating to English + pub is_recommended: bool, // Whether this is the recommended model for new users } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -84,6 +87,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.60, speed_score: 0.85, + supports_language_selection: true, + supports_translation: true, + is_recommended: false, }, ); @@ -104,6 +110,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.75, speed_score: 0.60, + supports_language_selection: true, + supports_translation: true, + is_recommended: false, }, ); @@ -123,6 +132,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.80, speed_score: 0.40, + supports_language_selection: true, + supports_translation: false, // Turbo doesn't support translation + is_recommended: false, }, ); @@ -142,6 +154,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.85, speed_score: 0.30, + supports_language_selection: true, + supports_translation: true, + is_recommended: false, }, ); @@ -162,6 +177,9 @@ impl ModelManager { engine_type: EngineType::Parakeet, accuracy_score: 0.85, speed_score: 0.85, + supports_language_selection: false, // Parakeet is English-only + supports_translation: false, // Parakeet doesn't support translation + is_recommended: false, }, ); @@ -181,6 +199,9 @@ impl ModelManager { engine_type: EngineType::Parakeet, accuracy_score: 0.80, speed_score: 0.85, + supports_language_selection: false, // Parakeet is English-only + supports_translation: false, // Parakeet doesn't support translation + is_recommended: true, // Recommended for new users }, ); @@ -663,6 +684,9 @@ impl ModelManager { self.update_download_status()?; debug!("ModelManager: download status updated"); + // Emit event to notify UI + let _ = self.app_handle.emit("model-deleted", model_id); + Ok(()) } diff --git a/src/bindings.ts b/src/bindings.ts index 325dc7f77..9e2e228fb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -513,14 +513,6 @@ async hasAnyModelsOrDownloads() : Promise> { else return { status: "error", error: e as any }; } }, -async getRecommendedFirstModel() : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("get_recommended_first_model") }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, async updateMicrophoneMode(alwaysOn: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("update_microphone_mode", { alwaysOn }) }; @@ -721,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 } +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_language_selection: boolean; supports_translation: boolean; is_recommended: 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/AccessibilityPermissions.tsx b/src/components/AccessibilityPermissions.tsx index 6454c8ccf..17bbcd1c4 100644 --- a/src/components/AccessibilityPermissions.tsx +++ b/src/components/AccessibilityPermissions.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { type } from "@tauri-apps/plugin-os"; import { checkAccessibilityPermission, requestAccessibilityPermission, @@ -20,6 +21,9 @@ const AccessibilityPermissions: React.FC = () => { const [permissionState, setPermissionState] = useState("request"); + // Accessibility permissions are only required on macOS + const isMacOS = type() === "macos"; + // Check permissions without requesting const checkPermissions = async (): Promise => { const hasPermissions: boolean = await checkAccessibilityPermission(); @@ -45,8 +49,10 @@ const AccessibilityPermissions: React.FC = () => { } }; - // On app boot - check permissions + // On app boot - check permissions (only on macOS) useEffect(() => { + if (!isMacOS) return; + const initialSetup = async (): Promise => { const hasPermissions: boolean = await checkAccessibilityPermission(); setHasAccessibility(hasPermissions); @@ -54,9 +60,10 @@ const AccessibilityPermissions: React.FC = () => { }; initialSetup(); - }, []); + }, [isMacOS]); - if (hasAccessibility) { + // Skip rendering on non-macOS platforms or if permission is already granted + if (!isMacOS || hasAccessibility) { return null; } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2522c16af..0d7211da8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react"; +import { Cog, FlaskConical, History, Info, Sparkles, Cpu } from "lucide-react"; import HandyTextLogo from "./icons/HandyTextLogo"; import HandyHand from "./icons/HandyHand"; import { useSettings } from "../hooks/useSettings"; @@ -11,6 +11,7 @@ import { DebugSettings, AboutSettings, PostProcessingSettings, + ModelsSettings, } from "./settings"; export type SidebarSection = keyof typeof SECTIONS_CONFIG; @@ -37,6 +38,12 @@ export const SECTIONS_CONFIG = { component: GeneralSettings, enabled: () => true, }, + models: { + labelKey: "sidebar.models", + icon: Cpu, + component: ModelsSettings, + enabled: () => true, + }, advanced: { labelKey: "sidebar.advanced", icon: Cog, diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index 4a2003d63..b0f82fb60 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { ask } from "@tauri-apps/plugin-dialog"; import type { ModelInfo } from "@/bindings"; import { formatModelSize } from "../../lib/utils/format"; import { @@ -43,6 +44,19 @@ const ModelDropdown: React.FC = ({ e.preventDefault(); e.stopPropagation(); + const model = models.find((m) => m.id === modelId); + const modelName = model?.name || modelId; + + const confirmed = await ask( + t("settings.models.deleteConfirm", { modelName }), + { + title: t("settings.models.deleteTitle"), + kind: "warning", + }, + ); + + if (!confirmed) return; + try { await onModelDelete(modelId); } catch (err) { @@ -184,8 +198,8 @@ const ModelDropdown: React.FC = ({
{getTranslatedModelName(model, t)} - {model.id === "parakeet-tdt-0.6b-v3" && isFirstRun && ( - + {model.is_recommended && isFirstRun && ( + {t("onboarding.recommended")} )} diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 5994534ea..98f6e2eb7 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -1,109 +1,247 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Download } from "lucide-react"; +import { + Check, + Download, + Globe, + Languages, + Loader2, + Trash2, +} from "lucide-react"; import type { ModelInfo } from "@/bindings"; import { formatModelSize } from "../../lib/utils/format"; import { - getTranslatedModelName, getTranslatedModelDescription, + getTranslatedModelName, } from "../../lib/utils/modelTranslation"; import Badge from "../ui/Badge"; +export type ModelCardStatus = + | "downloadable" + | "downloading" + | "extracting" + | "switching" + | "active" + | "available"; + interface ModelCardProps { model: ModelInfo; variant?: "default" | "featured"; + status?: ModelCardStatus; disabled?: boolean; className?: string; onSelect: (modelId: string) => void; + onDownload?: (modelId: string) => void; + onDelete?: (modelId: string) => void; + downloadProgress?: number; + downloadSpeed?: number; // MB/s } const ModelCard: React.FC = ({ model, variant = "default", + status = "downloadable", disabled = false, className = "", onSelect, + onDownload, + onDelete, + downloadProgress, + downloadSpeed, }) => { const { t } = useTranslation(); const isFeatured = variant === "featured"; + // Card is clickable if model is available/active, OR if downloadable without explicit download button + const canSelect = + status === "available" || + status === "active" || + (status === "downloadable" && !onDownload); // Get translated model name and description const displayName = getTranslatedModelName(model, t); const displayDescription = getTranslatedModelDescription(model, t); - const baseButtonClasses = - "flex justify-between items-center rounded-xl p-3 px-4 text-start transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] cursor-pointer group"; + const baseClasses = + "flex justify-between items-center rounded-xl p-4 text-left transition-all duration-200"; + + const getVariantClasses = () => { + if (status === "active") { + return "border-2 border-logo-primary/50 bg-logo-primary/10"; + } + if (isFeatured) { + return "border-2 border-logo-primary/25 bg-logo-primary/5"; + } + return "border-2 border-mid-gray/20"; + }; + + const getInteractiveClasses = () => { + if (!canSelect) return ""; + if (disabled) return "opacity-50 cursor-not-allowed"; + return "cursor-pointer hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.01] active:scale-[0.99] group"; + }; + + const handleClick = () => { + if (canSelect && !disabled) { + onSelect(model.id); + } + }; - const variantClasses = isFeatured - ? "border-2 border-logo-primary/25 bg-logo-primary/5 hover:border-logo-primary/40 hover:bg-logo-primary/8 hover:shadow-lg hover:scale-[1.02] disabled:hover:border-logo-primary/25 disabled:hover:bg-logo-primary/5 disabled:hover:shadow-none disabled:hover:scale-100" - : "border-2 border-mid-gray/20 hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.02] disabled:hover:border-mid-gray/20 disabled:hover:bg-transparent disabled:hover:shadow-none disabled:hover:scale-100"; + const handleDownload = (e: React.MouseEvent) => { + e.stopPropagation(); + onDownload?.(model.id); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(model.id); + }; return ( - + )} + {onDelete && status === "available" && ( + + )}
- - ); -}; - -const DownloadSize = ({ sizeMb }: { sizeMb: number }) => { - const { t } = useTranslation(); - - return ( -
-
); }; diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx index f88a9bd33..9731c7e53 100644 --- a/src/components/onboarding/Onboarding.tsx +++ b/src/components/onboarding/Onboarding.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { commands, type ModelInfo } from "@/bindings"; +import type { ModelInfo } from "@/bindings"; import ModelCard from "./ModelCard"; import HandyTextLogo from "../icons/HandyTextLogo"; +import { useModels } from "../../hooks/useModels"; interface OnboardingProps { onModelSelected: () => void; @@ -10,52 +11,33 @@ interface OnboardingProps { const Onboarding: React.FC = ({ onModelSelected }) => { const { t } = useTranslation(); - const [availableModels, setAvailableModels] = useState([]); + const { models, downloadModel, error: modelError } = useModels(); const [downloading, setDownloading] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - loadModels(); - }, []); - - const loadModels = async () => { - try { - const result = await commands.getAvailableModels(); - if (result.status === "ok") { - // Only show downloadable models for onboarding - setAvailableModels(result.data.filter((m) => !m.is_downloaded)); - } else { - setError(t("onboarding.errors.loadModels")); - } - } catch (err) { - console.error("Failed to load models:", err); - setError(t("onboarding.errors.loadModels")); - } - }; + // Only show downloadable models for onboarding + const availableModels = models.filter((m) => !m.is_downloaded); const handleDownloadModel = async (modelId: string) => { setDownloading(true); setError(null); + // Start the download (updates Zustand store) + const downloadPromise = downloadModel(modelId); + // Immediately transition to main app - download will continue in footer onModelSelected(); - try { - const result = await commands.downloadModel(modelId); - if (result.status === "error") { - console.error("Download failed:", result.error); - setError(t("onboarding.errors.downloadModel", { error: result.error })); - setDownloading(false); - } - } catch (err) { + // Note: We don't await or handle the result here since the component + // will unmount. The Zustand store handles download state, and any errors + // will be visible in the main app's ModelSelector. + downloadPromise.catch((err) => { console.error("Download failed:", err); - setError(t("onboarding.errors.downloadModel", { error: String(err) })); - setDownloading(false); - } + }); }; - const getRecommendedBadge = (modelId: string): boolean => { - return modelId === "parakeet-tdt-0.6b-v3"; + const isRecommendedModel = (model: ModelInfo): boolean => { + return model.is_recommended; }; return ( @@ -76,7 +58,7 @@ const Onboarding: React.FC = ({ onModelSelected }) => {
{availableModels - .filter((model) => getRecommendedBadge(model.id)) + .filter((model) => isRecommendedModel(model)) .map((model) => ( = ({ onModelSelected }) => { ))} {availableModels - .filter((model) => !getRecommendedBadge(model.id)) + .filter((model) => !isRecommendedModel(model)) .sort((a, b) => Number(a.size_mb) - Number(b.size_mb)) .map((model) => ( { const { t } = useTranslation(); diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index ca0f56ebc..61056592d 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -11,6 +11,7 @@ import { useSettings } from "../../../hooks/useSettings"; import { useModelStore } from "../../../stores/modelStore"; import { VolumeSlider } from "../VolumeSlider"; import { MuteWhileRecording } from "../MuteWhileRecording"; +import { ModelSettingsCard } from "./ModelSettingsCard"; export const GeneralSettings: React.FC = () => { const { t } = useTranslation(); @@ -27,6 +28,7 @@ export const GeneralSettings: React.FC = () => { )} + diff --git a/src/components/settings/general/ModelSettingsCard.tsx b/src/components/settings/general/ModelSettingsCard.tsx new file mode 100644 index 000000000..aed152ba9 --- /dev/null +++ b/src/components/settings/general/ModelSettingsCard.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsGroup } from "../../ui/SettingsGroup"; +import { LanguageSelector } from "../LanguageSelector"; +import { TranslateToEnglish } from "../TranslateToEnglish"; +import { useModels } from "../../../hooks/useModels"; + +export const ModelSettingsCard: React.FC = () => { + const { t } = useTranslation(); + const { currentModel, models } = useModels(); + + const currentModelInfo = models.find((m) => m.id === currentModel); + + const supportsLanguage = + currentModelInfo?.supports_language_selection ?? false; + const supportsTranslation = currentModelInfo?.supports_translation ?? false; + const hasAnySettings = supportsLanguage || supportsTranslation; + + // Don't render anything if no model is selected yet + if (!currentModel || !currentModelInfo) { + return null; + } + + return ( + + {hasAnySettings ? ( + <> + {supportsLanguage && ( + + )} + {supportsTranslation && ( + + )} + + ) : ( +
+ {t("settings.modelSettings.noSettingsNeeded")} +
+ )} +
+ ); +}; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index 830147999..3a6ac67cf 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -5,6 +5,7 @@ export { DebugSettings } from "./debug/DebugSettings"; export { HistorySettings } from "./history/HistorySettings"; export { AboutSettings } from "./about/AboutSettings"; export { PostProcessingSettings } from "./post-processing/PostProcessingSettings"; +export { ModelsSettings } from "./models/ModelsSettings"; // Individual setting components export { MicrophoneSelector } from "./MicrophoneSelector"; diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx new file mode 100644 index 000000000..4c4978471 --- /dev/null +++ b/src/components/settings/models/ModelsSettings.tsx @@ -0,0 +1,186 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ask } from "@tauri-apps/plugin-dialog"; +import { Globe, Languages } from "lucide-react"; +import type { ModelCardStatus } from "../../onboarding"; +import { ModelCard } from "../../onboarding"; +import { useModels } from "../../../hooks/useModels"; + +type ModelFilter = "all" | "multiLanguage" | "translation"; + +export const ModelsSettings: React.FC = () => { + const { t } = useTranslation(); + const [activeFilter, setActiveFilter] = useState("all"); + const [switchingModelId, setSwitchingModelId] = useState(null); + const { + models, + currentModel, + downloadingModels, + downloadProgress, + downloadStats, + extractingModels, + loading, + downloadModel, + selectModel, + deleteModel, + } = useModels(); + + const getModelStatus = (modelId: string): ModelCardStatus => { + if (extractingModels.has(modelId)) { + return "extracting"; + } + if (downloadingModels.has(modelId)) { + return "downloading"; + } + if (switchingModelId === modelId) { + return "switching"; + } + if (modelId === currentModel) { + return "active"; + } + const model = models.find((m) => m.id === modelId); + if (model?.is_downloaded) { + return "available"; + } + return "downloadable"; + }; + + const getDownloadProgress = (modelId: string): number | undefined => { + const progress = downloadProgress.get(modelId); + return progress?.percentage; + }; + + const getDownloadSpeed = (modelId: string): number | undefined => { + const stats = downloadStats.get(modelId); + return stats?.speed; + }; + + const handleModelSelect = async (modelId: string) => { + setSwitchingModelId(modelId); + try { + await selectModel(modelId); + } finally { + setSwitchingModelId(null); + } + }; + + const handleModelDownload = async (modelId: string) => { + await downloadModel(modelId); + }; + + const handleModelDelete = async (modelId: string) => { + const model = models.find((m) => m.id === modelId); + const modelName = model?.name || modelId; + + const confirmed = await ask( + t("settings.models.deleteConfirm", { modelName }), + { + title: t("settings.models.deleteTitle"), + kind: "warning", + }, + ); + + if (confirmed) { + try { + await deleteModel(modelId); + } catch (err) { + console.error(`Failed to delete model ${modelId}:`, err); + } + } + }; + + // Filter models based on active filter + const filteredModels = useMemo(() => { + return models.filter((model) => { + switch (activeFilter) { + case "multiLanguage": + return model.supports_language_selection; + case "translation": + return model.supports_translation; + default: + return true; + } + }); + }, [models, activeFilter]); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+
+

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

+

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

+
+
+ + + +
+ {filteredModels.length > 0 ? ( +
+ {filteredModels.map((model) => ( + + ))} +
+ ) : ( +
+ {t("settings.models.noModelsMatch")} +
+ )} +
+ ); +}; diff --git a/src/components/settings/models/index.ts b/src/components/settings/models/index.ts new file mode 100644 index 000000000..1de9a5030 --- /dev/null +++ b/src/components/settings/models/index.ts @@ -0,0 +1 @@ +export { ModelsSettings } from "./ModelsSettings"; diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 18aa9134a..d7a36734b 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -2,7 +2,7 @@ import React from "react"; interface BadgeProps { children: React.ReactNode; - variant?: "primary"; + variant?: "primary" | "success" | "secondary"; className?: string; } @@ -13,6 +13,8 @@ const Badge: React.FC = ({ }) => { const variantClasses = { primary: "bg-logo-primary", + success: "bg-green-500/20 text-green-400", + secondary: "bg-mid-gray/20 text-text/70", }; return ( diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index bb86826c5..80c2f9e7e 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "Allgemein", + "models": "Modelle", "advanced": "Erweitert", "postProcessing": "Nachbearbeitung", "history": "Verlauf", @@ -81,6 +82,7 @@ "downloadModels": "Modelle herunterladen", "chooseModel": "Modell auswählen", "active": "Aktiv", + "switching": "Wechseln...", "download": "Herunterladen", "downloadSize": "Downloadgröße", "noModelsAvailable": "Keine Modelle verfügbar", @@ -95,9 +97,34 @@ "modelError": "Modellfehler", "modelUnloaded": "Modell entladen", "noModelDownloadRequired": "Kein Modell - Download erforderlich", - "deleteModel": "{{modelName}} löschen" + "deleteModel": "{{modelName}} löschen", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "Unterstützt mehrere Eingabesprachen", + "multiLanguage": "Mehrsprachig", + "translation": "Kann ins Englische übersetzen", + "translate": "Ins Englische übersetzen" + } }, "settings": { + "modelSettings": { + "title": "{{model}}-Einstellungen", + "noSettingsNeeded": "Dieses Modell funktioniert automatisch ohne Konfiguration." + }, + "models": { + "title": "Transkriptionsmodelle", + "description": "Wähle ein Transkriptionsmodell aus oder lade zusätzliche Modelle herunter. Verschiedene Modelle bieten unterschiedliche Genauigkeits- und Geschwindigkeitsstufen.", + "downloaded": "Heruntergeladen", + "available": "Zum Download verfügbar", + "deleteConfirm": "Bist du sicher, dass du {{modelName}} löschen möchtest? Du musst es erneut herunterladen, um es zu verwenden.", + "deleteTitle": "Modell löschen", + "filters": { + "all": "Alle", + "multiLanguage": "Mehrsprachig", + "translation": "Übersetzung" + }, + "noModelsMatch": "Keine Modelle entsprechen diesem Filter." + }, "general": { "title": "Allgemein", "shortcut": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 8ad3740c5..a0d46b9f9 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "General", + "models": "Models", "advanced": "Advanced", "postProcessing": "Post Process", "history": "History", @@ -85,6 +86,7 @@ "downloadModels": "Download Models", "chooseModel": "Choose a Model", "active": "Active", + "switching": "Switching...", "download": "Download", "downloadSize": "Download size", "noModelsAvailable": "No models available", @@ -99,9 +101,20 @@ "modelError": "Model Error", "modelUnloaded": "Model Unloaded", "noModelDownloadRequired": "No Model - Download Required", - "deleteModel": "Delete {{modelName}}" + "deleteModel": "Delete {{modelName}}", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "Supports multiple input languages", + "multiLanguage": "Multi-language", + "translation": "Can translate to English", + "translate": "Translate to English" + } }, "settings": { + "modelSettings": { + "title": "{{model}} Settings", + "noSettingsNeeded": "This model works automatically with no configuration needed." + }, "general": { "title": "General", "shortcut": { @@ -144,6 +157,20 @@ "description": "Hold to record, release to stop" } }, + "models": { + "title": "Transcription Models", + "description": "Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.", + "downloaded": "Downloaded", + "available": "Available to Download", + "deleteConfirm": "Are you sure you want to delete {{modelName}}? You will need to download it again to use it.", + "deleteTitle": "Delete Model", + "filters": { + "all": "All", + "multiLanguage": "Multi-language", + "translation": "Translation" + }, + "noModelsMatch": "No models match this filter." + }, "sound": { "title": "Sound", "microphone": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 57ecbb80a..701d3d3c0 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "General", + "models": "Modelos", "advanced": "Avanzado", "postProcessing": "Post Proceso", "history": "Historial", @@ -81,6 +82,7 @@ "downloadModels": "Descargar Modelos", "chooseModel": "Elegir un Modelo", "active": "Activo", + "switching": "Cambiando...", "download": "Descargar", "downloadSize": "Tamaño de descarga", "noModelsAvailable": "No hay modelos disponibles", @@ -95,9 +97,34 @@ "modelError": "Error del Modelo", "modelUnloaded": "Modelo Descargado", "noModelDownloadRequired": "Sin Modelo - Descarga Requerida", - "deleteModel": "Eliminar {{modelName}}" + "deleteModel": "Eliminar {{modelName}}", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "Soporta múltiples idiomas de entrada", + "multiLanguage": "Multiidioma", + "translation": "Puede traducir al inglés", + "translate": "Traducir al inglés" + } }, "settings": { + "modelSettings": { + "title": "Configuración de {{model}}", + "noSettingsNeeded": "Este modelo funciona automáticamente sin necesidad de configuración." + }, + "models": { + "title": "Modelos de Transcripción", + "description": "Selecciona un modelo de transcripción o descarga modelos adicionales. Diferentes modelos ofrecen distintos niveles de precisión y velocidad.", + "downloaded": "Descargados", + "available": "Disponibles para Descargar", + "deleteConfirm": "¿Estás seguro de que deseas eliminar {{modelName}}? Necesitarás descargarlo de nuevo para usarlo.", + "deleteTitle": "Eliminar modelo", + "filters": { + "all": "Todos", + "multiLanguage": "Multiidioma", + "translation": "Traducción" + }, + "noModelsMatch": "Ningún modelo coincide con este filtro." + }, "general": { "title": "General", "shortcut": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 9290cacb7..6f79db0c1 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -9,6 +9,7 @@ }, "sidebar": { "general": "Général", + "models": "Modèles", "advanced": "Avancé", "postProcessing": "Post-traitement", "history": "Historique", @@ -82,6 +83,7 @@ "downloadModels": "Télécharger des Modèles", "chooseModel": "Choisir un Modèle", "active": "Actif", + "switching": "Changement...", "download": "Télécharger", "downloadSize": "Taille du téléchargement", "noModelsAvailable": "Aucun modèle disponible", @@ -96,9 +98,34 @@ "modelError": "Erreur du Modèle", "modelUnloaded": "Modèle Déchargé", "noModelDownloadRequired": "Aucun Modèle - Téléchargement Requis", - "deleteModel": "Supprimer {{modelName}}" + "deleteModel": "Supprimer {{modelName}}", + "downloadSpeed": "{{speed}} Mo/s", + "capabilities": { + "languageSelection": "Prend en charge plusieurs langues d'entrée", + "multiLanguage": "Multilingue", + "translation": "Peut traduire en anglais", + "translate": "Traduire en anglais" + } }, "settings": { + "modelSettings": { + "title": "Paramètres de {{model}}", + "noSettingsNeeded": "Ce modèle fonctionne automatiquement sans configuration nécessaire." + }, + "models": { + "title": "Modèles de Transcription", + "description": "Sélectionnez un modèle de transcription ou téléchargez des modèles supplémentaires. Différents modèles offrent différents niveaux de précision et de vitesse.", + "downloaded": "Téléchargés", + "available": "Disponibles au Téléchargement", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer {{modelName}} ? Vous devrez le télécharger à nouveau pour l'utiliser.", + "deleteTitle": "Supprimer le modèle", + "filters": { + "all": "Tous", + "multiLanguage": "Multilingue", + "translation": "Traduction" + }, + "noModelsMatch": "Aucun modèle ne correspond à ce filtre." + }, "general": { "title": "Général", "shortcut": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 1a362d82b..6b0233edb 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "Generale", + "models": "Modelli", "advanced": "Avanzate", "postProcessing": "Post-Elaborazione", "history": "Cronologia", @@ -81,6 +82,7 @@ "downloadModels": "Scarica Modelli", "chooseModel": "Scegli un Modello", "active": "Attivo", + "switching": "Cambio...", "download": "Scarica", "downloadSize": "Dimensione download", "noModelsAvailable": "Nessun modello disponibile", @@ -95,9 +97,34 @@ "modelError": "Errore del Modello", "modelUnloaded": "Modello Disattivato", "noModelDownloadRequired": "Nessun Modello - Download Richiesto", - "deleteModel": "Elimina {{modelName}}" + "deleteModel": "Elimina {{modelName}}", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "Supporta più lingue di input", + "multiLanguage": "Multilingua", + "translation": "Può tradurre in inglese", + "translate": "Traduci in inglese" + } }, "settings": { + "modelSettings": { + "title": "Impostazioni di {{model}}", + "noSettingsNeeded": "Questo modello funziona automaticamente senza bisogno di configurazione." + }, + "models": { + "title": "Modelli di Trascrizione", + "description": "Seleziona un modello di trascrizione o scarica modelli aggiuntivi. Diversi modelli offrono diversi livelli di accuratezza e velocità.", + "downloaded": "Scaricati", + "available": "Disponibili per il Download", + "deleteConfirm": "Sei sicuro di voler eliminare {{modelName}}? Dovrai scaricarlo di nuovo per usarlo.", + "deleteTitle": "Elimina modello", + "filters": { + "all": "Tutti", + "multiLanguage": "Multilingua", + "translation": "Traduzione" + }, + "noModelsMatch": "Nessun modello corrisponde a questo filtro." + }, "general": { "title": "Generale", "shortcut": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index dc57fc852..f585ff749 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "一般", + "models": "モデル", "advanced": "詳細設定", "postProcessing": "後処理", "history": "履歴", @@ -81,6 +82,7 @@ "downloadModels": "モデルをダウンロード", "chooseModel": "モデルを選択", "active": "アクティブ", + "switching": "切替中...", "download": "ダウンロード", "downloadSize": "ダウンロードサイズ", "noModelsAvailable": "利用可能なモデルがありません", @@ -95,9 +97,34 @@ "modelError": "モデルエラー", "modelUnloaded": "モデルがアンロードされました", "noModelDownloadRequired": "モデルなし - ダウンロードが必要", - "deleteModel": "{{modelName}}を削除" + "deleteModel": "{{modelName}}を削除", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "複数の入力言語をサポート", + "multiLanguage": "多言語", + "translation": "英語への翻訳が可能", + "translate": "英語に翻訳" + } }, "settings": { + "modelSettings": { + "title": "{{model}} 設定", + "noSettingsNeeded": "このモデルは設定不要で自動的に動作します。" + }, + "models": { + "title": "転写モデル", + "description": "転写モデルを選択するか、追加のモデルをダウンロードします。異なるモデルは精度と速度のレベルが異なります。", + "downloaded": "ダウンロード済み", + "available": "ダウンロード可能", + "deleteConfirm": "{{modelName}}を削除してもよろしいですか?再度使用するにはダウンロードが必要です。", + "deleteTitle": "モデルを削除", + "filters": { + "all": "すべて", + "multiLanguage": "多言語", + "translation": "翻訳" + }, + "noModelsMatch": "このフィルターに一致するモデルがありません。" + }, "general": { "title": "一般", "shortcut": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index e0300efa2..a0ea5a34c 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "Ogólne", + "models": "Modele", "advanced": "Zaawansowane", "postProcessing": "Postproces", "history": "Historia", @@ -81,6 +82,7 @@ "downloadModels": "Pobierz modele", "chooseModel": "Wybierz model", "active": "Aktywny", + "switching": "Przełączanie...", "download": "Pobierz", "downloadSize": "Rozmiar pobierania", "noModelsAvailable": "Brak dostępnych modeli", @@ -95,9 +97,34 @@ "modelError": "Błąd modelu", "modelUnloaded": "Model wyładowany", "noModelDownloadRequired": "Brak modelu – wymagane pobranie", - "deleteModel": "Usuń {{modelName}}" + "deleteModel": "Usuń {{modelName}}", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "Obsługuje wiele języków wejściowych", + "multiLanguage": "Wielojęzyczny", + "translation": "Może tłumaczyć na angielski", + "translate": "Tłumacz na angielski" + } }, "settings": { + "modelSettings": { + "title": "Ustawienia {{model}}", + "noSettingsNeeded": "Ten model działa automatycznie bez konieczności konfiguracji." + }, + "models": { + "title": "Modele transkrypcji", + "description": "Wybierz model transkrypcji lub pobierz dodatkowe modele. Różne modele oferują różne poziomy dokładności i szybkości.", + "downloaded": "Pobrane", + "available": "Dostępne do pobrania", + "deleteConfirm": "Czy na pewno chcesz usunąć {{modelName}}? Będziesz musiał pobrać go ponownie, aby go użyć.", + "deleteTitle": "Usuń model", + "filters": { + "all": "Wszystkie", + "multiLanguage": "Wielojęzyczne", + "translation": "Tłumaczenie" + }, + "noModelsMatch": "Żadne modele nie pasują do tego filtra." + }, "general": { "title": "Ogólne", "shortcut": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 2bcfe46c4..7eb211783 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "Общие", + "models": "Модели", "advanced": "Продвинутые", "postProcessing": "Постобработка", "history": "История", @@ -81,6 +82,7 @@ "downloadModels": "Скачать модели", "chooseModel": "Выберите модель", "active": "Активный", + "switching": "Переключение...", "download": "Скачать", "downloadSize": "Размер загрузки", "noModelsAvailable": "Нет доступных моделей", @@ -95,9 +97,34 @@ "modelError": "Ошибка модели", "modelUnloaded": "Модель выгружена", "noModelDownloadRequired": "Нет модели – требуется загрузка", - "deleteModel": "Удалить {{modelName}}" + "deleteModel": "Удалить {{modelName}}", + "downloadSpeed": "{{speed}} МБ/с", + "capabilities": { + "languageSelection": "Поддерживает несколько языков ввода", + "multiLanguage": "Многоязычный", + "translation": "Может переводить на английский", + "translate": "Перевести на английский" + } }, "settings": { + "modelSettings": { + "title": "Настройки {{model}}", + "noSettingsNeeded": "Эта модель работает автоматически без необходимости настройки." + }, + "models": { + "title": "Модели транскрипции", + "description": "Выберите модель транскрипции или загрузите дополнительные модели. Разные модели предлагают разные уровни точности и скорости.", + "downloaded": "Загружено", + "available": "Доступно для загрузки", + "deleteConfirm": "Вы уверены, что хотите удалить {{modelName}}? Вам нужно будет загрузить её снова, чтобы использовать.", + "deleteTitle": "Удалить модель", + "filters": { + "all": "Все", + "multiLanguage": "Многоязычные", + "translation": "Перевод" + }, + "noModelsMatch": "Нет моделей, соответствующих этому фильтру." + }, "general": { "title": "Общие", "shortcut": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 891dd04da..d1146538e 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -9,6 +9,7 @@ }, "sidebar": { "general": "Chung", + "models": "Mô hình", "advanced": "Nâng cao", "postProcessing": "Xử lý sau", "history": "Lịch sử", @@ -82,6 +83,7 @@ "downloadModels": "Tải Xuống Mô Hình", "chooseModel": "Chọn Mô Hình", "active": "Đang hoạt động", + "switching": "Đang chuyển...", "download": "Tải xuống", "downloadSize": "Kích thước tải xuống", "noModelsAvailable": "Không có mô hình nào", @@ -96,9 +98,34 @@ "modelError": "Lỗi Mô Hình", "modelUnloaded": "Mô Hình Đã Gỡ", "noModelDownloadRequired": "Chưa Có Mô Hình - Cần Tải Xuống", - "deleteModel": "Xóa {{modelName}}" + "deleteModel": "Xóa {{modelName}}", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "Hỗ trợ nhiều ngôn ngữ đầu vào", + "multiLanguage": "Đa ngôn ngữ", + "translation": "Có thể dịch sang tiếng Anh", + "translate": "Dịch sang tiếng Anh" + } }, "settings": { + "modelSettings": { + "title": "Cài đặt {{model}}", + "noSettingsNeeded": "Mô hình này hoạt động tự động mà không cần cấu hình." + }, + "models": { + "title": "Mô hình chuyển đổi", + "description": "Chọn mô hình chuyển đổi hoặc tải thêm mô hình. Các mô hình khác nhau có mức độ chính xác và tốc độ khác nhau.", + "downloaded": "Đã tải xuống", + "available": "Có thể tải xuống", + "deleteConfirm": "Bạn có chắc chắn muốn xóa {{modelName}}? Bạn sẽ cần tải lại để sử dụng.", + "deleteTitle": "Xóa mô hình", + "filters": { + "all": "Tất cả", + "multiLanguage": "Đa ngôn ngữ", + "translation": "Dịch thuật" + }, + "noModelsMatch": "Không có mô hình nào khớp với bộ lọc này." + }, "general": { "title": "Chung", "shortcut": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 75907c278..2e5604654 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "通用", + "models": "模型", "advanced": "高级", "postProcessing": "后处理", "history": "历史记录", @@ -81,6 +82,7 @@ "downloadModels": "下载模型", "chooseModel": "选择模型", "active": "活跃", + "switching": "切换中...", "download": "下载", "downloadSize": "下载大小", "noModelsAvailable": "没有可用的模型", @@ -95,9 +97,34 @@ "modelError": "模型错误", "modelUnloaded": "模型已卸载", "noModelDownloadRequired": "无模型 - 需要下载", - "deleteModel": "删除 {{modelName}}" + "deleteModel": "删除 {{modelName}}", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "支持多种输入语言", + "multiLanguage": "多语言", + "translation": "可翻译为英语", + "translate": "翻译为英语" + } }, "settings": { + "modelSettings": { + "title": "{{model}} 设置", + "noSettingsNeeded": "此模型自动运行,无需配置。" + }, + "models": { + "title": "转录模型", + "description": "选择转录模型或下载其他模型。不同模型提供不同的准确度和速度。", + "downloaded": "已下载", + "available": "可下载", + "deleteConfirm": "确定要删除 {{modelName}} 吗?您需要重新下载才能使用。", + "deleteTitle": "删除模型", + "filters": { + "all": "全部", + "multiLanguage": "多语言", + "translation": "翻译" + }, + "noModelsMatch": "没有符合此筛选条件的模型。" + }, "general": { "title": "通用", "shortcut": { diff --git a/src/stores/modelStore.ts b/src/stores/modelStore.ts index b4527717e..242010982 100644 --- a/src/stores/modelStore.ts +++ b/src/stores/modelStore.ts @@ -10,16 +10,25 @@ interface DownloadProgress { percentage: number; } -interface ModelStore { +interface DownloadStats { + startTime: number; + lastUpdate: number; + totalDownloaded: number; + speed: number; // MB/s +} + +interface ModelsStore { models: ModelInfo[]; currentModel: string; downloadingModels: Set; extractingModels: Set; downloadProgress: Map; + downloadStats: Map; loading: boolean; error: string | null; hasAnyModels: boolean; isFirstRun: boolean; + initialized: boolean; // Actions initialize: () => Promise; @@ -29,8 +38,6 @@ interface ModelStore { selectModel: (modelId: string) => Promise; downloadModel: (modelId: string) => Promise; deleteModel: (modelId: string) => Promise; - - // Getters getModelInfo: (modelId: string) => ModelInfo | undefined; isModelDownloading: (modelId: string) => boolean; isModelExtracting: (modelId: string) => boolean; @@ -38,85 +45,42 @@ interface ModelStore { // Internal setters setModels: (models: ModelInfo[]) => void; - setCurrentModel: (model: string) => void; - setLoading: (loading: boolean) => void; + setCurrentModel: (modelId: string) => void; setError: (error: string | null) => void; - setHasAnyModels: (hasAny: boolean) => void; - setIsFirstRun: (isFirst: boolean) => void; - addDownloadingModel: (modelId: string) => void; - removeDownloadingModel: (modelId: string) => void; - addExtractingModel: (modelId: string) => void; - removeExtractingModel: (modelId: string) => void; - setDownloadProgress: (modelId: string, progress: DownloadProgress) => void; - removeDownloadProgress: (modelId: string) => void; + setLoading: (loading: boolean) => void; } -export const useModelStore = create()( +export const useModelStore = create()( subscribeWithSelector((set, get) => ({ models: [], currentModel: "", downloadingModels: new Set(), extractingModels: new Set(), downloadProgress: new Map(), + downloadStats: new Map(), loading: true, error: null, hasAnyModels: false, isFirstRun: false, + initialized: false, // Internal setters setModels: (models) => set({ models }), setCurrentModel: (currentModel) => set({ currentModel }), - setLoading: (loading) => set({ loading }), setError: (error) => set({ error }), - setHasAnyModels: (hasAnyModels) => set({ hasAnyModels }), - setIsFirstRun: (isFirstRun) => set({ isFirstRun }), - addDownloadingModel: (modelId) => - set((state) => ({ - downloadingModels: new Set(state.downloadingModels).add(modelId), - })), - removeDownloadingModel: (modelId) => - set((state) => { - const next = new Set(state.downloadingModels); - next.delete(modelId); - return { downloadingModels: next }; - }), - addExtractingModel: (modelId) => - set((state) => ({ - extractingModels: new Set(state.extractingModels).add(modelId), - })), - removeExtractingModel: (modelId) => - set((state) => { - const next = new Set(state.extractingModels); - next.delete(modelId); - return { extractingModels: next }; - }), - setDownloadProgress: (modelId, progress) => - set((state) => ({ - downloadProgress: new Map(state.downloadProgress).set( - modelId, - progress, - ), - })), - removeDownloadProgress: (modelId) => - set((state) => { - const next = new Map(state.downloadProgress); - next.delete(modelId); - return { downloadProgress: next }; - }), - - // Getters - getModelInfo: (modelId) => - get().models.find((model) => model.id === modelId), - isModelDownloading: (modelId) => get().downloadingModels.has(modelId), - isModelExtracting: (modelId) => get().extractingModels.has(modelId), - getDownloadProgress: (modelId) => get().downloadProgress.get(modelId), - - // Actions + setLoading: (loading) => set({ loading }), + loadModels: async () => { try { const result = await commands.getAvailableModels(); if (result.status === "ok") { set({ models: result.data, error: null }); + + // Sync downloading state from backend + const currentlyDownloading = new Set( + result.data.filter((m) => m.is_downloading).map((m) => m.id), + ); + set({ downloadingModels: currentlyDownloading }); } else { set({ error: `Failed to load models: ${result.error}` }); } @@ -153,12 +117,16 @@ export const useModelStore = create()( } }, - selectModel: async (modelId) => { + selectModel: async (modelId: string) => { try { set({ error: null }); const result = await commands.setActiveModel(modelId); if (result.status === "ok") { - set({ currentModel: modelId, isFirstRun: false, hasAnyModels: true }); + set({ + currentModel: modelId, + isFirstRun: false, + hasAnyModels: true, + }); return true; } else { set({ error: `Failed to switch to model: ${result.error}` }); @@ -170,33 +138,41 @@ export const useModelStore = create()( } }, - downloadModel: async (modelId) => { - const { addDownloadingModel, removeDownloadingModel } = get(); + downloadModel: async (modelId: string) => { try { set({ error: null }); - addDownloadingModel(modelId); + set((state) => ({ + downloadingModels: new Set(state.downloadingModels).add(modelId), + })); const result = await commands.downloadModel(modelId); if (result.status === "ok") { return true; } else { set({ error: `Failed to download model: ${result.error}` }); - removeDownloadingModel(modelId); + set((state) => { + const next = new Set(state.downloadingModels); + next.delete(modelId); + return { downloadingModels: next }; + }); return false; } } catch (err) { set({ error: `Failed to download model: ${err}` }); - removeDownloadingModel(modelId); + set((state) => { + const next = new Set(state.downloadingModels); + next.delete(modelId); + return { downloadingModels: next }; + }); return false; } }, - deleteModel: async (modelId) => { - const { loadModels } = get(); + deleteModel: async (modelId: string) => { try { set({ error: null }); const result = await commands.deleteModel(modelId); if (result.status === "ok") { - await loadModels(); + await get().loadModels(); return true; } else { set({ error: `Failed to delete model: ${result.error}` }); @@ -208,47 +184,139 @@ export const useModelStore = create()( } }, + getModelInfo: (modelId: string) => { + return get().models.find((model) => model.id === modelId); + }, + + isModelDownloading: (modelId: string) => { + return get().downloadingModels.has(modelId); + }, + + isModelExtracting: (modelId: string) => { + return get().extractingModels.has(modelId); + }, + + getDownloadProgress: (modelId: string) => { + return get().downloadProgress.get(modelId); + }, + initialize: async () => { + if (get().initialized) return; + const { loadModels, loadCurrentModel, checkFirstRun } = get(); + + // Load initial data await Promise.all([loadModels(), loadCurrentModel(), checkFirstRun()]); + + // Set up event listeners + listen("model-download-progress", (event) => { + const progress = event.payload; + set((state) => ({ + downloadProgress: new Map(state.downloadProgress).set( + progress.model_id, + progress, + ), + })); + + // Update download stats for speed calculation + const now = Date.now(); + set((state) => { + const current = state.downloadStats.get(progress.model_id); + const newStats = new Map(state.downloadStats); + + if (!current) { + newStats.set(progress.model_id, { + startTime: now, + lastUpdate: now, + totalDownloaded: progress.downloaded, + speed: 0, + }); + } else { + const timeDiff = (now - current.lastUpdate) / 1000; + const bytesDiff = progress.downloaded - current.totalDownloaded; + + if (timeDiff > 0.5) { + const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; + const validCurrentSpeed = Math.max(0, currentSpeed); + const smoothedSpeed = + current.speed > 0 + ? current.speed * 0.8 + validCurrentSpeed * 0.2 + : validCurrentSpeed; + + newStats.set(progress.model_id, { + startTime: current.startTime, + lastUpdate: now, + totalDownloaded: progress.downloaded, + speed: Math.max(0, smoothedSpeed), + }); + } + } + + return { downloadStats: newStats }; + }); + }); + + listen("model-download-complete", (event) => { + const modelId = event.payload; + set((state) => { + const nextDownloading = new Set(state.downloadingModels); + nextDownloading.delete(modelId); + const nextProgress = new Map(state.downloadProgress); + nextProgress.delete(modelId); + const nextStats = new Map(state.downloadStats); + nextStats.delete(modelId); + return { + downloadingModels: nextDownloading, + downloadProgress: nextProgress, + downloadStats: nextStats, + }; + }); + get().loadModels(); + }); + + listen("model-extraction-started", (event) => { + const modelId = event.payload; + set((state) => ({ + extractingModels: new Set(state.extractingModels).add(modelId), + })); + }); + + listen("model-extraction-completed", (event) => { + const modelId = event.payload; + set((state) => { + const next = new Set(state.extractingModels); + next.delete(modelId); + return { extractingModels: next }; + }); + get().loadModels(); + }); + + listen<{ model_id: string; error: string }>( + "model-extraction-failed", + (event) => { + const modelId = event.payload.model_id; + set((state) => { + const next = new Set(state.extractingModels); + next.delete(modelId); + return { + extractingModels: next, + error: `Failed to extract model: ${event.payload.error}`, + }; + }); + }, + ); + + listen("model-deleted", () => { + get().loadModels(); + get().loadCurrentModel(); + }); + + listen("model-state-changed", () => { + get().loadModels(); + get().loadCurrentModel(); + }); + + set({ initialized: true }); }, })), ); - -// Set up event listeners at module load -listen("model-state-changed", () => { - useModelStore.getState().loadCurrentModel(); -}); - -listen("model-download-progress", (event) => { - useModelStore - .getState() - .setDownloadProgress(event.payload.model_id, event.payload); -}); - -listen("model-download-complete", (event) => { - const modelId = event.payload; - const state = useModelStore.getState(); - state.removeDownloadingModel(modelId); - state.removeDownloadProgress(modelId); - state.loadModels(); -}); - -listen("model-extraction-started", (event) => { - useModelStore.getState().addExtractingModel(event.payload); -}); - -listen("model-extraction-completed", (event) => { - const state = useModelStore.getState(); - state.removeExtractingModel(event.payload); - state.loadModels(); -}); - -listen<{ model_id: string; error: string }>( - "model-extraction-failed", - (event) => { - const state = useModelStore.getState(); - state.removeExtractingModel(event.payload.model_id); - state.setError(`Failed to extract model: ${event.payload.error}`); - }, -); From 069a5668e5e46364a72e372fd2fd83e02e0a5d80 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Thu, 1 Jan 2026 22:50:41 +0530 Subject: [PATCH 02/26] feat: add language filter to models settings page adds a searchable language dropdown filter to the models page that lets users filter models by language support. when a non-english language is selected, models that don't support multiple languages (like parakeet) are hidden. - add searchable language dropdown (right-aligned in filter row) - filter models using supports_language_selection capability - add allLanguages translation key to all 10 locales --- .../settings/models/ModelsSettings.tsx | 174 ++++++++++++++++-- src/i18n/locales/de/translation.json | 3 +- src/i18n/locales/en/translation.json | 3 +- src/i18n/locales/es/translation.json | 3 +- src/i18n/locales/fr/translation.json | 3 +- src/i18n/locales/it/translation.json | 3 +- src/i18n/locales/ja/translation.json | 3 +- src/i18n/locales/pl/translation.json | 3 +- src/i18n/locales/ru/translation.json | 3 +- src/i18n/locales/vi/translation.json | 3 +- src/i18n/locales/zh/translation.json | 3 +- 11 files changed, 183 insertions(+), 21 deletions(-) diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 4c4978471..89b508fc6 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -1,17 +1,34 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ask } from "@tauri-apps/plugin-dialog"; -import { Globe, Languages } from "lucide-react"; -import type { ModelCardStatus } from "../../onboarding"; -import { ModelCard } from "../../onboarding"; -import { useModels } from "../../../hooks/useModels"; +import { ChevronDown, Globe, Languages } from "lucide-react"; +import type { ModelCardStatus } from "@/components/onboarding"; +import { ModelCard } from "@/components/onboarding"; +import { useModels } from "@/hooks/useModels.ts"; +import { LANGUAGES } from "@/lib/constants/languages.ts"; +import type { ModelInfo } from "@/bindings"; type ModelFilter = "all" | "multiLanguage" | "translation"; +// check if model supports a language based on its capabilities +const modelSupportsLanguage = (model: ModelInfo, langCode: string): boolean => { + // models with language selection support all languages in the LANGUAGES list, like Whisper + if (model.supports_language_selection) { + return true; + } + // models without language selection only support English, like Parakeet + return langCode === "en"; +}; + export const ModelsSettings: React.FC = () => { const { t } = useTranslation(); const [activeFilter, setActiveFilter] = useState("all"); const [switchingModelId, setSwitchingModelId] = useState(null); + const [languageFilter, setLanguageFilter] = useState("all"); + const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false); + const [languageSearch, setLanguageSearch] = useState(""); + const languageDropdownRef = useRef(null); + const languageSearchInputRef = useRef(null); const { models, currentModel, @@ -25,6 +42,45 @@ export const ModelsSettings: React.FC = () => { deleteModel, } = useModels(); + // click outside handler for language dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + languageDropdownRef.current && + !languageDropdownRef.current.contains(event.target as Node) + ) { + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // focus search input when dropdown opens + useEffect(() => { + if (languageDropdownOpen && languageSearchInputRef.current) { + languageSearchInputRef.current.focus(); + } + }, [languageDropdownOpen]); + + // filtered languages for dropdown (exclude "auto") + const filteredLanguages = useMemo(() => { + return LANGUAGES.filter( + (lang) => + lang.value !== "auto" && + lang.label.toLowerCase().includes(languageSearch.toLowerCase()), + ); + }, [languageSearch]); + + // Get selected language label + const selectedLanguageLabel = useMemo(() => { + if (languageFilter === "all") { + return t("settings.models.filters.allLanguages"); + } + return LANGUAGES.find((lang) => lang.value === languageFilter)?.label || ""; + }, [languageFilter, t]); + const getModelStatus = (modelId: string): ModelCardStatus => { if (extractingModels.has(modelId)) { return "extracting"; @@ -89,19 +145,27 @@ export const ModelsSettings: React.FC = () => { } }; - // Filter models based on active filter + // Filter models based on active filter and language filter const filteredModels = useMemo(() => { return models.filter((model) => { + // Capability filters switch (activeFilter) { case "multiLanguage": - return model.supports_language_selection; + if (!model.supports_language_selection) return false; + break; case "translation": - return model.supports_translation; - default: - return true; + if (!model.supports_translation) return false; + break; } + + // Language filter + if (languageFilter !== "all") { + if (!modelSupportsLanguage(model, languageFilter)) return false; + } + + return true; }); - }, [models, activeFilter]); + }, [models, activeFilter, languageFilter]); if (loading) { return ( @@ -159,6 +223,94 @@ export const ModelsSettings: React.FC = () => { {t("settings.models.filters.translation")} + + {/* 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 focus:outline-none focus:ring-1 focus:ring-logo-primary" + /> +
+
+ + {filteredLanguages.map((lang) => ( + + ))} + {filteredLanguages.length === 0 && ( +
+ {t("settings.general.language.noResults")} +
+ )} +
+
+ )} +
{filteredModels.length > 0 ? (
diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 80c2f9e7e..b94138453 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -121,7 +121,8 @@ "filters": { "all": "Alle", "multiLanguage": "Mehrsprachig", - "translation": "Übersetzung" + "translation": "Übersetzung", + "allLanguages": "Alle Sprachen" }, "noModelsMatch": "Keine Modelle entsprechen diesem Filter." }, diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index a0d46b9f9..b2ec38b79 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -167,7 +167,8 @@ "filters": { "all": "All", "multiLanguage": "Multi-language", - "translation": "Translation" + "translation": "Translation", + "allLanguages": "All Languages" }, "noModelsMatch": "No models match this filter." }, diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 701d3d3c0..9f8e4a641 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -121,7 +121,8 @@ "filters": { "all": "Todos", "multiLanguage": "Multiidioma", - "translation": "Traducción" + "translation": "Traducción", + "allLanguages": "Todos los idiomas" }, "noModelsMatch": "Ningún modelo coincide con este filtro." }, diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 6f79db0c1..42449a943 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -122,7 +122,8 @@ "filters": { "all": "Tous", "multiLanguage": "Multilingue", - "translation": "Traduction" + "translation": "Traduction", + "allLanguages": "Toutes les langues" }, "noModelsMatch": "Aucun modèle ne correspond à ce filtre." }, diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 6b0233edb..d15a75625 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -121,7 +121,8 @@ "filters": { "all": "Tutti", "multiLanguage": "Multilingua", - "translation": "Traduzione" + "translation": "Traduzione", + "allLanguages": "Tutte le lingue" }, "noModelsMatch": "Nessun modello corrisponde a questo filtro." }, diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index f585ff749..d46c18e9a 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -121,7 +121,8 @@ "filters": { "all": "すべて", "multiLanguage": "多言語", - "translation": "翻訳" + "translation": "翻訳", + "allLanguages": "すべての言語" }, "noModelsMatch": "このフィルターに一致するモデルがありません。" }, diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index a0ea5a34c..8c12ad6e3 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -121,7 +121,8 @@ "filters": { "all": "Wszystkie", "multiLanguage": "Wielojęzyczne", - "translation": "Tłumaczenie" + "translation": "Tłumaczenie", + "allLanguages": "Wszystkie języki" }, "noModelsMatch": "Żadne modele nie pasują do tego filtra." }, diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 7eb211783..f2b41ea77 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -121,7 +121,8 @@ "filters": { "all": "Все", "multiLanguage": "Многоязычные", - "translation": "Перевод" + "translation": "Перевод", + "allLanguages": "Все языки" }, "noModelsMatch": "Нет моделей, соответствующих этому фильтру." }, diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index d1146538e..2358c8e14 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -122,7 +122,8 @@ "filters": { "all": "Tất cả", "multiLanguage": "Đa ngôn ngữ", - "translation": "Dịch thuật" + "translation": "Dịch thuật", + "allLanguages": "Tất cả ngôn ngữ" }, "noModelsMatch": "Không có mô hình nào khớp với bộ lọc này." }, diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 2e5604654..69291bc69 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -121,7 +121,8 @@ "filters": { "all": "全部", "multiLanguage": "多语言", - "translation": "翻译" + "translation": "翻译", + "allLanguages": "所有语言" }, "noModelsMatch": "没有符合此筛选条件的模型。" }, From 493ecde09c421e5e4a46fbf71f750195b96637a6 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Wed, 7 Jan 2026 02:25:52 +0800 Subject: [PATCH 03/26] feat: add translation consistency checker script - add scripts/check-translations.cjs to validate all language files - script dynamically discovers languages from directory structure - checks that all languages have same keys as english reference - detects missing and extra keys in each language - add check:translations npm script - integrate into github actions lint workflow - validates translations on every pull request --- .github/workflows/lint.yml | 3 + package.json | 3 +- scripts/check-translations.cjs | 247 +++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100755 scripts/check-translations.cjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a80574fbd..032b81a29 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,5 +14,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Check translation consistency + run: bun run check:translations + - name: Run ESLint run: bun run lint diff --git a/package.json b/package.json index 55359d18c..f3e49548b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "format:frontend": "prettier --write .", "format:backend": "cd src-tauri && cargo fmt", "test:playwright": "playwright test", - "test:playwright:ui": "playwright test --ui" + "test:playwright:ui": "playwright test --ui", + "check:translations": "node scripts/check-translations.cjs" }, "dependencies": { "@tailwindcss/vite": "^4.1.16", diff --git a/scripts/check-translations.cjs b/scripts/check-translations.cjs new file mode 100755 index 000000000..4d328c976 --- /dev/null +++ b/scripts/check-translations.cjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node + +/** + * Translation Consistency Checker + * + * This script validates that all language translation files have the same + * structure and keys as the English (en) reference file. + * + * It checks: + * - All translation files can be parsed as valid JSON + * - All languages have the same keys as English + * - No keys are missing in any language + * + * Usage: node scripts/check-translations.js + * Exit code: 0 if all checks pass, 1 if any checks fail + */ + +const fs = require("fs"); +const path = require("path"); + +// Configuration +const LOCALES_DIR = path.join(__dirname, "..", "src", "i18n", "locales"); +const REFERENCE_LANG = "en"; + +/** + * Get all language codes from the locales directory + * @returns {Array} Array of language codes (excluding reference lang) + */ +function getLanguages() { + const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory() && entry.name !== REFERENCE_LANG) + .map((entry) => entry.name) + .sort(); +} + +const LANGUAGES = getLanguages(); + +// Colors for terminal output +const colors = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", +}; + +function colorize(text, color) { + return `${colors[color]}${text}${colors.reset}`; +} + +/** + * Get all key paths from a nested object + * @param {Object} obj - The object to extract keys from + * @param {Array} prefix - Current path prefix + * @returns {Array>} Array of key paths + */ +function getAllKeyPaths(obj, prefix = []) { + let paths = []; + for (const key in obj) { + if (!obj.hasOwnProperty(key)) continue; + + const currentPath = prefix.concat([key]); + const value = obj[key]; + + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + // Recurse into nested objects + paths = paths.concat(getAllKeyPaths(value, currentPath)); + } else { + // Leaf node - add the path + paths.push(currentPath); + } + } + return paths; +} + +/** + * Check if a key path exists in an object + * @param {Object} obj - The object to check + * @param {Array} keyPath - The path to check + * @returns {boolean} True if the path exists + */ +function hasKeyPath(obj, keyPath) { + let current = obj; + for (const key of keyPath) { + if (current[key] === undefined) { + return false; + } + current = current[key]; + } + return true; +} + +/** + * Load and parse a translation file + * @param {string} lang - Language code + * @returns {Object|null} Parsed JSON or null if error + */ +function loadTranslationFile(lang) { + const filePath = path.join(LOCALES_DIR, lang, "translation.json"); + + try { + const content = fs.readFileSync(filePath, "utf8"); + return JSON.parse(content); + } catch (error) { + console.error(colorize(`✗ Error loading ${lang}/translation.json:`, "red")); + console.error(` ${error.message}`); + return null; + } +} + +/** + * Main validation function + */ +function validateTranslations() { + console.log(colorize("\n🌍 Translation Consistency Check\n", "blue")); + + // Load reference file + console.log(`Loading reference language: ${REFERENCE_LANG}`); + const referenceData = loadTranslationFile(REFERENCE_LANG); + + if (!referenceData) { + console.error( + colorize(`\n✗ Failed to load reference file (${REFERENCE_LANG})`, "red"), + ); + process.exit(1); + } + + // Get all key paths from reference + const referenceKeyPaths = getAllKeyPaths(referenceData); + console.log(`Reference has ${referenceKeyPaths.length} keys\n`); + + // Track validation results + let hasErrors = false; + const results = {}; + + // Validate each language + for (const lang of LANGUAGES) { + const langData = loadTranslationFile(lang); + + if (!langData) { + hasErrors = true; + results[lang] = { valid: false, missing: [], extra: [] }; + continue; + } + + // Find missing keys + const missing = referenceKeyPaths.filter( + (keyPath) => !hasKeyPath(langData, keyPath), + ); + + // Find extra keys (keys in language but not in reference) + const langKeyPaths = getAllKeyPaths(langData); + const extra = langKeyPaths.filter( + (keyPath) => !hasKeyPath(referenceData, keyPath), + ); + + results[lang] = { + valid: missing.length === 0 && extra.length === 0, + missing, + extra, + }; + + if (missing.length > 0 || extra.length > 0) { + hasErrors = true; + } + } + + // Print results + console.log(colorize("Results:", "blue")); + console.log("─".repeat(60)); + + for (const lang of LANGUAGES) { + const result = results[lang]; + + if (result.valid) { + console.log( + colorize(`✓ ${lang.toUpperCase()}: All keys present`, "green"), + ); + } else { + console.log(colorize(`✗ ${lang.toUpperCase()}: Issues found`, "red")); + + if (result.missing.length > 0) { + console.log( + colorize(` Missing ${result.missing.length} keys:`, "yellow"), + ); + result.missing.slice(0, 10).forEach((keyPath) => { + console.log(` - ${keyPath.join(".")}`); + }); + if (result.missing.length > 10) { + console.log( + colorize( + ` ... and ${result.missing.length - 10} more`, + "yellow", + ), + ); + } + } + + if (result.extra.length > 0) { + console.log( + colorize( + ` Extra ${result.extra.length} keys (not in reference):`, + "yellow", + ), + ); + result.extra.slice(0, 10).forEach((keyPath) => { + console.log(` - ${keyPath.join(".")}`); + }); + if (result.extra.length > 10) { + console.log( + colorize(` ... and ${result.extra.length - 10} more`, "yellow"), + ); + } + } + + console.log(""); + } + } + + console.log("─".repeat(60)); + + // Summary + const validCount = Object.values(results).filter((r) => r.valid).length; + const totalCount = LANGUAGES.length; + + if (hasErrors) { + console.log( + colorize( + `\n✗ Validation failed: ${validCount}/${totalCount} languages passed`, + "red", + ), + ); + process.exit(1); + } else { + console.log( + colorize( + `\n✓ All ${totalCount} languages have complete translations!`, + "green", + ), + ); + process.exit(0); + } +} + +// Run validation +validateTranslations(); From 198ef059aed03a0ee7e32625a58a313251eef6b5 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sat, 17 Jan 2026 12:44:30 -0500 Subject: [PATCH 04/26] fix: update imports and add missing translations after rebase - replace useModels hook with useModelStore in components - add permission error keys to all languages - add model settings keys to cs and tr (new languages from main) --- src/components/onboarding/Onboarding.tsx | 21 ++++---- .../settings/general/ModelSettingsCard.tsx | 7 +-- .../settings/models/ModelsSettings.tsx | 12 ++--- src/i18n/locales/cs/translation.json | 52 ++++++++++++++++++- src/i18n/locales/de/translation.json | 6 ++- src/i18n/locales/es/translation.json | 6 ++- src/i18n/locales/fr/translation.json | 6 ++- src/i18n/locales/it/translation.json | 6 ++- src/i18n/locales/ja/translation.json | 6 ++- src/i18n/locales/pl/translation.json | 6 ++- src/i18n/locales/ru/translation.json | 6 ++- src/i18n/locales/tr/translation.json | 32 +++++++++++- src/i18n/locales/uk/translation.json | 6 ++- src/i18n/locales/vi/translation.json | 6 ++- src/i18n/locales/zh/translation.json | 6 ++- 15 files changed, 154 insertions(+), 30 deletions(-) diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx index 9731c7e53..1a8a1f544 100644 --- a/src/components/onboarding/Onboarding.tsx +++ b/src/components/onboarding/Onboarding.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { ModelInfo } from "@/bindings"; import ModelCard from "./ModelCard"; import HandyTextLogo from "../icons/HandyTextLogo"; -import { useModels } from "../../hooks/useModels"; +import { useModelStore } from "../../stores/modelStore"; interface OnboardingProps { onModelSelected: () => void; @@ -11,12 +11,12 @@ interface OnboardingProps { const Onboarding: React.FC = ({ onModelSelected }) => { const { t } = useTranslation(); - const { models, downloadModel, error: modelError } = useModels(); + const { models, downloadModel, error: modelError } = useModelStore(); const [downloading, setDownloading] = useState(false); const [error, setError] = useState(null); // Only show downloadable models for onboarding - const availableModels = models.filter((m) => !m.is_downloaded); + const availableModels = models.filter((m: ModelInfo) => !m.is_downloaded); const handleDownloadModel = async (modelId: string) => { setDownloading(true); @@ -31,7 +31,7 @@ const Onboarding: React.FC = ({ onModelSelected }) => { // Note: We don't await or handle the result here since the component // will unmount. The Zustand store handles download state, and any errors // will be visible in the main app's ModelSelector. - downloadPromise.catch((err) => { + downloadPromise.catch((err: Error) => { console.error("Download failed:", err); }); }; @@ -58,8 +58,8 @@ const Onboarding: React.FC = ({ onModelSelected }) => {
{availableModels - .filter((model) => isRecommendedModel(model)) - .map((model) => ( + .filter((model: ModelInfo) => isRecommendedModel(model)) + .map((model: ModelInfo) => ( = ({ onModelSelected }) => { ))} {availableModels - .filter((model) => !isRecommendedModel(model)) - .sort((a, b) => Number(a.size_mb) - Number(b.size_mb)) - .map((model) => ( + .filter((model: ModelInfo) => !isRecommendedModel(model)) + .sort( + (a: ModelInfo, b: ModelInfo) => + Number(a.size_mb) - Number(b.size_mb), + ) + .map((model: ModelInfo) => ( { const { t } = useTranslation(); - const { currentModel, models } = useModels(); + const { currentModel, models } = useModelStore(); - const currentModelInfo = models.find((m) => m.id === currentModel); + const currentModelInfo = models.find((m: ModelInfo) => m.id === currentModel); const supportsLanguage = currentModelInfo?.supports_language_selection ?? false; diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 89b508fc6..67000c2db 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -4,7 +4,7 @@ import { ask } from "@tauri-apps/plugin-dialog"; import { ChevronDown, Globe, Languages } from "lucide-react"; import type { ModelCardStatus } from "@/components/onboarding"; import { ModelCard } from "@/components/onboarding"; -import { useModels } from "@/hooks/useModels.ts"; +import { useModelStore } from "@/stores/modelStore"; import { LANGUAGES } from "@/lib/constants/languages.ts"; import type { ModelInfo } from "@/bindings"; @@ -40,7 +40,7 @@ export const ModelsSettings: React.FC = () => { downloadModel, selectModel, deleteModel, - } = useModels(); + } = useModelStore(); // click outside handler for language dropdown useEffect(() => { @@ -94,7 +94,7 @@ export const ModelsSettings: React.FC = () => { if (modelId === currentModel) { return "active"; } - const model = models.find((m) => m.id === modelId); + const model = models.find((m: ModelInfo) => m.id === modelId); if (model?.is_downloaded) { return "available"; } @@ -125,7 +125,7 @@ export const ModelsSettings: React.FC = () => { }; const handleModelDelete = async (modelId: string) => { - const model = models.find((m) => m.id === modelId); + const model = models.find((m: ModelInfo) => m.id === modelId); const modelName = model?.name || modelId; const confirmed = await ask( @@ -147,7 +147,7 @@ export const ModelsSettings: React.FC = () => { // Filter models based on active filter and language filter const filteredModels = useMemo(() => { - return models.filter((model) => { + return models.filter((model: ModelInfo) => { // Capability filters switch (activeFilter) { case "multiLanguage": @@ -314,7 +314,7 @@ export const ModelsSettings: React.FC = () => {
{filteredModels.length > 0 ? (
- {filteredModels.map((model) => ( + {filteredModels.map((model: ModelInfo) => ( Date: Sat, 17 Jan 2026 13:10:46 -0500 Subject: [PATCH 05/26] fix: translate english placeholders in cs and tr locale files --- src/i18n/locales/cs/translation.json | 66 ++++++++++++++-------------- src/i18n/locales/tr/translation.json | 42 +++++++++--------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 02eac37a5..5c6a7645f 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -8,7 +8,7 @@ }, "sidebar": { "general": "Obecné", - "models": "Models", + "models": "Modely", "advanced": "Pokročilé", "postProcessing": "Následné zpracování", "history": "Historie", @@ -59,23 +59,23 @@ "downloadModel": "Nepodařilo se stáhnout model: {{error}}" }, "permissions": { - "title": "Permissions Required", - "description": "Handy needs a couple of permissions to work properly.", + "title": "Vyžadována oprávnění", + "description": "Handy potřebuje několik oprávnění pro správné fungování.", "microphone": { - "title": "Microphone Access", - "description": "Required to hear your voice for transcription." + "title": "Přístup k mikrofonu", + "description": "Potřebný pro zachycení vašeho hlasu k přepisu." }, "accessibility": { - "title": "Accessibility Access", - "description": "Required to type transcribed text into your applications." + "title": "Přístup k usnadnění", + "description": "Potřebný pro psaní přepsaného textu do vašich aplikací." }, - "grant": "Grant Permission", - "granted": "Granted", - "waiting": "Waiting...", - "allGranted": "All set!", + "grant": "Udělit oprávnění", + "granted": "Uděleno", + "waiting": "Čekání...", + "allGranted": "Vše připraveno!", "errors": { - "checkFailed": "Failed to check permissions. Please try again.", - "requestFailed": "Failed to request permission. Please try again." + "checkFailed": "Nepodařilo se zkontrolovat oprávnění. Zkuste to prosím znovu.", + "requestFailed": "Nepodařilo se požádat o oprávnění. Zkuste to prosím znovu." } } }, @@ -101,36 +101,36 @@ "modelUnloaded": "Model uvolněn", "noModelDownloadRequired": "Žádný model - je nutné stáhnout", "deleteModel": "Smazat {{modelName}}", - "switching": "Switching...", - "cancel": "Cancel", - "cancelDownload": "Cancel download", + "switching": "Přepínání...", + "cancel": "Zrušit", + "cancelDownload": "Zrušit stahování", "downloadSpeed": "{{speed}} MB/s", "capabilities": { - "languageSelection": "Supports multiple input languages", - "multiLanguage": "Multi-language", - "translation": "Can translate to English", - "translate": "Translate to English" + "languageSelection": "Podporuje více vstupních jazyků", + "multiLanguage": "Vícejazyčný", + "translation": "Umí překládat do angličtiny", + "translate": "Přeložit do angličtiny" } }, "settings": { "modelSettings": { - "title": "{{model}} Settings", - "noSettingsNeeded": "This model works automatically with no configuration needed." + "title": "Nastavení {{model}}", + "noSettingsNeeded": "Tento model funguje automaticky bez nutnosti konfigurace." }, "models": { - "title": "Transcription Models", - "description": "Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.", - "downloaded": "Downloaded", - "available": "Available to Download", - "deleteConfirm": "Are you sure you want to delete {{modelName}}? You will need to download it again to use it.", - "deleteTitle": "Delete Model", + "title": "Modely přepisu", + "description": "Vyberte model přepisu nebo stáhněte další modely. Různé modely nabízejí různé úrovně přesnosti a rychlosti.", + "downloaded": "Staženo", + "available": "K dispozici ke stažení", + "deleteConfirm": "Opravdu chcete smazat {{modelName}}? Pro opětovné použití jej budete muset znovu stáhnout.", + "deleteTitle": "Smazat model", "filters": { - "all": "All", - "multiLanguage": "Multi-language", - "translation": "Translation", - "allLanguages": "All Languages" + "all": "Vše", + "multiLanguage": "Vícejazyčný", + "translation": "Překlad", + "allLanguages": "Všechny jazyky" }, - "noModelsMatch": "No models match this filter." + "noModelsMatch": "Tomuto filtru neodpovídají žádné modely." }, "general": { "title": "Obecné", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 71fa3fc22..8a0969541 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -8,7 +8,7 @@ }, "sidebar": { "general": "Genel", - "models": "Models", + "models": "Modeller", "advanced": "Gelişmiş", "postProcessing": "Son İşlem", "history": "Geçmiş", @@ -101,36 +101,36 @@ "modelUnloaded": "Model Boşaltıldı", "noModelDownloadRequired": "Model Yok – İndirme Gerekli", "deleteModel": "{{modelName}} Sil", - "switching": "Switching...", - "cancel": "Cancel", - "cancelDownload": "Cancel download", + "switching": "Değiştiriliyor...", + "cancel": "İptal", + "cancelDownload": "İndirmeyi iptal et", "downloadSpeed": "{{speed}} MB/s", "capabilities": { - "languageSelection": "Supports multiple input languages", - "multiLanguage": "Multi-language", - "translation": "Can translate to English", - "translate": "Translate to English" + "languageSelection": "Birden fazla giriş dilini destekler", + "multiLanguage": "Çok dilli", + "translation": "İngilizce'ye çevirebilir", + "translate": "İngilizce'ye çevir" } }, "settings": { "modelSettings": { - "title": "{{model}} Settings", - "noSettingsNeeded": "This model works automatically with no configuration needed." + "title": "{{model}} Ayarları", + "noSettingsNeeded": "Bu model yapılandırma gerektirmeden otomatik olarak çalışır." }, "models": { - "title": "Transcription Models", - "description": "Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.", - "downloaded": "Downloaded", - "available": "Available to Download", - "deleteConfirm": "Are you sure you want to delete {{modelName}}? You will need to download it again to use it.", - "deleteTitle": "Delete Model", + "title": "Transkripsiyon Modelleri", + "description": "Bir transkripsiyon modeli seçin veya ek modeller indirin. Farklı modeller değişen doğruluk ve hız seviyeleri sunar.", + "downloaded": "İndirildi", + "available": "İndirilebilir", + "deleteConfirm": "{{modelName}} modelini silmek istediğinizden emin misiniz? Kullanmak için tekrar indirmeniz gerekecek.", + "deleteTitle": "Modeli Sil", "filters": { - "all": "All", - "multiLanguage": "Multi-language", - "translation": "Translation", - "allLanguages": "All Languages" + "all": "Tümü", + "multiLanguage": "Çok dilli", + "translation": "Çeviri", + "allLanguages": "Tüm Diller" }, - "noModelsMatch": "No models match this filter." + "noModelsMatch": "Bu filtreyle eşleşen model yok." }, "general": { "title": "Genel", From 3224eb68a0bb36a50cd5edecb4b411296d0646e7 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Tue, 27 Jan 2026 21:31:57 -0500 Subject: [PATCH 06/26] refactor: simplify model dropdown and migrate store to immer - model dropdown now only shows downloaded models (no download/delete) - convert store to immer with record types for immutability - remove unused translation keys (welcome, downloadPrompt, etc.) - add missing moonshine-base model fields - sync translations after rebase --- src-tauri/src/managers/model.rs | 3 + .../model-selector/ModelDropdown.tsx | 196 +-------------- .../model-selector/ModelSelector.tsx | 30 --- .../settings/models/ModelsSettings.tsx | 8 +- src/i18n/locales/cs/translation.json | 14 +- src/i18n/locales/de/translation.json | 12 +- src/i18n/locales/en/translation.json | 7 - src/i18n/locales/es/translation.json | 12 +- src/i18n/locales/fr/translation.json | 13 +- src/i18n/locales/it/translation.json | 12 +- src/i18n/locales/ja/translation.json | 12 +- src/i18n/locales/pl/translation.json | 12 +- src/i18n/locales/pt/translation.json | 42 +++- src/i18n/locales/ru/translation.json | 12 +- src/i18n/locales/tr/translation.json | 14 +- src/i18n/locales/uk/translation.json | 42 +++- src/i18n/locales/vi/translation.json | 13 +- src/i18n/locales/zh/translation.json | 12 +- src/stores/modelStore.ts | 228 +++++++++++------- 19 files changed, 275 insertions(+), 419 deletions(-) diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index ef90b59a9..77ee4c4aa 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -221,6 +221,9 @@ impl ModelManager { engine_type: EngineType::Moonshine, accuracy_score: 0.70, speed_score: 0.90, + supports_language_selection: false, // Moonshine is English-only + supports_translation: false, // Moonshine doesn't support translation + is_recommended: false, }, ); diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index b0f82fb60..cef68cbe9 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -1,105 +1,34 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { ask } from "@tauri-apps/plugin-dialog"; import type { ModelInfo } from "@/bindings"; -import { formatModelSize } from "../../lib/utils/format"; import { getTranslatedModelName, getTranslatedModelDescription, } from "../../lib/utils/modelTranslation"; -import { ProgressBar } from "../shared"; - -interface DownloadProgress { - model_id: string; - downloaded: number; - total: number; - percentage: number; -} interface ModelDropdownProps { models: ModelInfo[]; currentModelId: string; - downloadProgress: Record; onModelSelect: (modelId: string) => void; - onModelDownload: (modelId: string) => void; - onModelDelete: (modelId: string) => Promise; - onError?: (error: string) => void; } const ModelDropdown: React.FC = ({ models, currentModelId, - downloadProgress, onModelSelect, - onModelDownload, - onModelDelete, - onError, }) => { const { t } = useTranslation(); - const availableModels = models.filter((m) => m.is_downloaded); - const downloadableModels = models.filter((m) => !m.is_downloaded); - const isFirstRun = availableModels.length === 0 && models.length > 0; - - const handleDeleteClick = async (e: React.MouseEvent, modelId: string) => { - e.preventDefault(); - e.stopPropagation(); - - const model = models.find((m) => m.id === modelId); - const modelName = model?.name || modelId; - - const confirmed = await ask( - t("settings.models.deleteConfirm", { modelName }), - { - title: t("settings.models.deleteTitle"), - kind: "warning", - }, - ); - - if (!confirmed) return; - - try { - await onModelDelete(modelId); - } catch (err) { - const errorMsg = `Failed to delete model: ${err}`; - onError?.(errorMsg); - } - }; + const downloadedModels = models.filter((m) => m.is_downloaded); const handleModelClick = (modelId: string) => { - if (modelId in downloadProgress) { - return; // Don't allow interaction while downloading - } onModelSelect(modelId); }; - const handleDownloadClick = (modelId: string) => { - if (modelId in downloadProgress) { - return; // Don't allow interaction while downloading - } - onModelDownload(modelId); - }; - return ( -
- {/* First Run Welcome */} - {isFirstRun && ( -
-
- {t("modelSelector.welcome")} -
-
- {t("modelSelector.downloadPrompt")} -
-
- )} - - {/* Available Models */} - {availableModels.length > 0 && ( +
+ {downloadedModels.length > 0 ? (
-
- {t("modelSelector.availableModels")} -
- {availableModels.map((model) => ( + {downloadedModels.map((model) => (
handleModelClick(model.id)} @@ -126,121 +55,16 @@ const ModelDropdown: React.FC = ({ {getTranslatedModelDescription(model, t)}
-
- {currentModelId === model.id && ( -
- {t("modelSelector.active")} -
- )} - {currentModelId !== model.id && ( - - )} -
-
-
- ))} -
- )} - - {/* Downloadable Models */} - {downloadableModels.length > 0 && ( -
- {(availableModels.length > 0 || isFirstRun) && ( -
- )} -
- {isFirstRun - ? t("modelSelector.chooseModel") - : t("modelSelector.downloadModels")} -
- {downloadableModels.map((model) => { - const isDownloading = model.id in downloadProgress; - const progress = downloadProgress[model.id]; - - return ( -
handleDownloadClick(model.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleDownloadClick(model.id); - } - }} - tabIndex={0} - role="button" - aria-disabled={isDownloading} - className={`w-full px-3 py-2 text-start hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ - isDownloading - ? "opacity-50 cursor-not-allowed hover:bg-transparent" - : "" - }`} - > -
-
-
- {getTranslatedModelName(model, t)} - {model.is_recommended && isFirstRun && ( - - {t("onboarding.recommended")} - - )} -
-
- {getTranslatedModelDescription(model, t)} -
-
- {t("modelSelector.downloadSize")} ·{" "} - {formatModelSize(Number(model.size_mb))} -
-
-
- {isDownloading && progress - ? `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%` - : t("modelSelector.download")} -
-
- - {isDownloading && progress && ( -
- + {currentModelId === model.id && ( +
+ {t("modelSelector.active")}
)}
- ); - })} +
+ ))}
- )} - - {/* No Models Available */} - {availableModels.length === 0 && downloadableModels.length === 0 && ( + ) : (
{t("modelSelector.noModelsAvailable")}
diff --git a/src/components/model-selector/ModelSelector.tsx b/src/components/model-selector/ModelSelector.tsx index f78134056..368b03017 100644 --- a/src/components/model-selector/ModelSelector.tsx +++ b/src/components/model-selector/ModelSelector.tsx @@ -310,24 +310,6 @@ const ModelSelector: React.FC = ({ onError }) => { } }; - const handleModelDownload = async (modelId: string) => { - try { - setModelError(null); - const result = await commands.downloadModel(modelId); - if (result.status === "error") { - const errorMsg = result.error; - setModelError(errorMsg); - setModelStatus("error"); - onError?.(errorMsg); - } - } catch (err) { - const errorMsg = `${err}`; - setModelError(errorMsg); - setModelStatus("error"); - onError?.(errorMsg); - } - }; - const getCurrentModel = () => { return models.find((m) => m.id === currentModelId); }; @@ -399,14 +381,6 @@ const ModelSelector: React.FC = ({ onError }) => { } }; - const handleModelDelete = async (modelId: string) => { - const result = await commands.deleteModel(modelId); - if (result.status === "ok") { - await loadModels(); - setModelError(null); - } - }; - return ( <> {/* Model Status and Switcher */} @@ -423,11 +397,7 @@ const ModelSelector: React.FC = ({ onError }) => { )}
diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 67000c2db..05bf903a7 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -82,10 +82,10 @@ export const ModelsSettings: React.FC = () => { }, [languageFilter, t]); const getModelStatus = (modelId: string): ModelCardStatus => { - if (extractingModels.has(modelId)) { + if (modelId in extractingModels) { return "extracting"; } - if (downloadingModels.has(modelId)) { + if (modelId in downloadingModels) { return "downloading"; } if (switchingModelId === modelId) { @@ -102,12 +102,12 @@ export const ModelsSettings: React.FC = () => { }; const getDownloadProgress = (modelId: string): number | undefined => { - const progress = downloadProgress.get(modelId); + const progress = downloadProgress[modelId]; return progress?.percentage; }; const getDownloadSpeed = (modelId: string): number | undefined => { - const stats = downloadStats.get(modelId); + const stats = downloadStats[modelId]; return stats?.speed; }; diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 5c6a7645f..54bc0ba9d 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -80,14 +80,7 @@ } }, "modelSelector": { - "welcome": "Vítejte v Handy!", - "downloadPrompt": "Pro zahájení přepisu si níže stáhněte model.", - "availableModels": "Dostupné modely", - "downloadModels": "Stáhnout modely", - "chooseModel": "Vybrat model", "active": "Aktivní", - "download": "Stáhnout", - "downloadSize": "Velikost stažení", "noModelsAvailable": "Nejsou dostupné žádné modely", "extracting": "Rozbaluji {{modelName}}...", "extractingMultiple": "Rozbaluji {{count}} modelů...", @@ -102,8 +95,6 @@ "noModelDownloadRequired": "Žádný model - je nutné stáhnout", "deleteModel": "Smazat {{modelName}}", "switching": "Přepínání...", - "cancel": "Zrušit", - "cancelDownload": "Zrušit stahování", "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Podporuje více vstupních jazyků", @@ -394,6 +385,11 @@ "label": "Přidat koncovou mezeru", "description": "Přidat mezeru po vloženém přepisu" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Data aplikace:", "models": "Modely:", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 32ca49b6b..a1e325674 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -80,15 +80,8 @@ } }, "modelSelector": { - "welcome": "Willkommen bei Handy!", - "downloadPrompt": "Lade ein Modell herunter, um mit der Transkription zu beginnen.", - "availableModels": "Verfügbare Modelle", - "downloadModels": "Modelle herunterladen", - "chooseModel": "Modell auswählen", "active": "Aktiv", "switching": "Wechseln...", - "download": "Herunterladen", - "downloadSize": "Downloadgröße", "noModelsAvailable": "Keine Modelle verfügbar", "extracting": "Entpacke {{modelName}}...", "extractingMultiple": "Entpacke {{count}} Modelle...", @@ -395,6 +388,11 @@ "label": "Leerzeichen anhängen", "description": "Leerzeichen nach eingefügter Transkription hinzufügen" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "App-Daten:", "models": "Modelle:", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index b2ec38b79..9ead7ef17 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -80,15 +80,8 @@ } }, "modelSelector": { - "welcome": "Welcome to Handy!", - "downloadPrompt": "Download a model below to get started with transcription.", - "availableModels": "Available Models", - "downloadModels": "Download Models", - "chooseModel": "Choose a Model", "active": "Active", "switching": "Switching...", - "download": "Download", - "downloadSize": "Download size", "noModelsAvailable": "No models available", "extracting": "Extracting {{modelName}}...", "extractingMultiple": "Extracting {{count}} models...", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 3e6321603..c26335f71 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -80,15 +80,8 @@ } }, "modelSelector": { - "welcome": "¡Bienvenido a Handy!", - "downloadPrompt": "Descarga un modelo a continuación para comenzar con la transcripción.", - "availableModels": "Modelos Disponibles", - "downloadModels": "Descargar Modelos", - "chooseModel": "Elegir un Modelo", "active": "Activo", "switching": "Cambiando...", - "download": "Descargar", - "downloadSize": "Tamaño de descarga", "noModelsAvailable": "No hay modelos disponibles", "extracting": "Extrayendo {{modelName}}...", "extractingMultiple": "Extrayendo {{count}} modelos...", @@ -395,6 +388,11 @@ "label": "Agregar Espacio Final", "description": "Agregar un espacio después de la transcripción pegada" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Datos de la Aplicación:", "models": "Modelos:", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 012026d30..9f49f2ec6 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -1,5 +1,4 @@ { - "_comment": "French translation for Handy. Contribute at: https://github.com/cjpais/Handy", "tray": { "settings": "Paramètres...", "checkUpdates": "Rechercher des mises à jour...", @@ -81,15 +80,8 @@ } }, "modelSelector": { - "welcome": "Bienvenue sur Handy !", - "downloadPrompt": "Téléchargez un modèle ci-dessous pour commencer la transcription.", - "availableModels": "Modèles Disponibles", - "downloadModels": "Télécharger des Modèles", - "chooseModel": "Choisir un Modèle", "active": "Actif", "switching": "Changement...", - "download": "Télécharger", - "downloadSize": "Taille du téléchargement", "noModelsAvailable": "Aucun modèle disponible", "extracting": "Extraction de {{modelName}}...", "extractingMultiple": "Extraction de {{count}} modèles...", @@ -396,6 +388,11 @@ "label": "Ajouter un espace final", "description": "Ajouter un espace après la transcription collée" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Données de l'application :", "models": "Modèles :", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 4e9fd99e8..bc9471206 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -80,15 +80,8 @@ } }, "modelSelector": { - "welcome": "Benvenuto in Handy!", - "downloadPrompt": "Scarica un modello per cominciare a trascrivere.", - "availableModels": "Modelli Disponibili", - "downloadModels": "Scarica Modelli", - "chooseModel": "Scegli un Modello", "active": "Attivo", "switching": "Cambio...", - "download": "Scarica", - "downloadSize": "Dimensione download", "noModelsAvailable": "Nessun modello disponibile", "extracting": "Estrazione di {{modelName}}...", "extractingMultiple": "Estrazione di {{count}} modelli...", @@ -395,6 +388,11 @@ "label": "Aggiungi Spazio Finale", "description": "Aggiungi uno spazio dopo la trascrizione incollata" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Dati App:", "models": "Modelli:", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 71a243faa..024873a19 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -80,15 +80,8 @@ } }, "modelSelector": { - "welcome": "Handyへようこそ!", - "downloadPrompt": "文字起こしを開始するには、下からモデルをダウンロードしてください。", - "availableModels": "利用可能なモデル", - "downloadModels": "モデルをダウンロード", - "chooseModel": "モデルを選択", "active": "アクティブ", "switching": "切替中...", - "download": "ダウンロード", - "downloadSize": "ダウンロードサイズ", "noModelsAvailable": "利用可能なモデルがありません", "extracting": "{{modelName}}を展開中...", "extractingMultiple": "{{count}}個のモデルを展開中...", @@ -395,6 +388,11 @@ "label": "末尾にスペースを追加", "description": "貼り付けた文字起こしの後にスペースを追加" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "アプリデータ:", "models": "モデル:", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 4699873cc..80feb3cb5 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -80,15 +80,8 @@ } }, "modelSelector": { - "welcome": "Witamy w Handy!", - "downloadPrompt": "Pobierz model poniżej, aby rozpocząć transkrypcję.", - "availableModels": "Dostępne modele", - "downloadModels": "Pobierz modele", - "chooseModel": "Wybierz model", "active": "Aktywny", "switching": "Przełączanie...", - "download": "Pobierz", - "downloadSize": "Rozmiar pobierania", "noModelsAvailable": "Brak dostępnych modeli", "extracting": "Rozpakowywanie {{modelName}}...", "extractingMultiple": "Rozpakowywanie {{count}} modeli...", @@ -395,6 +388,11 @@ "label": "Dodaj spację na końcu", "description": "Dodaj spację po wklejonej transkrypcji" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Dane aplikacji:", "models": "Modele:", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 2d518b262..7ad11dd42 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "Geral", + "models": "Models", "advanced": "Avançado", "postProcessing": "Pós-Processamento", "history": "Histórico", @@ -79,14 +80,7 @@ } }, "modelSelector": { - "welcome": "Bem-vindo ao Handy!", - "downloadPrompt": "Baixe um modelo abaixo para começar a transcrever.", - "availableModels": "Modelos Disponíveis", - "downloadModels": "Baixar Modelos", - "chooseModel": "Escolher um Modelo", "active": "Ativo", - "download": "Baixar", - "downloadSize": "Tamanho do download", "noModelsAvailable": "Nenhum modelo disponível", "extracting": "Extraindo {{modelName}}...", "extractingMultiple": "Extraindo {{count}} modelos...", @@ -99,9 +93,21 @@ "modelError": "Erro no Modelo", "modelUnloaded": "Modelo Descarregado", "noModelDownloadRequired": "Sem Modelo - Download Necessário", - "deleteModel": "Excluir {{modelName}}" + "deleteModel": "Excluir {{modelName}}", + "switching": "Switching...", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "Supports multiple input languages", + "multiLanguage": "Multi-language", + "translation": "Can translate to English", + "translate": "Translate to English" + } }, "settings": { + "modelSettings": { + "title": "{{model}} Settings", + "noSettingsNeeded": "This model works automatically with no configuration needed." + }, "general": { "title": "Geral", "shortcut": { @@ -140,6 +146,21 @@ "description": "Segure para gravar, solte para parar" } }, + "models": { + "title": "Transcription Models", + "description": "Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.", + "downloaded": "Downloaded", + "available": "Available to Download", + "deleteConfirm": "Are you sure you want to delete {{modelName}}? You will need to download it again to use it.", + "deleteTitle": "Delete Model", + "filters": { + "all": "All", + "multiLanguage": "Multi-language", + "translation": "Translation", + "allLanguages": "All Languages" + }, + "noModelsMatch": "No models match this filter." + }, "sound": { "title": "Som", "microphone": { @@ -367,6 +388,11 @@ "label": "Adicionar Espaço Final", "description": "Adicionar um espaço após a transcrição colada" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Dados do App:", "models": "Modelos:", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index ed5708e3d..d27b32f5e 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -80,15 +80,8 @@ } }, "modelSelector": { - "welcome": "Добро пожаловать в Handy!", - "downloadPrompt": "Загрузите модель ниже, чтобы начать транскрипцию.", - "availableModels": "Доступные модели", - "downloadModels": "Скачать модели", - "chooseModel": "Выберите модель", "active": "Активный", "switching": "Переключение...", - "download": "Скачать", - "downloadSize": "Размер загрузки", "noModelsAvailable": "Нет доступных моделей", "extracting": "Извлечение {{modelName}}...", "extractingMultiple": "Извлечение моделей {{count}}...", @@ -395,6 +388,11 @@ "label": "Добавить конечный пробел", "description": "Добавить пробел после вставленной транскрипции" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Данные приложения:", "models": "Модели:", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 8a0969541..f75ac08f8 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -80,14 +80,7 @@ } }, "modelSelector": { - "welcome": "Handy'ye Hoş Geldiniz!", - "downloadPrompt": "Transkripsiyona başlamak için aşağıdan bir model indirin.", - "availableModels": "Mevcut Modeller", - "downloadModels": "Modelleri İndir", - "chooseModel": "Bir Model Seçin", "active": "Aktif", - "download": "İndir", - "downloadSize": "İndirme boyutu", "noModelsAvailable": "Kullanılabilir model yok", "extracting": "{{modelName}} çıkarılıyor...", "extractingMultiple": "{{count}} model çıkarılıyor...", @@ -102,8 +95,6 @@ "noModelDownloadRequired": "Model Yok – İndirme Gerekli", "deleteModel": "{{modelName}} Sil", "switching": "Değiştiriliyor...", - "cancel": "İptal", - "cancelDownload": "İndirmeyi iptal et", "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Birden fazla giriş dilini destekler", @@ -394,6 +385,11 @@ "label": "Sonuna Boşluk Ekle", "description": "Yapıştırılan transkripsiyondan sonra boşluk ekler" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Uygulama Verileri:", "models": "Modeller:", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 066594f06..6fcb71711 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "Загальні", + "models": "Models", "advanced": "Розширені", "postProcessing": "Постобробка", "history": "Історія", @@ -79,14 +80,7 @@ } }, "modelSelector": { - "welcome": "Ласкаво просимо до Handy!", - "downloadPrompt": "Завантажте модель нижче, щоб почати транскрипцію.", - "availableModels": "Доступні моделі", - "downloadModels": "Завантажити моделі", - "chooseModel": "Оберіть модель", "active": "Активна", - "download": "Завантажити", - "downloadSize": "Розмір завантаження", "noModelsAvailable": "Немає доступних моделей", "extracting": "Розпакування {{modelName}}...", "extractingMultiple": "Розпакування {{count}} моделей...", @@ -99,9 +93,21 @@ "modelError": "Помилка моделі", "modelUnloaded": "Модель вивантажена", "noModelDownloadRequired": "Немає моделі - потрібно завантажити", - "deleteModel": "Видалити {{modelName}}" + "deleteModel": "Видалити {{modelName}}", + "switching": "Switching...", + "downloadSpeed": "{{speed}} MB/s", + "capabilities": { + "languageSelection": "Supports multiple input languages", + "multiLanguage": "Multi-language", + "translation": "Can translate to English", + "translate": "Translate to English" + } }, "settings": { + "modelSettings": { + "title": "{{model}} Settings", + "noSettingsNeeded": "This model works automatically with no configuration needed." + }, "general": { "title": "Загальні", "shortcut": { @@ -140,6 +146,21 @@ "description": "Утримуйте для запису, відпустіть для зупинки" } }, + "models": { + "title": "Transcription Models", + "description": "Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.", + "downloaded": "Downloaded", + "available": "Available to Download", + "deleteConfirm": "Are you sure you want to delete {{modelName}}? You will need to download it again to use it.", + "deleteTitle": "Delete Model", + "filters": { + "all": "All", + "multiLanguage": "Multi-language", + "translation": "Translation", + "allLanguages": "All Languages" + }, + "noModelsMatch": "No models match this filter." + }, "sound": { "title": "Звук", "microphone": { @@ -367,6 +388,11 @@ "label": "Додавати пробіл в кінці", "description": "Додавати пробіл після вставленої транскрипції" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Дані програми:", "models": "Моделі:", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 9d46c6c59..c7620606f 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -1,5 +1,4 @@ { - "_comment": "Vietnamese translation for Handy. Contribute at: https://github.com/cjpais/Handy", "tray": { "settings": "Cài đặt...", "checkUpdates": "Kiểm tra cập nhật...", @@ -81,15 +80,8 @@ } }, "modelSelector": { - "welcome": "Chào mừng đến với Handy!", - "downloadPrompt": "Tải xuống mô hình bên dưới để bắt đầu chuyển đổi.", - "availableModels": "Các Mô Hình Có Sẵn", - "downloadModels": "Tải Xuống Mô Hình", - "chooseModel": "Chọn Mô Hình", "active": "Đang hoạt động", "switching": "Đang chuyển...", - "download": "Tải xuống", - "downloadSize": "Kích thước tải xuống", "noModelsAvailable": "Không có mô hình nào", "extracting": "Đang giải nén {{modelName}}...", "extractingMultiple": "Đang giải nén {{count}} mô hình...", @@ -396,6 +388,11 @@ "label": "Thêm dấu cách cuối", "description": "Thêm một dấu cách sau bản ghi đã dán" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "Dữ liệu ứng dụng:", "models": "Mô hình:", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index aed7afddd..478c0022b 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -80,15 +80,8 @@ } }, "modelSelector": { - "welcome": "欢迎使用 Handy!", - "downloadPrompt": "请下载以下模型以开始转录。", - "availableModels": "可用模型", - "downloadModels": "下载模型", - "chooseModel": "选择模型", "active": "活跃", "switching": "切换中...", - "download": "下载", - "downloadSize": "下载大小", "noModelsAvailable": "没有可用的模型", "extracting": "正在解压 {{modelName}}...", "extractingMultiple": "正在解压 {{count}} 个模型...", @@ -395,6 +388,11 @@ "label": "追加尾部空格", "description": "在粘贴的转录后添加空格" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "应用数据:", "models": "模型:", diff --git a/src/stores/modelStore.ts b/src/stores/modelStore.ts index 242010982..8af5518d3 100644 --- a/src/stores/modelStore.ts +++ b/src/stores/modelStore.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; +import { produce } from "immer"; import { listen } from "@tauri-apps/api/event"; import { commands, type ModelInfo } from "@/bindings"; @@ -17,13 +18,14 @@ interface DownloadStats { speed: number; // MB/s } +// Using Record instead of Set/Map for Immer compatibility interface ModelsStore { models: ModelInfo[]; currentModel: string; - downloadingModels: Set; - extractingModels: Set; - downloadProgress: Map; - downloadStats: Map; + downloadingModels: Record; + extractingModels: Record; + downloadProgress: Record; + downloadStats: Record; loading: boolean; error: string | null; hasAnyModels: boolean; @@ -37,6 +39,7 @@ interface ModelsStore { checkFirstRun: () => Promise; selectModel: (modelId: string) => Promise; downloadModel: (modelId: string) => Promise; + cancelDownload: (modelId: string) => Promise; deleteModel: (modelId: string) => Promise; getModelInfo: (modelId: string) => ModelInfo | undefined; isModelDownloading: (modelId: string) => boolean; @@ -54,10 +57,10 @@ export const useModelStore = create()( subscribeWithSelector((set, get) => ({ models: [], currentModel: "", - downloadingModels: new Set(), - extractingModels: new Set(), - downloadProgress: new Map(), - downloadStats: new Map(), + downloadingModels: {}, + extractingModels: {}, + downloadProgress: {}, + downloadStats: {}, loading: true, error: null, hasAnyModels: false, @@ -77,10 +80,29 @@ export const useModelStore = create()( set({ models: result.data, error: null }); // Sync downloading state from backend - const currentlyDownloading = new Set( - result.data.filter((m) => m.is_downloading).map((m) => m.id), + set( + produce((state) => { + const backendDownloading: Record = {}; + result.data + .filter((m) => m.is_downloading) + .forEach((m) => { + backendDownloading[m.id] = true; + }); + + // Merge: keep frontend state if downloading, add backend state + Object.keys(backendDownloading).forEach((id) => { + state.downloadingModels[id] = true; + }); + + // Remove models that backend says are NOT downloading AND + // frontend doesn't have progress for (completed/cancelled) + Object.keys(state.downloadingModels).forEach((id) => { + if (!backendDownloading[id] && !state.downloadProgress[id]) { + delete state.downloadingModels[id]; + } + }); + }), ); - set({ downloadingModels: currentlyDownloading }); } else { set({ error: `Failed to load models: ${result.error}` }); } @@ -141,28 +163,56 @@ export const useModelStore = create()( downloadModel: async (modelId: string) => { try { set({ error: null }); - set((state) => ({ - downloadingModels: new Set(state.downloadingModels).add(modelId), - })); + set( + produce((state) => { + state.downloadingModels[modelId] = true; + }), + ); const result = await commands.downloadModel(modelId); if (result.status === "ok") { return true; } else { set({ error: `Failed to download model: ${result.error}` }); - set((state) => { - const next = new Set(state.downloadingModels); - next.delete(modelId); - return { downloadingModels: next }; - }); + set( + produce((state) => { + delete state.downloadingModels[modelId]; + }), + ); return false; } } catch (err) { set({ error: `Failed to download model: ${err}` }); - set((state) => { - const next = new Set(state.downloadingModels); - next.delete(modelId); - return { downloadingModels: next }; - }); + set( + produce((state) => { + delete state.downloadingModels[modelId]; + }), + ); + return false; + } + }, + + cancelDownload: async (modelId: string) => { + try { + set({ error: null }); + const result = await commands.cancelDownload(modelId); + if (result.status === "ok") { + set( + produce((state) => { + delete state.downloadingModels[modelId]; + delete state.downloadProgress[modelId]; + delete state.downloadStats[modelId]; + }), + ); + + // Reload models to sync with backend state + await get().loadModels(); + return true; + } else { + set({ error: `Failed to cancel download: ${result.error}` }); + return false; + } + } catch (err) { + set({ error: `Failed to cancel download: ${err}` }); return false; } }, @@ -189,15 +239,15 @@ export const useModelStore = create()( }, isModelDownloading: (modelId: string) => { - return get().downloadingModels.has(modelId); + return modelId in get().downloadingModels; }, isModelExtracting: (modelId: string) => { - return get().extractingModels.has(modelId); + return modelId in get().extractingModels; }, getDownloadProgress: (modelId: string) => { - return get().downloadProgress.get(modelId); + return get().downloadProgress[modelId]; }, initialize: async () => { @@ -211,83 +261,77 @@ export const useModelStore = create()( // Set up event listeners listen("model-download-progress", (event) => { const progress = event.payload; - set((state) => ({ - downloadProgress: new Map(state.downloadProgress).set( - progress.model_id, - progress, - ), - })); + set( + produce((state) => { + state.downloadProgress[progress.model_id] = progress; + }), + ); // Update download stats for speed calculation const now = Date.now(); - set((state) => { - const current = state.downloadStats.get(progress.model_id); - const newStats = new Map(state.downloadStats); - - if (!current) { - newStats.set(progress.model_id, { - startTime: now, - lastUpdate: now, - totalDownloaded: progress.downloaded, - speed: 0, - }); - } else { - const timeDiff = (now - current.lastUpdate) / 1000; - const bytesDiff = progress.downloaded - current.totalDownloaded; - - if (timeDiff > 0.5) { - const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; - const validCurrentSpeed = Math.max(0, currentSpeed); - const smoothedSpeed = - current.speed > 0 - ? current.speed * 0.8 + validCurrentSpeed * 0.2 - : validCurrentSpeed; - - newStats.set(progress.model_id, { - startTime: current.startTime, + set( + produce((state) => { + const current = state.downloadStats[progress.model_id]; + + if (!current) { + state.downloadStats[progress.model_id] = { + startTime: now, lastUpdate: now, totalDownloaded: progress.downloaded, - speed: Math.max(0, smoothedSpeed), - }); + speed: 0, + }; + } else { + const timeDiff = (now - current.lastUpdate) / 1000; + const bytesDiff = progress.downloaded - current.totalDownloaded; + + if (timeDiff > 0.5) { + const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; + const validCurrentSpeed = Math.max(0, currentSpeed); + const smoothedSpeed = + current.speed > 0 + ? current.speed * 0.8 + validCurrentSpeed * 0.2 + : validCurrentSpeed; + + state.downloadStats[progress.model_id] = { + startTime: current.startTime, + lastUpdate: now, + totalDownloaded: progress.downloaded, + speed: Math.max(0, smoothedSpeed), + }; + } } - } - - return { downloadStats: newStats }; - }); + }), + ); }); listen("model-download-complete", (event) => { const modelId = event.payload; - set((state) => { - const nextDownloading = new Set(state.downloadingModels); - nextDownloading.delete(modelId); - const nextProgress = new Map(state.downloadProgress); - nextProgress.delete(modelId); - const nextStats = new Map(state.downloadStats); - nextStats.delete(modelId); - return { - downloadingModels: nextDownloading, - downloadProgress: nextProgress, - downloadStats: nextStats, - }; - }); + set( + produce((state) => { + delete state.downloadingModels[modelId]; + delete state.downloadProgress[modelId]; + delete state.downloadStats[modelId]; + }), + ); get().loadModels(); }); listen("model-extraction-started", (event) => { const modelId = event.payload; - set((state) => ({ - extractingModels: new Set(state.extractingModels).add(modelId), - })); + set( + produce((state) => { + state.extractingModels[modelId] = true; + }), + ); }); listen("model-extraction-completed", (event) => { const modelId = event.payload; - set((state) => { - const next = new Set(state.extractingModels); - next.delete(modelId); - return { extractingModels: next }; - }); + set( + produce((state) => { + delete state.extractingModels[modelId]; + }), + ); get().loadModels(); }); @@ -295,14 +339,12 @@ export const useModelStore = create()( "model-extraction-failed", (event) => { const modelId = event.payload.model_id; - set((state) => { - const next = new Set(state.extractingModels); - next.delete(modelId); - return { - extractingModels: next, - error: `Failed to extract model: ${event.payload.error}`, - }; - }); + set( + produce((state) => { + delete state.extractingModels[modelId]; + state.error = `Failed to extract model: ${event.payload.error}`; + }), + ); }, ); From a7fbdc7f33d65d727fc119b09fff67fac40f4926 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Tue, 27 Jan 2026 22:17:26 -0500 Subject: [PATCH 07/26] fix: add download cancellation support and ui improvements - add full download cancellation with Arc flags in rust backend - add progress event throttling (100ms) to prevent ui freeze - add cancel button to model card in settings page - add model-deleted event listener to refresh dropdown after deletion - remove pink background from recommended models in settings (keep badge only) - add cancel/cancelDownload translation keys to all 14 languages --- src-tauri/src/managers/model.rs | 107 ++++++++++++++---- .../model-selector/ModelSelector.tsx | 7 ++ src/components/onboarding/ModelCard.tsx | 36 ++++-- .../settings/models/ModelsSettings.tsx | 11 +- src/i18n/locales/cs/translation.json | 4 +- src/i18n/locales/de/translation.json | 4 +- src/i18n/locales/en/translation.json | 2 + src/i18n/locales/es/translation.json | 4 +- src/i18n/locales/fr/translation.json | 4 +- src/i18n/locales/it/translation.json | 4 +- src/i18n/locales/ja/translation.json | 4 +- src/i18n/locales/pl/translation.json | 4 +- src/i18n/locales/pt/translation.json | 4 +- src/i18n/locales/ru/translation.json | 4 +- src/i18n/locales/tr/translation.json | 4 +- src/i18n/locales/uk/translation.json | 4 +- src/i18n/locales/vi/translation.json | 4 +- src/i18n/locales/zh/translation.json | 4 +- 18 files changed, 169 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 77ee4c4aa..31fa0717d 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -10,7 +10,9 @@ use std::fs; use std::fs::File; use std::io::Write; use std::path::PathBuf; -use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use tar::Archive; use tauri::{AppHandle, Emitter, Manager}; @@ -53,6 +55,7 @@ pub struct ModelManager { app_handle: AppHandle, models_dir: PathBuf, available_models: Mutex>, + cancel_flags: Arc>>>, } impl ModelManager { @@ -231,6 +234,7 @@ impl ModelManager { app_handle: app_handle.clone(), models_dir, available_models: Mutex::new(available_models), + cancel_flags: Arc::new(Mutex::new(HashMap::new())), }; // Migrate any bundled models to user directory @@ -400,6 +404,13 @@ impl ModelManager { } } + // Create cancellation flag for this download + let cancel_flag = Arc::new(AtomicBool::new(false)); + { + let mut flags = self.cancel_flags.lock().unwrap(); + flags.insert(model_id.to_string(), cancel_flag.clone()); + } + // Create HTTP client with range request for resuming let client = reqwest::Client::new(); let mut request = client.get(&url); @@ -480,8 +491,36 @@ impl ModelManager { .app_handle .emit("model-download-progress", &initial_progress); + // Throttle progress events to max 10/sec (100ms intervals) + let mut last_emit = Instant::now(); + let throttle_duration = Duration::from_millis(100); + // Download with progress while let Some(chunk) = stream.next().await { + // Check if download was cancelled + if cancel_flag.load(Ordering::Relaxed) { + // Close the file before returning + drop(file); + info!("Download cancelled for: {}", model_id); + + // Update state to mark as not downloading + { + let mut models = self.available_models.lock().unwrap(); + if let Some(model) = models.get_mut(model_id) { + model.is_downloading = false; + } + } + + // Remove cancel flag + { + let mut flags = self.cancel_flags.lock().unwrap(); + flags.remove(model_id); + } + + // Keep partial file for resume functionality + return Ok(()); + } + let chunk = chunk.map_err(|e| { // Mark as not downloading on error { @@ -502,17 +541,34 @@ impl ModelManager { 0.0 }; - // Emit progress event - let progress = DownloadProgress { - model_id: model_id.to_string(), - downloaded, - total: total_size, - percentage, - }; - - let _ = self.app_handle.emit("model-download-progress", &progress); + // Emit progress event (throttled to avoid UI freeze) + if last_emit.elapsed() >= throttle_duration { + let progress = DownloadProgress { + model_id: model_id.to_string(), + downloaded, + total: total_size, + percentage, + }; + let _ = self.app_handle.emit("model-download-progress", &progress); + last_emit = Instant::now(); + } } + // Emit final progress to ensure 100% is shown + let final_progress = DownloadProgress { + model_id: model_id.to_string(), + downloaded, + total: total_size, + percentage: if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 100.0 + }, + }; + let _ = self + .app_handle + .emit("model-download-progress", &final_progress); + file.flush()?; drop(file); // Ensure file is closed before moving @@ -620,6 +676,12 @@ impl ModelManager { } } + // Remove cancel flag on successful completion + { + let mut flags = self.cancel_flags.lock().unwrap(); + flags.remove(model_id); + } + // Emit completion event let _ = self.app_handle.emit("model-download-complete", model_id); @@ -741,15 +803,18 @@ impl ModelManager { pub fn cancel_download(&self, model_id: &str) -> Result<()> { debug!("ModelManager: cancel_download called for: {}", model_id); - let _model_info = { - let models = self.available_models.lock().unwrap(); - models.get(model_id).cloned() - }; - - let _model_info = - _model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?; + // Set the cancellation flag to stop the download loop + { + let flags = self.cancel_flags.lock().unwrap(); + if let Some(flag) = flags.get(model_id) { + flag.store(true, Ordering::Relaxed); + info!("Cancellation flag set for: {}", model_id); + } else { + warn!("No active download found for: {}", model_id); + } + } - // Mark as not downloading + // Update state immediately for UI responsiveness { let mut models = self.available_models.lock().unwrap(); if let Some(model) = models.get_mut(model_id) { @@ -757,14 +822,10 @@ impl ModelManager { } } - // Note: The actual download cancellation would need to be handled - // by the download task itself. This just updates the state. - // The partial file is kept so the download can be resumed later. - // Update download status to reflect current state self.update_download_status()?; - info!("Download cancelled for: {}", model_id); + info!("Download cancellation initiated for: {}", model_id); Ok(()) } } diff --git a/src/components/model-selector/ModelSelector.tsx b/src/components/model-selector/ModelSelector.tsx index 368b03017..8f658f58c 100644 --- a/src/components/model-selector/ModelSelector.tsx +++ b/src/components/model-selector/ModelSelector.tsx @@ -227,6 +227,12 @@ const ModelSelector: React.FC = ({ onError }) => { setModelStatus("error"); }); + // Listen for model deletion + const modelDeletedUnlisten = listen("model-deleted", () => { + loadModels(); // Refresh models list + loadCurrentModel(); // Update current model in case deleted model was active + }); + // Click outside to close dropdown const handleClickOutside = (event: MouseEvent) => { if ( @@ -247,6 +253,7 @@ const ModelSelector: React.FC = ({ onError }) => { extractionStartedUnlisten.then((fn) => fn()); extractionCompletedUnlisten.then((fn) => fn()); extractionFailedUnlisten.then((fn) => fn()); + modelDeletedUnlisten.then((fn) => fn()); }; }, []); diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 98f6e2eb7..741f1ec51 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -33,6 +33,7 @@ interface ModelCardProps { onSelect: (modelId: string) => void; onDownload?: (modelId: string) => void; onDelete?: (modelId: string) => void; + onCancel?: (modelId: string) => void; downloadProgress?: number; downloadSpeed?: number; // MB/s } @@ -46,6 +47,7 @@ const ModelCard: React.FC = ({ onSelect, onDownload, onDelete, + onCancel, downloadProgress, downloadSpeed, }) => { @@ -167,19 +169,35 @@ const ModelCard: React.FC = ({ style={{ width: `${downloadProgress}%` }} />
-
- +
+ {t("modelSelector.downloading", { percentage: Math.round(downloadProgress), })} - {downloadSpeed !== undefined && downloadSpeed > 0 && ( - - {t("modelSelector.downloadSpeed", { - speed: downloadSpeed.toFixed(1), - })} - - )} +
+ {downloadSpeed !== undefined && downloadSpeed > 0 && ( + + {t("modelSelector.downloadSpeed", { + speed: downloadSpeed.toFixed(1), + })} + + )} + {onCancel && ( + + )} +
)} diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 05bf903a7..389ccd80b 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -38,6 +38,7 @@ export const ModelsSettings: React.FC = () => { extractingModels, loading, downloadModel, + cancelDownload, selectModel, deleteModel, } = useModelStore(); @@ -145,6 +146,14 @@ export const ModelsSettings: React.FC = () => { } }; + const handleModelCancel = async (modelId: string) => { + try { + await cancelDownload(modelId); + } catch (err) { + console.error(`Failed to cancel download for ${modelId}:`, err); + } + }; + // Filter models based on active filter and language filter const filteredModels = useMemo(() => { return models.filter((model: ModelInfo) => { @@ -319,10 +328,10 @@ export const ModelsSettings: React.FC = () => { key={model.id} model={model} status={getModelStatus(model.id)} - variant={model.is_recommended ? "featured" : "default"} onSelect={handleModelSelect} onDownload={handleModelDownload} onDelete={handleModelDelete} + onCancel={handleModelCancel} downloadProgress={getDownloadProgress(model.id)} downloadSpeed={getDownloadSpeed(model.id)} /> diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 54bc0ba9d..d78174e7b 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Vícejazyčný", "translation": "Umí překládat do angličtiny", "translate": "Přeložit do angličtiny" - } + }, + "cancel": "Zrušit", + "cancelDownload": "Zrušit stahování" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index a1e325674..2e9840c07 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Mehrsprachig", "translation": "Kann ins Englische übersetzen", "translate": "Ins Englische übersetzen" - } + }, + "cancel": "Abbrechen", + "cancelDownload": "Download abbrechen" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 9ead7ef17..bc9a46db4 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -96,6 +96,8 @@ "noModelDownloadRequired": "No Model - Download Required", "deleteModel": "Delete {{modelName}}", "downloadSpeed": "{{speed}} MB/s", + "cancel": "Cancel", + "cancelDownload": "Cancel download", "capabilities": { "languageSelection": "Supports multiple input languages", "multiLanguage": "Multi-language", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index c26335f71..018f59b4d 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Multiidioma", "translation": "Puede traducir al inglés", "translate": "Traducir al inglés" - } + }, + "cancel": "Cancelar", + "cancelDownload": "Cancelar descarga" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 9f49f2ec6..84db91920 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Multilingue", "translation": "Peut traduire en anglais", "translate": "Traduire en anglais" - } + }, + "cancel": "Annuler", + "cancelDownload": "Annuler le téléchargement" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index bc9471206..45156b6fd 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Multilingua", "translation": "Può tradurre in inglese", "translate": "Traduci in inglese" - } + }, + "cancel": "Annulla", + "cancelDownload": "Annulla download" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 024873a19..792fa3936 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "多言語", "translation": "英語への翻訳が可能", "translate": "英語に翻訳" - } + }, + "cancel": "キャンセル", + "cancelDownload": "ダウンロードをキャンセル" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 80feb3cb5..78eb1b691 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Wielojęzyczny", "translation": "Może tłumaczyć na angielski", "translate": "Tłumacz na angielski" - } + }, + "cancel": "Anuluj", + "cancelDownload": "Anuluj pobieranie" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 7ad11dd42..d95baa1ab 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Multi-language", "translation": "Can translate to English", "translate": "Translate to English" - } + }, + "cancel": "Cancelar", + "cancelDownload": "Cancelar download" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index d27b32f5e..87b804289 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Многоязычный", "translation": "Может переводить на английский", "translate": "Перевести на английский" - } + }, + "cancel": "Отмена", + "cancelDownload": "Отменить загрузку" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index f75ac08f8..ad2559467 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Çok dilli", "translation": "İngilizce'ye çevirebilir", "translate": "İngilizce'ye çevir" - } + }, + "cancel": "İptal", + "cancelDownload": "İndirmeyi iptal et" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 6fcb71711..25de779ed 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Multi-language", "translation": "Can translate to English", "translate": "Translate to English" - } + }, + "cancel": "Скасувати", + "cancelDownload": "Скасувати завантаження" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index c7620606f..e562c3628 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "Đa ngôn ngữ", "translation": "Có thể dịch sang tiếng Anh", "translate": "Dịch sang tiếng Anh" - } + }, + "cancel": "Hủy", + "cancelDownload": "Hủy tải xuống" }, "settings": { "modelSettings": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 478c0022b..164e253aa 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -101,7 +101,9 @@ "multiLanguage": "多语言", "translation": "可翻译为英语", "translate": "翻译为英语" - } + }, + "cancel": "取消", + "cancelDownload": "取消下载" }, "settings": { "modelSettings": { From a498f40cf800ae5e86af31ffc3a6e22e45d8d2cc Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Tue, 27 Jan 2026 22:42:51 -0500 Subject: [PATCH 08/26] fix: remove duplicate language/translate settings from general and advanced settings are now only in ModelSettingsCard, not duplicated in their old locations --- src/components/settings/advanced/AdvancedSettings.tsx | 9 --------- src/components/settings/general/GeneralSettings.tsx | 8 -------- 2 files changed, 17 deletions(-) diff --git a/src/components/settings/advanced/AdvancedSettings.tsx b/src/components/settings/advanced/AdvancedSettings.tsx index a5af607b5..fa220ea67 100644 --- a/src/components/settings/advanced/AdvancedSettings.tsx +++ b/src/components/settings/advanced/AdvancedSettings.tsx @@ -8,7 +8,6 @@ import { StartHidden } from "../StartHidden"; import { AutostartToggle } from "../AutostartToggle"; import { PasteMethodSetting } from "../PasteMethod"; import { ClipboardHandlingSetting } from "../ClipboardHandling"; -import { useModelStore } from "../../../stores/modelStore"; import { PostProcessingToggle } from "../PostProcessingToggle"; import { AppendTrailingSpace } from "../AppendTrailingSpace"; import { HistoryLimit } from "../HistoryLimit"; @@ -16,15 +15,10 @@ import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod"; import { ExperimentalToggle } from "../ExperimentalToggle"; import { useSettings } from "../../../hooks/useSettings"; import { KeyboardImplementationSelector } from "../debug/KeyboardImplementationSelector"; -import { TranslateToEnglish } from "../TranslateToEnglish"; export const AdvancedSettings: React.FC = () => { const { t } = useTranslation(); - const { currentModel, getModelInfo } = useModelStore(); const { getSetting } = useSettings(); - const currentModelInfo = getModelInfo(currentModel); - const showTranslateToEnglish = - currentModelInfo?.engine_type === "Whisper" && currentModel !== "turbo"; const experimentalEnabled = getSetting("experimental_enabled") || false; return ( @@ -43,9 +37,6 @@ export const AdvancedSettings: React.FC = () => {
- {showTranslateToEnglish && ( - - )} diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index 61056592d..88bedf8c2 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -1,14 +1,12 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { MicrophoneSelector } from "../MicrophoneSelector"; -import { LanguageSelector } from "../LanguageSelector"; import { ShortcutInput } from "../ShortcutInput"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { OutputDeviceSelector } from "../OutputDeviceSelector"; import { PushToTalk } from "../PushToTalk"; import { AudioFeedback } from "../AudioFeedback"; import { useSettings } from "../../../hooks/useSettings"; -import { useModelStore } from "../../../stores/modelStore"; import { VolumeSlider } from "../VolumeSlider"; import { MuteWhileRecording } from "../MuteWhileRecording"; import { ModelSettingsCard } from "./ModelSettingsCard"; @@ -16,16 +14,10 @@ import { ModelSettingsCard } from "./ModelSettingsCard"; export const GeneralSettings: React.FC = () => { const { t } = useTranslation(); const { audioFeedbackEnabled } = useSettings(); - const { currentModel, getModelInfo } = useModelStore(); - const currentModelInfo = getModelInfo(currentModel); - const showLanguageSelector = currentModelInfo?.engine_type === "Whisper"; return (
- {showLanguageSelector && ( - - )} From e15ac6d007475bdf81774e6e05ee732134ba4f8f Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Tue, 27 Jan 2026 22:50:26 -0500 Subject: [PATCH 09/26] fix: prevent model dropdown from being clipped by window edge --- src/components/model-selector/ModelDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index cef68cbe9..a2f9a69f0 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -25,7 +25,7 @@ const ModelDropdown: React.FC = ({ }; return ( -
+
{downloadedModels.length > 0 ? (
{downloadedModels.map((model) => ( From 8adc41cc19a6253e618af37633d946c69321a7f4 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 1 Feb 2026 12:00:33 +0800 Subject: [PATCH 10/26] add languages explicitly, clean up some ui --- src-tauri/src/managers/model.rs | 58 ++++++++++----- src/bindings.ts | 2 +- src/components/onboarding/ModelCard.tsx | 35 +++++++--- .../settings/general/ModelSettingsCard.tsx | 28 +++----- .../settings/models/ModelsSettings.tsx | 70 ++----------------- src/i18n/locales/en/translation.json | 2 + src/lib/constants/languages.ts | 1 + 7 files changed, 89 insertions(+), 107 deletions(-) diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 31fa0717d..b3ce12a8a 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -36,11 +36,11 @@ pub struct ModelInfo { pub partial_size: u64, pub is_directory: bool, pub engine_type: EngineType, - pub accuracy_score: f32, // 0.0 to 1.0, higher is more accurate - pub speed_score: f32, // 0.0 to 1.0, higher is faster - pub supports_language_selection: bool, // Whether the model supports selecting input language + pub accuracy_score: f32, // 0.0 to 1.0, higher is more accurate + pub speed_score: f32, // 0.0 to 1.0, higher is faster pub supports_translation: bool, // Whether the model supports translating to English - pub is_recommended: bool, // Whether this is the recommended model for new users + pub is_recommended: bool, // Whether this is the recommended model for new users + pub supported_languages: Vec, // Languages this model can transcribe } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -73,6 +73,22 @@ impl ModelManager { let mut available_models = HashMap::new(); + // Whisper supported languages (99 languages from tokenizer) + // Including zh-Hans and zh-Hant variants to match frontend language codes + let whisper_languages: Vec = vec![ + "en", "zh", "zh-Hans", "zh-Hant", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", + "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", + "ro", "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", + "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", "is", + "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", "sn", "yo", + "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", "ht", + "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", "as", "tt", "haw", "ln", + "ha", "ba", "jw", "su", "yue", + ] + .into_iter() + .map(String::from) + .collect(); + // TODO this should be read from a JSON file or something.. available_models.insert( "small".to_string(), @@ -90,9 +106,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.60, speed_score: 0.85, - supports_language_selection: true, supports_translation: true, is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -113,9 +129,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.75, speed_score: 0.60, - supports_language_selection: true, supports_translation: true, is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -135,9 +151,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.80, speed_score: 0.40, - supports_language_selection: true, supports_translation: false, // Turbo doesn't support translation is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -157,9 +173,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.85, speed_score: 0.30, - supports_language_selection: true, supports_translation: true, is_recommended: false, + supported_languages: whisper_languages, }, ); @@ -180,18 +196,28 @@ impl ModelManager { engine_type: EngineType::Parakeet, accuracy_score: 0.85, speed_score: 0.85, - supports_language_selection: false, // Parakeet is English-only - supports_translation: false, // Parakeet doesn't support translation + supports_translation: false, is_recommended: false, + supported_languages: vec!["en".to_string()], }, ); + // Parakeet V3 supported languages (25 EU languages + Russian/Ukrainian): + // bg, hr, cs, da, nl, en, et, fi, fr, de, el, hu, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, ru, uk + let parakeet_v3_languages: Vec = vec![ + "bg", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", "hu", "it", "lv", + "lt", "mt", "pl", "pt", "ro", "sk", "sl", "es", "sv", "ru", "uk", + ] + .into_iter() + .map(String::from) + .collect(); + available_models.insert( "parakeet-tdt-0.6b-v3".to_string(), ModelInfo { id: "parakeet-tdt-0.6b-v3".to_string(), name: "Parakeet V3".to_string(), - description: "Fast and accurate".to_string(), + description: "Fast and accurate. Supports 25 European languages.".to_string(), filename: "parakeet-tdt-0.6b-v3-int8".to_string(), // Directory name url: Some("https://blob.handy.computer/parakeet-v3-int8.tar.gz".to_string()), size_mb: 478, // Approximate size for int8 quantized model @@ -202,9 +228,9 @@ impl ModelManager { engine_type: EngineType::Parakeet, accuracy_score: 0.80, speed_score: 0.85, - supports_language_selection: false, // Parakeet is English-only - supports_translation: false, // Parakeet doesn't support translation - is_recommended: true, // Recommended for new users + supports_translation: false, + is_recommended: true, + supported_languages: parakeet_v3_languages, }, ); @@ -224,9 +250,9 @@ impl ModelManager { engine_type: EngineType::Moonshine, accuracy_score: 0.70, speed_score: 0.90, - supports_language_selection: false, // Moonshine is English-only - supports_translation: false, // Moonshine doesn't support translation + supports_translation: false, is_recommended: false, + supported_languages: vec!["en".to_string()], }, ); diff --git a/src/bindings.ts b/src/bindings.ts index 9e2e228fb..ff3846d32 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_language_selection: boolean; supports_translation: boolean; is_recommended: boolean } +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 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/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 741f1ec51..737d9b6ca 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -14,8 +14,23 @@ import { getTranslatedModelDescription, getTranslatedModelName, } from "../../lib/utils/modelTranslation"; +import { LANGUAGES } from "../../lib/constants/languages"; import Badge from "../ui/Badge"; +// Get display text for model's language support +const getLanguageDisplayText = ( + supportedLanguages: string[], + t: (key: string, options?: Record) => string, +): string => { + if (supportedLanguages.length === 1) { + const langCode = supportedLanguages[0]; + const langName = + LANGUAGES.find((l) => l.value === langCode)?.label || langCode; + return t("modelSelector.capabilities.languageOnly", { language: langName }); + } + return t("modelSelector.capabilities.multiLanguage"); +}; + export type ModelCardStatus = | "downloadable" | "downloading" @@ -142,15 +157,17 @@ const ModelCard: React.FC = ({ {displayDescription}

- {model.supports_language_selection && ( -
- - {t("modelSelector.capabilities.multiLanguage")} -
- )} +
+ + {getLanguageDisplayText(model.supported_languages, t)} +
{model.supports_translation && (
{ const currentModelInfo = models.find((m: ModelInfo) => m.id === currentModel); - const supportsLanguage = - currentModelInfo?.supports_language_selection ?? false; + // Only Whisper models support manual language selection + const supportsLanguageSelection = currentModelInfo?.engine_type === "Whisper"; const supportsTranslation = currentModelInfo?.supports_translation ?? false; - const hasAnySettings = supportsLanguage || supportsTranslation; + const hasAnySettings = supportsLanguageSelection || supportsTranslation; - // Don't render anything if no model is selected yet - if (!currentModel || !currentModelInfo) { + // Don't render anything if no model is selected or no settings available + if (!currentModel || !currentModelInfo || !hasAnySettings) { return null; } @@ -28,19 +28,11 @@ export const ModelSettingsCard: React.FC = () => { model: currentModelInfo.name, })} > - {hasAnySettings ? ( - <> - {supportsLanguage && ( - - )} - {supportsTranslation && ( - - )} - - ) : ( -
- {t("settings.modelSettings.noSettingsNeeded")} -
+ {supportsLanguageSelection && ( + + )} + {supportsTranslation && ( + )} ); diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 389ccd80b..695ac1f21 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -1,28 +1,20 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ask } from "@tauri-apps/plugin-dialog"; -import { ChevronDown, Globe, Languages } from "lucide-react"; +import { ChevronDown, Globe } from "lucide-react"; import type { ModelCardStatus } from "@/components/onboarding"; import { ModelCard } from "@/components/onboarding"; import { useModelStore } from "@/stores/modelStore"; import { LANGUAGES } from "@/lib/constants/languages.ts"; import type { ModelInfo } from "@/bindings"; -type ModelFilter = "all" | "multiLanguage" | "translation"; - -// check if model supports a language based on its capabilities +// check if model supports a language based on its supported_languages list const modelSupportsLanguage = (model: ModelInfo, langCode: string): boolean => { - // models with language selection support all languages in the LANGUAGES list, like Whisper - if (model.supports_language_selection) { - return true; - } - // models without language selection only support English, like Parakeet - return langCode === "en"; + return model.supported_languages.includes(langCode); }; export const ModelsSettings: React.FC = () => { const { t } = useTranslation(); - const [activeFilter, setActiveFilter] = useState("all"); const [switchingModelId, setSwitchingModelId] = useState(null); const [languageFilter, setLanguageFilter] = useState("all"); const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false); @@ -154,27 +146,15 @@ export const ModelsSettings: React.FC = () => { } }; - // Filter models based on active filter and language filter + // Filter models based on language filter const filteredModels = useMemo(() => { return models.filter((model: ModelInfo) => { - // Capability filters - switch (activeFilter) { - case "multiLanguage": - if (!model.supports_language_selection) return false; - break; - case "translation": - if (!model.supports_translation) return false; - break; - } - - // Language filter if (languageFilter !== "all") { if (!modelSupportsLanguage(model, languageFilter)) return false; } - return true; }); - }, [models, activeFilter, languageFilter]); + }, [models, languageFilter]); if (loading) { return ( @@ -196,45 +176,9 @@ export const ModelsSettings: React.FC = () => { {t("settings.models.description")}

-
- - - - +
{/* Language filter dropdown */} -
+
+ {/* Right side: accuracy/speed bars + action buttons */}
From 0c2c8de9c13b1477ca26c32d3ffb93cc1f33be73 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sun, 1 Feb 2026 16:47:56 -0500 Subject: [PATCH 13/26] fix: prevent model deletion from interrupting active extractions Added extracting_models HashSet to track models currently being extracted. The update_download_status() function now skips cleanup of .extracting directories for models that are actively extracting, preventing a race condition where deleting one model would interrupt another model's extraction process. --- src-tauri/src/managers/model.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 1bfc6310b..0ac628d57 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -5,7 +5,7 @@ use futures_util::StreamExt; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use specta::Type; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; use std::io::Write; @@ -56,6 +56,7 @@ pub struct ModelManager { models_dir: PathBuf, available_models: Mutex>, cancel_flags: Arc>>>, + extracting_models: Arc>>, } impl ModelManager { @@ -261,6 +262,7 @@ impl ModelManager { models_dir, available_models: Mutex::new(available_models), cancel_flags: Arc::new(Mutex::new(HashMap::new())), + extracting_models: Arc::new(Mutex::new(HashSet::new())), }; // Migrate any bundled models to user directory @@ -325,7 +327,12 @@ impl ModelManager { .join(format!("{}.extracting", &model.filename)); // Clean up any leftover .extracting directories from interrupted extractions - if extracting_path.exists() { + // But only if this model is NOT currently being extracted + let is_currently_extracting = { + let extracting = self.extracting_models.lock().unwrap(); + extracting.contains(&model.id) + }; + if extracting_path.exists() && !is_currently_extracting { warn!("Cleaning up interrupted extraction for model: {}", model.id); let _ = fs::remove_dir_all(&extracting_path); } @@ -620,6 +627,12 @@ impl ModelManager { // Handle directory-based models (extract tar.gz) vs file-based models if model_info.is_directory { + // Track that this model is being extracted + { + let mut extracting = self.extracting_models.lock().unwrap(); + extracting.insert(model_id.to_string()); + } + // Emit extraction started event let _ = self.app_handle.emit("model-extraction-started", model_id); info!("Extracting archive for directory-based model: {}", model_id); @@ -648,6 +661,11 @@ impl ModelManager { let error_msg = format!("Failed to extract archive: {}", e); // Clean up failed extraction let _ = fs::remove_dir_all(&temp_extract_dir); + // Remove from extracting set + { + let mut extracting = self.extracting_models.lock().unwrap(); + extracting.remove(model_id); + } let _ = self.app_handle.emit( "model-extraction-failed", &serde_json::json!({ @@ -682,6 +700,11 @@ impl ModelManager { } info!("Successfully extracted archive for model: {}", model_id); + // Remove from extracting set + { + let mut extracting = self.extracting_models.lock().unwrap(); + extracting.remove(model_id); + } // Emit extraction completed event let _ = self.app_handle.emit("model-extraction-completed", model_id); From c76d875d8b14bedabca8a965ec8e906affa6a21a Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sun, 1 Feb 2026 16:53:05 -0500 Subject: [PATCH 14/26] refactor: migrate ModelCard buttons to Button component Added two new Button variants for common patterns: - primary-soft: soft/tinted primary buttons (used for download) - danger-ghost: subtle destructive actions (used for delete/cancel) Migrated all hardcoded buttons in ModelCard to use the shared Button component for consistency and maintainability. --- src/components/onboarding/ModelCard.tsx | 27 ++++++++++++++----------- src/components/ui/Button.tsx | 6 +++++- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 68dd4612e..953a7c7ad 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -16,6 +16,7 @@ import { } from "../../lib/utils/modelTranslation"; import { LANGUAGES } from "../../lib/constants/languages"; import Badge from "../ui/Badge"; +import { Button } from "../ui/Button"; // Get display text for model's language support const getLanguageDisplayText = ( @@ -201,18 +202,18 @@ const ModelCard: React.FC = ({ )} {onCancel && ( - + )}
@@ -257,25 +258,27 @@ const ModelCard: React.FC = ({
{status === "downloadable" && onDownload && ( - + )} {onDelete && status === "available" && ( - + )}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index adb389db0..d7878ec2a 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,7 +1,7 @@ import React from "react"; interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: "primary" | "secondary" | "danger" | "ghost"; + variant?: "primary" | "primary-soft" | "secondary" | "danger" | "danger-ghost" | "ghost"; size?: "sm" | "md" | "lg"; } @@ -18,10 +18,14 @@ export const Button: React.FC = ({ const variantClasses = { primary: "text-white bg-background-ui border-background-ui hover:bg-background-ui/80 hover:border-background-ui/80 focus:ring-1 focus:ring-background-ui", + "primary-soft": + "text-text bg-logo-primary/20 border-transparent hover:bg-logo-primary/30 focus:ring-1 focus:ring-logo-primary", secondary: "bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary focus:outline-none", danger: "text-white bg-red-600 border-mid-gray/20 hover:bg-red-700 hover:border-red-700 focus:ring-1 focus:ring-red-500", + "danger-ghost": + "text-red-400 border-transparent hover:text-red-300 hover:bg-red-500/10 focus:bg-red-500/20", ghost: "text-current border-transparent hover:bg-mid-gray/10 hover:border-logo-primary focus:bg-mid-gray/20", }; From 99ab6cebdf2371434ba6e6c9ba27d77d510df2bc Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sun, 1 Feb 2026 16:56:13 -0500 Subject: [PATCH 15/26] feat: separate downloaded and available models into sections Split the models list into "Your Models" and "Available to Download" sections for clearer visual distinction between downloaded and downloadable models. Also adds missing translation keys to all locales: - modelSelector.capabilities.singleLanguage - modelSelector.capabilities.languageOnly - settings.models.yourModels - settings.models.availableModels --- .../settings/models/ModelsSettings.tsx | 78 +++++++++++++++---- src/components/ui/Button.tsx | 8 +- src/i18n/locales/ar/translation.json | 49 +++++++++--- src/i18n/locales/cs/translation.json | 12 ++- src/i18n/locales/de/translation.json | 12 ++- src/i18n/locales/en/translation.json | 6 ++ src/i18n/locales/es/translation.json | 12 ++- src/i18n/locales/fr/translation.json | 12 ++- src/i18n/locales/it/translation.json | 12 ++- src/i18n/locales/ja/translation.json | 12 ++- src/i18n/locales/pl/translation.json | 12 ++- src/i18n/locales/pt/translation.json | 34 ++++---- src/i18n/locales/ru/translation.json | 12 ++- src/i18n/locales/tr/translation.json | 12 ++- src/i18n/locales/uk/translation.json | 34 ++++---- src/i18n/locales/vi/translation.json | 12 ++- src/i18n/locales/zh/translation.json | 12 ++- 17 files changed, 269 insertions(+), 72 deletions(-) diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 695ac1f21..de17236f7 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -156,6 +156,26 @@ export const ModelsSettings: React.FC = () => { }); }, [models, languageFilter]); + // Split filtered models into downloaded and available sections + const { downloadedModels, availableModels } = useMemo(() => { + const downloaded: ModelInfo[] = []; + const available: ModelInfo[] = []; + + for (const model of filteredModels) { + const isDownloaded = + model.is_downloaded || + model.id in downloadingModels || + model.id in extractingModels; + if (isDownloaded) { + downloaded.push(model); + } else { + available.push(model); + } + } + + return { downloadedModels: downloaded, availableModels: available }; + }, [filteredModels, downloadingModels, extractingModels]); + if (loading) { return (
@@ -266,20 +286,50 @@ export const ModelsSettings: React.FC = () => {
{filteredModels.length > 0 ? ( -
- {filteredModels.map((model: ModelInfo) => ( - - ))} +
+ {/* Downloaded Models Section */} + {downloadedModels.length > 0 && ( +
+

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

+ {downloadedModels.map((model: ModelInfo) => ( + + ))} +
+ )} + + {/* Available Models Section */} + {availableModels.length > 0 && ( +
+

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

+ {availableModels.map((model: ModelInfo) => ( + + ))} +
+ )}
) : (
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index d7878ec2a..d4cc41428 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,7 +1,13 @@ import React from "react"; interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: "primary" | "primary-soft" | "secondary" | "danger" | "danger-ghost" | "ghost"; + variant?: + | "primary" + | "primary-soft" + | "secondary" + | "danger" + | "danger-ghost" + | "ghost"; size?: "sm" | "md" | "lg"; } diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 697e96b25..ac0781f8c 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -12,7 +12,8 @@ "postProcessing": "معالجة لاحقة", "history": "السجل", "debug": "تصحيح الأخطاء", - "about": "حول" + "about": "حول", + "models": "النماذج" }, "onboarding": { "subtitle": "للبدء، اختر نموذج التفريغ الصوتي", @@ -79,14 +80,7 @@ } }, "modelSelector": { - "welcome": "مرحباً بك في Handy!", - "downloadPrompt": ".قم بتنزيل نموذج أدناه للبدء في التفريغ الصوتي", - "availableModels": "النماذج المتاحة", - "downloadModels": "تنزيل النماذج", - "chooseModel": "اختر نموذجاً", "active": "نشط", - "download": "تنزيل", - "downloadSize": "حجم التنزيل", "noModelsAvailable": "لا توجد نماذج متاحة", "extracting": "...جاري استخراج {{modelName}}", "extractingMultiple": "...جاري استخراج {{count}} من النماذج", @@ -99,7 +93,19 @@ "modelError": "خطأ في النموذج", "modelUnloaded": "تم إلغاء تحميل النموذج", "noModelDownloadRequired": "لا يوجد نموذج - التنزيل مطلوب", - "deleteModel": "حذف {{modelName}}" + "deleteModel": "حذف {{modelName}}", + "switching": "جارٍ التبديل...", + "downloadSpeed": "{{speed}} ميجابايت/ث", + "cancel": "إلغاء", + "cancelDownload": "إلغاء التنزيل", + "capabilities": { + "languageSelection": "يدعم اختيار اللغة", + "singleLanguage": "يدعم هذه اللغة فقط", + "multiLanguage": "متعدد اللغات", + "languageOnly": "{{language}} فقط", + "translation": "يدعم الترجمة", + "translate": "ترجمة" + } }, "settings": { "general": { @@ -373,6 +379,10 @@ "appData": "بيانات التطبيق:", "models": "النماذج:", "settings": "الإعدادات:" + }, + "pasteDelay": { + "title": "تأخير اللصق", + "description": "التأخير قبل إرسال ضغطة مفتاح اللصق (بالمللي ثانية). قم بزيادتها إذا تم لصق نص خاطئ." } }, "about": { @@ -403,6 +413,27 @@ "details": ".يستخدم Handy برنامج Whisper.cpp لمعالجة سريعة ومحلية لتحويل الكلام إلى نص. شكراً للعمل الرائع الذي قام به Georgi Gerganov والمساهمون" } } + }, + "modelSettings": { + "title": "إعدادات النموذج", + "noSettingsNeeded": "لا توجد إعدادات مطلوبة لهذا النموذج" + }, + "models": { + "title": "نماذج النسخ", + "description": "اختر نموذج نسخ أو قم بتنزيل نماذج إضافية. تقدم النماذج المختلفة مستويات متفاوتة من الدقة والسرعة.", + "yourModels": "نماذجك", + "availableModels": "متاح للتنزيل", + "downloaded": "تم التنزيل", + "available": "متاح للتنزيل", + "deleteConfirm": "هل أنت متأكد أنك تريد حذف {{modelName}}؟ ستحتاج إلى تنزيله مرة أخرى لاستخدامه.", + "deleteTitle": "حذف النموذج", + "filters": { + "all": "الكل", + "multiLanguage": "متعدد اللغات", + "translation": "ترجمة", + "allLanguages": "جميع اللغات" + }, + "noModelsMatch": "لا توجد نماذج مطابقة لهذا الفلتر." } }, "footer": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index d78174e7b..3e15c8e94 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Podporuje více vstupních jazyků", "multiLanguage": "Vícejazyčný", "translation": "Umí překládat do angličtiny", - "translate": "Přeložit do angličtiny" + "translate": "Přeložit do angličtiny", + "singleLanguage": "Podporuje pouze tento jazyk", + "languageOnly": "Pouze {{language}}" }, "cancel": "Zrušit", "cancelDownload": "Zrušit stahování" @@ -123,7 +125,9 @@ "translation": "Překlad", "allLanguages": "Všechny jazyky" }, - "noModelsMatch": "Tomuto filtru neodpovídají žádné modely." + "noModelsMatch": "Tomuto filtru neodpovídají žádné modely.", + "yourModels": "Vaše modely", + "availableModels": "Dostupné ke stažení" }, "general": { "title": "Obecné", @@ -396,6 +400,10 @@ "appData": "Data aplikace:", "models": "Modely:", "settings": "Nastavení:" + }, + "pasteDelay": { + "title": "Zpoždění vložení", + "description": "Zpoždění před odesláním klávesy pro vložení (v milisekundách). Zvyšte, pokud se vkládá špatný text." } }, "about": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 2e9840c07..c72b87ecc 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Unterstützt mehrere Eingabesprachen", "multiLanguage": "Mehrsprachig", "translation": "Kann ins Englische übersetzen", - "translate": "Ins Englische übersetzen" + "translate": "Ins Englische übersetzen", + "singleLanguage": "Unterstützt nur diese Sprache", + "languageOnly": "Nur {{language}}" }, "cancel": "Abbrechen", "cancelDownload": "Download abbrechen" @@ -123,7 +125,9 @@ "translation": "Übersetzung", "allLanguages": "Alle Sprachen" }, - "noModelsMatch": "Keine Modelle entsprechen diesem Filter." + "noModelsMatch": "Keine Modelle entsprechen diesem Filter.", + "yourModels": "Ihre Modelle", + "availableModels": "Zum Download verfügbar" }, "general": { "title": "Allgemein", @@ -399,6 +403,10 @@ "appData": "App-Daten:", "models": "Modelle:", "settings": "Einstellungen:" + }, + "pasteDelay": { + "title": "Einfügeverzögerung", + "description": "Verzögerung vor dem Senden des Einfüge-Tastendrucks (in Millisekunden). Erhöhen Sie den Wert, wenn falscher Text eingefügt wird." } }, "about": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 911919129..9047236d5 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -157,6 +157,8 @@ "models": { "title": "Transcription Models", "description": "Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.", + "yourModels": "Your Models", + "availableModels": "Available to Download", "downloaded": "Downloaded", "available": "Available to Download", "deleteConfirm": "Are you sure you want to delete {{modelName}}? You will need to download it again to use it.", @@ -409,6 +411,10 @@ "appData": "App Data:", "models": "Models:", "settings": "Settings:" + }, + "pasteDelay": { + "title": "Paste Delay", + "description": "Delay before sending paste keystroke (in milliseconds). Increase if wrong text is being pasted." } }, "about": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 018f59b4d..ec2bcb529 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Soporta múltiples idiomas de entrada", "multiLanguage": "Multiidioma", "translation": "Puede traducir al inglés", - "translate": "Traducir al inglés" + "translate": "Traducir al inglés", + "singleLanguage": "Solo admite este idioma", + "languageOnly": "Solo {{language}}" }, "cancel": "Cancelar", "cancelDownload": "Cancelar descarga" @@ -123,7 +125,9 @@ "translation": "Traducción", "allLanguages": "Todos los idiomas" }, - "noModelsMatch": "Ningún modelo coincide con este filtro." + "noModelsMatch": "Ningún modelo coincide con este filtro.", + "yourModels": "Tus modelos", + "availableModels": "Disponibles para descargar" }, "general": { "title": "General", @@ -399,6 +403,10 @@ "appData": "Datos de la Aplicación:", "models": "Modelos:", "settings": "Configuración:" + }, + "pasteDelay": { + "title": "Retraso de pegado", + "description": "Retraso antes de enviar la pulsación de tecla de pegar (en milisegundos). Aumente si se está pegando texto incorrecto." } }, "about": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 84db91920..fb7c595fe 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Prend en charge plusieurs langues d'entrée", "multiLanguage": "Multilingue", "translation": "Peut traduire en anglais", - "translate": "Traduire en anglais" + "translate": "Traduire en anglais", + "singleLanguage": "Prend en charge uniquement cette langue", + "languageOnly": "{{language}} uniquement" }, "cancel": "Annuler", "cancelDownload": "Annuler le téléchargement" @@ -123,7 +125,9 @@ "translation": "Traduction", "allLanguages": "Toutes les langues" }, - "noModelsMatch": "Aucun modèle ne correspond à ce filtre." + "noModelsMatch": "Aucun modèle ne correspond à ce filtre.", + "yourModels": "Vos modèles", + "availableModels": "Disponibles au téléchargement" }, "general": { "title": "Général", @@ -399,6 +403,10 @@ "appData": "Données de l'application :", "models": "Modèles :", "settings": "Paramètres :" + }, + "pasteDelay": { + "title": "Délai de collage", + "description": "Délai avant l'envoi de la touche de collage (en millisecondes). Augmentez si le mauvais texte est collé." } }, "about": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 45156b6fd..8201c13d5 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Supporta più lingue di input", "multiLanguage": "Multilingua", "translation": "Può tradurre in inglese", - "translate": "Traduci in inglese" + "translate": "Traduci in inglese", + "singleLanguage": "Supporta solo questa lingua", + "languageOnly": "Solo {{language}}" }, "cancel": "Annulla", "cancelDownload": "Annulla download" @@ -123,7 +125,9 @@ "translation": "Traduzione", "allLanguages": "Tutte le lingue" }, - "noModelsMatch": "Nessun modello corrisponde a questo filtro." + "noModelsMatch": "Nessun modello corrisponde a questo filtro.", + "yourModels": "I tuoi modelli", + "availableModels": "Disponibili per il download" }, "general": { "title": "Generale", @@ -399,6 +403,10 @@ "appData": "Dati App:", "models": "Modelli:", "settings": "Impostazioni:" + }, + "pasteDelay": { + "title": "Ritardo incolla", + "description": "Ritardo prima dell'invio del tasto incolla (in millisecondi). Aumentare se viene incollato il testo sbagliato." } }, "about": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 792fa3936..7a34aea87 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -100,7 +100,9 @@ "languageSelection": "複数の入力言語をサポート", "multiLanguage": "多言語", "translation": "英語への翻訳が可能", - "translate": "英語に翻訳" + "translate": "英語に翻訳", + "singleLanguage": "この言語のみ対応", + "languageOnly": "{{language}}のみ" }, "cancel": "キャンセル", "cancelDownload": "ダウンロードをキャンセル" @@ -123,7 +125,9 @@ "translation": "翻訳", "allLanguages": "すべての言語" }, - "noModelsMatch": "このフィルターに一致するモデルがありません。" + "noModelsMatch": "このフィルターに一致するモデルがありません。", + "yourModels": "あなたのモデル", + "availableModels": "ダウンロード可能" }, "general": { "title": "一般", @@ -399,6 +403,10 @@ "appData": "アプリデータ:", "models": "モデル:", "settings": "設定:" + }, + "pasteDelay": { + "title": "貼り付け遅延", + "description": "貼り付けキー送信前の遅延(ミリ秒)。間違ったテキストが貼り付けられる場合は増やしてください。" } }, "about": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 78eb1b691..d618c2bb0 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Obsługuje wiele języków wejściowych", "multiLanguage": "Wielojęzyczny", "translation": "Może tłumaczyć na angielski", - "translate": "Tłumacz na angielski" + "translate": "Tłumacz na angielski", + "singleLanguage": "Obsługuje tylko ten język", + "languageOnly": "Tylko {{language}}" }, "cancel": "Anuluj", "cancelDownload": "Anuluj pobieranie" @@ -123,7 +125,9 @@ "translation": "Tłumaczenie", "allLanguages": "Wszystkie języki" }, - "noModelsMatch": "Żadne modele nie pasują do tego filtra." + "noModelsMatch": "Żadne modele nie pasują do tego filtra.", + "yourModels": "Twoje modele", + "availableModels": "Dostępne do pobrania" }, "general": { "title": "Ogólne", @@ -399,6 +403,10 @@ "appData": "Dane aplikacji:", "models": "Modele:", "settings": "Ustawienia:" + }, + "pasteDelay": { + "title": "Opóźnienie wklejania", + "description": "Opóźnienie przed wysłaniem klawisza wklejania (w milisekundach). Zwiększ, jeśli wklejany jest nieprawidłowy tekst." } }, "about": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index d95baa1ab..bd3899e43 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Supports multiple input languages", "multiLanguage": "Multi-language", "translation": "Can translate to English", - "translate": "Translate to English" + "translate": "Translate to English", + "singleLanguage": "Suporta apenas este idioma", + "languageOnly": "Apenas {{language}}" }, "cancel": "Cancelar", "cancelDownload": "Cancelar download" @@ -149,19 +151,21 @@ } }, "models": { - "title": "Transcription Models", - "description": "Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.", - "downloaded": "Downloaded", - "available": "Available to Download", - "deleteConfirm": "Are you sure you want to delete {{modelName}}? You will need to download it again to use it.", - "deleteTitle": "Delete Model", + "title": "Modelos de Transcrição", + "description": "Selecione um modelo de transcrição ou baixe modelos adicionais. Diferentes modelos oferecem diferentes níveis de precisão e velocidade.", + "downloaded": "Baixados", + "available": "Disponíveis para Download", + "deleteConfirm": "Tem certeza de que deseja excluir {{modelName}}? Você precisará baixá-lo novamente para usá-lo.", + "deleteTitle": "Excluir Modelo", "filters": { - "all": "All", - "multiLanguage": "Multi-language", - "translation": "Translation", - "allLanguages": "All Languages" - }, - "noModelsMatch": "No models match this filter." + "all": "Todos", + "multiLanguage": "Multi-idioma", + "translation": "Tradução", + "allLanguages": "Todos os Idiomas" + }, + "noModelsMatch": "Nenhum modelo corresponde a este filtro.", + "yourModels": "Seus modelos", + "availableModels": "Disponíveis para download" }, "sound": { "title": "Som", @@ -399,6 +403,10 @@ "appData": "Dados do App:", "models": "Modelos:", "settings": "Configurações:" + }, + "pasteDelay": { + "title": "Atraso de colagem", + "description": "Atraso antes de enviar a tecla de colar (em milissegundos). Aumente se o texto errado estiver sendo colado." } }, "about": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 87b804289..07074a125 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Поддерживает несколько языков ввода", "multiLanguage": "Многоязычный", "translation": "Может переводить на английский", - "translate": "Перевести на английский" + "translate": "Перевести на английский", + "singleLanguage": "Поддерживает только этот язык", + "languageOnly": "Только {{language}}" }, "cancel": "Отмена", "cancelDownload": "Отменить загрузку" @@ -123,7 +125,9 @@ "translation": "Перевод", "allLanguages": "Все языки" }, - "noModelsMatch": "Нет моделей, соответствующих этому фильтру." + "noModelsMatch": "Нет моделей, соответствующих этому фильтру.", + "yourModels": "Ваши модели", + "availableModels": "Доступны для загрузки" }, "general": { "title": "Общие", @@ -399,6 +403,10 @@ "appData": "Данные приложения:", "models": "Модели:", "settings": "Настройки:" + }, + "pasteDelay": { + "title": "Задержка вставки", + "description": "Задержка перед отправкой нажатия клавиши вставки (в миллисекундах). Увеличьте, если вставляется неправильный текст." } }, "about": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index ad2559467..0fb3bc07d 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Birden fazla giriş dilini destekler", "multiLanguage": "Çok dilli", "translation": "İngilizce'ye çevirebilir", - "translate": "İngilizce'ye çevir" + "translate": "İngilizce'ye çevir", + "singleLanguage": "Yalnızca bu dili destekler", + "languageOnly": "Yalnızca {{language}}" }, "cancel": "İptal", "cancelDownload": "İndirmeyi iptal et" @@ -123,7 +125,9 @@ "translation": "Çeviri", "allLanguages": "Tüm Diller" }, - "noModelsMatch": "Bu filtreyle eşleşen model yok." + "noModelsMatch": "Bu filtreyle eşleşen model yok.", + "yourModels": "Modelleriniz", + "availableModels": "İndirilebilir" }, "general": { "title": "Genel", @@ -396,6 +400,10 @@ "appData": "Uygulama Verileri:", "models": "Modeller:", "settings": "Ayarlar:" + }, + "pasteDelay": { + "title": "Yapıştırma gecikmesi", + "description": "Yapıştırma tuşu göndermeden önce gecikme (milisaniye cinsinden). Yanlış metin yapıştırılıyorsa artırın." } }, "about": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 25de779ed..48f22d36a 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Supports multiple input languages", "multiLanguage": "Multi-language", "translation": "Can translate to English", - "translate": "Translate to English" + "translate": "Translate to English", + "singleLanguage": "Підтримує лише цю мову", + "languageOnly": "Лише {{language}}" }, "cancel": "Скасувати", "cancelDownload": "Скасувати завантаження" @@ -149,19 +151,21 @@ } }, "models": { - "title": "Transcription Models", - "description": "Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.", - "downloaded": "Downloaded", - "available": "Available to Download", - "deleteConfirm": "Are you sure you want to delete {{modelName}}? You will need to download it again to use it.", - "deleteTitle": "Delete Model", + "title": "Моделі транскрипції", + "description": "Виберіть модель транскрипції або завантажте додаткові моделі. Різні моделі пропонують різні рівні точності та швидкості.", + "downloaded": "Завантажено", + "available": "Доступні для завантаження", + "deleteConfirm": "Ви впевнені, що хочете видалити {{modelName}}? Вам потрібно буде завантажити її знову, щоб використовувати.", + "deleteTitle": "Видалити модель", "filters": { - "all": "All", - "multiLanguage": "Multi-language", - "translation": "Translation", - "allLanguages": "All Languages" - }, - "noModelsMatch": "No models match this filter." + "all": "Усі", + "multiLanguage": "Багатомовні", + "translation": "Переклад", + "allLanguages": "Усі мови" + }, + "noModelsMatch": "Жодна модель не відповідає цьому фільтру.", + "yourModels": "Ваші моделі", + "availableModels": "Доступні для завантаження" }, "sound": { "title": "Звук", @@ -399,6 +403,10 @@ "appData": "Дані програми:", "models": "Моделі:", "settings": "Налаштування:" + }, + "pasteDelay": { + "title": "Затримка вставки", + "description": "Затримка перед надсиланням натискання клавіші вставки (у мілісекундах). Збільшіть, якщо вставляється неправильний текст." } }, "about": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index e562c3628..2e2e4df66 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -100,7 +100,9 @@ "languageSelection": "Hỗ trợ nhiều ngôn ngữ đầu vào", "multiLanguage": "Đa ngôn ngữ", "translation": "Có thể dịch sang tiếng Anh", - "translate": "Dịch sang tiếng Anh" + "translate": "Dịch sang tiếng Anh", + "singleLanguage": "Chỉ hỗ trợ ngôn ngữ này", + "languageOnly": "Chỉ {{language}}" }, "cancel": "Hủy", "cancelDownload": "Hủy tải xuống" @@ -123,7 +125,9 @@ "translation": "Dịch thuật", "allLanguages": "Tất cả ngôn ngữ" }, - "noModelsMatch": "Không có mô hình nào khớp với bộ lọc này." + "noModelsMatch": "Không có mô hình nào khớp với bộ lọc này.", + "yourModels": "Mô hình của bạn", + "availableModels": "Có sẵn để tải xuống" }, "general": { "title": "Chung", @@ -399,6 +403,10 @@ "appData": "Dữ liệu ứng dụng:", "models": "Mô hình:", "settings": "Cài đặt:" + }, + "pasteDelay": { + "title": "Độ trễ dán", + "description": "Độ trễ trước khi gửi phím dán (tính bằng mili giây). Tăng nếu văn bản sai đang được dán." } }, "about": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 164e253aa..a5b04f13e 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -100,7 +100,9 @@ "languageSelection": "支持多种输入语言", "multiLanguage": "多语言", "translation": "可翻译为英语", - "translate": "翻译为英语" + "translate": "翻译为英语", + "singleLanguage": "仅支持此语言", + "languageOnly": "仅 {{language}}" }, "cancel": "取消", "cancelDownload": "取消下载" @@ -123,7 +125,9 @@ "translation": "翻译", "allLanguages": "所有语言" }, - "noModelsMatch": "没有符合此筛选条件的模型。" + "noModelsMatch": "没有符合此筛选条件的模型。", + "yourModels": "您的模型", + "availableModels": "可供下载" }, "general": { "title": "通用", @@ -399,6 +403,10 @@ "appData": "应用数据:", "models": "模型:", "settings": "设置:" + }, + "pasteDelay": { + "title": "粘贴延迟", + "description": "发送粘贴按键前的延迟(毫秒)。如果粘贴了错误的文本,请增加此值。" } }, "about": { From ef6757fe1cafe4844fd7cfc0e386024722bd0a2f Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sat, 7 Feb 2026 19:56:06 -0500 Subject: [PATCH 16/26] fix: add missing translations after rebase onto main add post-processing hotkey translations to all 15 locales and backfill 29 missing keys for korean locale added in main. --- src/i18n/locales/ar/translation.json | 7 ++++ src/i18n/locales/cs/translation.json | 7 ++++ src/i18n/locales/de/translation.json | 4 +++ src/i18n/locales/es/translation.json | 4 +++ src/i18n/locales/fr/translation.json | 4 +++ src/i18n/locales/it/translation.json | 4 +++ src/i18n/locales/ja/translation.json | 4 +++ src/i18n/locales/ko/translation.json | 50 +++++++++++++++++++++++----- src/i18n/locales/pl/translation.json | 4 +++ src/i18n/locales/pt/translation.json | 4 +++ src/i18n/locales/ru/translation.json | 4 +++ src/i18n/locales/tr/translation.json | 7 ++++ src/i18n/locales/uk/translation.json | 4 +++ src/i18n/locales/vi/translation.json | 4 +++ src/i18n/locales/zh/translation.json | 4 +++ 15 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index ac0781f8c..0f1dccb7c 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -125,6 +125,10 @@ "cancel": { "name": "اختصار الإلغاء", "description": ".اختصار لوحة المفاتيح لإلغاء التسجيل الحالي" + }, + "transcribe_with_post_process": { + "name": "مفتاح المعالجة اللاحقة", + "description": "اختياري: مفتاح اختصار مخصص يطبق دائماً المعالجة اللاحقة بالذكاء الاصطناعي على التفريغ الصوتي." } }, "errors": { @@ -247,6 +251,9 @@ }, "postProcessing": { "title": "معالجة لاحقة", + "hotkey": { + "title": "مفتاح الاختصار" + }, "api": { "title": "API (متوافق مع OpenAI)", "provider": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 3e15c8e94..a51758e80 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "Zkratka zrušení", "description": "Klávesová zkratka pro zrušení aktuálního nahrávání." + }, + "transcribe_with_post_process": { + "name": "Klávesa pro následné zpracování", + "description": "Volitelné: Vyhrazená klávesová zkratka, která vždy použije AI následné zpracování na váš přepis." } }, "errors": { @@ -268,6 +272,9 @@ }, "postProcessing": { "title": "Následné zpracování", + "hotkey": { + "title": "Klávesová zkratka" + }, "api": { "title": "API (kompatibilní s OpenAI)", "provider": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index c72b87ecc..2ebb8e313 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "Abbrechen-Tastenkürzel", "description": "Das Tastenkürzel zum Abbrechen der aktuellen Aufnahme." + }, + "transcribe_with_post_process": { + "name": "Nachbearbeitungs-Tastenkürzel", + "description": "Optional: Ein dediziertes Tastenkürzel, das immer die KI-Nachbearbeitung auf Ihre Transkription anwendet." } }, "errors": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index ec2bcb529..49b1b9e87 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "Atajo de Cancelar", "description": "El atajo de teclado para cancelar la grabación actual." + }, + "transcribe_with_post_process": { + "name": "Tecla de Post Procesamiento", + "description": "Opcional: Una tecla de acceso rápido dedicada que siempre aplica post procesamiento con IA a tu transcripción." } }, "errors": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index fb7c595fe..16a177051 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "Raccourci d'Annulation", "description": "Le raccourci clavier pour annuler l'enregistrement en cours." + }, + "transcribe_with_post_process": { + "name": "Raccourci de post-traitement", + "description": "Facultatif : Un raccourci dédié qui applique toujours le post-traitement IA à votre transcription." } }, "errors": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 8201c13d5..a123a9852 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "Scorciatoia Annulla", "description": "La scorciatoia da tastiera per annullare la registrazione in corso." + }, + "transcribe_with_post_process": { + "name": "Tasto di post-elaborazione", + "description": "Facoltativo: Un tasto di scelta rapida dedicato che applica sempre la post-elaborazione IA alla trascrizione." } }, "errors": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 7a34aea87..9a462f246 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "キャンセルショートカット", "description": "現在の録音をキャンセルするためのキーボードショートカット。" + }, + "transcribe_with_post_process": { + "name": "後処理ホットキー", + "description": "オプション:文字起こしに常にAI後処理を適用する専用ホットキー。" } }, "errors": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 80555708a..7b9015573 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -8,6 +8,7 @@ }, "sidebar": { "general": "일반", + "models": "모델", "advanced": "고급", "postProcessing": "후처리", "history": "히스토리", @@ -79,14 +80,8 @@ } }, "modelSelector": { - "welcome": "Handy에 오신 것을 환영합니다!", - "downloadPrompt": "음성 인식을 시작하려면 아래에서 모델을 다운로드하세요.", - "availableModels": "사용 가능한 모델", - "downloadModels": "모델 다운로드", - "chooseModel": "모델 선택", "active": "활성", - "download": "다운로드", - "downloadSize": "다운로드 크기", + "switching": "전환 중...", "noModelsAvailable": "사용 가능한 모델이 없습니다", "extracting": "{{modelName}} 압축 해제 중...", "extractingMultiple": "{{count}}개 모델 압축 해제 중...", @@ -99,9 +94,24 @@ "modelError": "모델 오류", "modelUnloaded": "모델 언로드됨", "noModelDownloadRequired": "모델 없음 - 다운로드 필요", - "deleteModel": "{{modelName}} 삭제" + "deleteModel": "{{modelName}} 삭제", + "downloadSpeed": "{{speed}} MB/s", + "cancel": "취소", + "cancelDownload": "다운로드 취소", + "capabilities": { + "languageSelection": "여러 입력 언어를 지원합니다", + "singleLanguage": "이 언어만 지원합니다", + "multiLanguage": "다국어", + "languageOnly": "{{language}} 전용", + "translation": "영어로 번역 가능", + "translate": "영어로 번역" + } }, "settings": { + "modelSettings": { + "title": "{{model}} 설정", + "noSettingsNeeded": "이 모델은 별도의 설정 없이 자동으로 작동합니다." + }, "general": { "title": "일반", "shortcut": { @@ -119,6 +129,10 @@ "cancel": { "name": "취소 단축키", "description": "현재 녹음을 취소하는 키보드 단축키입니다." + }, + "transcribe_with_post_process": { + "name": "후처리 단축키", + "description": "선택 사항: 항상 AI 후처리를 적용하는 전용 단축키입니다." } }, "errors": { @@ -163,6 +177,23 @@ "description": "오디오 피드백 사운드의 볼륨 조절" } }, + "models": { + "title": "음성 인식 모델", + "description": "음성 인식 모델을 선택하거나 추가 모델을 다운로드하세요. 모델마다 정확도와 속도가 다릅니다.", + "yourModels": "내 모델", + "availableModels": "다운로드 가능", + "downloaded": "다운로드됨", + "available": "다운로드 가능", + "deleteConfirm": "{{modelName}}을(를) 삭제하시겠습니까? 다시 사용하려면 다운로드해야 합니다.", + "deleteTitle": "모델 삭제", + "filters": { + "all": "전체", + "multiLanguage": "다국어", + "translation": "번역", + "allLanguages": "모든 언어" + }, + "noModelsMatch": "이 필터에 맞는 모델이 없습니다." + }, "advanced": { "title": "고급", "groups": { @@ -241,6 +272,9 @@ }, "postProcessing": { "title": "후처리", + "hotkey": { + "title": "단축키" + }, "api": { "title": "API (OpenAI 호환)", "provider": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index d618c2bb0..f1074337c 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "Skrót anulowania", "description": "Skrót klawiaturowy do anulowania bieżącego nagrywania." + }, + "transcribe_with_post_process": { + "name": "Skrót postprocessingu", + "description": "Opcjonalnie: Dedykowany skrót klawiszowy, który zawsze stosuje postprocessing AI do transkrypcji." } }, "errors": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index bd3899e43..e2a404703 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -129,6 +129,10 @@ "cancel": { "name": "Atalho de Cancelar", "description": "O atalho de teclado para cancelar a gravação atual." + }, + "transcribe_with_post_process": { + "name": "Tecla de Pós-Processamento", + "description": "Opcional: Uma tecla de atalho dedicada que sempre aplica pós-processamento com IA à sua transcrição." } }, "errors": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 07074a125..a4407ac54 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "Горячая клавиша отмены", "description": "Сочетание клавиш для отмены текущей записи." + }, + "transcribe_with_post_process": { + "name": "Горячая клавиша постобработки", + "description": "Необязательно: Специальная горячая клавиша, которая всегда применяет AI-постобработку к вашей транскрипции." } }, "errors": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 0fb3bc07d..f25d0b80f 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "İptal Kısayolu", "description": "Mevcut kaydı iptal etmek için klavye kısayolu." + }, + "transcribe_with_post_process": { + "name": "Son İşlem Kısayolu", + "description": "İsteğe bağlı: Transkripsiyonunuza her zaman AI son işleme uygulayan özel bir kısayol tuşu." } }, "errors": { @@ -268,6 +272,9 @@ }, "postProcessing": { "title": "Son İşlem", + "hotkey": { + "title": "Kısayol Tuşu" + }, "api": { "title": "API (OpenAI Uyumlu)", "provider": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 48f22d36a..692f49021 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -129,6 +129,10 @@ "cancel": { "name": "Гаряча клавіша скасування", "description": "Комбінація клавіш для скасування поточного запису." + }, + "transcribe_with_post_process": { + "name": "Гаряча клавіша постобробки", + "description": "Необов'язково: Спеціальна гаряча клавіша, яка завжди застосовує AI-постобробку до вашої транскрипції." } }, "errors": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 2e2e4df66..a24e4b24e 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "Phím tắt hủy", "description": "Phím tắt để hủy bản ghi hiện tại." + }, + "transcribe_with_post_process": { + "name": "Phím tắt xử lý sau", + "description": "Tùy chọn: Phím tắt chuyên dụng luôn áp dụng xử lý sau bằng AI cho bản chuyển đổi của bạn." } }, "errors": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index a5b04f13e..e230949d1 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -146,6 +146,10 @@ "cancel": { "name": "取消快捷键", "description": "用于取消当前录制的键盘快捷键。" + }, + "transcribe_with_post_process": { + "name": "后处理快捷键", + "description": "可选:一个专用快捷键,始终对您的转录应用 AI 后处理。" } }, "errors": { From 90341ef683caccdfcdb00cec360267c52b0a84d3 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sat, 7 Feb 2026 20:24:22 -0500 Subject: [PATCH 17/26] fix: add label to model delete button for clearer destructive state --- src/components/onboarding/ModelCard.tsx | 5 +++-- src/i18n/locales/pt/translation.json | 16 ++++++++-------- src/i18n/locales/uk/translation.json | 16 ++++++++-------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 953a7c7ad..04f6ebc93 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -275,9 +275,10 @@ const ModelCard: React.FC = ({ size="sm" onClick={handleDelete} title={t("modelSelector.deleteModel", { modelName: displayName })} - className="p-2" + className="flex items-center gap-1.5" > - + + {t("common.delete")} )}
diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index e2a404703..73815db2b 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -8,7 +8,7 @@ }, "sidebar": { "general": "Geral", - "models": "Models", + "models": "Modelos", "advanced": "Avançado", "postProcessing": "Pós-Processamento", "history": "Histórico", @@ -94,13 +94,13 @@ "modelUnloaded": "Modelo Descarregado", "noModelDownloadRequired": "Sem Modelo - Download Necessário", "deleteModel": "Excluir {{modelName}}", - "switching": "Switching...", + "switching": "Alternando...", "downloadSpeed": "{{speed}} MB/s", "capabilities": { - "languageSelection": "Supports multiple input languages", - "multiLanguage": "Multi-language", - "translation": "Can translate to English", - "translate": "Translate to English", + "languageSelection": "Suporta vários idiomas de entrada", + "multiLanguage": "Multi-idioma", + "translation": "Pode traduzir para inglês", + "translate": "Traduzir para inglês", "singleLanguage": "Suporta apenas este idioma", "languageOnly": "Apenas {{language}}" }, @@ -109,8 +109,8 @@ }, "settings": { "modelSettings": { - "title": "{{model}} Settings", - "noSettingsNeeded": "This model works automatically with no configuration needed." + "title": "Configurações de {{model}}", + "noSettingsNeeded": "Este modelo funciona automaticamente sem necessidade de configuração." }, "general": { "title": "Geral", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 692f49021..4a383df58 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -8,7 +8,7 @@ }, "sidebar": { "general": "Загальні", - "models": "Models", + "models": "Моделі", "advanced": "Розширені", "postProcessing": "Постобробка", "history": "Історія", @@ -94,13 +94,13 @@ "modelUnloaded": "Модель вивантажена", "noModelDownloadRequired": "Немає моделі - потрібно завантажити", "deleteModel": "Видалити {{modelName}}", - "switching": "Switching...", + "switching": "Перемикання...", "downloadSpeed": "{{speed}} MB/s", "capabilities": { - "languageSelection": "Supports multiple input languages", - "multiLanguage": "Multi-language", - "translation": "Can translate to English", - "translate": "Translate to English", + "languageSelection": "Підтримує кілька мов введення", + "multiLanguage": "Багатомовна", + "translation": "Може перекладати на англійську", + "translate": "Перекласти на англійську", "singleLanguage": "Підтримує лише цю мову", "languageOnly": "Лише {{language}}" }, @@ -109,8 +109,8 @@ }, "settings": { "modelSettings": { - "title": "{{model}} Settings", - "noSettingsNeeded": "This model works automatically with no configuration needed." + "title": "Налаштування {{model}}", + "noSettingsNeeded": "Ця модель працює автоматично без потреби в налаштуванні." }, "general": { "title": "Загальні", From 337656c3a21f58db2fb47e61c521d2f2fbfc0e18 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 8 Feb 2026 10:36:51 +0800 Subject: [PATCH 18/26] check translations as typescript --- package.json | 2 +- ...translations.cjs => check-translations.ts} | 103 +++++++----------- 2 files changed, 42 insertions(+), 63 deletions(-) rename scripts/{check-translations.cjs => check-translations.ts} (69%) mode change 100755 => 100644 diff --git a/package.json b/package.json index f3e49548b..cb4ba5868 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "format:backend": "cd src-tauri && cargo fmt", "test:playwright": "playwright test", "test:playwright:ui": "playwright test --ui", - "check:translations": "node scripts/check-translations.cjs" + "check:translations": "bun scripts/check-translations.ts" }, "dependencies": { "@tailwindcss/vite": "^4.1.16", diff --git a/scripts/check-translations.cjs b/scripts/check-translations.ts old mode 100755 new mode 100644 similarity index 69% rename from scripts/check-translations.cjs rename to scripts/check-translations.ts index 4d328c976..293e5f0f3 --- a/scripts/check-translations.cjs +++ b/scripts/check-translations.ts @@ -1,32 +1,22 @@ -#!/usr/bin/env node - -/** - * Translation Consistency Checker - * - * This script validates that all language translation files have the same - * structure and keys as the English (en) reference file. - * - * It checks: - * - All translation files can be parsed as valid JSON - * - All languages have the same keys as English - * - No keys are missing in any language - * - * Usage: node scripts/check-translations.js - * Exit code: 0 if all checks pass, 1 if any checks fail - */ - -const fs = require("fs"); -const path = require("path"); +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Configuration const LOCALES_DIR = path.join(__dirname, "..", "src", "i18n", "locales"); const REFERENCE_LANG = "en"; -/** - * Get all language codes from the locales directory - * @returns {Array} Array of language codes (excluding reference lang) - */ -function getLanguages() { +type TranslationData = Record; + +interface ValidationResult { + valid: boolean; + missing: string[][]; + extra: string[][]; +} + +function getLanguages(): string[] { const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory() && entry.name !== REFERENCE_LANG) @@ -37,7 +27,7 @@ function getLanguages() { const LANGUAGES = getLanguages(); // Colors for terminal output -const colors = { +const colors: Record = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", @@ -45,74 +35,63 @@ const colors = { blue: "\x1b[34m", }; -function colorize(text, color) { +function colorize(text: string, color: string): string { return `${colors[color]}${text}${colors.reset}`; } -/** - * Get all key paths from a nested object - * @param {Object} obj - The object to extract keys from - * @param {Array} prefix - Current path prefix - * @returns {Array>} Array of key paths - */ -function getAllKeyPaths(obj, prefix = []) { - let paths = []; +function getAllKeyPaths( + obj: TranslationData, + prefix: string[] = [], +): string[][] { + let paths: string[][] = []; for (const key in obj) { - if (!obj.hasOwnProperty(key)) continue; + if (!Object.hasOwn(obj, key)) continue; const currentPath = prefix.concat([key]); const value = obj[key]; if (typeof value === "object" && value !== null && !Array.isArray(value)) { - // Recurse into nested objects - paths = paths.concat(getAllKeyPaths(value, currentPath)); + paths = paths.concat( + getAllKeyPaths(value as TranslationData, currentPath), + ); } else { - // Leaf node - add the path paths.push(currentPath); } } return paths; } -/** - * Check if a key path exists in an object - * @param {Object} obj - The object to check - * @param {Array} keyPath - The path to check - * @returns {boolean} True if the path exists - */ -function hasKeyPath(obj, keyPath) { - let current = obj; +function hasKeyPath(obj: TranslationData, keyPath: string[]): boolean { + let current: unknown = obj; for (const key of keyPath) { - if (current[key] === undefined) { + if ( + typeof current !== "object" || + current === null || + (current as Record)[key] === undefined + ) { return false; } - current = current[key]; + current = (current as Record)[key]; } return true; } -/** - * Load and parse a translation file - * @param {string} lang - Language code - * @returns {Object|null} Parsed JSON or null if error - */ -function loadTranslationFile(lang) { +function loadTranslationFile(lang: string): TranslationData | null { const filePath = path.join(LOCALES_DIR, lang, "translation.json"); try { const content = fs.readFileSync(filePath, "utf8"); - return JSON.parse(content); + return JSON.parse(content) as TranslationData; } catch (error) { - console.error(colorize(`✗ Error loading ${lang}/translation.json:`, "red")); - console.error(` ${error.message}`); + console.error( + colorize(`✗ Error loading ${lang}/translation.json:`, "red"), + ); + console.error(` ${(error as Error).message}`); return null; } } -/** - * Main validation function - */ -function validateTranslations() { +function validateTranslations(): void { console.log(colorize("\n🌍 Translation Consistency Check\n", "blue")); // Load reference file @@ -132,7 +111,7 @@ function validateTranslations() { // Track validation results let hasErrors = false; - const results = {}; + const results: Record = {}; // Validate each language for (const lang of LANGUAGES) { From bd110c13cba4f80ea85cfc256520096171ec62ce Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 8 Feb 2026 10:37:27 +0800 Subject: [PATCH 19/26] format --- scripts/check-translations.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/check-translations.ts b/scripts/check-translations.ts index 293e5f0f3..f19d8d3b9 100644 --- a/scripts/check-translations.ts +++ b/scripts/check-translations.ts @@ -83,9 +83,7 @@ function loadTranslationFile(lang: string): TranslationData | null { const content = fs.readFileSync(filePath, "utf8"); return JSON.parse(content) as TranslationData; } catch (error) { - console.error( - colorize(`✗ Error loading ${lang}/translation.json:`, "red"), - ); + console.error(colorize(`✗ Error loading ${lang}/translation.json:`, "red")); console.error(` ${(error as Error).message}`); return null; } From 957d0c670961489ee648d4d88009f3aec875d189 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 8 Feb 2026 10:47:10 +0800 Subject: [PATCH 20/26] better text for dropdown --- src/components/model-selector/ModelDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index a2f9a69f0..83da5b57e 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -48,7 +48,7 @@ const ModelDropdown: React.FC = ({ >
-
+
{getTranslatedModelName(model, t)}
From 6b02a2c97e25851d03e09df3c164a9b6eeae157b Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 8 Feb 2026 10:50:19 +0800 Subject: [PATCH 21/26] wip ui tweaks --- src/components/onboarding/ModelCard.tsx | 245 ++++++++++++------------ 1 file changed, 125 insertions(+), 120 deletions(-) diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 04f6ebc93..c51ac7cd0 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -80,7 +80,7 @@ const ModelCard: React.FC = ({ const displayDescription = getTranslatedModelDescription(model, t); const baseClasses = - "flex justify-between items-start rounded-xl p-4 text-left transition-all duration-200"; + "flex flex-col rounded-xl p-4 text-left transition-all duration-200"; const getVariantClasses = () => { if (status === "active") { @@ -131,139 +131,92 @@ const ModelCard: React.FC = ({ .filter(Boolean) .join(" ")} > -
-
-

- {displayName} -

- {model.is_recommended && ( - {t("onboarding.recommended")} - )} - {status === "active" && ( - - - {t("modelSelector.active")} - - )} - {status === "switching" && ( - - - {t("modelSelector.switching")} - - )} -
-

- {displayDescription} -

-
-
- - {getLanguageDisplayText(model.supported_languages, t)} -
- {model.supports_translation && ( -
+
+
+

- - {t("modelSelector.capabilities.translate")} -

- )} + {displayName} + + {model.is_recommended && ( + {t("onboarding.recommended")} + )} + {status === "active" && ( + + + {t("modelSelector.active")} + + )} + {status === "switching" && ( + + + {t("modelSelector.switching")} + + )} +
+

+ {displayDescription} +

- {status === "downloading" && downloadProgress !== undefined && ( -
-
-
-
-
- - {t("modelSelector.downloading", { - percentage: Math.round(downloadProgress), - })} - -
- {downloadSpeed !== undefined && downloadSpeed > 0 && ( - - {t("modelSelector.downloadSpeed", { - speed: downloadSpeed.toFixed(1), - })} - - )} - {onCancel && ( - - )} +
+
+
+

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

+
+
-
- )} - {status === "extracting" && ( -
-
-
+
+

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

+
+
+
-

- {t("modelSelector.extractingGeneric")} -

- )} +
- {/* Right side: accuracy/speed bars + action buttons */} -
-
-
-

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

-
-
-
-
-
-

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

-
-
-
-
+ {/* Bottom row: tags + action buttons (full width) */} +
+
+ + {getLanguageDisplayText(model.supported_languages, t)}
+ {model.supports_translation && ( +
+ + {t("modelSelector.capabilities.translate")} +
+ )} {status === "downloadable" && onDownload && ( )}
+ + {/* Download/extract progress */} + {status === "downloading" && downloadProgress !== undefined && ( +
+
+
+
+
+ + {t("modelSelector.downloading", { + percentage: Math.round(downloadProgress), + })} + +
+ {downloadSpeed !== undefined && downloadSpeed > 0 && ( + + {t("modelSelector.downloadSpeed", { + speed: downloadSpeed.toFixed(1), + })} + + )} + {onCancel && ( + + )} +
+
+
+ )} + {status === "extracting" && ( +
+
+
+
+

+ {t("modelSelector.extractingGeneric")} +

+
+ )}
); }; From d7c8ffde411ac88de63a02a92efadae629120bdd Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 8 Feb 2026 11:22:34 +0800 Subject: [PATCH 22/26] rounded + ui tweaks --- src-tauri/src/commands/models.rs | 14 +++++++++++++ src/components/AccessibilityPermissions.tsx | 2 +- src/components/onboarding/ModelCard.tsx | 20 +++++++++++-------- .../settings/GlobalShortcutInput.tsx | 4 ++-- .../settings/HandyKeysShortcutInput.tsx | 4 ++-- .../settings/history/HistorySettings.tsx | 2 +- .../settings/models/ModelsSettings.tsx | 18 ++++++++++++++--- .../PostProcessingSettings.tsx | 2 +- src/components/ui/Button.tsx | 2 +- src/components/ui/Dropdown.tsx | 4 ++-- src/components/ui/Input.tsx | 2 +- src/components/ui/PathDisplay.tsx | 2 +- src/components/ui/ResetButton.tsx | 2 +- src/components/ui/TextDisplay.tsx | 4 ++-- src/components/ui/Textarea.tsx | 2 +- src/i18n/locales/ar/translation.json | 1 + src/i18n/locales/cs/translation.json | 1 + src/i18n/locales/de/translation.json | 1 + src/i18n/locales/en/translation.json | 1 + src/i18n/locales/es/translation.json | 1 + src/i18n/locales/fr/translation.json | 1 + src/i18n/locales/it/translation.json | 1 + src/i18n/locales/ja/translation.json | 1 + src/i18n/locales/ko/translation.json | 1 + src/i18n/locales/pl/translation.json | 1 + src/i18n/locales/pt/translation.json | 1 + src/i18n/locales/ru/translation.json | 1 + src/i18n/locales/tr/translation.json | 1 + src/i18n/locales/uk/translation.json | 1 + src/i18n/locales/vi/translation.json | 1 + src/i18n/locales/zh/translation.json | 1 + src/stores/modelStore.ts | 1 + 32 files changed, 74 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index 26fc00a42..cb4898468 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -36,9 +36,23 @@ pub async fn download_model( #[tauri::command] #[specta::specta] pub async fn delete_model( + app_handle: AppHandle, model_manager: State<'_, Arc>, + transcription_manager: State<'_, Arc>, model_id: String, ) -> Result<(), String> { + // If deleting the active model, unload it and clear the setting + let settings = get_settings(&app_handle); + if settings.selected_model == model_id { + transcription_manager + .unload_model() + .map_err(|e| format!("Failed to unload model: {}", e))?; + + let mut settings = get_settings(&app_handle); + settings.selected_model = String::new(); + write_settings(&app_handle, settings); + } + model_manager .delete_model(&model_id) .map_err(|e| e.to_string()) diff --git a/src/components/AccessibilityPermissions.tsx b/src/components/AccessibilityPermissions.tsx index 17bbcd1c4..2826403fc 100644 --- a/src/components/AccessibilityPermissions.tsx +++ b/src/components/AccessibilityPermissions.tsx @@ -77,7 +77,7 @@ const AccessibilityPermissions: React.FC = () => { verify: { text: t("accessibility.openSettings"), className: - "bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-3 rounded text-sm flex items-center justify-center cursor-pointer", + "bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-3 rounded-md text-sm flex items-center justify-center cursor-pointer", }, granted: null, }; diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index c51ac7cd0..83a8dd688 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -52,6 +52,7 @@ interface ModelCardProps { onCancel?: (modelId: string) => void; downloadProgress?: number; downloadSpeed?: number; // MB/s + showRecommended?: boolean; } const ModelCard: React.FC = ({ @@ -66,6 +67,7 @@ const ModelCard: React.FC = ({ onCancel, downloadProgress, downloadSpeed, + showRecommended = true, }) => { const { t } = useTranslation(); const isFeatured = variant === "featured"; @@ -80,7 +82,7 @@ const ModelCard: React.FC = ({ const displayDescription = getTranslatedModelDescription(model, t); const baseClasses = - "flex flex-col rounded-xl p-4 text-left transition-all duration-200"; + "flex flex-col rounded-xl px-4 py-3 gap-2 text-left transition-all duration-200"; const getVariantClasses = () => { if (status === "active") { @@ -132,19 +134,19 @@ const ModelCard: React.FC = ({ .join(" ")} > {/* Top section: name/description + score bars */} -
+
-
+

{displayName}

- {model.is_recommended && ( + {showRecommended && model.is_recommended && ( {t("onboarding.recommended")} )} {status === "active" && ( - + {t("modelSelector.active")} @@ -156,7 +158,7 @@ const ModelCard: React.FC = ({ )}
-

+

{displayDescription}

@@ -188,8 +190,10 @@ const ModelCard: React.FC = ({
+
+ {/* Bottom row: tags + action buttons (full width) */} -
+
= ({ {formatModelSize(Number(model.size_mb))} )} - {onDelete && status === "available" && ( + {onDelete && (status === "available" || status === "active") && (
@@ -304,6 +314,7 @@ export const ModelsSettings: React.FC = () => { onCancel={handleModelCancel} downloadProgress={getDownloadProgress(model.id)} downloadSpeed={getDownloadSpeed(model.id)} + showRecommended={false} /> ))}
@@ -326,6 +337,7 @@ export const ModelsSettings: React.FC = () => { onCancel={handleModelCancel} downloadProgress={getDownloadProgress(model.id)} downloadSpeed={getDownloadSpeed(model.id)} + showRecommended={false} /> ))}
diff --git a/src/components/settings/post-processing/PostProcessingSettings.tsx b/src/components/settings/post-processing/PostProcessingSettings.tsx index 2dfbbf262..4750a0d4d 100644 --- a/src/components/settings/post-processing/PostProcessingSettings.tsx +++ b/src/components/settings/post-processing/PostProcessingSettings.tsx @@ -344,7 +344,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { )} {!isCreating && !selectedPrompt && ( -
+

{hasPrompts ? t("settings.postProcessing.prompts.selectToEdit") diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index d4cc41428..df92fdc90 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -19,7 +19,7 @@ export const Button: React.FC = ({ ...props }) => { const baseClasses = - "font-medium rounded border focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; + "font-medium rounded-lg border focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; const variantClasses = { primary: diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index de3bf7a7c..792c676d4 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -62,7 +62,7 @@ export const Dropdown: React.FC = ({

{isOpen && !disabled && ( -
+
{options.length === 0 ? (
{t("common.noOptionsFound")} diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 767643eac..95e03e266 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -11,7 +11,7 @@ export const Input: React.FC = ({ ...props }) => { const baseClasses = - "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-start transition-all duration-150"; + "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded-md text-start transition-all duration-150"; const interactiveClasses = disabled ? "opacity-60 cursor-not-allowed bg-mid-gray/10 border-mid-gray/40" diff --git a/src/components/ui/PathDisplay.tsx b/src/components/ui/PathDisplay.tsx index f30f17c3f..ecf03cb6b 100644 --- a/src/components/ui/PathDisplay.tsx +++ b/src/components/ui/PathDisplay.tsx @@ -17,7 +17,7 @@ export const PathDisplay: React.FC = ({ return (
-
+
{path}
+ )} {onDelete && (status === "available" || status === "active") && ( - - {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) => ( + {filteredModels.length > 0 ? ( +
+ {/* Downloaded Models Section */} + {downloadedModels.length > 0 && ( +
+
+

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

+ {/* Language filter dropdown */} +
- ))} - {filteredLanguages.length === 0 && ( -
- {t("settings.general.language.noResults")} -
- )} + + {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.length === 0 && ( +
+ {t("settings.general.language.noResults")} +
+ )} +
+
+ )} +
-
- )} -
-
- {filteredModels.length > 0 ? ( -
- {/* Downloaded Models Section */} - {downloadedModels.length > 0 && ( -
-

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

{downloadedModels.map((model: ModelInfo) => ( Date: Sun, 8 Feb 2026 11:54:40 +0800 Subject: [PATCH 25/26] block for model downloading in onboarding --- src/components/onboarding/Onboarding.tsx | 101 ++++++++++++++++------- src/i18n/locales/ar/translation.json | 1 + src/i18n/locales/cs/translation.json | 1 + src/i18n/locales/de/translation.json | 1 + src/i18n/locales/en/translation.json | 1 + src/i18n/locales/es/translation.json | 1 + src/i18n/locales/fr/translation.json | 1 + src/i18n/locales/it/translation.json | 1 + src/i18n/locales/ja/translation.json | 1 + src/i18n/locales/ko/translation.json | 1 + src/i18n/locales/pl/translation.json | 1 + src/i18n/locales/pt/translation.json | 1 + src/i18n/locales/ru/translation.json | 1 + src/i18n/locales/tr/translation.json | 1 + src/i18n/locales/uk/translation.json | 1 + src/i18n/locales/vi/translation.json | 1 + src/i18n/locales/zh/translation.json | 1 + 17 files changed, 85 insertions(+), 32 deletions(-) diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx index 1a8a1f544..0ec332037 100644 --- a/src/components/onboarding/Onboarding.tsx +++ b/src/components/onboarding/Onboarding.tsx @@ -1,6 +1,8 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import type { ModelInfo } from "@/bindings"; +import type { ModelCardStatus } from "./ModelCard"; import ModelCard from "./ModelCard"; import HandyTextLogo from "../icons/HandyTextLogo"; import { useModelStore } from "../../stores/modelStore"; @@ -11,33 +13,64 @@ interface OnboardingProps { const Onboarding: React.FC = ({ onModelSelected }) => { const { t } = useTranslation(); - const { models, downloadModel, error: modelError } = useModelStore(); - const [downloading, setDownloading] = useState(false); - const [error, setError] = useState(null); + const { + models, + downloadModel, + selectModel, + downloadingModels, + extractingModels, + downloadProgress, + downloadStats, + } = useModelStore(); + const [selectedModelId, setSelectedModelId] = useState(null); - // Only show downloadable models for onboarding - const availableModels = models.filter((m: ModelInfo) => !m.is_downloaded); + const isDownloading = selectedModelId !== null; + + // Watch for the selected model to finish downloading + extracting + useEffect(() => { + if (!selectedModelId) return; + + const model = models.find((m) => m.id === selectedModelId); + const stillDownloading = selectedModelId in downloadingModels; + const stillExtracting = selectedModelId in extractingModels; + + if (model?.is_downloaded && !stillDownloading && !stillExtracting) { + // Model is ready — select it and transition + selectModel(selectedModelId).then(() => { + onModelSelected(); + }); + } + }, [ + selectedModelId, + models, + downloadingModels, + extractingModels, + selectModel, + onModelSelected, + ]); const handleDownloadModel = async (modelId: string) => { - setDownloading(true); - setError(null); + setSelectedModelId(modelId); - // Start the download (updates Zustand store) - const downloadPromise = downloadModel(modelId); + const success = await downloadModel(modelId); + if (!success) { + toast.error(t("onboarding.downloadFailed")); + setSelectedModelId(null); + } + }; - // Immediately transition to main app - download will continue in footer - onModelSelected(); + const getModelStatus = (modelId: string): ModelCardStatus => { + if (modelId in extractingModels) return "extracting"; + if (modelId in downloadingModels) return "downloading"; + return "downloadable"; + }; - // Note: We don't await or handle the result here since the component - // will unmount. The Zustand store handles download state, and any errors - // will be visible in the main app's ModelSelector. - downloadPromise.catch((err: Error) => { - console.error("Download failed:", err); - }); + const getModelDownloadProgress = (modelId: string): number | undefined => { + return downloadProgress[modelId]?.percentage; }; - const isRecommendedModel = (model: ModelInfo): boolean => { - return model.is_recommended; + const getModelDownloadSpeed = (modelId: string): number | undefined => { + return downloadStats[modelId]?.speed; }; return ( @@ -50,27 +83,27 @@ const Onboarding: React.FC = ({ onModelSelected }) => {
- {error && ( -
-

{error}

-
- )} -
- {availableModels - .filter((model: ModelInfo) => isRecommendedModel(model)) + {models + .filter((m: ModelInfo) => !m.is_downloaded) + .filter((model: ModelInfo) => model.is_recommended) .map((model: ModelInfo) => ( ))} - {availableModels - .filter((model: ModelInfo) => !isRecommendedModel(model)) + {models + .filter((m: ModelInfo) => !m.is_downloaded) + .filter((model: ModelInfo) => !model.is_recommended) .sort( (a: ModelInfo, b: ModelInfo) => Number(a.size_mb) - Number(b.size_mb), @@ -79,8 +112,12 @@ const Onboarding: React.FC = ({ onModelSelected }) => { ))}
diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 61b2be108..92ab7ccab 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -20,6 +20,7 @@ "recommended": "موصى به", "download": "تنزيل", "downloading": "...جاري التنزيل", + "downloadFailed": "فشل التنزيل. يرجى المحاولة مرة أخرى.", "modelCard": { "accuracy": "الدقة", "speed": "السرعة" diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index b626fb668..239974fa3 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í...", + "downloadFailed": "Stahování se nezdařilo. Zkuste to prosím znovu.", "modelCard": { "accuracy": "přesnost", "speed": "rychlost" diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 77d22437d..b51d98287 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...", + "downloadFailed": "Download fehlgeschlagen. Bitte erneut versuchen.", "modelCard": { "accuracy": "Genauigkeit", "speed": "Geschwindigkeit" diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 1b9507393..1a1a10c51 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...", + "downloadFailed": "Download failed. Please try again.", "modelCard": { "accuracy": "accuracy", "speed": "speed" diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index fe416a189..353fe6373 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...", + "downloadFailed": "La descarga falló. Por favor, inténtalo de nuevo.", "modelCard": { "accuracy": "precisión", "speed": "velocidad" diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index b525d8d65..c21a05beb 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...", + "downloadFailed": "Échec du téléchargement. Veuillez réessayer.", "modelCard": { "accuracy": "précision", "speed": "vitesse" diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 4b36af4f7..c7885a506 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...", + "downloadFailed": "Download fallito. Riprova.", "modelCard": { "accuracy": "accuratezza", "speed": "velocità" diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index d7aca761b..592115901 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -20,6 +20,7 @@ "recommended": "おすすめ", "download": "ダウンロード", "downloading": "ダウンロード中...", + "downloadFailed": "ダウンロードに失敗しました。もう一度お試しください。", "modelCard": { "accuracy": "精度", "speed": "速度" diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 9000590e2..73397c834 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -20,6 +20,7 @@ "recommended": "추천", "download": "다운로드", "downloading": "다운로드 중...", + "downloadFailed": "다운로드에 실패했습니다. 다시 시도해주세요.", "modelCard": { "accuracy": "정확도", "speed": "속도" diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 55d6e27a2..9c5405209 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...", + "downloadFailed": "Pobieranie nie powiodło się. Spróbuj ponownie.", "modelCard": { "accuracy": "dokładność", "speed": "szybkość" diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 50f7842e4..950f6ab93 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...", + "downloadFailed": "Falha no download. Por favor, tente novamente.", "modelCard": { "accuracy": "precisão", "speed": "velocidade" diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 11a597952..9132e9bd9 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -20,6 +20,7 @@ "recommended": "Рекомендуется", "download": "Скачать", "downloading": "Загрузка...", + "downloadFailed": "Загрузка не удалась. Пожалуйста, попробуйте снова.", "modelCard": { "accuracy": "точность", "speed": "скорость" diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 351cba013..d0d587cfc 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...", + "downloadFailed": "İndirme başarısız oldu. Lütfen tekrar deneyin.", "modelCard": { "accuracy": "doğruluk", "speed": "hız" diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index d18292041..0af4137a5 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -20,6 +20,7 @@ "recommended": "Рекомендовано", "download": "Завантажити", "downloading": "Завантаження...", + "downloadFailed": "Завантаження не вдалося. Будь ласка, спробуйте ще раз.", "modelCard": { "accuracy": "точність", "speed": "швидкість" diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index ac5b2fc51..81f077755 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...", + "downloadFailed": "Tải xuống thất bại. Vui lòng thử lại.", "modelCard": { "accuracy": "độ chính xác", "speed": "tốc độ" diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 0c12def93..5c16a4ad9 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -20,6 +20,7 @@ "recommended": "推荐", "download": "下载", "downloading": "下载中...", + "downloadFailed": "下载失败。请重试。", "modelCard": { "accuracy": "准确度", "speed": "速度" From 34b43588971df2eb7a34cdbb814ccfdb4b47f37f Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sun, 8 Feb 2026 12:14:36 +0800 Subject: [PATCH 26/26] small fixes --- .../model-selector/ModelDropdown.tsx | 2 +- .../model-selector/ModelSelector.tsx | 354 +++++------------- src/components/onboarding/Onboarding.tsx | 9 +- src/stores/modelStore.ts | 11 + 4 files changed, 104 insertions(+), 272 deletions(-) diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx index 83da5b57e..f2d57017c 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -25,7 +25,7 @@ const ModelDropdown: React.FC = ({ }; return ( -
+
{downloadedModels.length > 0 ? (
{downloadedModels.map((model) => ( diff --git a/src/components/model-selector/ModelSelector.tsx b/src/components/model-selector/ModelSelector.tsx index f953065e5..5045c6b90 100644 --- a/src/components/model-selector/ModelSelector.tsx +++ b/src/components/model-selector/ModelSelector.tsx @@ -1,9 +1,9 @@ import React, { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; -import { produce } from "immer"; -import { commands, type ModelInfo } from "@/bindings"; +import { commands } from "@/bindings"; import { getTranslatedModelName } from "../../lib/utils/modelTranslation"; +import { useModelStore } from "../../stores/modelStore"; import ModelStatusButton from "./ModelStatusButton"; import ModelDropdown from "./ModelDropdown"; import DownloadProgressDisplay from "./DownloadProgressDisplay"; @@ -15,13 +15,6 @@ interface ModelStateEvent { error?: string; } -interface DownloadProgress { - model_id: string; - downloaded: number; - total: number; - percentage: number; -} - type ModelStatus = | "ready" | "loading" @@ -31,46 +24,59 @@ type ModelStatus = | "unloaded" | "none"; -interface DownloadStats { - startTime: number; - lastUpdate: number; - totalDownloaded: number; - speed: number; -} - interface ModelSelectorProps { onError?: (error: string) => void; } const ModelSelector: React.FC = ({ onError }) => { const { t } = useTranslation(); - const [models, setModels] = useState([]); - const [currentModelId, setCurrentModelId] = useState(""); + const { + models, + currentModel, + downloadProgress, + downloadStats, + extractingModels, + selectModel, + } = useModelStore(); + const [modelStatus, setModelStatus] = useState("unloaded"); const [modelError, setModelError] = useState(null); - const [modelDownloadProgress, setModelDownloadProgress] = useState< - Record - >({}); const [showModelDropdown, setShowModelDropdown] = useState(false); - const [downloadStats, setDownloadStats] = useState< - Record - >({}); - const [extractingModels, setExtractingModels] = useState< - Record - >({}); + // Track pending model switch for optimistic display + const [pendingModelId, setPendingModelId] = useState(null); const dropdownRef = useRef(null); + const displayModelId = pendingModelId || currentModel; + + // Check model status when currentModel changes useEffect(() => { - loadModels(); - loadCurrentModel(); + const checkStatus = async () => { + if (currentModel) { + try { + const statusResult = await commands.getTranscriptionModelStatus(); + if (statusResult.status === "ok") { + setModelStatus( + statusResult.data === currentModel ? "ready" : "unloaded", + ); + } + } catch { + setModelStatus("error"); + setModelError("Failed to check model status"); + } + } else { + setModelStatus("none"); + } + }; + checkStatus(); + }, [currentModel]); - // Listen for model state changes + useEffect(() => { + // Listen for model loading lifecycle events const modelStateUnlisten = listen( "model-state-changed", (event) => { - const { event_type, model_id, model_name, error } = event.payload; - + const { event_type, error } = event.payload; switch (event_type) { case "loading_started": setModelStatus("loading"); @@ -79,11 +85,12 @@ const ModelSelector: React.FC = ({ onError }) => { case "loading_completed": setModelStatus("ready"); setModelError(null); - if (model_id) setCurrentModelId(model_id); + setPendingModelId(null); break; case "loading_failed": setModelStatus("error"); setModelError(error || "Failed to load model"); + setPendingModelId(null); break; case "unloaded": setModelStatus("unloaded"); @@ -93,171 +100,30 @@ const ModelSelector: React.FC = ({ onError }) => { }, ); - // Listen for model download progress - const downloadProgressUnlisten = listen( - "model-download-progress", - (event) => { - const progress = event.payload; - setModelDownloadProgress( - produce((downloadProgress) => { - downloadProgress[progress.model_id] = progress; - }), - ); - setModelStatus("downloading"); - - // Update download stats for speed calculation - const now = Date.now(); - setDownloadStats( - produce((stats) => { - const current = stats[progress.model_id]; - - if (!current) { - // First progress update - initialize - stats[progress.model_id] = { - startTime: now, - lastUpdate: now, - totalDownloaded: progress.downloaded, - speed: 0, - }; - } else { - // Calculate speed over last few seconds - const timeDiff = (now - current.lastUpdate) / 1000; // seconds - const bytesDiff = progress.downloaded - current.totalDownloaded; - - if (timeDiff > 0.5) { - // Update speed every 500ms - const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; // MB/s - // Smooth the speed with exponential moving average, but ensure positive values - const validCurrentSpeed = Math.max(0, currentSpeed); - const smoothedSpeed = - current.speed > 0 - ? current.speed * 0.8 + validCurrentSpeed * 0.2 - : validCurrentSpeed; - - stats[progress.model_id] = { - startTime: current.startTime, - lastUpdate: now, - totalDownloaded: progress.downloaded, - speed: Math.max(0, smoothedSpeed), - }; - } - } - }), - ); - }, - ); - - // Listen for model download completion + // Auto-select model when download completes (fires after extraction too) const downloadCompleteUnlisten = listen( "model-download-complete", (event) => { const modelId = event.payload; - setModelDownloadProgress( - produce((progress) => { - delete progress[modelId]; - }), - ); - setDownloadStats( - produce((stats) => { - delete stats[modelId]; - }), - ); - loadModels(); // Refresh models list - - // Auto-select the newly downloaded model (skip if recording in progress) - setTimeout(async () => { - const isRecording = await commands.isRecording(); - if (isRecording) { - return; // Skip auto-switch if recording in progress - } - loadCurrentModel(); - handleModelSelect(modelId); - }, 500); - }, - ); - - // Listen for model download cancellation - const downloadCancelledUnlisten = listen( - "model-download-cancelled", - (event) => { - const modelId = event.payload; - setModelDownloadProgress( - produce((progress) => { - delete progress[modelId]; - }), - ); - setDownloadStats( - produce((stats) => { - delete stats[modelId]; - }), - ); - // Reset status if no other downloads in progress - setModelDownloadProgress((currentProgress) => { - if (Object.keys(currentProgress).length === 0) { - loadCurrentModel(); // Restore normal status display - } - return currentProgress; - }); - }, - ); - - // Listen for extraction events - const extractionStartedUnlisten = listen( - "model-extraction-started", - (event) => { - const modelId = event.payload; - setExtractingModels( - produce((extracting) => { - extracting[modelId] = true; - }), - ); - setModelStatus("extracting"); - }, - ); - - const extractionCompletedUnlisten = listen( - "model-extraction-completed", - (event) => { - const modelId = event.payload; - setExtractingModels( - produce((extracting) => { - delete extracting[modelId]; - }), - ); - loadModels(); // Refresh models list - - // Auto-select the newly extracted model (skip if recording in progress) setTimeout(async () => { - const isRecording = await commands.isRecording(); - if (isRecording) { - return; // Skip auto-switch if recording in progress + try { + const isRecording = await commands.isRecording(); + if (!isRecording) { + setPendingModelId(modelId); + setModelError(null); + setShowModelDropdown(false); + const success = await selectModel(modelId); + if (!success) { + setPendingModelId(null); + } + } + } catch { + // Ignore errors in auto-select } - loadCurrentModel(); - handleModelSelect(modelId); }, 500); }, ); - const extractionFailedUnlisten = listen<{ - model_id: string; - error: string; - }>("model-extraction-failed", (event) => { - const modelId = event.payload.model_id; - setExtractingModels( - produce((extracting) => { - delete extracting[modelId]; - }), - ); - setModelError(`Failed to extract model: ${event.payload.error}`); - setModelStatus("error"); - }); - - // Listen for model deletion - const modelDeletedUnlisten = listen("model-deleted", () => { - loadModels(); // Refresh models list - loadCurrentModel(); // Update current model in case deleted model was active - }); - // Click outside to close dropdown const handleClickOutside = (event: MouseEvent) => { if ( @@ -273,80 +139,23 @@ const ModelSelector: React.FC = ({ onError }) => { return () => { document.removeEventListener("mousedown", handleClickOutside); modelStateUnlisten.then((fn) => fn()); - downloadProgressUnlisten.then((fn) => fn()); downloadCompleteUnlisten.then((fn) => fn()); - downloadCancelledUnlisten.then((fn) => fn()); - extractionStartedUnlisten.then((fn) => fn()); - extractionCompletedUnlisten.then((fn) => fn()); - extractionFailedUnlisten.then((fn) => fn()); - modelDeletedUnlisten.then((fn) => fn()); }; - }, []); - - const loadModels = async () => { - try { - const result = await commands.getAvailableModels(); - if (result.status === "ok") { - setModels(result.data); - } - } catch (err) { - console.error("Failed to load models:", err); - } - }; - - const loadCurrentModel = async () => { - try { - const result = await commands.getCurrentModel(); - if (result.status === "ok") { - const current = result.data; - setCurrentModelId(current); - - if (current) { - // Check if model is actually loaded - const statusResult = await commands.getTranscriptionModelStatus(); - if (statusResult.status === "ok") { - const transcriptionStatus = statusResult.data; - if (transcriptionStatus === current) { - setModelStatus("ready"); - } else { - setModelStatus("unloaded"); - } - } - } else { - setModelStatus("none"); - } - } - } catch (err) { - console.error("Failed to load current model:", err); - setModelStatus("error"); - setModelError("Failed to check model status"); - } - }; + }, [selectModel]); const handleModelSelect = async (modelId: string) => { - try { - setCurrentModelId(modelId); // Set optimistically so loading text shows correct model - setModelError(null); - setShowModelDropdown(false); - const result = await commands.setActiveModel(modelId); - if (result.status === "error") { - const errorMsg = result.error; - setModelError(errorMsg); - setModelStatus("error"); - onError?.(errorMsg); - } - } catch (err) { - const errorMsg = `${err}`; - setModelError(errorMsg); + setPendingModelId(modelId); + setModelError(null); + setShowModelDropdown(false); + const success = await selectModel(modelId); + if (!success) { + setPendingModelId(null); setModelStatus("error"); - onError?.(errorMsg); + setModelError("Failed to switch model"); + onError?.("Failed to switch model"); } }; - const getCurrentModel = () => { - return models.find((m) => m.id === currentModelId); - }; - const getModelDisplayText = (): string => { const extractingKeys = Object.keys(extractingModels); if (extractingKeys.length > 0) { @@ -364,7 +173,7 @@ const ModelSelector: React.FC = ({ onError }) => { } } - const progressValues = Object.values(modelDownloadProgress); + const progressValues = Object.values(downloadProgress); if (progressValues.length > 0) { if (progressValues.length === 1) { const progress = progressValues[0]; @@ -380,46 +189,53 @@ const ModelSelector: React.FC = ({ onError }) => { } } - const currentModel = getCurrentModel(); + const currentModelInfo = models.find((m) => m.id === displayModelId); switch (modelStatus) { case "ready": - return currentModel - ? getTranslatedModelName(currentModel, t) + return currentModelInfo + ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelReady"); case "loading": - return currentModel + return currentModelInfo ? t("modelSelector.loading", { - modelName: getTranslatedModelName(currentModel, t), + modelName: getTranslatedModelName(currentModelInfo, t), }) : t("modelSelector.loadingGeneric"); case "extracting": - return currentModel + return currentModelInfo ? t("modelSelector.extracting", { - modelName: getTranslatedModelName(currentModel, t), + modelName: getTranslatedModelName(currentModelInfo, t), }) : t("modelSelector.extractingGeneric"); case "error": return modelError || t("modelSelector.modelError"); case "unloaded": - return currentModel - ? getTranslatedModelName(currentModel, t) + return currentModelInfo + ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelUnloaded"); case "none": return t("modelSelector.noModelDownloadRequired"); default: - return currentModel - ? getTranslatedModelName(currentModel, t) + return currentModelInfo + ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelUnloaded"); } }; + // Derive display status from model status + store state + const getDisplayStatus = (): ModelStatus => { + if (Object.keys(extractingModels).length > 0) return "extracting"; + if (Object.keys(downloadProgress).length > 0) return "downloading"; + return modelStatus; + }; + return ( <> {/* Model Status and Switcher */}
setShowModelDropdown(!showModelDropdown)} @@ -429,7 +245,7 @@ const ModelSelector: React.FC = ({ onError }) => { {showModelDropdown && ( )} @@ -437,7 +253,7 @@ const ModelSelector: React.FC = ({ onError }) => { {/* Download Progress Bar for Models */} diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx index 0ec332037..34fe373b6 100644 --- a/src/components/onboarding/Onboarding.tsx +++ b/src/components/onboarding/Onboarding.tsx @@ -36,8 +36,13 @@ const Onboarding: React.FC = ({ onModelSelected }) => { if (model?.is_downloaded && !stillDownloading && !stillExtracting) { // Model is ready — select it and transition - selectModel(selectedModelId).then(() => { - onModelSelected(); + selectModel(selectedModelId).then((success) => { + if (success) { + onModelSelected(); + } else { + toast.error(t("onboarding.errors.selectModel")); + setSelectedModelId(null); + } }); } }, [ diff --git a/src/stores/modelStore.ts b/src/stores/modelStore.ts index 40ac54e84..97d6d7abd 100644 --- a/src/stores/modelStore.ts +++ b/src/stores/modelStore.ts @@ -355,6 +355,17 @@ export const useModelStore = create()( }, ); + listen("model-download-cancelled", (event) => { + const modelId = event.payload; + set( + produce((state) => { + delete state.downloadingModels[modelId]; + delete state.downloadProgress[modelId]; + delete state.downloadStats[modelId]; + }), + ); + }); + listen("model-deleted", () => { get().loadModels(); get().loadCurrentModel();