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<AtomicBool> 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 <cj@cjpais.com>
This commit is contained in:
Viren Mohindra 2026-02-07 23:18:22 -05:00 committed by GitHub
parent 665558bac4
commit ef24cfefda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2501 additions and 916 deletions

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>;
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<string, string> = {
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<string, unknown>)[key] === undefined
) {
return false;
}
current = (current as Record<string, unknown>)[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<string, ValidationResult> = {};
// 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();

89
src-tauri/Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -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<ModelManager>>,
transcription_manager: State<'_, Arc<TranscriptionManager>>,
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<String, String> {
// Recommend Parakeet V3 model for first-time users - fastest and most accurate
Ok("parakeet-tdt-0.6b-v3".to_string())
}

View file

@ -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")]
{

View file

@ -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<String>, // 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<HashMap<String, ModelInfo>>,
cancel_flags: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
extracting_models: Arc<Mutex<HashSet<String>>>,
}
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<String> = 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<String> = 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(())
}
}

View file

@ -513,14 +513,6 @@ async hasAnyModelsOrDownloads() : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async getRecommendedFirstModel() : Promise<Result<string, string>> {
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<Result<null, string>> {
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"

View file

@ -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<PermissionState>("request");
// Accessibility permissions are only required on macOS
const isMacOS = type() === "macos";
// Check permissions without requesting
const checkPermissions = async (): Promise<boolean> => {
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<void> => {
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,
};

View file

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

View file

@ -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<string, DownloadProgress>;
onModelSelect: (modelId: string) => void;
onModelDownload: (modelId: string) => void;
onModelDelete: (modelId: string) => Promise<void>;
onError?: (error: string) => void;
}
const ModelDropdown: React.FC<ModelDropdownProps> = ({
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 (
<div className="absolute bottom-full start-0 mb-2 w-64 bg-background border border-mid-gray/20 rounded-lg shadow-lg py-2 z-50">
{/* First Run Welcome */}
{isFirstRun && (
<div className="px-3 py-2 bg-logo-primary/10 border-b border-logo-primary/20">
<div className="text-xs font-medium text-logo-primary mb-1">
{t("modelSelector.welcome")}
</div>
<div className="text-xs text-text/70">
{t("modelSelector.downloadPrompt")}
</div>
</div>
)}
{/* Available Models */}
{availableModels.length > 0 && (
<div className="absolute bottom-full start-0 mb-2 w-64 max-h-[60vh] overflow-y-auto bg-background border border-mid-gray/20 rounded-lg shadow-lg py-2 z-50">
{downloadedModels.length > 0 ? (
<div>
<div className="px-3 py-1 text-xs font-medium text-text/80 border-b border-mid-gray/10">
{t("modelSelector.availableModels")}
</div>
{availableModels.map((model) => (
{downloadedModels.map((model) => (
<div
key={model.id}
onClick={() => handleModelClick(model.id)}
@ -105,128 +48,23 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm">
<div className="text-sm text-text/80">
{getTranslatedModelName(model, t)}
</div>
<div className="text-xs text-text/40 italic pe-4">
{getTranslatedModelDescription(model, t)}
</div>
</div>
<div className="flex items-center gap-2">
{currentModelId === model.id && (
<div className="text-xs text-logo-primary">
{t("modelSelector.active")}
</div>
)}
{currentModelId !== model.id && (
<button
onClick={(e) => handleDeleteClick(e, model.id)}
className="text-red-400 hover:text-red-300 p-1 hover:bg-red-500/10 rounded transition-colors"
title={t("modelSelector.deleteModel", {
modelName: getTranslatedModelName(model, t),
})}
>
<svg
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
{currentModelId === model.id && (
<div className="text-xs text-logo-primary">
{t("modelSelector.active")}
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Downloadable Models */}
{downloadableModels.length > 0 && (
<div>
{(availableModels.length > 0 || isFirstRun) && (
<div className="border-t border-mid-gray/10 my-1" />
)}
<div className="px-3 py-1 text-xs font-medium text-text/80">
{isFirstRun
? t("modelSelector.chooseModel")
: t("modelSelector.downloadModels")}
</div>
{downloadableModels.map((model) => {
const isDownloading = model.id in downloadProgress;
const progress = downloadProgress[model.id];
return (
<div
key={model.id}
onClick={() => 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"
: ""
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm">
{getTranslatedModelName(model, t)}
{model.id === "parakeet-tdt-0.6b-v3" && isFirstRun && (
<span className="ms-2 text-xs bg-logo-primary/20 text-logo-primary px-1.5 py-0.5 rounded">
{t("onboarding.recommended")}
</span>
)}
</div>
<div className="text-xs text-text/40 italic pe-4">
{getTranslatedModelDescription(model, t)}
</div>
<div className="mt-1 text-xs text-text/50 tabular-nums">
{t("modelSelector.downloadSize")} ·{" "}
{formatModelSize(Number(model.size_mb))}
</div>
</div>
<div className="text-xs text-logo-primary tabular-nums">
{isDownloading && progress
? `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%`
: t("modelSelector.download")}
</div>
</div>
{isDownloading && progress && (
<div className="mt-2">
<ProgressBar
progress={[
{
id: model.id,
percentage: progress.percentage,
label: model.name,
},
]}
size="small"
/>
</div>
)}
</div>
);
})}
</div>
)}
{/* No Models Available */}
{availableModels.length === 0 && downloadableModels.length === 0 && (
) : (
<div className="px-3 py-2 text-sm text-text/60">
{t("modelSelector.noModelsAvailable")}
</div>

View file

@ -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<ModelSelectorProps> = ({ onError }) => {
const { t } = useTranslation();
const [models, setModels] = useState<ModelInfo[]>([]);
const [currentModelId, setCurrentModelId] = useState<string>("");
const {
models,
currentModel,
downloadProgress,
downloadStats,
extractingModels,
selectModel,
} = useModelStore();
const [modelStatus, setModelStatus] = useState<ModelStatus>("unloaded");
const [modelError, setModelError] = useState<string | null>(null);
const [modelDownloadProgress, setModelDownloadProgress] = useState<
Record<string, DownloadProgress>
>({});
const [showModelDropdown, setShowModelDropdown] = useState(false);
const [downloadStats, setDownloadStats] = useState<
Record<string, DownloadStats>
>({});
const [extractingModels, setExtractingModels] = useState<
Record<string, true>
>({});
// Track pending model switch for optimistic display
const [pendingModelId, setPendingModelId] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(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<ModelStateEvent>(
"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<ModelSelectorProps> = ({ 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<ModelSelectorProps> = ({ onError }) => {
},
);
// Listen for model download progress
const downloadProgressUnlisten = listen<DownloadProgress>(
"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<string>(
"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<string>(
"model-extraction-started",
(event) => {
const modelId = event.payload;
setExtractingModels(
produce((extracting) => {
extracting[modelId] = true;
}),
);
setModelStatus("extracting");
},
);
const extractionCompletedUnlisten = listen<string>(
"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<ModelSelectorProps> = ({ 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<ModelSelectorProps> = ({ 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<ModelSelectorProps> = ({ 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<ModelSelectorProps> = ({ onError }) => {
{/* Model Status and Switcher */}
<div className="relative" ref={dropdownRef}>
<ModelStatusButton
status={modelStatus}
status={getDisplayStatus()}
displayText={getModelDisplayText()}
isDropdownOpen={showModelDropdown}
onClick={() => setShowModelDropdown(!showModelDropdown)}
@ -422,19 +245,15 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
{showModelDropdown && (
<ModelDropdown
models={models}
currentModelId={currentModelId}
downloadProgress={modelDownloadProgress}
currentModelId={displayModelId}
onModelSelect={handleModelSelect}
onModelDownload={handleModelDownload}
onModelDelete={handleModelDelete}
onError={onError}
/>
)}
</div>
{/* Download Progress Bar for Models */}
<DownloadProgressDisplay
downloadProgress={modelDownloadProgress}
downloadProgress={downloadProgress}
downloadStats={downloadStats}
/>
</>

View file

@ -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, unknown>) => 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<ModelCardProps> = ({
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 (
<button
onClick={() => onSelect(model.id)}
disabled={disabled}
className={[baseButtonClasses, variantClasses, className]
<div
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" && isClickable) handleClick();
}}
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
className={[
baseClasses,
getVariantClasses(),
getInteractiveClasses(),
className,
]
.filter(Boolean)
.join(" ")}
type="button"
>
<div className="flex flex-col items-ce">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-text group-hover:text-logo-primary transition-colors">
{displayName}
</h3>
<DownloadSize sizeMb={Number(model.size_mb)} />
{isFeatured && (
<Badge variant="primary">{t("onboarding.recommended")}</Badge>
)}
</div>
<p className="text-text/60 text-sm leading-relaxed">
{displayDescription}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-xs text-text/70 w-16 text-end">
{t("onboarding.modelCard.accuracy")}
</p>
<div className="w-20 h-2 bg-mid-gray/20 rounded-full overflow-hidden">
<div
className="h-full bg-logo-primary rounded-full transition-all duration-300"
style={{ width: `${model.accuracy_score * 100}%` }}
/>
{/* Top section: name/description + score bars */}
<div className="flex justify-between items-center w-full">
<div className="flex flex-col items-start flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h3
className={`text-base font-semibold text-text ${isClickable ? "group-hover:text-logo-primary" : ""} transition-colors`}
>
{displayName}
</h3>
{showRecommended && model.is_recommended && (
<Badge variant="primary">{t("onboarding.recommended")}</Badge>
)}
{status === "active" && (
<Badge variant="primary">
<Check className="w-3 h-3 mr-1" />
{t("modelSelector.active")}
</Badge>
)}
{status === "switching" && (
<Badge variant="secondary">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
{t("modelSelector.switching")}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<p className="text-xs text-text/70 w-16 text-end">
{t("onboarding.modelCard.speed")}
<p className="text-text/60 text-sm leading-relaxed">
{displayDescription}
</p>
<div className="w-20 h-2 bg-mid-gray/20 rounded-full overflow-hidden">
<div
className="h-full bg-logo-primary rounded-full transition-all duration-300"
style={{ width: `${model.speed_score * 100}%` }}
/>
</div>
<div className="hidden sm:flex items-center ml-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-xs text-text/60 w-14 text-right">
{t("onboarding.modelCard.accuracy")}
</p>
<div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
<div
className="h-full bg-logo-primary rounded-full"
style={{ width: `${model.accuracy_score * 100}%` }}
/>
</div>
</div>
<div className="flex items-center gap-2">
<p className="text-xs text-text/60 w-14 text-right">
{t("onboarding.modelCard.speed")}
</p>
<div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
<div
className="h-full bg-logo-primary rounded-full"
style={{ width: `${model.speed_score * 100}%` }}
/>
</div>
</div>
</div>
</div>
</div>
</button>
);
};
const DownloadSize = ({ sizeMb }: { sizeMb: number }) => {
const { t } = useTranslation();
<hr className="w-full border-mid-gray/20" />
return (
<div className="flex items-center gap-1.5 text-xs text-text/60 tabular-nums">
<Download
aria-hidden="true"
className="h-3.5 w-3.5 text-text/45"
strokeWidth={1.75}
/>
<span className="sr-only">{t("modelSelector.downloadSize")}</span>
<span className="font-medium text-text/70">
{formatModelSize(sizeMb)}
</span>
{/* Bottom row: tags + action buttons (full width) */}
<div className="flex items-center gap-3 w-full -mb-0.5 mt-0.5 h-5">
<div
className="flex items-center gap-1 text-xs text-text/50"
title={
model.supported_languages.length === 1
? t("modelSelector.capabilities.singleLanguage")
: t("modelSelector.capabilities.languageSelection")
}
>
<Globe className="w-3.5 h-3.5" />
<span>{getLanguageDisplayText(model.supported_languages, t)}</span>
</div>
{model.supports_translation && (
<div
className="flex items-center gap-1 text-xs text-text/50"
title={t("modelSelector.capabilities.translation")}
>
<Languages className="w-3.5 h-3.5" />
<span>{t("modelSelector.capabilities.translate")}</span>
</div>
)}
{status === "downloadable" && (
<span className="flex items-center gap-1.5 ml-auto text-xs text-text/50">
<Download className="w-3.5 h-3.5" />
<span>{formatModelSize(Number(model.size_mb))}</span>
</span>
)}
{onDelete && (status === "available" || status === "active") && (
<Button
variant="danger-ghost"
size="sm"
onClick={handleDelete}
title={t("modelSelector.deleteModel", { modelName: displayName })}
className="flex items-center gap-1.5 ml-auto"
>
<Trash2 className="w-3.5 h-3.5" />
<span>{t("common.delete")}</span>
</Button>
)}
</div>
{/* Download/extract progress */}
{status === "downloading" && downloadProgress !== undefined && (
<div className="w-full mt-3">
<div className="w-full h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
<div
className="h-full bg-logo-primary rounded-full transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-text/50">
{t("modelSelector.downloading", {
percentage: Math.round(downloadProgress),
})}
</span>
<div className="flex items-center gap-2">
{downloadSpeed !== undefined && downloadSpeed > 0 && (
<span className="tabular-nums text-text/50">
{t("modelSelector.downloadSpeed", {
speed: downloadSpeed.toFixed(1),
})}
</span>
)}
{onCancel && (
<Button
variant="danger-ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCancel(model.id);
}}
aria-label={t("modelSelector.cancelDownload")}
>
{t("modelSelector.cancel")}
</Button>
)}
</div>
</div>
</div>
)}
{status === "extracting" && (
<div className="w-full mt-3">
<div className="w-full h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
<div className="h-full bg-logo-primary rounded-full animate-pulse w-full" />
</div>
<p className="text-xs text-text/50 mt-1">
{t("modelSelector.extractingGeneric")}
</p>
</div>
)}
</div>
);
};

View file

@ -1,8 +1,11 @@
import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { commands, type ModelInfo } from "@/bindings";
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";
interface OnboardingProps {
onModelSelected: () => void;
@ -10,52 +13,69 @@ interface OnboardingProps {
const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
const { t } = useTranslation();
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
models,
downloadModel,
selectModel,
downloadingModels,
extractingModels,
downloadProgress,
downloadStats,
} = useModelStore();
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const isDownloading = selectedModelId !== null;
// Watch for the selected model to finish downloading + extracting
useEffect(() => {
loadModels();
}, []);
if (!selectedModelId) return;
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"));
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((success) => {
if (success) {
onModelSelected();
} else {
toast.error(t("onboarding.errors.selectModel"));
setSelectedModelId(null);
}
});
}
};
}, [
selectedModelId,
models,
downloadingModels,
extractingModels,
selectModel,
onModelSelected,
]);
const handleDownloadModel = async (modelId: string) => {
setDownloading(true);
setError(null);
setSelectedModelId(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) {
console.error("Download failed:", err);
setError(t("onboarding.errors.downloadModel", { error: String(err) }));
setDownloading(false);
const success = await downloadModel(modelId);
if (!success) {
toast.error(t("onboarding.downloadFailed"));
setSelectedModelId(null);
}
};
const getRecommendedBadge = (modelId: string): boolean => {
return modelId === "parakeet-tdt-0.6b-v3";
const getModelStatus = (modelId: string): ModelCardStatus => {
if (modelId in extractingModels) return "extracting";
if (modelId in downloadingModels) return "downloading";
return "downloadable";
};
const getModelDownloadProgress = (modelId: string): number | undefined => {
return downloadProgress[modelId]?.percentage;
};
const getModelDownloadSpeed = (modelId: string): number | undefined => {
return downloadStats[modelId]?.speed;
};
return (
@ -68,34 +88,41 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
</div>
<div className="max-w-[600px] w-full mx-auto text-center flex-1 flex flex-col min-h-0">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-4 shrink-0">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
<div className="flex flex-col gap-4 pb-6">
{availableModels
.filter((model) => getRecommendedBadge(model.id))
.map((model) => (
{models
.filter((m: ModelInfo) => !m.is_downloaded)
.filter((model: ModelInfo) => model.is_recommended)
.map((model: ModelInfo) => (
<ModelCard
key={model.id}
model={model}
variant="featured"
disabled={downloading}
status={getModelStatus(model.id)}
disabled={isDownloading}
onSelect={handleDownloadModel}
onDownload={handleDownloadModel}
downloadProgress={getModelDownloadProgress(model.id)}
downloadSpeed={getModelDownloadSpeed(model.id)}
/>
))}
{availableModels
.filter((model) => !getRecommendedBadge(model.id))
.sort((a, b) => Number(a.size_mb) - Number(b.size_mb))
.map((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),
)
.map((model: ModelInfo) => (
<ModelCard
key={model.id}
model={model}
disabled={downloading}
status={getModelStatus(model.id)}
disabled={isDownloading}
onSelect={handleDownloadModel}
onDownload={handleDownloadModel}
downloadProgress={getModelDownloadProgress(model.id)}
downloadSpeed={getModelDownloadSpeed(model.id)}
/>
))}
</div>

View file

@ -1,2 +1,4 @@
export { default } from "./Onboarding";
export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding";
export { default as ModelCard } from "./ModelCard";
export type { ModelCardStatus } from "./ModelCard";

View file

@ -293,13 +293,13 @@ export const GlobalShortcutInput: React.FC<GlobalShortcutInputProps> = ({
{editingShortcutId === shortcutId ? (
<div
ref={(ref) => setShortcutRef(shortcutId, ref)}
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded"
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded-md"
>
{formatCurrentKeys()}
</div>
) : (
<div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded-md cursor-pointer hover:border-logo-primary"
onClick={() => startRecording(shortcutId)}
>
{formatKeyCombination(binding.current_binding, osType)}

View file

@ -278,13 +278,13 @@ export const HandyKeysShortcutInput: React.FC<HandyKeysShortcutInputProps> = ({
{isRecording ? (
<div
ref={shortcutRef}
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded"
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded-md"
>
{formatCurrentKeys()}
</div>
) : (
<div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded-md cursor-pointer hover:border-logo-primary"
onClick={startRecording}
>
{formatKeyCombination(binding.current_binding, osType)}

View file

@ -1,7 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ShowOverlay } from "../ShowOverlay";
import { TranslateToEnglish } from "../TranslateToEnglish";
import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout";
import { CustomWords } from "../CustomWords";
import { SettingsGroup } from "../../ui/SettingsGroup";
@ -9,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";
@ -20,11 +18,7 @@ import { KeyboardImplementationSelector } from "../debug/KeyboardImplementationS
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 = () => {
</SettingsGroup>
<SettingsGroup title={t("settings.advanced.groups.transcription")}>
{showTranslateToEnglish && (
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
)}
<CustomWords descriptionMode="tooltip" grouped />
<AppendTrailingSpace descriptionMode="tooltip" grouped={true} />
</SettingsGroup>

View file

@ -1,32 +1,26 @@
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";
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 (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title={t("settings.general.title")}>
<ShortcutInput shortcutId="transcribe" grouped={true} />
{showLanguageSelector && (
<LanguageSelector descriptionMode="tooltip" grouped={true} />
)}
<PushToTalk descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
<ModelSettingsCard />
<SettingsGroup title={t("settings.sound.title")}>
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
<MuteWhileRecording descriptionMode="tooltip" grouped={true} />

View file

@ -0,0 +1,39 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingsGroup } from "../../ui/SettingsGroup";
import { LanguageSelector } from "../LanguageSelector";
import { TranslateToEnglish } from "../TranslateToEnglish";
import { useModelStore } from "../../../stores/modelStore";
import type { ModelInfo } from "@/bindings";
export const ModelSettingsCard: React.FC = () => {
const { t } = useTranslation();
const { currentModel, models } = useModelStore();
const currentModelInfo = models.find((m: ModelInfo) => m.id === currentModel);
// Only Whisper models support manual language selection
const supportsLanguageSelection = currentModelInfo?.engine_type === "Whisper";
const supportsTranslation = currentModelInfo?.supports_translation ?? false;
const hasAnySettings = supportsLanguageSelection || supportsTranslation;
// Don't render anything if no model is selected or no settings available
if (!currentModel || !currentModelInfo || !hasAnySettings) {
return null;
}
return (
<SettingsGroup
title={t("settings.modelSettings.title", {
model: currentModelInfo.name,
})}
>
{supportsLanguageSelection && (
<LanguageSelector descriptionMode="tooltip" grouped={true} />
)}
{supportsTranslation && (
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
)}
</SettingsGroup>
);
};

View file

@ -273,7 +273,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
</button>
<button
onClick={onToggleSaved}
className={`p-2 rounded transition-colors cursor-pointer ${
className={`p-2 rounded-md transition-colors cursor-pointer ${
entry.saved
? "text-logo-primary hover:text-logo-primary/80"
: "text-text/50 hover:text-logo-primary"

View file

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

View file

@ -0,0 +1,360 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ask } from "@tauri-apps/plugin-dialog";
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";
// check if model supports a language based on its supported_languages list
const modelSupportsLanguage = (model: ModelInfo, langCode: string): boolean => {
return model.supported_languages.includes(langCode);
};
export const ModelsSettings: React.FC = () => {
const { t } = useTranslation();
const [switchingModelId, setSwitchingModelId] = useState<string | null>(null);
const [languageFilter, setLanguageFilter] = useState("all");
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false);
const [languageSearch, setLanguageSearch] = useState("");
const languageDropdownRef = useRef<HTMLDivElement>(null);
const languageSearchInputRef = useRef<HTMLInputElement>(null);
const {
models,
currentModel,
downloadingModels,
downloadProgress,
downloadStats,
extractingModels,
loading,
downloadModel,
cancelDownload,
selectModel,
deleteModel,
} = useModelStore();
// 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 (modelId in extractingModels) {
return "extracting";
}
if (modelId in downloadingModels) {
return "downloading";
}
if (switchingModelId === modelId) {
return "switching";
}
if (modelId === currentModel) {
return "active";
}
const model = models.find((m: ModelInfo) => m.id === modelId);
if (model?.is_downloaded) {
return "available";
}
return "downloadable";
};
const getDownloadProgress = (modelId: string): number | undefined => {
const progress = downloadProgress[modelId];
return progress?.percentage;
};
const getDownloadSpeed = (modelId: string): number | undefined => {
const stats = downloadStats[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: ModelInfo) => m.id === modelId);
const modelName = model?.name || modelId;
const isActive = modelId === currentModel;
const confirmed = await ask(
isActive
? t("settings.models.deleteActiveConfirm", { modelName })
: 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);
}
}
};
const handleModelCancel = async (modelId: string) => {
try {
await cancelDownload(modelId);
} catch (err) {
console.error(`Failed to cancel download for ${modelId}:`, err);
}
};
// Filter models based on language filter
const filteredModels = useMemo(() => {
return models.filter((model: ModelInfo) => {
if (languageFilter !== "all") {
if (!modelSupportsLanguage(model, languageFilter)) return false;
}
return true;
});
}, [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);
}
}
// Sort downloaded models so the active model is always first
downloaded.sort((a, b) => {
if (a.id === currentModel) return -1;
if (b.id === currentModel) return 1;
return 0;
});
return { downloadedModels: downloaded, availableModels: available };
}, [filteredModels, downloadingModels, extractingModels, currentModel]);
if (loading) {
return (
<div className="max-w-3xl w-full mx-auto">
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-logo-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
);
}
return (
<div className="max-w-3xl w-full mx-auto space-y-4">
<div className="mb-4">
<h1 className="text-xl font-semibold mb-2">
{t("settings.models.title")}
</h1>
<p className="text-sm text-text/60">
{t("settings.models.description")}
</p>
</div>
{filteredModels.length > 0 ? (
<div className="space-y-6">
{/* Downloaded Models Section */}
{downloadedModels.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-text/60">
{t("settings.models.yourModels")}
</h2>
{/* Language filter dropdown */}
<div className="relative" ref={languageDropdownRef}>
<button
type="button"
onClick={() =>
setLanguageDropdownOpen(!languageDropdownOpen)
}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
languageFilter !== "all"
? "bg-logo-primary/20 text-logo-primary"
: "bg-mid-gray/10 text-text/60 hover:bg-mid-gray/20"
}`}
>
<Globe className="w-3.5 h-3.5" />
<span className="max-w-[120px] truncate">
{selectedLanguageLabel}
</span>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${
languageDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{languageDropdownOpen && (
<div className="absolute top-full right-0 mt-1 w-56 bg-background border border-mid-gray/80 rounded-lg shadow-lg z-50 overflow-hidden">
<div className="p-2 border-b border-mid-gray/40">
<input
ref={languageSearchInputRef}
type="text"
value={languageSearch}
onChange={(e) => setLanguageSearch(e.target.value)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
filteredLanguages.length > 0
) {
setLanguageFilter(filteredLanguages[0].value);
setLanguageDropdownOpen(false);
setLanguageSearch("");
} else if (e.key === "Escape") {
setLanguageDropdownOpen(false);
setLanguageSearch("");
}
}}
placeholder={t(
"settings.general.language.searchPlaceholder",
)}
className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary"
/>
</div>
<div className="max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setLanguageFilter("all");
setLanguageDropdownOpen(false);
setLanguageSearch("");
}}
className={`w-full px-3 py-1.5 text-sm text-left transition-colors ${
languageFilter === "all"
? "bg-logo-primary/20 text-logo-primary font-semibold"
: "hover:bg-mid-gray/10"
}`}
>
{t("settings.models.filters.allLanguages")}
</button>
{filteredLanguages.map((lang) => (
<button
key={lang.value}
type="button"
onClick={() => {
setLanguageFilter(lang.value);
setLanguageDropdownOpen(false);
setLanguageSearch("");
}}
className={`w-full px-3 py-1.5 text-sm text-left transition-colors ${
languageFilter === lang.value
? "bg-logo-primary/20 text-logo-primary font-semibold"
: "hover:bg-mid-gray/10"
}`}
>
{lang.label}
</button>
))}
{filteredLanguages.length === 0 && (
<div className="px-3 py-2 text-sm text-text/50 text-center">
{t("settings.general.language.noResults")}
</div>
)}
</div>
</div>
)}
</div>
</div>
{downloadedModels.map((model: ModelInfo) => (
<ModelCard
key={model.id}
model={model}
status={getModelStatus(model.id)}
onSelect={handleModelSelect}
onDownload={handleModelDownload}
onDelete={handleModelDelete}
onCancel={handleModelCancel}
downloadProgress={getDownloadProgress(model.id)}
downloadSpeed={getDownloadSpeed(model.id)}
showRecommended={false}
/>
))}
</div>
)}
{/* Available Models Section */}
{availableModels.length > 0 && (
<div className="space-y-3">
<h2 className="text-sm font-medium text-text/60">
{t("settings.models.availableModels")}
</h2>
{availableModels.map((model: ModelInfo) => (
<ModelCard
key={model.id}
model={model}
status={getModelStatus(model.id)}
onSelect={handleModelSelect}
onDownload={handleModelDownload}
onDelete={handleModelDelete}
onCancel={handleModelCancel}
downloadProgress={getDownloadProgress(model.id)}
downloadSpeed={getDownloadSpeed(model.id)}
showRecommended={false}
/>
))}
</div>
)}
</div>
) : (
<div className="text-center py-8 text-text/50">
{t("settings.models.noModelsMatch")}
</div>
)}
</div>
);
};

View file

@ -0,0 +1 @@
export { ModelsSettings } from "./ModelsSettings";

View file

@ -344,7 +344,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
)}
{!isCreating && !selectedPrompt && (
<div className="p-3 bg-mid-gray/5 rounded border border-mid-gray/20">
<div className="p-3 bg-mid-gray/5 rounded-md border border-mid-gray/20">
<p className="text-sm text-mid-gray">
{hasPrompts
? t("settings.postProcessing.prompts.selectToEdit")

View file

@ -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<BadgeProps> = ({
}) => {
const variantClasses = {
primary: "bg-logo-primary",
success: "bg-green-500/20 text-green-400",
secondary: "bg-mid-gray/20 text-text/70",
};
return (

View file

@ -1,7 +1,13 @@
import React from "react";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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<ButtonProps> = ({
...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",
};

View file

@ -62,7 +62,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
<div className={`relative ${className}`} ref={dropdownRef}>
<button
type="button"
className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded min-w-[200px] text-start flex items-center justify-between transition-all duration-150 ${
className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded-md min-w-[200px] text-start flex items-center justify-between transition-all duration-150 ${
disabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
@ -86,7 +86,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
</svg>
</button>
{isOpen && !disabled && (
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-y-auto">
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded-md shadow-lg z-50 max-h-60 overflow-y-auto">
{options.length === 0 ? (
<div className="px-2 py-1 text-sm text-mid-gray">
{t("common.noOptionsFound")}

View file

@ -11,7 +11,7 @@ export const Input: React.FC<InputProps> = ({
...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"

View file

@ -17,7 +17,7 @@ export const PathDisplay: React.FC<PathDisplayProps> = ({
return (
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 px-2 py-2 bg-mid-gray/10 border border-mid-gray/80 rounded text-xs font-mono break-all select-text cursor-text">
<div className="flex-1 min-w-0 px-2 py-2 bg-mid-gray/10 border border-mid-gray/80 rounded-lg text-xs font-mono break-all select-text cursor-text">
{path}
</div>
<Button

View file

@ -14,7 +14,7 @@ export const ResetButton: React.FC<ResetButtonProps> = React.memo(
<button
type="button"
aria-label={ariaLabel}
className={`p-1 rounded border border-transparent transition-all duration-150 ${
className={`p-1 rounded-md border border-transparent transition-all duration-150 ${
disabled
? "opacity-50 cursor-not-allowed text-text/40"
: "hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] hover:cursor-pointer hover:border-logo-primary text-text/80"

View file

@ -55,7 +55,7 @@ export const TextDisplay: React.FC<TextDisplayProps> = ({
<div className="flex items-center space-x-2">
<div className="flex-1 min-w-0">
<div
className={`px-2 min-h-8 flex items-center bg-mid-gray/10 border border-mid-gray/80 rounded text-xs ${textClasses} ${!value ? "opacity-60" : ""}`}
className={`px-2 min-h-8 flex items-center bg-mid-gray/10 border border-mid-gray/80 rounded-md text-xs ${textClasses} ${!value ? "opacity-60" : ""}`}
>
{displayValue}
</div>
@ -63,7 +63,7 @@ export const TextDisplay: React.FC<TextDisplayProps> = ({
{copyable && value && (
<button
onClick={handleCopy}
className="flex items-center justify-center px-2 py-1 w-12 min-h-8 text-xs font-semibold bg-mid-gray/10 hover:bg-logo-primary/10 border border-mid-gray/80 hover:border-logo-primary hover:text-logo-primary rounded transition-all duration-150 flex-shrink-0 cursor-pointer"
className="flex items-center justify-center px-2 py-1 w-12 min-h-8 text-xs font-semibold bg-mid-gray/10 hover:bg-logo-primary/10 border border-mid-gray/80 hover:border-logo-primary hover:text-logo-primary rounded-md transition-all duration-150 flex-shrink-0 cursor-pointer"
title="Copy to clipboard"
>
{showCopied ? (

View file

@ -11,7 +11,7 @@ export const Textarea: React.FC<TextareaProps> = ({
...props
}) => {
const baseClasses =
"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-start transition-[background-color,border-color] duration-150 hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/10 focus:border-logo-primary resize-y";
"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded-md text-start transition-[background-color,border-color] duration-150 hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/10 focus:border-logo-primary resize-y";
const variantClasses = {
default: "px-3 py-2 min-h-[100px]",

View file

@ -12,13 +12,15 @@
"postProcessing": "معالجة لاحقة",
"history": "السجل",
"debug": "تصحيح الأخطاء",
"about": "حول"
"about": "حول",
"models": "النماذج"
},
"onboarding": {
"subtitle": "للبدء، اختر نموذج التفريغ الصوتي",
"recommended": "موصى به",
"download": "تنزيل",
"downloading": "...جاري التنزيل",
"downloadFailed": "فشل التنزيل. يرجى المحاولة مرة أخرى.",
"modelCard": {
"accuracy": "الدقة",
"speed": "السرعة"
@ -79,14 +81,7 @@
}
},
"modelSelector": {
"welcome": "مرحباً بك في Handy!",
"downloadPrompt": ".قم بتنزيل نموذج أدناه للبدء في التفريغ الصوتي",
"availableModels": "النماذج المتاحة",
"downloadModels": "تنزيل النماذج",
"chooseModel": "اختر نموذجاً",
"active": "نشط",
"download": "تنزيل",
"downloadSize": "حجم التنزيل",
"noModelsAvailable": "لا توجد نماذج متاحة",
"extracting": "...جاري استخراج {{modelName}}",
"extractingMultiple": "...جاري استخراج {{count}} من النماذج",
@ -99,7 +94,19 @@
"modelError": "خطأ في النموذج",
"modelUnloaded": "تم إلغاء تحميل النموذج",
"noModelDownloadRequired": "لا يوجد نموذج - التنزيل مطلوب",
"deleteModel": "حذف {{modelName}}"
"deleteModel": "حذف {{modelName}}",
"switching": "جارٍ التبديل...",
"downloadSpeed": "{{speed}} ميجابايت/ث",
"cancel": "إلغاء",
"cancelDownload": "إلغاء التنزيل",
"capabilities": {
"languageSelection": "يدعم اختيار اللغة",
"singleLanguage": "يدعم هذه اللغة فقط",
"multiLanguage": "متعدد اللغات",
"languageOnly": "{{language}} فقط",
"translation": "يدعم الترجمة",
"translate": "ترجمة"
}
},
"settings": {
"general": {
@ -119,6 +126,10 @@
"cancel": {
"name": "اختصار الإلغاء",
"description": ".اختصار لوحة المفاتيح لإلغاء التسجيل الحالي"
},
"transcribe_with_post_process": {
"name": "مفتاح المعالجة اللاحقة",
"description": "اختياري: مفتاح اختصار مخصص يطبق دائماً المعالجة اللاحقة بالذكاء الاصطناعي على التفريغ الصوتي."
}
},
"errors": {
@ -241,6 +252,9 @@
},
"postProcessing": {
"title": "معالجة لاحقة",
"hotkey": {
"title": "مفتاح الاختصار"
},
"api": {
"title": "API (متوافق مع OpenAI)",
"provider": {
@ -373,6 +387,10 @@
"appData": "بيانات التطبيق:",
"models": "النماذج:",
"settings": "الإعدادات:"
},
"pasteDelay": {
"title": "تأخير اللصق",
"description": "التأخير قبل إرسال ضغطة مفتاح اللصق (بالمللي ثانية). قم بزيادتها إذا تم لصق نص خاطئ."
}
},
"about": {
@ -403,6 +421,28 @@
"details": ".يستخدم Handy برنامج Whisper.cpp لمعالجة سريعة ومحلية لتحويل الكلام إلى نص. شكراً للعمل الرائع الذي قام به Georgi Gerganov والمساهمون"
}
}
},
"modelSettings": {
"title": "إعدادات النموذج",
"noSettingsNeeded": "لا توجد إعدادات مطلوبة لهذا النموذج"
},
"models": {
"title": "نماذج النسخ",
"description": "اختر نموذج نسخ أو قم بتنزيل نماذج إضافية. تقدم النماذج المختلفة مستويات متفاوتة من الدقة والسرعة.",
"yourModels": "النماذج المحمّلة",
"availableModels": "متاح للتنزيل",
"downloaded": "تم التنزيل",
"available": "متاح للتنزيل",
"deleteConfirm": "هل أنت متأكد أنك تريد حذف {{modelName}}؟ ستحتاج إلى تنزيله مرة أخرى لاستخدامه.",
"deleteActiveConfirm": "{{modelName}} هو النموذج النشط حاليًا. حذفه سيوقف النسخ حتى تختار نموذجًا جديدًا. هل أنت متأكد؟",
"deleteTitle": "حذف النموذج",
"filters": {
"all": "الكل",
"multiLanguage": "متعدد اللغات",
"translation": "ترجمة",
"allLanguages": "جميع اللغات"
},
"noModelsMatch": "لا توجد نماذج مطابقة لهذا الفلتر."
}
},
"footer": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "Obecné",
"models": "Modely",
"advanced": "Pokročilé",
"postProcessing": "Následné zpracování",
"history": "Historie",
@ -19,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"
@ -56,17 +58,30 @@
"errors": {
"loadModels": "Nepodařilo se načíst dostupné modely",
"downloadModel": "Nepodařilo se stáhnout model: {{error}}"
},
"permissions": {
"title": "Vyžadována oprávnění",
"description": "Handy potřebuje několik oprávnění pro správné fungování.",
"microphone": {
"title": "Přístup k mikrofonu",
"description": "Potřebný pro zachycení vašeho hlasu k přepisu."
},
"accessibility": {
"title": "Přístup k usnadnění",
"description": "Potřebný pro psaní přepsaného textu do vašich aplikací."
},
"grant": "Udělit oprávnění",
"granted": "Uděleno",
"waiting": "Čekání...",
"allGranted": "Vše připraveno!",
"errors": {
"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."
}
}
},
"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ů...",
@ -79,9 +94,43 @@
"modelError": "Chyba modelu",
"modelUnloaded": "Model uvolněn",
"noModelDownloadRequired": "Žádný model - je nutné stáhnout",
"deleteModel": "Smazat {{modelName}}"
"deleteModel": "Smazat {{modelName}}",
"switching": "Přepínání...",
"downloadSpeed": "{{speed}} MB/s",
"capabilities": {
"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",
"singleLanguage": "Podporuje pouze tento jazyk",
"languageOnly": "Pouze {{language}}"
},
"cancel": "Zrušit",
"cancelDownload": "Zrušit stahování"
},
"settings": {
"modelSettings": {
"title": "Nastavení {{model}}",
"noSettingsNeeded": "Tento model funguje automaticky bez nutnosti konfigurace."
},
"models": {
"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.",
"deleteActiveConfirm": "{{modelName}} je váš aktivní model. Jeho smazáním se zastaví přepisy, dokud nevyberete nový model. Opravdu chcete pokračovat?",
"deleteTitle": "Smazat model",
"filters": {
"all": "Vše",
"multiLanguage": "Vícejazyčný",
"translation": "Překlad",
"allLanguages": "Všechny jazyky"
},
"noModelsMatch": "Tomuto filtru neodpovídají žádné modely.",
"yourModels": "Stažené modely",
"availableModels": "Dostupné ke stažení"
},
"general": {
"title": "Obecné",
"shortcut": {
@ -99,6 +148,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": {
@ -221,6 +274,9 @@
},
"postProcessing": {
"title": "Následné zpracování",
"hotkey": {
"title": "Klávesová zkratka"
},
"api": {
"title": "API (kompatibilní s OpenAI)",
"provider": {
@ -344,10 +400,19 @@
"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:",
"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": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "Allgemein",
"models": "Modelle",
"advanced": "Erweitert",
"postProcessing": "Nachbearbeitung",
"history": "Verlauf",
@ -19,6 +20,7 @@
"recommended": "Empfohlen",
"download": "Herunterladen",
"downloading": "Wird heruntergeladen...",
"downloadFailed": "Download fehlgeschlagen. Bitte erneut versuchen.",
"modelCard": {
"accuracy": "Genauigkeit",
"speed": "Geschwindigkeit"
@ -71,18 +73,16 @@
"grant": "Berechtigung erteilen",
"granted": "Erteilt",
"waiting": "Warten...",
"allGranted": "Alles bereit!"
"allGranted": "Alles bereit!",
"errors": {
"checkFailed": "Berechtigungsprüfung fehlgeschlagen. Bitte erneut versuchen.",
"requestFailed": "Berechtigungsanfrage fehlgeschlagen. Bitte erneut versuchen."
}
}
},
"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",
"download": "Herunterladen",
"downloadSize": "Downloadgröße",
"switching": "Wechseln...",
"noModelsAvailable": "Keine Modelle verfügbar",
"extracting": "Entpacke {{modelName}}...",
"extractingMultiple": "Entpacke {{count}} Modelle...",
@ -95,9 +95,42 @@
"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",
"singleLanguage": "Unterstützt nur diese Sprache",
"languageOnly": "Nur {{language}}"
},
"cancel": "Abbrechen",
"cancelDownload": "Download abbrechen"
},
"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.",
"deleteActiveConfirm": "{{modelName}} ist dein aktives Modell. Das Löschen stoppt Transkriptionen, bis du ein neues Modell auswählst. Bist du sicher?",
"deleteTitle": "Modell löschen",
"filters": {
"all": "Alle",
"multiLanguage": "Mehrsprachig",
"translation": "Übersetzung",
"allLanguages": "Alle Sprachen"
},
"noModelsMatch": "Keine Modelle entsprechen diesem Filter.",
"yourModels": "Heruntergeladene Modelle",
"availableModels": "Zum Download verfügbar"
},
"general": {
"title": "Allgemein",
"shortcut": {
@ -115,6 +148,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": {
@ -363,10 +400,19 @@
"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:",
"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": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "General",
"models": "Models",
"advanced": "Advanced",
"postProcessing": "Post Process",
"history": "History",
@ -19,6 +20,7 @@
"recommended": "Recommended",
"download": "Download",
"downloading": "Downloading...",
"downloadFailed": "Download failed. Please try again.",
"modelCard": {
"accuracy": "accuracy",
"speed": "speed"
@ -79,14 +81,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",
"download": "Download",
"downloadSize": "Download size",
"switching": "Switching...",
"noModelsAvailable": "No models available",
"extracting": "Extracting {{modelName}}...",
"extractingMultiple": "Extracting {{count}} models...",
@ -99,9 +95,24 @@
"modelError": "Model Error",
"modelUnloaded": "Model Unloaded",
"noModelDownloadRequired": "No Model - Download Required",
"deleteModel": "Delete {{modelName}}"
"deleteModel": "Delete {{modelName}}",
"downloadSpeed": "{{speed}} MB/s",
"cancel": "Cancel",
"cancelDownload": "Cancel download",
"capabilities": {
"languageSelection": "Supports multiple input languages",
"singleLanguage": "Supports this language only",
"multiLanguage": "Multi-language",
"languageOnly": "{{language}} Only",
"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 +155,24 @@
"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.",
"yourModels": "Downloaded 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.",
"deleteActiveConfirm": "{{modelName}} is your active model. Deleting it will stop transcriptions until you select a new model. Are you sure?",
"deleteTitle": "Delete Model",
"filters": {
"all": "All",
"multiLanguage": "Multi-language",
"translation": "Translation",
"allLanguages": "All Languages"
},
"noModelsMatch": "No models match this filter."
},
"sound": {
"title": "Sound",
"microphone": {
@ -384,6 +413,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": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "General",
"models": "Modelos",
"advanced": "Avanzado",
"postProcessing": "Post Proceso",
"history": "Historial",
@ -19,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"
@ -71,18 +73,16 @@
"grant": "Conceder Permiso",
"granted": "Concedido",
"waiting": "Esperando...",
"allGranted": "¡Todo listo!"
"allGranted": "¡Todo listo!",
"errors": {
"checkFailed": "Error al verificar permisos. Por favor, inténtalo de nuevo.",
"requestFailed": "Error al solicitar permiso. Por favor, inténtalo de nuevo."
}
}
},
"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",
"download": "Descargar",
"downloadSize": "Tamaño de descarga",
"switching": "Cambiando...",
"noModelsAvailable": "No hay modelos disponibles",
"extracting": "Extrayendo {{modelName}}...",
"extractingMultiple": "Extrayendo {{count}} modelos...",
@ -95,9 +95,42 @@
"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",
"singleLanguage": "Solo admite este idioma",
"languageOnly": "Solo {{language}}"
},
"cancel": "Cancelar",
"cancelDownload": "Cancelar descarga"
},
"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.",
"deleteActiveConfirm": "{{modelName}} es tu modelo activo. Eliminarlo detendrá las transcripciones hasta que selecciones un nuevo modelo. ¿Estás seguro?",
"deleteTitle": "Eliminar modelo",
"filters": {
"all": "Todos",
"multiLanguage": "Multiidioma",
"translation": "Traducción",
"allLanguages": "Todos los idiomas"
},
"noModelsMatch": "Ningún modelo coincide con este filtro.",
"yourModels": "Modelos descargados",
"availableModels": "Disponibles para descargar"
},
"general": {
"title": "General",
"shortcut": {
@ -115,6 +148,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": {
@ -363,10 +400,19 @@
"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:",
"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": {

View file

@ -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...",
@ -9,6 +8,7 @@
},
"sidebar": {
"general": "Général",
"models": "Modèles",
"advanced": "Avancé",
"postProcessing": "Post-traitement",
"history": "Historique",
@ -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"
@ -72,18 +73,16 @@
"grant": "Accorder l'autorisation",
"granted": "Accordé",
"waiting": "En attente...",
"allGranted": "Tout est prêt !"
"allGranted": "Tout est prêt !",
"errors": {
"checkFailed": "Échec de la vérification des autorisations. Veuillez réessayer.",
"requestFailed": "Échec de la demande d'autorisation. Veuillez réessayer."
}
}
},
"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",
"download": "Télécharger",
"downloadSize": "Taille du téléchargement",
"switching": "Changement...",
"noModelsAvailable": "Aucun modèle disponible",
"extracting": "Extraction de {{modelName}}...",
"extractingMultiple": "Extraction de {{count}} modèles...",
@ -96,9 +95,42 @@
"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",
"singleLanguage": "Prend en charge uniquement cette langue",
"languageOnly": "{{language}} uniquement"
},
"cancel": "Annuler",
"cancelDownload": "Annuler le téléchargement"
},
"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.",
"deleteActiveConfirm": "{{modelName}} est votre modèle actif. Le supprimer arrêtera les transcriptions jusqu'à ce que vous sélectionniez un nouveau modèle. Êtes-vous sûr ?",
"deleteTitle": "Supprimer le modèle",
"filters": {
"all": "Tous",
"multiLanguage": "Multilingue",
"translation": "Traduction",
"allLanguages": "Toutes les langues"
},
"noModelsMatch": "Aucun modèle ne correspond à ce filtre.",
"yourModels": "Modèles téléchargés",
"availableModels": "Disponibles au téléchargement"
},
"general": {
"title": "Général",
"shortcut": {
@ -116,6 +148,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": {
@ -364,10 +400,19 @@
"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 :",
"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": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "Generale",
"models": "Modelli",
"advanced": "Avanzate",
"postProcessing": "Post-Elaborazione",
"history": "Cronologia",
@ -19,6 +20,7 @@
"recommended": "Raccomandato",
"download": "Scaricare",
"downloading": "Download in corso...",
"downloadFailed": "Download fallito. Riprova.",
"modelCard": {
"accuracy": "accuratezza",
"speed": "velocità"
@ -71,18 +73,16 @@
"grant": "Concedi Permesso",
"granted": "Concesso",
"waiting": "In attesa...",
"allGranted": "Tutto pronto!"
"allGranted": "Tutto pronto!",
"errors": {
"checkFailed": "Impossibile verificare i permessi. Riprova.",
"requestFailed": "Impossibile richiedere il permesso. Riprova."
}
}
},
"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",
"download": "Scarica",
"downloadSize": "Dimensione download",
"switching": "Cambio...",
"noModelsAvailable": "Nessun modello disponibile",
"extracting": "Estrazione di {{modelName}}...",
"extractingMultiple": "Estrazione di {{count}} modelli...",
@ -95,9 +95,42 @@
"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",
"singleLanguage": "Supporta solo questa lingua",
"languageOnly": "Solo {{language}}"
},
"cancel": "Annulla",
"cancelDownload": "Annulla download"
},
"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.",
"deleteActiveConfirm": "{{modelName}} è il tuo modello attivo. Eliminarlo interromperà le trascrizioni fino a quando non selezionerai un nuovo modello. Sei sicuro?",
"deleteTitle": "Elimina modello",
"filters": {
"all": "Tutti",
"multiLanguage": "Multilingua",
"translation": "Traduzione",
"allLanguages": "Tutte le lingue"
},
"noModelsMatch": "Nessun modello corrisponde a questo filtro.",
"yourModels": "Modelli scaricati",
"availableModels": "Disponibili per il download"
},
"general": {
"title": "Generale",
"shortcut": {
@ -115,6 +148,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": {
@ -363,10 +400,19 @@
"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:",
"settings": "Impostazioni:"
},
"pasteDelay": {
"title": "Ritardo incolla",
"description": "Ritardo prima dell'invio del tasto incolla (in millisecondi). Aumentare se viene incollato il testo sbagliato."
}
},
"about": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "一般",
"models": "モデル",
"advanced": "詳細設定",
"postProcessing": "後処理",
"history": "履歴",
@ -19,6 +20,7 @@
"recommended": "おすすめ",
"download": "ダウンロード",
"downloading": "ダウンロード中...",
"downloadFailed": "ダウンロードに失敗しました。もう一度お試しください。",
"modelCard": {
"accuracy": "精度",
"speed": "速度"
@ -71,18 +73,16 @@
"grant": "権限を許可",
"granted": "許可済み",
"waiting": "待機中...",
"allGranted": "準備完了!"
"allGranted": "準備完了!",
"errors": {
"checkFailed": "権限の確認に失敗しました。もう一度お試しください。",
"requestFailed": "権限のリクエストに失敗しました。もう一度お試しください。"
}
}
},
"modelSelector": {
"welcome": "Handyへようこそ",
"downloadPrompt": "文字起こしを開始するには、下からモデルをダウンロードしてください。",
"availableModels": "利用可能なモデル",
"downloadModels": "モデルをダウンロード",
"chooseModel": "モデルを選択",
"active": "アクティブ",
"download": "ダウンロード",
"downloadSize": "ダウンロードサイズ",
"switching": "切替中...",
"noModelsAvailable": "利用可能なモデルがありません",
"extracting": "{{modelName}}を展開中...",
"extractingMultiple": "{{count}}個のモデルを展開中...",
@ -95,9 +95,42 @@
"modelError": "モデルエラー",
"modelUnloaded": "モデルがアンロードされました",
"noModelDownloadRequired": "モデルなし - ダウンロードが必要",
"deleteModel": "{{modelName}}を削除"
"deleteModel": "{{modelName}}を削除",
"downloadSpeed": "{{speed}} MB/s",
"capabilities": {
"languageSelection": "複数の入力言語をサポート",
"multiLanguage": "多言語",
"translation": "英語への翻訳が可能",
"translate": "英語に翻訳",
"singleLanguage": "この言語のみ対応",
"languageOnly": "{{language}}のみ"
},
"cancel": "キャンセル",
"cancelDownload": "ダウンロードをキャンセル"
},
"settings": {
"modelSettings": {
"title": "{{model}} 設定",
"noSettingsNeeded": "このモデルは設定不要で自動的に動作します。"
},
"models": {
"title": "転写モデル",
"description": "転写モデルを選択するか、追加のモデルをダウンロードします。異なるモデルは精度と速度のレベルが異なります。",
"downloaded": "ダウンロード済み",
"available": "ダウンロード可能",
"deleteConfirm": "{{modelName}}を削除してもよろしいですか?再度使用するにはダウンロードが必要です。",
"deleteActiveConfirm": "{{modelName}}は現在使用中のモデルです。削除すると、新しいモデルを選択するまで文字起こしが停止します。本当に削除しますか?",
"deleteTitle": "モデルを削除",
"filters": {
"all": "すべて",
"multiLanguage": "多言語",
"translation": "翻訳",
"allLanguages": "すべての言語"
},
"noModelsMatch": "このフィルターに一致するモデルがありません。",
"yourModels": "ダウンロード済みモデル",
"availableModels": "ダウンロード可能"
},
"general": {
"title": "一般",
"shortcut": {
@ -115,6 +148,10 @@
"cancel": {
"name": "キャンセルショートカット",
"description": "現在の録音をキャンセルするためのキーボードショートカット。"
},
"transcribe_with_post_process": {
"name": "後処理ホットキー",
"description": "オプション文字起こしに常にAI後処理を適用する専用ホットキー。"
}
},
"errors": {
@ -363,10 +400,19 @@
"label": "末尾にスペースを追加",
"description": "貼り付けた文字起こしの後にスペースを追加"
},
"keyboardImplementation": {
"title": "Keyboard Implementation",
"description": "Choose the keyboard shortcut backend.",
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
},
"paths": {
"appData": "アプリデータ:",
"models": "モデル:",
"settings": "設定:"
},
"pasteDelay": {
"title": "貼り付け遅延",
"description": "貼り付けキー送信前の遅延(ミリ秒)。間違ったテキストが貼り付けられる場合は増やしてください。"
}
},
"about": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "일반",
"models": "모델",
"advanced": "고급",
"postProcessing": "후처리",
"history": "히스토리",
@ -19,6 +20,7 @@
"recommended": "추천",
"download": "다운로드",
"downloading": "다운로드 중...",
"downloadFailed": "다운로드에 실패했습니다. 다시 시도해주세요.",
"modelCard": {
"accuracy": "정확도",
"speed": "속도"
@ -79,14 +81,8 @@
}
},
"modelSelector": {
"welcome": "Handy에 오신 것을 환영합니다!",
"downloadPrompt": "음성 인식을 시작하려면 아래에서 모델을 다운로드하세요.",
"availableModels": "사용 가능한 모델",
"downloadModels": "모델 다운로드",
"chooseModel": "모델 선택",
"active": "활성",
"download": "다운로드",
"downloadSize": "다운로드 크기",
"switching": "전환 중...",
"noModelsAvailable": "사용 가능한 모델이 없습니다",
"extracting": "{{modelName}} 압축 해제 중...",
"extractingMultiple": "{{count}}개 모델 압축 해제 중...",
@ -99,9 +95,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 +130,10 @@
"cancel": {
"name": "취소 단축키",
"description": "현재 녹음을 취소하는 키보드 단축키입니다."
},
"transcribe_with_post_process": {
"name": "후처리 단축키",
"description": "선택 사항: 항상 AI 후처리를 적용하는 전용 단축키입니다."
}
},
"errors": {
@ -163,6 +178,24 @@
"description": "오디오 피드백 사운드의 볼륨 조절"
}
},
"models": {
"title": "음성 인식 모델",
"description": "음성 인식 모델을 선택하거나 추가 모델을 다운로드하세요. 모델마다 정확도와 속도가 다릅니다.",
"yourModels": "다운로드된 모델",
"availableModels": "다운로드 가능",
"downloaded": "다운로드됨",
"available": "다운로드 가능",
"deleteConfirm": "{{modelName}}을(를) 삭제하시겠습니까? 다시 사용하려면 다운로드해야 합니다.",
"deleteActiveConfirm": "{{modelName}}은(는) 현재 활성 모델입니다. 삭제하면 새 모델을 선택할 때까지 전사가 중지됩니다. 정말 삭제하시겠습니까?",
"deleteTitle": "모델 삭제",
"filters": {
"all": "전체",
"multiLanguage": "다국어",
"translation": "번역",
"allLanguages": "모든 언어"
},
"noModelsMatch": "이 필터에 맞는 모델이 없습니다."
},
"advanced": {
"title": "고급",
"groups": {
@ -241,6 +274,9 @@
},
"postProcessing": {
"title": "후처리",
"hotkey": {
"title": "단축키"
},
"api": {
"title": "API (OpenAI 호환)",
"provider": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "Ogólne",
"models": "Modele",
"advanced": "Zaawansowane",
"postProcessing": "Postproces",
"history": "Historia",
@ -19,6 +20,7 @@
"recommended": "Polecane",
"download": "Pobierz",
"downloading": "Pobieranie...",
"downloadFailed": "Pobieranie nie powiodło się. Spróbuj ponownie.",
"modelCard": {
"accuracy": "dokładność",
"speed": "szybkość"
@ -71,18 +73,16 @@
"grant": "Przyznaj uprawnienie",
"granted": "Przyznano",
"waiting": "Oczekiwanie...",
"allGranted": "Wszystko gotowe!"
"allGranted": "Wszystko gotowe!",
"errors": {
"checkFailed": "Nie udało się sprawdzić uprawnień. Spróbuj ponownie.",
"requestFailed": "Nie udało się poprosić o uprawnienie. Spróbuj ponownie."
}
}
},
"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",
"download": "Pobierz",
"downloadSize": "Rozmiar pobierania",
"switching": "Przełączanie...",
"noModelsAvailable": "Brak dostępnych modeli",
"extracting": "Rozpakowywanie {{modelName}}...",
"extractingMultiple": "Rozpakowywanie {{count}} modeli...",
@ -95,9 +95,42 @@
"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",
"singleLanguage": "Obsługuje tylko ten język",
"languageOnly": "Tylko {{language}}"
},
"cancel": "Anuluj",
"cancelDownload": "Anuluj pobieranie"
},
"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ć.",
"deleteActiveConfirm": "{{modelName}} jest Twoim aktywnym modelem. Usunięcie go zatrzyma transkrypcje, dopóki nie wybierzesz nowego modelu. Czy na pewno chcesz kontynuować?",
"deleteTitle": "Usuń model",
"filters": {
"all": "Wszystkie",
"multiLanguage": "Wielojęzyczne",
"translation": "Tłumaczenie",
"allLanguages": "Wszystkie języki"
},
"noModelsMatch": "Żadne modele nie pasują do tego filtra.",
"yourModels": "Pobrane modele",
"availableModels": "Dostępne do pobrania"
},
"general": {
"title": "Ogólne",
"shortcut": {
@ -115,6 +148,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": {
@ -363,10 +400,19 @@
"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:",
"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": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "Geral",
"models": "Modelos",
"advanced": "Avançado",
"postProcessing": "Pós-Processamento",
"history": "Histórico",
@ -19,6 +20,7 @@
"recommended": "Recomendado",
"download": "Baixar",
"downloading": "Baixando...",
"downloadFailed": "Falha no download. Por favor, tente novamente.",
"modelCard": {
"accuracy": "precisão",
"speed": "velocidade"
@ -79,14 +81,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 +94,25 @@
"modelError": "Erro no Modelo",
"modelUnloaded": "Modelo Descarregado",
"noModelDownloadRequired": "Sem Modelo - Download Necessário",
"deleteModel": "Excluir {{modelName}}"
"deleteModel": "Excluir {{modelName}}",
"switching": "Alternando...",
"downloadSpeed": "{{speed}} MB/s",
"capabilities": {
"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}}"
},
"cancel": "Cancelar",
"cancelDownload": "Cancelar download"
},
"settings": {
"modelSettings": {
"title": "Configurações de {{model}}",
"noSettingsNeeded": "Este modelo funciona automaticamente sem necessidade de configuração."
},
"general": {
"title": "Geral",
"shortcut": {
@ -119,6 +130,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": {
@ -140,6 +155,24 @@
"description": "Segure para gravar, solte para parar"
}
},
"models": {
"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.",
"deleteActiveConfirm": "{{modelName}} é o seu modelo ativo. Excluí-lo interromperá as transcrições até que você selecione um novo modelo. Tem certeza?",
"deleteTitle": "Excluir Modelo",
"filters": {
"all": "Todos",
"multiLanguage": "Multi-idioma",
"translation": "Tradução",
"allLanguages": "Todos os Idiomas"
},
"noModelsMatch": "Nenhum modelo corresponde a este filtro.",
"yourModels": "Modelos baixados",
"availableModels": "Disponíveis para download"
},
"sound": {
"title": "Som",
"microphone": {
@ -367,10 +400,19 @@
"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:",
"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": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "Общие",
"models": "Модели",
"advanced": "Продвинутые",
"postProcessing": "Постобработка",
"history": "История",
@ -19,6 +20,7 @@
"recommended": "Рекомендуется",
"download": "Скачать",
"downloading": "Загрузка...",
"downloadFailed": "Загрузка не удалась. Пожалуйста, попробуйте снова.",
"modelCard": {
"accuracy": "точность",
"speed": "скорость"
@ -71,18 +73,16 @@
"grant": "Предоставить разрешение",
"granted": "Предоставлено",
"waiting": "Ожидание...",
"allGranted": "Всё готово!"
"allGranted": "Всё готово!",
"errors": {
"checkFailed": "Не удалось проверить разрешения. Пожалуйста, попробуйте снова.",
"requestFailed": "Не удалось запросить разрешение. Пожалуйста, попробуйте снова."
}
}
},
"modelSelector": {
"welcome": "Добро пожаловать в Handy!",
"downloadPrompt": "Загрузите модель ниже, чтобы начать транскрипцию.",
"availableModels": "Доступные модели",
"downloadModels": "Скачать модели",
"chooseModel": "Выберите модель",
"active": "Активный",
"download": "Скачать",
"downloadSize": "Размер загрузки",
"switching": "Переключение...",
"noModelsAvailable": "Нет доступных моделей",
"extracting": "Извлечение {{modelName}}...",
"extractingMultiple": "Извлечение моделей {{count}}...",
@ -95,9 +95,42 @@
"modelError": "Ошибка модели",
"modelUnloaded": "Модель выгружена",
"noModelDownloadRequired": "Нет модели требуется загрузка",
"deleteModel": "Удалить {{modelName}}"
"deleteModel": "Удалить {{modelName}}",
"downloadSpeed": "{{speed}} МБ/с",
"capabilities": {
"languageSelection": "Поддерживает несколько языков ввода",
"multiLanguage": "Многоязычный",
"translation": "Может переводить на английский",
"translate": "Перевести на английский",
"singleLanguage": "Поддерживает только этот язык",
"languageOnly": "Только {{language}}"
},
"cancel": "Отмена",
"cancelDownload": "Отменить загрузку"
},
"settings": {
"modelSettings": {
"title": "Настройки {{model}}",
"noSettingsNeeded": "Эта модель работает автоматически без необходимости настройки."
},
"models": {
"title": "Модели транскрипции",
"description": "Выберите модель транскрипции или загрузите дополнительные модели. Разные модели предлагают разные уровни точности и скорости.",
"downloaded": "Загружено",
"available": "Доступно для загрузки",
"deleteConfirm": "Вы уверены, что хотите удалить {{modelName}}? Вам нужно будет загрузить её снова, чтобы использовать.",
"deleteActiveConfirm": "{{modelName}} — ваша активная модель. Удаление остановит транскрипцию, пока вы не выберете новую модель. Вы уверены?",
"deleteTitle": "Удалить модель",
"filters": {
"all": "Все",
"multiLanguage": "Многоязычные",
"translation": "Перевод",
"allLanguages": "Все языки"
},
"noModelsMatch": "Нет моделей, соответствующих этому фильтру.",
"yourModels": "Загруженные модели",
"availableModels": "Доступны для загрузки"
},
"general": {
"title": "Общие",
"shortcut": {
@ -115,6 +148,10 @@
"cancel": {
"name": "Горячая клавиша отмены",
"description": "Сочетание клавиш для отмены текущей записи."
},
"transcribe_with_post_process": {
"name": "Горячая клавиша постобработки",
"description": "Необязательно: Специальная горячая клавиша, которая всегда применяет AI-постобработку к вашей транскрипции."
}
},
"errors": {
@ -363,10 +400,19 @@
"label": "Добавить конечный пробел",
"description": "Добавить пробел после вставленной транскрипции"
},
"keyboardImplementation": {
"title": "Keyboard Implementation",
"description": "Choose the keyboard shortcut backend.",
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
},
"paths": {
"appData": "Данные приложения:",
"models": "Модели:",
"settings": "Настройки:"
},
"pasteDelay": {
"title": "Задержка вставки",
"description": "Задержка перед отправкой нажатия клавиши вставки (в миллисекундах). Увеличьте, если вставляется неправильный текст."
}
},
"about": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "Genel",
"models": "Modeller",
"advanced": "Gelişmiş",
"postProcessing": "Son İşlem",
"history": "Geçmiş",
@ -19,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"
@ -79,14 +81,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...",
@ -99,9 +94,43 @@
"modelError": "Model Hatası",
"modelUnloaded": "Model Boşaltıldı",
"noModelDownloadRequired": "Model Yok İndirme Gerekli",
"deleteModel": "{{modelName}} Sil"
"deleteModel": "{{modelName}} Sil",
"switching": "Değiştiriliyor...",
"downloadSpeed": "{{speed}} MB/s",
"capabilities": {
"languageSelection": "Birden fazla giriş dilini destekler",
"multiLanguage": "Çok dilli",
"translation": "İngilizce'ye çevirebilir",
"translate": "İngilizce'ye çevir",
"singleLanguage": "Yalnızca bu dili destekler",
"languageOnly": "Yalnızca {{language}}"
},
"cancel": "İptal",
"cancelDownload": "İndirmeyi iptal et"
},
"settings": {
"modelSettings": {
"title": "{{model}} Ayarları",
"noSettingsNeeded": "Bu model yapılandırma gerektirmeden otomatik olarak çalışır."
},
"models": {
"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.",
"deleteActiveConfirm": "{{modelName}} aktif modelinizdir. Silmek, yeni bir model seçene kadar transkripsiyonları durduracaktır. Emin misiniz?",
"deleteTitle": "Modeli Sil",
"filters": {
"all": "Tümü",
"multiLanguage": "Çok dilli",
"translation": "Çeviri",
"allLanguages": "Tüm Diller"
},
"noModelsMatch": "Bu filtreyle eşleşen model yok.",
"yourModels": "İndirilen modeller",
"availableModels": "İndirilebilir"
},
"general": {
"title": "Genel",
"shortcut": {
@ -119,6 +148,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": {
@ -241,6 +274,9 @@
},
"postProcessing": {
"title": "Son İşlem",
"hotkey": {
"title": "Kısayol Tuşu"
},
"api": {
"title": "API (OpenAI Uyumlu)",
"provider": {
@ -364,10 +400,19 @@
"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:",
"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": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "Загальні",
"models": "Моделі",
"advanced": "Розширені",
"postProcessing": "Постобробка",
"history": "Історія",
@ -19,6 +20,7 @@
"recommended": "Рекомендовано",
"download": "Завантажити",
"downloading": "Завантаження...",
"downloadFailed": "Завантаження не вдалося. Будь ласка, спробуйте ще раз.",
"modelCard": {
"accuracy": "точність",
"speed": "швидкість"
@ -71,18 +73,15 @@
"grant": "Надати дозвіл",
"granted": "Надано",
"waiting": "Очікування...",
"allGranted": "Все готово!"
"allGranted": "Все готово!",
"errors": {
"checkFailed": "Не вдалося перевірити дозволи. Будь ласка, спробуйте ще раз.",
"requestFailed": "Не вдалося запросити дозвіл. Будь ласка, спробуйте ще раз."
}
}
},
"modelSelector": {
"welcome": "Ласкаво просимо до Handy!",
"downloadPrompt": "Завантажте модель нижче, щоб почати транскрипцію.",
"availableModels": "Доступні моделі",
"downloadModels": "Завантажити моделі",
"chooseModel": "Оберіть модель",
"active": "Активна",
"download": "Завантажити",
"downloadSize": "Розмір завантаження",
"noModelsAvailable": "Немає доступних моделей",
"extracting": "Розпакування {{modelName}}...",
"extractingMultiple": "Розпакування {{count}} моделей...",
@ -95,9 +94,25 @@
"modelError": "Помилка моделі",
"modelUnloaded": "Модель вивантажена",
"noModelDownloadRequired": "Немає моделі - потрібно завантажити",
"deleteModel": "Видалити {{modelName}}"
"deleteModel": "Видалити {{modelName}}",
"switching": "Перемикання...",
"downloadSpeed": "{{speed}} MB/s",
"capabilities": {
"languageSelection": "Підтримує кілька мов введення",
"multiLanguage": "Багатомовна",
"translation": "Може перекладати на англійську",
"translate": "Перекласти на англійську",
"singleLanguage": "Підтримує лише цю мову",
"languageOnly": "Лише {{language}}"
},
"cancel": "Скасувати",
"cancelDownload": "Скасувати завантаження"
},
"settings": {
"modelSettings": {
"title": "Налаштування {{model}}",
"noSettingsNeeded": "Ця модель працює автоматично без потреби в налаштуванні."
},
"general": {
"title": "Загальні",
"shortcut": {
@ -115,6 +130,10 @@
"cancel": {
"name": "Гаряча клавіша скасування",
"description": "Комбінація клавіш для скасування поточного запису."
},
"transcribe_with_post_process": {
"name": "Гаряча клавіша постобробки",
"description": "Необов'язково: Спеціальна гаряча клавіша, яка завжди застосовує AI-постобробку до вашої транскрипції."
}
},
"errors": {
@ -136,6 +155,24 @@
"description": "Утримуйте для запису, відпустіть для зупинки"
}
},
"models": {
"title": "Моделі транскрипції",
"description": "Виберіть модель транскрипції або завантажте додаткові моделі. Різні моделі пропонують різні рівні точності та швидкості.",
"downloaded": "Завантажено",
"available": "Доступні для завантаження",
"deleteConfirm": "Ви впевнені, що хочете видалити {{modelName}}? Вам потрібно буде завантажити її знову, щоб використовувати.",
"deleteActiveConfirm": "{{modelName}} — ваша активна модель. Видалення зупинить транскрипцію, доки ви не оберете нову модель. Ви впевнені?",
"deleteTitle": "Видалити модель",
"filters": {
"all": "Усі",
"multiLanguage": "Багатомовні",
"translation": "Переклад",
"allLanguages": "Усі мови"
},
"noModelsMatch": "Жодна модель не відповідає цьому фільтру.",
"yourModels": "Завантажені моделі",
"availableModels": "Доступні для завантаження"
},
"sound": {
"title": "Звук",
"microphone": {
@ -363,10 +400,19 @@
"label": "Додавати пробіл в кінці",
"description": "Додавати пробіл після вставленої транскрипції"
},
"keyboardImplementation": {
"title": "Keyboard Implementation",
"description": "Choose the keyboard shortcut backend.",
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
},
"paths": {
"appData": "Дані програми:",
"models": "Моделі:",
"settings": "Налаштування:"
},
"pasteDelay": {
"title": "Затримка вставки",
"description": "Затримка перед надсиланням натискання клавіші вставки (у мілісекундах). Збільшіть, якщо вставляється неправильний текст."
}
},
"about": {

View file

@ -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...",
@ -9,6 +8,7 @@
},
"sidebar": {
"general": "Chung",
"models": "Mô hình",
"advanced": "Nâng cao",
"postProcessing": "Xử lý sau",
"history": "Lịch sử",
@ -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 độ"
@ -72,18 +73,16 @@
"grant": "Cấp quyền",
"granted": "Đã cấp",
"waiting": "Đang chờ...",
"allGranted": "Hoàn tất!"
"allGranted": "Hoàn tất!",
"errors": {
"checkFailed": "Không thể kiểm tra quyền. Vui lòng thử lại.",
"requestFailed": "Không thể yêu cầu quyền. Vui lòng thử lại."
}
}
},
"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",
"download": "Tải xuống",
"downloadSize": "Kích thước tải xuống",
"switching": "Đang chuyển...",
"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...",
@ -96,9 +95,42 @@
"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",
"singleLanguage": "Chỉ hỗ trợ ngôn ngữ này",
"languageOnly": "Chỉ {{language}}"
},
"cancel": "Hủy",
"cancelDownload": "Hủy tải xuống"
},
"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.",
"deleteActiveConfirm": "{{modelName}} là mô hình đang hoạt động của bạn. Xóa nó sẽ dừng phiên âm cho đến khi bạn chọn một mô hình mới. Bạn có chắc không?",
"deleteTitle": "Xóa mô hình",
"filters": {
"all": "Tất cả",
"multiLanguage": "Đa ngôn ngữ",
"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.",
"yourModels": "Mô hình đã tải",
"availableModels": "Có sẵn để tải xuống"
},
"general": {
"title": "Chung",
"shortcut": {
@ -116,6 +148,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": {
@ -364,10 +400,19 @@
"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:",
"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": {

View file

@ -8,6 +8,7 @@
},
"sidebar": {
"general": "通用",
"models": "模型",
"advanced": "高级",
"postProcessing": "后处理",
"history": "历史记录",
@ -19,6 +20,7 @@
"recommended": "推荐",
"download": "下载",
"downloading": "下载中...",
"downloadFailed": "下载失败。请重试。",
"modelCard": {
"accuracy": "准确度",
"speed": "速度"
@ -71,18 +73,16 @@
"grant": "授予权限",
"granted": "已授予",
"waiting": "等待中...",
"allGranted": "全部完成!"
"allGranted": "全部完成!",
"errors": {
"checkFailed": "检查权限失败。请重试。",
"requestFailed": "请求权限失败。请重试。"
}
}
},
"modelSelector": {
"welcome": "欢迎使用 Handy",
"downloadPrompt": "请下载以下模型以开始转录。",
"availableModels": "可用模型",
"downloadModels": "下载模型",
"chooseModel": "选择模型",
"active": "活跃",
"download": "下载",
"downloadSize": "下载大小",
"switching": "切换中...",
"noModelsAvailable": "没有可用的模型",
"extracting": "正在解压 {{modelName}}...",
"extractingMultiple": "正在解压 {{count}} 个模型...",
@ -95,9 +95,42 @@
"modelError": "模型错误",
"modelUnloaded": "模型已卸载",
"noModelDownloadRequired": "无模型 - 需要下载",
"deleteModel": "删除 {{modelName}}"
"deleteModel": "删除 {{modelName}}",
"downloadSpeed": "{{speed}} MB/s",
"capabilities": {
"languageSelection": "支持多种输入语言",
"multiLanguage": "多语言",
"translation": "可翻译为英语",
"translate": "翻译为英语",
"singleLanguage": "仅支持此语言",
"languageOnly": "仅 {{language}}"
},
"cancel": "取消",
"cancelDownload": "取消下载"
},
"settings": {
"modelSettings": {
"title": "{{model}} 设置",
"noSettingsNeeded": "此模型自动运行,无需配置。"
},
"models": {
"title": "转录模型",
"description": "选择转录模型或下载其他模型。不同模型提供不同的准确度和速度。",
"downloaded": "已下载",
"available": "可下载",
"deleteConfirm": "确定要删除 {{modelName}} 吗?您需要重新下载才能使用。",
"deleteActiveConfirm": "{{modelName}} 是您当前使用的模型。删除后将停止转录,直到您选择新的模型。确定要删除吗?",
"deleteTitle": "删除模型",
"filters": {
"all": "全部",
"multiLanguage": "多语言",
"translation": "翻译",
"allLanguages": "所有语言"
},
"noModelsMatch": "没有符合此筛选条件的模型。",
"yourModels": "已下载的模型",
"availableModels": "可供下载"
},
"general": {
"title": "通用",
"shortcut": {
@ -115,6 +148,10 @@
"cancel": {
"name": "取消快捷键",
"description": "用于取消当前录制的键盘快捷键。"
},
"transcribe_with_post_process": {
"name": "后处理快捷键",
"description": "可选:一个专用快捷键,始终对您的转录应用 AI 后处理。"
}
},
"errors": {
@ -363,10 +400,19 @@
"label": "追加尾部空格",
"description": "在粘贴的转录后添加空格"
},
"keyboardImplementation": {
"title": "Keyboard Implementation",
"description": "Choose the keyboard shortcut backend.",
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
},
"paths": {
"appData": "应用数据:",
"models": "模型:",
"settings": "设置:"
},
"pasteDelay": {
"title": "粘贴延迟",
"description": "发送粘贴按键前的延迟(毫秒)。如果粘贴了错误的文本,请增加此值。"
}
},
"about": {

View file

@ -57,4 +57,5 @@ export const LANGUAGES: Language[] = [
{ value: "et", label: "Estonian" },
{ value: "mk", label: "Macedonian" },
{ value: "br", label: "Breton" },
{ value: "mt", label: "Maltese" },
];

View file

@ -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";
@ -10,16 +11,26 @@ interface DownloadProgress {
percentage: number;
}
interface ModelStore {
interface DownloadStats {
startTime: number;
lastUpdate: number;
totalDownloaded: number;
speed: number; // MB/s
}
// Using Record instead of Set/Map for Immer compatibility
interface ModelsStore {
models: ModelInfo[];
currentModel: string;
downloadingModels: Set<string>;
extractingModels: Set<string>;
downloadProgress: Map<string, DownloadProgress>;
downloadingModels: Record<string, true>;
extractingModels: Record<string, true>;
downloadProgress: Record<string, DownloadProgress>;
downloadStats: Record<string, DownloadStats>;
loading: boolean;
error: string | null;
hasAnyModels: boolean;
isFirstRun: boolean;
initialized: boolean;
// Actions
initialize: () => Promise<void>;
@ -28,9 +39,8 @@ interface ModelStore {
checkFirstRun: () => Promise<boolean>;
selectModel: (modelId: string) => Promise<boolean>;
downloadModel: (modelId: string) => Promise<boolean>;
cancelDownload: (modelId: string) => Promise<boolean>;
deleteModel: (modelId: string) => Promise<boolean>;
// Getters
getModelInfo: (modelId: string) => ModelInfo | undefined;
isModelDownloading: (modelId: string) => boolean;
isModelExtracting: (modelId: string) => boolean;
@ -38,85 +48,61 @@ 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<ModelStore>()(
export const useModelStore = create<ModelsStore>()(
subscribeWithSelector((set, get) => ({
models: [],
currentModel: "",
downloadingModels: new Set(),
extractingModels: new Set(),
downloadProgress: new Map(),
downloadingModels: {},
extractingModels: {},
downloadProgress: {},
downloadStats: {},
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 };
}),
setLoading: (loading) => set({ loading }),
// 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
loadModels: async () => {
try {
const result = await commands.getAvailableModels();
if (result.status === "ok") {
set({ models: result.data, error: null });
// Sync downloading state from backend
set(
produce((state) => {
const backendDownloading: Record<string, true> = {};
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];
}
});
}),
);
} else {
set({ error: `Failed to load models: ${result.error}` });
}
@ -153,12 +139,16 @@ export const useModelStore = create<ModelStore>()(
}
},
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 +160,76 @@ export const useModelStore = create<ModelStore>()(
}
},
downloadModel: async (modelId) => {
const { addDownloadingModel, removeDownloadingModel } = get();
downloadModel: async (modelId: string) => {
try {
set({ error: null });
addDownloadingModel(modelId);
set(
produce((state) => {
state.downloadingModels[modelId] = true;
state.downloadProgress[modelId] = {
model_id: modelId,
downloaded: 0,
total: 0,
percentage: 0,
};
}),
);
const result = await commands.downloadModel(modelId);
if (result.status === "ok") {
return true;
} else {
set({ error: `Failed to download model: ${result.error}` });
removeDownloadingModel(modelId);
set(
produce((state) => {
delete state.downloadingModels[modelId];
}),
);
return false;
}
} catch (err) {
set({ error: `Failed to download model: ${err}` });
removeDownloadingModel(modelId);
set(
produce((state) => {
delete state.downloadingModels[modelId];
}),
);
return false;
}
},
deleteModel: async (modelId) => {
const { loadModels } = get();
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;
}
},
deleteModel: async (modelId: string) => {
try {
set({ error: null });
const result = await commands.deleteModel(modelId);
if (result.status === "ok") {
await loadModels();
await get().loadModels();
await get().loadCurrentModel();
return true;
} else {
set({ error: `Failed to delete model: ${result.error}` });
@ -208,47 +241,142 @@ export const useModelStore = create<ModelStore>()(
}
},
getModelInfo: (modelId: string) => {
return get().models.find((model) => model.id === modelId);
},
isModelDownloading: (modelId: string) => {
return modelId in get().downloadingModels;
},
isModelExtracting: (modelId: string) => {
return modelId in get().extractingModels;
},
getDownloadProgress: (modelId: string) => {
return get().downloadProgress[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<DownloadProgress>("model-download-progress", (event) => {
const progress = event.payload;
set(
produce((state) => {
state.downloadProgress[progress.model_id] = progress;
}),
);
// Update download stats for speed calculation
const now = Date.now();
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: 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),
};
}
}
}),
);
});
listen<string>("model-download-complete", (event) => {
const modelId = event.payload;
set(
produce((state) => {
delete state.downloadingModels[modelId];
delete state.downloadProgress[modelId];
delete state.downloadStats[modelId];
}),
);
get().loadModels();
});
listen<string>("model-extraction-started", (event) => {
const modelId = event.payload;
set(
produce((state) => {
state.extractingModels[modelId] = true;
}),
);
});
listen<string>("model-extraction-completed", (event) => {
const modelId = event.payload;
set(
produce((state) => {
delete state.extractingModels[modelId];
}),
);
get().loadModels();
});
listen<{ model_id: string; error: string }>(
"model-extraction-failed",
(event) => {
const modelId = event.payload.model_id;
set(
produce((state) => {
delete state.extractingModels[modelId];
state.error = `Failed to extract model: ${event.payload.error}`;
}),
);
},
);
listen<string>("model-download-cancelled", (event) => {
const modelId = event.payload;
set(
produce((state) => {
delete state.downloadingModels[modelId];
delete state.downloadProgress[modelId];
delete state.downloadStats[modelId];
}),
);
});
listen<string>("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<DownloadProgress>("model-download-progress", (event) => {
useModelStore
.getState()
.setDownloadProgress(event.payload.model_id, event.payload);
});
listen<string>("model-download-complete", (event) => {
const modelId = event.payload;
const state = useModelStore.getState();
state.removeDownloadingModel(modelId);
state.removeDownloadProgress(modelId);
state.loadModels();
});
listen<string>("model-extraction-started", (event) => {
useModelStore.getState().addExtractingModel(event.payload);
});
listen<string>("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}`);
},
);