From ef24cfefda6a0406300fd2fddc8e40d095484fd3 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sat, 7 Feb 2026 23:18:22 -0500 Subject: [PATCH] feat: add models settings page with filtering and management (#478) * 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 * 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 * 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 * 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) * fix: translate english placeholders in cs and tr locale files * 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 * 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 * fix: remove duplicate language/translate settings from general and advanced settings are now only in ModelSettingsCard, not duplicated in their old locations * fix: prevent model dropdown from being clipped by window edge * add languages explicitly, clean up some ui * fix: clear bottom progress bar when download is cancelled The ModelSelector component maintains its own local state for download progress. When a download was cancelled, the Rust backend would update its state but never emitted an event to notify the frontend. This caused the bottom progress bar to remain stuck showing "Downloading X%". Added model-download-cancelled event emission in Rust and corresponding listener in ModelSelector to clear progress state on cancellation. * fix: align model card content to top to prevent floating elements Changed ModelCard flex alignment from items-center to items-start so the accuracy/speed bars stay at the top when the card expands (e.g., during download with progress bar visible). * 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. * 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. * 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 * 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. * fix: add label to model delete button for clearer destructive state * check translations as typescript * format * better text for dropdown * wip ui tweaks * rounded + ui tweaks * fix download not 0% immediately, ui tweaks * tweak name * block for model downloading in onboarding * small fixes --------- Co-authored-by: CJ Pais --- .github/workflows/lint.yml | 3 + bun.lock | 3 + package.json | 8 +- scripts/check-translations.ts | 224 +++++++++++ src-tauri/Cargo.lock | 89 +++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/commands/models.rs | 21 +- src-tauri/src/lib.rs | 49 +-- src-tauri/src/managers/model.rs | 196 ++++++++-- src/bindings.ts | 10 +- src/components/AccessibilityPermissions.tsx | 15 +- src/components/Sidebar.tsx | 9 +- .../model-selector/ModelDropdown.tsx | 184 +-------- .../model-selector/ModelSelector.tsx | 349 ++++------------- src/components/onboarding/ModelCard.tsx | 300 ++++++++++++--- src/components/onboarding/Onboarding.tsx | 133 ++++--- src/components/onboarding/index.ts | 2 + .../settings/GlobalShortcutInput.tsx | 4 +- .../settings/HandyKeysShortcutInput.tsx | 4 +- .../settings/advanced/AdvancedSettings.tsx | 9 - .../settings/general/GeneralSettings.tsx | 10 +- .../settings/general/ModelSettingsCard.tsx | 39 ++ .../settings/history/HistorySettings.tsx | 2 +- src/components/settings/index.ts | 1 + .../settings/models/ModelsSettings.tsx | 360 ++++++++++++++++++ src/components/settings/models/index.ts | 1 + .../PostProcessingSettings.tsx | 2 +- src/components/ui/Badge.tsx | 4 +- src/components/ui/Button.tsx | 14 +- 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 | 58 ++- src/i18n/locales/cs/translation.json | 81 +++- src/i18n/locales/de/translation.json | 64 +++- src/i18n/locales/en/translation.json | 49 ++- src/i18n/locales/es/translation.json | 64 +++- src/i18n/locales/fr/translation.json | 65 +++- src/i18n/locales/it/translation.json | 64 +++- src/i18n/locales/ja/translation.json | 64 +++- src/i18n/locales/ko/translation.json | 52 ++- src/i18n/locales/pl/translation.json | 64 +++- src/i18n/locales/pt/translation.json | 58 ++- src/i18n/locales/ru/translation.json | 64 +++- src/i18n/locales/tr/translation.json | 61 ++- src/i18n/locales/uk/translation.json | 64 +++- src/i18n/locales/vi/translation.json | 65 +++- src/i18n/locales/zh/translation.json | 64 +++- src/lib/constants/languages.ts | 1 + src/stores/modelStore.ts | 352 +++++++++++------ 54 files changed, 2501 insertions(+), 916 deletions(-) create mode 100644 scripts/check-translations.ts 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/.github/workflows/lint.yml b/.github/workflows/lint.yml index a80574f..032b81a 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/bun.lock b/bun.lock index c0f3202..c45196b 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 f039567..cb4ba58 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,15 @@ "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": "bun scripts/check-translations.ts" }, "dependencies": { "@tailwindcss/vite": "^4.1.16", "@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 +32,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/scripts/check-translations.ts b/scripts/check-translations.ts new file mode 100644 index 0000000..f19d8d3 --- /dev/null +++ b/scripts/check-translations.ts @@ -0,0 +1,224 @@ +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"; + +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) + .map((entry) => entry.name) + .sort(); +} + +const LANGUAGES = getLanguages(); + +// Colors for terminal output +const colors: Record = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", +}; + +function colorize(text: string, color: string): string { + return `${colors[color]}${text}${colors.reset}`; +} + +function getAllKeyPaths( + obj: TranslationData, + prefix: string[] = [], +): string[][] { + let paths: string[][] = []; + for (const key in obj) { + if (!Object.hasOwn(obj, key)) continue; + + const currentPath = prefix.concat([key]); + const value = obj[key]; + + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + paths = paths.concat( + getAllKeyPaths(value as TranslationData, currentPath), + ); + } else { + paths.push(currentPath); + } + } + return paths; +} + +function hasKeyPath(obj: TranslationData, keyPath: string[]): boolean { + let current: unknown = obj; + for (const key of keyPath) { + if ( + typeof current !== "object" || + current === null || + (current as Record)[key] === undefined + ) { + return false; + } + current = (current as Record)[key]; + } + return true; +} + +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) as TranslationData; + } catch (error) { + console.error(colorize(`✗ Error loading ${lang}/translation.json:`, "red")); + console.error(` ${(error as Error).message}`); + return null; + } +} + +function validateTranslations(): void { + 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: Record = {}; + + // 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(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f372c4..881ae30 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 0b7bb0c..a638de7 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 84974d5..d051e54 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 b2e30b6..cb48984 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()) @@ -128,10 +142,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 c80a8b9..c45adf9 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 d64dab5..0ac628d 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -5,12 +5,14 @@ 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; 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}; @@ -34,8 +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 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 supported_languages: Vec, // Languages this model can transcribe } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -50,6 +55,8 @@ pub struct ModelManager { app_handle: AppHandle, models_dir: PathBuf, available_models: Mutex>, + cancel_flags: Arc>>>, + extracting_models: Arc>>, } impl ModelManager { @@ -67,6 +74,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(), @@ -84,6 +107,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.60, speed_score: 0.85, + supports_translation: true, + is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -104,6 +130,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.75, speed_score: 0.60, + supports_translation: true, + is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -123,6 +152,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.80, speed_score: 0.40, + supports_translation: false, // Turbo doesn't support translation + is_recommended: false, + supported_languages: whisper_languages.clone(), }, ); @@ -142,6 +174,9 @@ impl ModelManager { engine_type: EngineType::Whisper, accuracy_score: 0.85, speed_score: 0.30, + supports_translation: true, + is_recommended: false, + supported_languages: whisper_languages, }, ); @@ -162,15 +197,28 @@ impl ModelManager { engine_type: EngineType::Parakeet, accuracy_score: 0.85, speed_score: 0.85, + 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 @@ -181,6 +229,9 @@ impl ModelManager { engine_type: EngineType::Parakeet, accuracy_score: 0.80, speed_score: 0.85, + supports_translation: false, + is_recommended: true, + supported_languages: parakeet_v3_languages, }, ); @@ -200,6 +251,9 @@ impl ModelManager { engine_type: EngineType::Moonshine, accuracy_score: 0.70, speed_score: 0.90, + supports_translation: false, + is_recommended: false, + supported_languages: vec!["en".to_string()], }, ); @@ -207,6 +261,8 @@ impl ModelManager { app_handle: app_handle.clone(), 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 @@ -271,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); } @@ -376,6 +437,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); @@ -456,8 +524,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 { @@ -478,17 +574,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 @@ -514,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); @@ -542,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!({ @@ -576,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); @@ -596,6 +725,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); @@ -663,6 +798,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(()) } @@ -714,15 +852,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() - }; + // 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); + } + } - let _model_info = - _model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", 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) { @@ -730,14 +871,13 @@ 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); + // Emit cancellation event so all UI components can clear their state + let _ = self.app_handle.emit("model-download-cancelled", model_id); + + info!("Download cancellation initiated for: {}", model_id); Ok(()) } } diff --git a/src/bindings.ts b/src/bindings.ts index 325dc7f..ff3846d 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_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/AccessibilityPermissions.tsx b/src/components/AccessibilityPermissions.tsx index 6454c8c..2826403 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; } @@ -70,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/Sidebar.tsx b/src/components/Sidebar.tsx index 2522c16..0d7211d 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 4a2003d..f2d5701 100644 --- a/src/components/model-selector/ModelDropdown.tsx +++ b/src/components/model-selector/ModelDropdown.tsx @@ -1,91 +1,34 @@ import React from "react"; import { useTranslation } from "react-i18next"; 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(); - - 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)} @@ -105,128 +48,23 @@ const ModelDropdown: React.FC = ({ >
-
+
{getTranslatedModelName(model, t)}
{getTranslatedModelDescription(model, t)}
-
- {currentModelId === model.id && ( -
- {t("modelSelector.active")} -
- )} - {currentModelId !== model.id && ( - - )} -
+ {currentModelId === model.id && ( +
+ {t("modelSelector.active")} +
+ )}
))}
- )} - - {/* 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.id === "parakeet-tdt-0.6b-v3" && 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 && ( -
- -
- )} -
- ); - })} -
- )} - - {/* 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 f781340..5045c6b 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); - useEffect(() => { - loadModels(); - loadCurrentModel(); + const displayModelId = pendingModelId || currentModel; - // Listen for model state changes + // Check model status when currentModel changes + useEffect(() => { + 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]); + + 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,140 +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 + 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); }, ); - // 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 - } - 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"); - }); - // Click outside to close dropdown const handleClickOutside = (event: MouseEvent) => { if ( @@ -242,96 +139,23 @@ const ModelSelector: React.FC = ({ onError }) => { return () => { document.removeEventListener("mousedown", handleClickOutside); modelStateUnlisten.then((fn) => fn()); - downloadProgressUnlisten.then((fn) => fn()); downloadCompleteUnlisten.then((fn) => fn()); - extractionStartedUnlisten.then((fn) => fn()); - extractionCompletedUnlisten.then((fn) => fn()); - extractionFailedUnlisten.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 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); - }; - const getModelDisplayText = (): string => { const extractingKeys = Object.keys(extractingModels); if (extractingKeys.length > 0) { @@ -349,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]; @@ -365,46 +189,45 @@ 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"); } }; - const handleModelDelete = async (modelId: string) => { - const result = await commands.deleteModel(modelId); - if (result.status === "ok") { - await loadModels(); - setModelError(null); - } + // 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 ( @@ -412,7 +235,7 @@ const ModelSelector: React.FC = ({ onError }) => { {/* Model Status and Switcher */}
setShowModelDropdown(!showModelDropdown)} @@ -422,19 +245,15 @@ const ModelSelector: React.FC = ({ onError }) => { {showModelDropdown && ( )}
{/* Download Progress Bar for Models */} diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 5994534..f119ebd 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -1,109 +1,285 @@ 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 { 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 = ( + 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" + | "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; + onCancel?: (modelId: string) => void; + downloadProgress?: number; + downloadSpeed?: number; // MB/s + showRecommended?: boolean; } const ModelCard: React.FC = ({ model, variant = "default", + status = "downloadable", disabled = false, className = "", onSelect, + onDownload, + onDelete, + onCancel, + downloadProgress, + downloadSpeed, + showRecommended = true, }) => { const { t } = useTranslation(); const isFeatured = variant === "featured"; + const isClickable = + status === "available" || status === "active" || status === "downloadable"; // 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 flex-col rounded-xl px-4 py-3 gap-2 text-left transition-all duration-200"; - 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 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 (!isClickable) 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 (!isClickable || disabled) return; + if (status === "downloadable" && onDownload) { + onDownload(model.id); + } else { + onSelect(model.id); + } + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(model.id); + }; return ( - - ); -}; -const DownloadSize = ({ sizeMb }: { sizeMb: number }) => { - const { t } = useTranslation(); +
- return ( -
-
+ ); +}; diff --git a/src/components/settings/models/index.ts b/src/components/settings/models/index.ts new file mode 100644 index 0000000..1de9a50 --- /dev/null +++ b/src/components/settings/models/index.ts @@ -0,0 +1 @@ +export { ModelsSettings } from "./ModelsSettings"; diff --git a/src/components/settings/post-processing/PostProcessingSettings.tsx b/src/components/settings/post-processing/PostProcessingSettings.tsx index 2dfbbf2..4750a0d 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/Badge.tsx b/src/components/ui/Badge.tsx index 18aa913..d7a3673 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/components/ui/Button.tsx b/src/components/ui/Button.tsx index adb389d..df92fdc 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" | "secondary" | "danger" | "ghost"; + variant?: + | "primary" + | "primary-soft" + | "secondary" + | "danger" + | "danger-ghost" + | "ghost"; size?: "sm" | "md" | "lg"; } @@ -13,15 +19,19 @@ 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: "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", }; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index de3bf7a..792c676 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 767643e..95e03e2 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 f30f17c..ecf03cb 100644 --- a/src/components/ui/PathDisplay.tsx +++ b/src/components/ui/PathDisplay.tsx @@ -17,7 +17,7 @@ export const PathDisplay: React.FC = ({ return (
-
+
{path}