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:
parent
665558bac4
commit
ef24cfefda
54 changed files with 2501 additions and 916 deletions
3
.github/workflows/lint.yml
vendored
3
.github/workflows/lint.yml
vendored
|
|
@ -14,5 +14,8 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check translation consistency
|
||||||
|
run: bun run check:translations
|
||||||
|
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: bun run lint
|
run: bun run lint
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -9,6 +9,7 @@
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
"@tauri-apps/plugin-autostart": "~2.5.1",
|
"@tauri-apps/plugin-autostart": "~2.5.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
|
||||||
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-fs": "~2.4.4",
|
"@tauri-apps/plugin-fs": "~2.4.4",
|
||||||
"@tauri-apps/plugin-global-shortcut": "~2.3.1",
|
"@tauri-apps/plugin-global-shortcut": "~2.3.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.2",
|
"@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-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-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=="],
|
"@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g=="],
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,15 @@
|
||||||
"format:frontend": "prettier --write .",
|
"format:frontend": "prettier --write .",
|
||||||
"format:backend": "cd src-tauri && cargo fmt",
|
"format:backend": "cd src-tauri && cargo fmt",
|
||||||
"test:playwright": "playwright test",
|
"test:playwright": "playwright test",
|
||||||
"test:playwright:ui": "playwright test --ui"
|
"test:playwright:ui": "playwright test --ui",
|
||||||
|
"check:translations": "bun scripts/check-translations.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
"@tauri-apps/plugin-autostart": "~2.5.1",
|
"@tauri-apps/plugin-autostart": "~2.5.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
|
||||||
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-fs": "~2.4.4",
|
"@tauri-apps/plugin-fs": "~2.4.4",
|
||||||
"@tauri-apps/plugin-global-shortcut": "~2.3.1",
|
"@tauri-apps/plugin-global-shortcut": "~2.3.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.2",
|
"@tauri-apps/plugin-opener": "^2.5.2",
|
||||||
|
|
@ -30,16 +32,16 @@
|
||||||
"@tauri-apps/plugin-sql": "~2.3.1",
|
"@tauri-apps/plugin-sql": "~2.3.1",
|
||||||
"@tauri-apps/plugin-store": "~2.4.1",
|
"@tauri-apps/plugin-store": "~2.4.1",
|
||||||
"@tauri-apps/plugin-updater": "~2.9.0",
|
"@tauri-apps/plugin-updater": "~2.9.0",
|
||||||
"react-select": "^5.8.0",
|
|
||||||
"tauri-plugin-macos-permissions-api": "2.3.0",
|
|
||||||
"i18next": "^25.7.2",
|
"i18next": "^25.7.2",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^16.4.1",
|
"react-i18next": "^16.4.1",
|
||||||
|
"react-select": "^5.8.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
|
"tauri-plugin-macos-permissions-api": "2.3.0",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
224
scripts/check-translations.ts
Normal file
224
scripts/check-translations.ts
Normal 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
89
src-tauri/Cargo.lock
generated
|
|
@ -189,6 +189,27 @@ version = "0.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -1401,6 +1422,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
"objc2 0.6.3",
|
"objc2 0.6.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1415,6 +1438,15 @@ dependencies = [
|
||||||
"syn 2.0.108",
|
"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]]
|
[[package]]
|
||||||
name = "dlopen2"
|
name = "dlopen2"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|
@ -2446,6 +2478,7 @@ dependencies = [
|
||||||
"tauri-nspanel",
|
"tauri-nspanel",
|
||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
|
|
@ -4991,6 +5024,31 @@ dependencies = [
|
||||||
"webpki-roots",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -5299,6 +5357,12 @@ dependencies = [
|
||||||
"syn 2.0.108",
|
"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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
@ -6308,6 +6372,24 @@ dependencies = [
|
||||||
"thiserror 2.0.17",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-fs"
|
name = "tauri-plugin-fs"
|
||||||
version = "2.4.4"
|
version = "2.4.4"
|
||||||
|
|
@ -6778,8 +6860,10 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"mio 1.1.0",
|
"mio 1.1.0",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -7467,6 +7551,7 @@ dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
"rustix 1.1.2",
|
"rustix 1.1.2",
|
||||||
|
"scoped-tls",
|
||||||
"smallvec 1.15.1",
|
"smallvec 1.15.1",
|
||||||
"wayland-sys",
|
"wayland-sys",
|
||||||
]
|
]
|
||||||
|
|
@ -7525,6 +7610,8 @@ version = "0.31.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"dlib",
|
||||||
|
"log",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -8530,6 +8617,7 @@ dependencies = [
|
||||||
"ordered-stream",
|
"ordered-stream",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|
@ -8683,6 +8771,7 @@ dependencies = [
|
||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
|
"url",
|
||||||
"winnow 0.7.13",
|
"winnow 0.7.13",
|
||||||
"zvariant_derive",
|
"zvariant_derive",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ ferrous-opencc = "0.2.3"
|
||||||
specta = "=2.0.0-rc.22"
|
specta = "=2.0.0-rc.22"
|
||||||
specta-typescript = "0.0.9"
|
specta-typescript = "0.0.9"
|
||||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
signal-hook = "0.3"
|
signal-hook = "0.3"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"store:default",
|
"store:default",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"process:default",
|
"process:default",
|
||||||
|
"dialog:default",
|
||||||
"global-shortcut:allow-is-registered",
|
"global-shortcut:allow-is-registered",
|
||||||
"global-shortcut:allow-register",
|
"global-shortcut:allow-register",
|
||||||
"global-shortcut:allow-unregister",
|
"global-shortcut:allow-unregister",
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,23 @@ pub async fn download_model(
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn delete_model(
|
pub async fn delete_model(
|
||||||
|
app_handle: AppHandle,
|
||||||
model_manager: State<'_, Arc<ModelManager>>,
|
model_manager: State<'_, Arc<ModelManager>>,
|
||||||
|
transcription_manager: State<'_, Arc<TranscriptionManager>>,
|
||||||
model_id: String,
|
model_id: String,
|
||||||
) -> Result<(), 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
|
model_manager
|
||||||
.delete_model(&model_id)
|
.delete_model(&model_id)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
|
|
@ -128,10 +142,3 @@ pub async fn cancel_download(
|
||||||
.cancel_download(&model_id)
|
.cancel_download(&model_id)
|
||||||
.map_err(|e| e.to_string())
|
.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())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,6 @@ pub fn run() {
|
||||||
commands::models::is_model_loading,
|
commands::models::is_model_loading,
|
||||||
commands::models::has_any_models_available,
|
commands::models::has_any_models_available,
|
||||||
commands::models::has_any_models_or_downloads,
|
commands::models::has_any_models_or_downloads,
|
||||||
commands::models::get_recommended_first_model,
|
|
||||||
commands::audio::update_microphone_mode,
|
commands::audio::update_microphone_mode,
|
||||||
commands::audio::get_microphone_mode,
|
commands::audio::get_microphone_mode,
|
||||||
commands::audio::get_available_microphones,
|
commands::audio::get_available_microphones,
|
||||||
|
|
@ -333,29 +332,31 @@ pub fn run() {
|
||||||
)
|
)
|
||||||
.expect("Failed to export typescript bindings");
|
.expect("Failed to export typescript bindings");
|
||||||
|
|
||||||
let mut builder = tauri::Builder::default().plugin(
|
let mut builder = tauri::Builder::default()
|
||||||
LogBuilder::new()
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.level(log::LevelFilter::Trace) // Set to most verbose level globally
|
.plugin(
|
||||||
.max_file_size(500_000)
|
LogBuilder::new()
|
||||||
.rotation_strategy(RotationStrategy::KeepOne)
|
.level(log::LevelFilter::Trace) // Set to most verbose level globally
|
||||||
.clear_targets()
|
.max_file_size(500_000)
|
||||||
.targets([
|
.rotation_strategy(RotationStrategy::KeepOne)
|
||||||
// Console output respects RUST_LOG environment variable
|
.clear_targets()
|
||||||
Target::new(TargetKind::Stdout).filter({
|
.targets([
|
||||||
let console_filter = console_filter.clone();
|
// Console output respects RUST_LOG environment variable
|
||||||
move |metadata| console_filter.enabled(metadata)
|
Target::new(TargetKind::Stdout).filter({
|
||||||
}),
|
let console_filter = console_filter.clone();
|
||||||
// File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic)
|
move |metadata| console_filter.enabled(metadata)
|
||||||
Target::new(TargetKind::LogDir {
|
}),
|
||||||
file_name: Some("handy".into()),
|
// File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic)
|
||||||
})
|
Target::new(TargetKind::LogDir {
|
||||||
.filter(|metadata| {
|
file_name: Some("handy".into()),
|
||||||
let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed);
|
})
|
||||||
metadata.level() <= level_filter_from_u8(file_level)
|
.filter(|metadata| {
|
||||||
}),
|
let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed);
|
||||||
])
|
metadata.level() <= level_filter_from_u8(file_level)
|
||||||
.build(),
|
}),
|
||||||
);
|
])
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ use futures_util::StreamExt;
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
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 tar::Archive;
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
|
|
@ -34,8 +36,11 @@ pub struct ModelInfo {
|
||||||
pub partial_size: u64,
|
pub partial_size: u64,
|
||||||
pub is_directory: bool,
|
pub is_directory: bool,
|
||||||
pub engine_type: EngineType,
|
pub engine_type: EngineType,
|
||||||
pub accuracy_score: f32, // 0.0 to 1.0, higher is more accurate
|
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 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
|
|
@ -50,6 +55,8 @@ pub struct ModelManager {
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
models_dir: PathBuf,
|
models_dir: PathBuf,
|
||||||
available_models: Mutex<HashMap<String, ModelInfo>>,
|
available_models: Mutex<HashMap<String, ModelInfo>>,
|
||||||
|
cancel_flags: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
|
||||||
|
extracting_models: Arc<Mutex<HashSet<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelManager {
|
impl ModelManager {
|
||||||
|
|
@ -67,6 +74,22 @@ impl ModelManager {
|
||||||
|
|
||||||
let mut available_models = HashMap::new();
|
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..
|
// TODO this should be read from a JSON file or something..
|
||||||
available_models.insert(
|
available_models.insert(
|
||||||
"small".to_string(),
|
"small".to_string(),
|
||||||
|
|
@ -84,6 +107,9 @@ impl ModelManager {
|
||||||
engine_type: EngineType::Whisper,
|
engine_type: EngineType::Whisper,
|
||||||
accuracy_score: 0.60,
|
accuracy_score: 0.60,
|
||||||
speed_score: 0.85,
|
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,
|
engine_type: EngineType::Whisper,
|
||||||
accuracy_score: 0.75,
|
accuracy_score: 0.75,
|
||||||
speed_score: 0.60,
|
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,
|
engine_type: EngineType::Whisper,
|
||||||
accuracy_score: 0.80,
|
accuracy_score: 0.80,
|
||||||
speed_score: 0.40,
|
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,
|
engine_type: EngineType::Whisper,
|
||||||
accuracy_score: 0.85,
|
accuracy_score: 0.85,
|
||||||
speed_score: 0.30,
|
speed_score: 0.30,
|
||||||
|
supports_translation: true,
|
||||||
|
is_recommended: false,
|
||||||
|
supported_languages: whisper_languages,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -162,15 +197,28 @@ impl ModelManager {
|
||||||
engine_type: EngineType::Parakeet,
|
engine_type: EngineType::Parakeet,
|
||||||
accuracy_score: 0.85,
|
accuracy_score: 0.85,
|
||||||
speed_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(
|
available_models.insert(
|
||||||
"parakeet-tdt-0.6b-v3".to_string(),
|
"parakeet-tdt-0.6b-v3".to_string(),
|
||||||
ModelInfo {
|
ModelInfo {
|
||||||
id: "parakeet-tdt-0.6b-v3".to_string(),
|
id: "parakeet-tdt-0.6b-v3".to_string(),
|
||||||
name: "Parakeet 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
|
filename: "parakeet-tdt-0.6b-v3-int8".to_string(), // Directory name
|
||||||
url: Some("https://blob.handy.computer/parakeet-v3-int8.tar.gz".to_string()),
|
url: Some("https://blob.handy.computer/parakeet-v3-int8.tar.gz".to_string()),
|
||||||
size_mb: 478, // Approximate size for int8 quantized model
|
size_mb: 478, // Approximate size for int8 quantized model
|
||||||
|
|
@ -181,6 +229,9 @@ impl ModelManager {
|
||||||
engine_type: EngineType::Parakeet,
|
engine_type: EngineType::Parakeet,
|
||||||
accuracy_score: 0.80,
|
accuracy_score: 0.80,
|
||||||
speed_score: 0.85,
|
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,
|
engine_type: EngineType::Moonshine,
|
||||||
accuracy_score: 0.70,
|
accuracy_score: 0.70,
|
||||||
speed_score: 0.90,
|
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(),
|
app_handle: app_handle.clone(),
|
||||||
models_dir,
|
models_dir,
|
||||||
available_models: Mutex::new(available_models),
|
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
|
// Migrate any bundled models to user directory
|
||||||
|
|
@ -271,7 +327,12 @@ impl ModelManager {
|
||||||
.join(format!("{}.extracting", &model.filename));
|
.join(format!("{}.extracting", &model.filename));
|
||||||
|
|
||||||
// Clean up any leftover .extracting directories from interrupted extractions
|
// 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);
|
warn!("Cleaning up interrupted extraction for model: {}", model.id);
|
||||||
let _ = fs::remove_dir_all(&extracting_path);
|
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
|
// Create HTTP client with range request for resuming
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mut request = client.get(&url);
|
let mut request = client.get(&url);
|
||||||
|
|
@ -456,8 +524,36 @@ impl ModelManager {
|
||||||
.app_handle
|
.app_handle
|
||||||
.emit("model-download-progress", &initial_progress);
|
.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
|
// Download with progress
|
||||||
while let Some(chunk) = stream.next().await {
|
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| {
|
let chunk = chunk.map_err(|e| {
|
||||||
// Mark as not downloading on error
|
// Mark as not downloading on error
|
||||||
{
|
{
|
||||||
|
|
@ -478,17 +574,34 @@ impl ModelManager {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Emit progress event
|
// Emit progress event (throttled to avoid UI freeze)
|
||||||
let progress = DownloadProgress {
|
if last_emit.elapsed() >= throttle_duration {
|
||||||
model_id: model_id.to_string(),
|
let progress = DownloadProgress {
|
||||||
downloaded,
|
model_id: model_id.to_string(),
|
||||||
total: total_size,
|
downloaded,
|
||||||
percentage,
|
total: total_size,
|
||||||
};
|
percentage,
|
||||||
|
};
|
||||||
let _ = self.app_handle.emit("model-download-progress", &progress);
|
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()?;
|
file.flush()?;
|
||||||
drop(file); // Ensure file is closed before moving
|
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
|
// Handle directory-based models (extract tar.gz) vs file-based models
|
||||||
if model_info.is_directory {
|
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
|
// Emit extraction started event
|
||||||
let _ = self.app_handle.emit("model-extraction-started", model_id);
|
let _ = self.app_handle.emit("model-extraction-started", model_id);
|
||||||
info!("Extracting archive for directory-based model: {}", 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);
|
let error_msg = format!("Failed to extract archive: {}", e);
|
||||||
// Clean up failed extraction
|
// Clean up failed extraction
|
||||||
let _ = fs::remove_dir_all(&temp_extract_dir);
|
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(
|
let _ = self.app_handle.emit(
|
||||||
"model-extraction-failed",
|
"model-extraction-failed",
|
||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
|
|
@ -576,6 +700,11 @@ impl ModelManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Successfully extracted archive for model: {}", model_id);
|
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
|
// Emit extraction completed event
|
||||||
let _ = self.app_handle.emit("model-extraction-completed", model_id);
|
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
|
// Emit completion event
|
||||||
let _ = self.app_handle.emit("model-download-complete", model_id);
|
let _ = self.app_handle.emit("model-download-complete", model_id);
|
||||||
|
|
||||||
|
|
@ -663,6 +798,9 @@ impl ModelManager {
|
||||||
self.update_download_status()?;
|
self.update_download_status()?;
|
||||||
debug!("ModelManager: download status updated");
|
debug!("ModelManager: download status updated");
|
||||||
|
|
||||||
|
// Emit event to notify UI
|
||||||
|
let _ = self.app_handle.emit("model-deleted", model_id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -714,15 +852,18 @@ impl ModelManager {
|
||||||
pub fn cancel_download(&self, model_id: &str) -> Result<()> {
|
pub fn cancel_download(&self, model_id: &str) -> Result<()> {
|
||||||
debug!("ModelManager: cancel_download called for: {}", model_id);
|
debug!("ModelManager: cancel_download called for: {}", model_id);
|
||||||
|
|
||||||
let _model_info = {
|
// Set the cancellation flag to stop the download loop
|
||||||
let models = self.available_models.lock().unwrap();
|
{
|
||||||
models.get(model_id).cloned()
|
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 =
|
// Update state immediately for UI responsiveness
|
||||||
_model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?;
|
|
||||||
|
|
||||||
// Mark as not downloading
|
|
||||||
{
|
{
|
||||||
let mut models = self.available_models.lock().unwrap();
|
let mut models = self.available_models.lock().unwrap();
|
||||||
if let Some(model) = models.get_mut(model_id) {
|
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
|
// Update download status to reflect current state
|
||||||
self.update_download_status()?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -513,14 +513,6 @@ async hasAnyModelsOrDownloads() : Promise<Result<boolean, string>> {
|
||||||
else return { status: "error", error: e as any };
|
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>> {
|
async updateMicrophoneMode(alwaysOn: boolean) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("update_microphone_mode", { alwaysOn }) };
|
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 KeyboardImplementation = "tauri" | "handy_keys"
|
||||||
export type LLMPrompt = { id: string; name: string; prompt: string }
|
export type LLMPrompt = { id: string; name: string; prompt: string }
|
||||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"
|
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 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 ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5"
|
||||||
export type OverlayPosition = "none" | "top" | "bottom"
|
export type OverlayPosition = "none" | "top" | "bottom"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import {
|
import {
|
||||||
checkAccessibilityPermission,
|
checkAccessibilityPermission,
|
||||||
requestAccessibilityPermission,
|
requestAccessibilityPermission,
|
||||||
|
|
@ -20,6 +21,9 @@ const AccessibilityPermissions: React.FC = () => {
|
||||||
const [permissionState, setPermissionState] =
|
const [permissionState, setPermissionState] =
|
||||||
useState<PermissionState>("request");
|
useState<PermissionState>("request");
|
||||||
|
|
||||||
|
// Accessibility permissions are only required on macOS
|
||||||
|
const isMacOS = type() === "macos";
|
||||||
|
|
||||||
// Check permissions without requesting
|
// Check permissions without requesting
|
||||||
const checkPermissions = async (): Promise<boolean> => {
|
const checkPermissions = async (): Promise<boolean> => {
|
||||||
const hasPermissions: boolean = await checkAccessibilityPermission();
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (!isMacOS) return;
|
||||||
|
|
||||||
const initialSetup = async (): Promise<void> => {
|
const initialSetup = async (): Promise<void> => {
|
||||||
const hasPermissions: boolean = await checkAccessibilityPermission();
|
const hasPermissions: boolean = await checkAccessibilityPermission();
|
||||||
setHasAccessibility(hasPermissions);
|
setHasAccessibility(hasPermissions);
|
||||||
|
|
@ -54,9 +60,10 @@ const AccessibilityPermissions: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
initialSetup();
|
initialSetup();
|
||||||
}, []);
|
}, [isMacOS]);
|
||||||
|
|
||||||
if (hasAccessibility) {
|
// Skip rendering on non-macOS platforms or if permission is already granted
|
||||||
|
if (!isMacOS || hasAccessibility) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +77,7 @@ const AccessibilityPermissions: React.FC = () => {
|
||||||
verify: {
|
verify: {
|
||||||
text: t("accessibility.openSettings"),
|
text: t("accessibility.openSettings"),
|
||||||
className:
|
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,
|
granted: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 HandyTextLogo from "./icons/HandyTextLogo";
|
||||||
import HandyHand from "./icons/HandyHand";
|
import HandyHand from "./icons/HandyHand";
|
||||||
import { useSettings } from "../hooks/useSettings";
|
import { useSettings } from "../hooks/useSettings";
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
DebugSettings,
|
DebugSettings,
|
||||||
AboutSettings,
|
AboutSettings,
|
||||||
PostProcessingSettings,
|
PostProcessingSettings,
|
||||||
|
ModelsSettings,
|
||||||
} from "./settings";
|
} from "./settings";
|
||||||
|
|
||||||
export type SidebarSection = keyof typeof SECTIONS_CONFIG;
|
export type SidebarSection = keyof typeof SECTIONS_CONFIG;
|
||||||
|
|
@ -37,6 +38,12 @@ export const SECTIONS_CONFIG = {
|
||||||
component: GeneralSettings,
|
component: GeneralSettings,
|
||||||
enabled: () => true,
|
enabled: () => true,
|
||||||
},
|
},
|
||||||
|
models: {
|
||||||
|
labelKey: "sidebar.models",
|
||||||
|
icon: Cpu,
|
||||||
|
component: ModelsSettings,
|
||||||
|
enabled: () => true,
|
||||||
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
labelKey: "sidebar.advanced",
|
labelKey: "sidebar.advanced",
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,34 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ModelInfo } from "@/bindings";
|
import type { ModelInfo } from "@/bindings";
|
||||||
import { formatModelSize } from "../../lib/utils/format";
|
|
||||||
import {
|
import {
|
||||||
getTranslatedModelName,
|
getTranslatedModelName,
|
||||||
getTranslatedModelDescription,
|
getTranslatedModelDescription,
|
||||||
} from "../../lib/utils/modelTranslation";
|
} from "../../lib/utils/modelTranslation";
|
||||||
import { ProgressBar } from "../shared";
|
|
||||||
|
|
||||||
interface DownloadProgress {
|
|
||||||
model_id: string;
|
|
||||||
downloaded: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelDropdownProps {
|
interface ModelDropdownProps {
|
||||||
models: ModelInfo[];
|
models: ModelInfo[];
|
||||||
currentModelId: string;
|
currentModelId: string;
|
||||||
downloadProgress: Record<string, DownloadProgress>;
|
|
||||||
onModelSelect: (modelId: string) => void;
|
onModelSelect: (modelId: string) => void;
|
||||||
onModelDownload: (modelId: string) => void;
|
|
||||||
onModelDelete: (modelId: string) => Promise<void>;
|
|
||||||
onError?: (error: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||||
models,
|
models,
|
||||||
currentModelId,
|
currentModelId,
|
||||||
downloadProgress,
|
|
||||||
onModelSelect,
|
onModelSelect,
|
||||||
onModelDownload,
|
|
||||||
onModelDelete,
|
|
||||||
onError,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const availableModels = models.filter((m) => m.is_downloaded);
|
const downloadedModels = 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 handleModelClick = (modelId: string) => {
|
const handleModelClick = (modelId: string) => {
|
||||||
if (modelId in downloadProgress) {
|
|
||||||
return; // Don't allow interaction while downloading
|
|
||||||
}
|
|
||||||
onModelSelect(modelId);
|
onModelSelect(modelId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadClick = (modelId: string) => {
|
|
||||||
if (modelId in downloadProgress) {
|
|
||||||
return; // Don't allow interaction while downloading
|
|
||||||
}
|
|
||||||
onModelDownload(modelId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
{/* First Run Welcome */}
|
{downloadedModels.length > 0 ? (
|
||||||
{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>
|
<div>
|
||||||
<div className="px-3 py-1 text-xs font-medium text-text/80 border-b border-mid-gray/10">
|
{downloadedModels.map((model) => (
|
||||||
{t("modelSelector.availableModels")}
|
|
||||||
</div>
|
|
||||||
{availableModels.map((model) => (
|
|
||||||
<div
|
<div
|
||||||
key={model.id}
|
key={model.id}
|
||||||
onClick={() => handleModelClick(model.id)}
|
onClick={() => handleModelClick(model.id)}
|
||||||
|
|
@ -105,128 +48,23 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm">
|
<div className="text-sm text-text/80">
|
||||||
{getTranslatedModelName(model, t)}
|
{getTranslatedModelName(model, t)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text/40 italic pe-4">
|
<div className="text-xs text-text/40 italic pe-4">
|
||||||
{getTranslatedModelDescription(model, t)}
|
{getTranslatedModelDescription(model, t)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{currentModelId === model.id && (
|
||||||
{currentModelId === model.id && (
|
<div className="text-xs text-logo-primary">
|
||||||
<div className="text-xs text-logo-primary">
|
{t("modelSelector.active")}
|
||||||
{t("modelSelector.active")}
|
</div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="px-3 py-2 text-sm text-text/60">
|
||||||
{t("modelSelector.noModelsAvailable")}
|
{t("modelSelector.noModelsAvailable")}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { produce } from "immer";
|
import { commands } from "@/bindings";
|
||||||
import { commands, type ModelInfo } from "@/bindings";
|
|
||||||
import { getTranslatedModelName } from "../../lib/utils/modelTranslation";
|
import { getTranslatedModelName } from "../../lib/utils/modelTranslation";
|
||||||
|
import { useModelStore } from "../../stores/modelStore";
|
||||||
import ModelStatusButton from "./ModelStatusButton";
|
import ModelStatusButton from "./ModelStatusButton";
|
||||||
import ModelDropdown from "./ModelDropdown";
|
import ModelDropdown from "./ModelDropdown";
|
||||||
import DownloadProgressDisplay from "./DownloadProgressDisplay";
|
import DownloadProgressDisplay from "./DownloadProgressDisplay";
|
||||||
|
|
@ -15,13 +15,6 @@ interface ModelStateEvent {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadProgress {
|
|
||||||
model_id: string;
|
|
||||||
downloaded: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelStatus =
|
type ModelStatus =
|
||||||
| "ready"
|
| "ready"
|
||||||
| "loading"
|
| "loading"
|
||||||
|
|
@ -31,46 +24,59 @@ type ModelStatus =
|
||||||
| "unloaded"
|
| "unloaded"
|
||||||
| "none";
|
| "none";
|
||||||
|
|
||||||
interface DownloadStats {
|
|
||||||
startTime: number;
|
|
||||||
lastUpdate: number;
|
|
||||||
totalDownloaded: number;
|
|
||||||
speed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
const {
|
||||||
const [currentModelId, setCurrentModelId] = useState<string>("");
|
models,
|
||||||
|
currentModel,
|
||||||
|
downloadProgress,
|
||||||
|
downloadStats,
|
||||||
|
extractingModels,
|
||||||
|
selectModel,
|
||||||
|
} = useModelStore();
|
||||||
|
|
||||||
const [modelStatus, setModelStatus] = useState<ModelStatus>("unloaded");
|
const [modelStatus, setModelStatus] = useState<ModelStatus>("unloaded");
|
||||||
const [modelError, setModelError] = useState<string | null>(null);
|
const [modelError, setModelError] = useState<string | null>(null);
|
||||||
const [modelDownloadProgress, setModelDownloadProgress] = useState<
|
|
||||||
Record<string, DownloadProgress>
|
|
||||||
>({});
|
|
||||||
const [showModelDropdown, setShowModelDropdown] = useState(false);
|
const [showModelDropdown, setShowModelDropdown] = useState(false);
|
||||||
const [downloadStats, setDownloadStats] = useState<
|
// Track pending model switch for optimistic display
|
||||||
Record<string, DownloadStats>
|
const [pendingModelId, setPendingModelId] = useState<string | null>(null);
|
||||||
>({});
|
|
||||||
const [extractingModels, setExtractingModels] = useState<
|
|
||||||
Record<string, true>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const displayModelId = pendingModelId || currentModel;
|
||||||
loadModels();
|
|
||||||
loadCurrentModel();
|
|
||||||
|
|
||||||
// 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>(
|
const modelStateUnlisten = listen<ModelStateEvent>(
|
||||||
"model-state-changed",
|
"model-state-changed",
|
||||||
(event) => {
|
(event) => {
|
||||||
const { event_type, model_id, model_name, error } = event.payload;
|
const { event_type, error } = event.payload;
|
||||||
|
|
||||||
switch (event_type) {
|
switch (event_type) {
|
||||||
case "loading_started":
|
case "loading_started":
|
||||||
setModelStatus("loading");
|
setModelStatus("loading");
|
||||||
|
|
@ -79,11 +85,12 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
case "loading_completed":
|
case "loading_completed":
|
||||||
setModelStatus("ready");
|
setModelStatus("ready");
|
||||||
setModelError(null);
|
setModelError(null);
|
||||||
if (model_id) setCurrentModelId(model_id);
|
setPendingModelId(null);
|
||||||
break;
|
break;
|
||||||
case "loading_failed":
|
case "loading_failed":
|
||||||
setModelStatus("error");
|
setModelStatus("error");
|
||||||
setModelError(error || "Failed to load model");
|
setModelError(error || "Failed to load model");
|
||||||
|
setPendingModelId(null);
|
||||||
break;
|
break;
|
||||||
case "unloaded":
|
case "unloaded":
|
||||||
setModelStatus("unloaded");
|
setModelStatus("unloaded");
|
||||||
|
|
@ -93,140 +100,30 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Listen for model download progress
|
// Auto-select model when download completes (fires after extraction too)
|
||||||
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
|
|
||||||
const downloadCompleteUnlisten = listen<string>(
|
const downloadCompleteUnlisten = listen<string>(
|
||||||
"model-download-complete",
|
"model-download-complete",
|
||||||
(event) => {
|
(event) => {
|
||||||
const modelId = event.payload;
|
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 () => {
|
setTimeout(async () => {
|
||||||
const isRecording = await commands.isRecording();
|
try {
|
||||||
if (isRecording) {
|
const isRecording = await commands.isRecording();
|
||||||
return; // Skip auto-switch if recording in progress
|
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);
|
}, 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
|
// Click outside to close dropdown
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -242,96 +139,23 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
modelStateUnlisten.then((fn) => fn());
|
modelStateUnlisten.then((fn) => fn());
|
||||||
downloadProgressUnlisten.then((fn) => fn());
|
|
||||||
downloadCompleteUnlisten.then((fn) => fn());
|
downloadCompleteUnlisten.then((fn) => fn());
|
||||||
extractionStartedUnlisten.then((fn) => fn());
|
|
||||||
extractionCompletedUnlisten.then((fn) => fn());
|
|
||||||
extractionFailedUnlisten.then((fn) => fn());
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, [selectModel]);
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModelSelect = async (modelId: string) => {
|
const handleModelSelect = async (modelId: string) => {
|
||||||
try {
|
setPendingModelId(modelId);
|
||||||
setCurrentModelId(modelId); // Set optimistically so loading text shows correct model
|
setModelError(null);
|
||||||
setModelError(null);
|
setShowModelDropdown(false);
|
||||||
setShowModelDropdown(false);
|
const success = await selectModel(modelId);
|
||||||
const result = await commands.setActiveModel(modelId);
|
if (!success) {
|
||||||
if (result.status === "error") {
|
setPendingModelId(null);
|
||||||
const errorMsg = result.error;
|
|
||||||
setModelError(errorMsg);
|
|
||||||
setModelStatus("error");
|
|
||||||
onError?.(errorMsg);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = `${err}`;
|
|
||||||
setModelError(errorMsg);
|
|
||||||
setModelStatus("error");
|
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 getModelDisplayText = (): string => {
|
||||||
const extractingKeys = Object.keys(extractingModels);
|
const extractingKeys = Object.keys(extractingModels);
|
||||||
if (extractingKeys.length > 0) {
|
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 > 0) {
|
||||||
if (progressValues.length === 1) {
|
if (progressValues.length === 1) {
|
||||||
const progress = progressValues[0];
|
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) {
|
switch (modelStatus) {
|
||||||
case "ready":
|
case "ready":
|
||||||
return currentModel
|
return currentModelInfo
|
||||||
? getTranslatedModelName(currentModel, t)
|
? getTranslatedModelName(currentModelInfo, t)
|
||||||
: t("modelSelector.modelReady");
|
: t("modelSelector.modelReady");
|
||||||
case "loading":
|
case "loading":
|
||||||
return currentModel
|
return currentModelInfo
|
||||||
? t("modelSelector.loading", {
|
? t("modelSelector.loading", {
|
||||||
modelName: getTranslatedModelName(currentModel, t),
|
modelName: getTranslatedModelName(currentModelInfo, t),
|
||||||
})
|
})
|
||||||
: t("modelSelector.loadingGeneric");
|
: t("modelSelector.loadingGeneric");
|
||||||
case "extracting":
|
case "extracting":
|
||||||
return currentModel
|
return currentModelInfo
|
||||||
? t("modelSelector.extracting", {
|
? t("modelSelector.extracting", {
|
||||||
modelName: getTranslatedModelName(currentModel, t),
|
modelName: getTranslatedModelName(currentModelInfo, t),
|
||||||
})
|
})
|
||||||
: t("modelSelector.extractingGeneric");
|
: t("modelSelector.extractingGeneric");
|
||||||
case "error":
|
case "error":
|
||||||
return modelError || t("modelSelector.modelError");
|
return modelError || t("modelSelector.modelError");
|
||||||
case "unloaded":
|
case "unloaded":
|
||||||
return currentModel
|
return currentModelInfo
|
||||||
? getTranslatedModelName(currentModel, t)
|
? getTranslatedModelName(currentModelInfo, t)
|
||||||
: t("modelSelector.modelUnloaded");
|
: t("modelSelector.modelUnloaded");
|
||||||
case "none":
|
case "none":
|
||||||
return t("modelSelector.noModelDownloadRequired");
|
return t("modelSelector.noModelDownloadRequired");
|
||||||
default:
|
default:
|
||||||
return currentModel
|
return currentModelInfo
|
||||||
? getTranslatedModelName(currentModel, t)
|
? getTranslatedModelName(currentModelInfo, t)
|
||||||
: t("modelSelector.modelUnloaded");
|
: t("modelSelector.modelUnloaded");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelDelete = async (modelId: string) => {
|
// Derive display status from model status + store state
|
||||||
const result = await commands.deleteModel(modelId);
|
const getDisplayStatus = (): ModelStatus => {
|
||||||
if (result.status === "ok") {
|
if (Object.keys(extractingModels).length > 0) return "extracting";
|
||||||
await loadModels();
|
if (Object.keys(downloadProgress).length > 0) return "downloading";
|
||||||
setModelError(null);
|
return modelStatus;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -412,7 +235,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
{/* Model Status and Switcher */}
|
{/* Model Status and Switcher */}
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<ModelStatusButton
|
<ModelStatusButton
|
||||||
status={modelStatus}
|
status={getDisplayStatus()}
|
||||||
displayText={getModelDisplayText()}
|
displayText={getModelDisplayText()}
|
||||||
isDropdownOpen={showModelDropdown}
|
isDropdownOpen={showModelDropdown}
|
||||||
onClick={() => setShowModelDropdown(!showModelDropdown)}
|
onClick={() => setShowModelDropdown(!showModelDropdown)}
|
||||||
|
|
@ -422,19 +245,15 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
{showModelDropdown && (
|
{showModelDropdown && (
|
||||||
<ModelDropdown
|
<ModelDropdown
|
||||||
models={models}
|
models={models}
|
||||||
currentModelId={currentModelId}
|
currentModelId={displayModelId}
|
||||||
downloadProgress={modelDownloadProgress}
|
|
||||||
onModelSelect={handleModelSelect}
|
onModelSelect={handleModelSelect}
|
||||||
onModelDownload={handleModelDownload}
|
|
||||||
onModelDelete={handleModelDelete}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download Progress Bar for Models */}
|
{/* Download Progress Bar for Models */}
|
||||||
<DownloadProgressDisplay
|
<DownloadProgressDisplay
|
||||||
downloadProgress={modelDownloadProgress}
|
downloadProgress={downloadProgress}
|
||||||
downloadStats={downloadStats}
|
downloadStats={downloadStats}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,285 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 type { ModelInfo } from "@/bindings";
|
||||||
import { formatModelSize } from "../../lib/utils/format";
|
import { formatModelSize } from "../../lib/utils/format";
|
||||||
import {
|
import {
|
||||||
getTranslatedModelName,
|
|
||||||
getTranslatedModelDescription,
|
getTranslatedModelDescription,
|
||||||
|
getTranslatedModelName,
|
||||||
} from "../../lib/utils/modelTranslation";
|
} from "../../lib/utils/modelTranslation";
|
||||||
|
import { LANGUAGES } from "../../lib/constants/languages";
|
||||||
import Badge from "../ui/Badge";
|
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 {
|
interface ModelCardProps {
|
||||||
model: ModelInfo;
|
model: ModelInfo;
|
||||||
variant?: "default" | "featured";
|
variant?: "default" | "featured";
|
||||||
|
status?: ModelCardStatus;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
onSelect: (modelId: string) => void;
|
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> = ({
|
const ModelCard: React.FC<ModelCardProps> = ({
|
||||||
model,
|
model,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
status = "downloadable",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = "",
|
className = "",
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
|
onCancel,
|
||||||
|
downloadProgress,
|
||||||
|
downloadSpeed,
|
||||||
|
showRecommended = true,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isFeatured = variant === "featured";
|
const isFeatured = variant === "featured";
|
||||||
|
const isClickable =
|
||||||
|
status === "available" || status === "active" || status === "downloadable";
|
||||||
|
|
||||||
// Get translated model name and description
|
// Get translated model name and description
|
||||||
const displayName = getTranslatedModelName(model, t);
|
const displayName = getTranslatedModelName(model, t);
|
||||||
const displayDescription = getTranslatedModelDescription(model, t);
|
const displayDescription = getTranslatedModelDescription(model, t);
|
||||||
|
|
||||||
const baseButtonClasses =
|
const baseClasses =
|
||||||
"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";
|
"flex flex-col rounded-xl px-4 py-3 gap-2 text-left transition-all duration-200";
|
||||||
|
|
||||||
const variantClasses = isFeatured
|
const getVariantClasses = () => {
|
||||||
? "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"
|
if (status === "active") {
|
||||||
: "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";
|
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 (
|
return (
|
||||||
<button
|
<div
|
||||||
onClick={() => onSelect(model.id)}
|
onClick={handleClick}
|
||||||
disabled={disabled}
|
onKeyDown={(e) => {
|
||||||
className={[baseButtonClasses, variantClasses, className]
|
if (e.key === "Enter" && isClickable) handleClick();
|
||||||
|
}}
|
||||||
|
role={isClickable ? "button" : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
className={[
|
||||||
|
baseClasses,
|
||||||
|
getVariantClasses(),
|
||||||
|
getInteractiveClasses(),
|
||||||
|
className,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-ce">
|
{/* Top section: name/description + score bars */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex justify-between items-center w-full">
|
||||||
<h3 className="text-lg font-semibold text-text group-hover:text-logo-primary transition-colors">
|
<div className="flex flex-col items-start flex-1 min-w-0">
|
||||||
{displayName}
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
</h3>
|
<h3
|
||||||
<DownloadSize sizeMb={Number(model.size_mb)} />
|
className={`text-base font-semibold text-text ${isClickable ? "group-hover:text-logo-primary" : ""} transition-colors`}
|
||||||
{isFeatured && (
|
>
|
||||||
<Badge variant="primary">{t("onboarding.recommended")}</Badge>
|
{displayName}
|
||||||
)}
|
</h3>
|
||||||
</div>
|
{showRecommended && model.is_recommended && (
|
||||||
<p className="text-text/60 text-sm leading-relaxed">
|
<Badge variant="primary">{t("onboarding.recommended")}</Badge>
|
||||||
{displayDescription}
|
)}
|
||||||
</p>
|
{status === "active" && (
|
||||||
</div>
|
<Badge variant="primary">
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
<div className="space-y-1">
|
{t("modelSelector.active")}
|
||||||
<div className="flex items-center gap-2">
|
</Badge>
|
||||||
<p className="text-xs text-text/70 w-16 text-end">
|
)}
|
||||||
{t("onboarding.modelCard.accuracy")}
|
{status === "switching" && (
|
||||||
</p>
|
<Badge variant="secondary">
|
||||||
<div className="w-20 h-2 bg-mid-gray/20 rounded-full overflow-hidden">
|
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||||
<div
|
{t("modelSelector.switching")}
|
||||||
className="h-full bg-logo-primary rounded-full transition-all duration-300"
|
</Badge>
|
||||||
style={{ width: `${model.accuracy_score * 100}%` }}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-text/60 text-sm leading-relaxed">
|
||||||
<div className="flex items-center gap-2">
|
{displayDescription}
|
||||||
<p className="text-xs text-text/70 w-16 text-end">
|
|
||||||
{t("onboarding.modelCard.speed")}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="w-20 h-2 bg-mid-gray/20 rounded-full overflow-hidden">
|
</div>
|
||||||
<div
|
<div className="hidden sm:flex items-center ml-4">
|
||||||
className="h-full bg-logo-primary rounded-full transition-all duration-300"
|
<div className="space-y-1">
|
||||||
style={{ width: `${model.speed_score * 100}%` }}
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<p className="text-xs text-text/60 w-14 text-right">
|
||||||
|
{t("onboarding.modelCard.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DownloadSize = ({ sizeMb }: { sizeMb: number }) => {
|
<hr className="w-full border-mid-gray/20" />
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
{/* Bottom row: tags + action buttons (full width) */}
|
||||||
<div className="flex items-center gap-1.5 text-xs text-text/60 tabular-nums">
|
<div className="flex items-center gap-3 w-full -mb-0.5 mt-0.5 h-5">
|
||||||
<Download
|
<div
|
||||||
aria-hidden="true"
|
className="flex items-center gap-1 text-xs text-text/50"
|
||||||
className="h-3.5 w-3.5 text-text/45"
|
title={
|
||||||
strokeWidth={1.75}
|
model.supported_languages.length === 1
|
||||||
/>
|
? t("modelSelector.capabilities.singleLanguage")
|
||||||
<span className="sr-only">{t("modelSelector.downloadSize")}</span>
|
: t("modelSelector.capabilities.languageSelection")
|
||||||
<span className="font-medium text-text/70">
|
}
|
||||||
{formatModelSize(sizeMb)}
|
>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 ModelCard from "./ModelCard";
|
||||||
import HandyTextLogo from "../icons/HandyTextLogo";
|
import HandyTextLogo from "../icons/HandyTextLogo";
|
||||||
|
import { useModelStore } from "../../stores/modelStore";
|
||||||
|
|
||||||
interface OnboardingProps {
|
interface OnboardingProps {
|
||||||
onModelSelected: () => void;
|
onModelSelected: () => void;
|
||||||
|
|
@ -10,52 +13,69 @@ interface OnboardingProps {
|
||||||
|
|
||||||
const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
const {
|
||||||
const [downloading, setDownloading] = useState(false);
|
models,
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
loadModels();
|
if (!selectedModelId) return;
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadModels = async () => {
|
const model = models.find((m) => m.id === selectedModelId);
|
||||||
try {
|
const stillDownloading = selectedModelId in downloadingModels;
|
||||||
const result = await commands.getAvailableModels();
|
const stillExtracting = selectedModelId in extractingModels;
|
||||||
if (result.status === "ok") {
|
|
||||||
// Only show downloadable models for onboarding
|
if (model?.is_downloaded && !stillDownloading && !stillExtracting) {
|
||||||
setAvailableModels(result.data.filter((m) => !m.is_downloaded));
|
// Model is ready — select it and transition
|
||||||
} else {
|
selectModel(selectedModelId).then((success) => {
|
||||||
setError(t("onboarding.errors.loadModels"));
|
if (success) {
|
||||||
}
|
onModelSelected();
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error("Failed to load models:", err);
|
toast.error(t("onboarding.errors.selectModel"));
|
||||||
setError(t("onboarding.errors.loadModels"));
|
setSelectedModelId(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [
|
||||||
|
selectedModelId,
|
||||||
|
models,
|
||||||
|
downloadingModels,
|
||||||
|
extractingModels,
|
||||||
|
selectModel,
|
||||||
|
onModelSelected,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDownloadModel = async (modelId: string) => {
|
const handleDownloadModel = async (modelId: string) => {
|
||||||
setDownloading(true);
|
setSelectedModelId(modelId);
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Immediately transition to main app - download will continue in footer
|
const success = await downloadModel(modelId);
|
||||||
onModelSelected();
|
if (!success) {
|
||||||
|
toast.error(t("onboarding.downloadFailed"));
|
||||||
try {
|
setSelectedModelId(null);
|
||||||
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 getRecommendedBadge = (modelId: string): boolean => {
|
const getModelStatus = (modelId: string): ModelCardStatus => {
|
||||||
return modelId === "parakeet-tdt-0.6b-v3";
|
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 (
|
return (
|
||||||
|
|
@ -68,34 +88,41 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-[600px] w-full mx-auto text-center flex-1 flex flex-col min-h-0">
|
<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">
|
<div className="flex flex-col gap-4 pb-6">
|
||||||
{availableModels
|
{models
|
||||||
.filter((model) => getRecommendedBadge(model.id))
|
.filter((m: ModelInfo) => !m.is_downloaded)
|
||||||
.map((model) => (
|
.filter((model: ModelInfo) => model.is_recommended)
|
||||||
|
.map((model: ModelInfo) => (
|
||||||
<ModelCard
|
<ModelCard
|
||||||
key={model.id}
|
key={model.id}
|
||||||
model={model}
|
model={model}
|
||||||
variant="featured"
|
variant="featured"
|
||||||
disabled={downloading}
|
status={getModelStatus(model.id)}
|
||||||
|
disabled={isDownloading}
|
||||||
onSelect={handleDownloadModel}
|
onSelect={handleDownloadModel}
|
||||||
|
onDownload={handleDownloadModel}
|
||||||
|
downloadProgress={getModelDownloadProgress(model.id)}
|
||||||
|
downloadSpeed={getModelDownloadSpeed(model.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{availableModels
|
{models
|
||||||
.filter((model) => !getRecommendedBadge(model.id))
|
.filter((m: ModelInfo) => !m.is_downloaded)
|
||||||
.sort((a, b) => Number(a.size_mb) - Number(b.size_mb))
|
.filter((model: ModelInfo) => !model.is_recommended)
|
||||||
.map((model) => (
|
.sort(
|
||||||
|
(a: ModelInfo, b: ModelInfo) =>
|
||||||
|
Number(a.size_mb) - Number(b.size_mb),
|
||||||
|
)
|
||||||
|
.map((model: ModelInfo) => (
|
||||||
<ModelCard
|
<ModelCard
|
||||||
key={model.id}
|
key={model.id}
|
||||||
model={model}
|
model={model}
|
||||||
disabled={downloading}
|
status={getModelStatus(model.id)}
|
||||||
|
disabled={isDownloading}
|
||||||
onSelect={handleDownloadModel}
|
onSelect={handleDownloadModel}
|
||||||
|
onDownload={handleDownloadModel}
|
||||||
|
downloadProgress={getModelDownloadProgress(model.id)}
|
||||||
|
downloadSpeed={getModelDownloadSpeed(model.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
export { default } from "./Onboarding";
|
export { default } from "./Onboarding";
|
||||||
export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding";
|
export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding";
|
||||||
|
export { default as ModelCard } from "./ModelCard";
|
||||||
|
export type { ModelCardStatus } from "./ModelCard";
|
||||||
|
|
|
||||||
|
|
@ -293,13 +293,13 @@ export const GlobalShortcutInput: React.FC<GlobalShortcutInputProps> = ({
|
||||||
{editingShortcutId === shortcutId ? (
|
{editingShortcutId === shortcutId ? (
|
||||||
<div
|
<div
|
||||||
ref={(ref) => setShortcutRef(shortcutId, ref)}
|
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()}
|
{formatCurrentKeys()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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)}
|
onClick={() => startRecording(shortcutId)}
|
||||||
>
|
>
|
||||||
{formatKeyCombination(binding.current_binding, osType)}
|
{formatKeyCombination(binding.current_binding, osType)}
|
||||||
|
|
|
||||||
|
|
@ -278,13 +278,13 @@ export const HandyKeysShortcutInput: React.FC<HandyKeysShortcutInputProps> = ({
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<div
|
<div
|
||||||
ref={shortcutRef}
|
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()}
|
{formatCurrentKeys()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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}
|
onClick={startRecording}
|
||||||
>
|
>
|
||||||
{formatKeyCombination(binding.current_binding, osType)}
|
{formatKeyCombination(binding.current_binding, osType)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ShowOverlay } from "../ShowOverlay";
|
import { ShowOverlay } from "../ShowOverlay";
|
||||||
import { TranslateToEnglish } from "../TranslateToEnglish";
|
|
||||||
import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout";
|
import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout";
|
||||||
import { CustomWords } from "../CustomWords";
|
import { CustomWords } from "../CustomWords";
|
||||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||||
|
|
@ -9,7 +8,6 @@ import { StartHidden } from "../StartHidden";
|
||||||
import { AutostartToggle } from "../AutostartToggle";
|
import { AutostartToggle } from "../AutostartToggle";
|
||||||
import { PasteMethodSetting } from "../PasteMethod";
|
import { PasteMethodSetting } from "../PasteMethod";
|
||||||
import { ClipboardHandlingSetting } from "../ClipboardHandling";
|
import { ClipboardHandlingSetting } from "../ClipboardHandling";
|
||||||
import { useModelStore } from "../../../stores/modelStore";
|
|
||||||
import { PostProcessingToggle } from "../PostProcessingToggle";
|
import { PostProcessingToggle } from "../PostProcessingToggle";
|
||||||
import { AppendTrailingSpace } from "../AppendTrailingSpace";
|
import { AppendTrailingSpace } from "../AppendTrailingSpace";
|
||||||
import { HistoryLimit } from "../HistoryLimit";
|
import { HistoryLimit } from "../HistoryLimit";
|
||||||
|
|
@ -20,11 +18,7 @@ import { KeyboardImplementationSelector } from "../debug/KeyboardImplementationS
|
||||||
|
|
||||||
export const AdvancedSettings: React.FC = () => {
|
export const AdvancedSettings: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { currentModel, getModelInfo } = useModelStore();
|
|
||||||
const { getSetting } = useSettings();
|
const { getSetting } = useSettings();
|
||||||
const currentModelInfo = getModelInfo(currentModel);
|
|
||||||
const showTranslateToEnglish =
|
|
||||||
currentModelInfo?.engine_type === "Whisper" && currentModel !== "turbo";
|
|
||||||
const experimentalEnabled = getSetting("experimental_enabled") || false;
|
const experimentalEnabled = getSetting("experimental_enabled") || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -43,9 +37,6 @@ export const AdvancedSettings: React.FC = () => {
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup title={t("settings.advanced.groups.transcription")}>
|
<SettingsGroup title={t("settings.advanced.groups.transcription")}>
|
||||||
{showTranslateToEnglish && (
|
|
||||||
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
|
|
||||||
)}
|
|
||||||
<CustomWords descriptionMode="tooltip" grouped />
|
<CustomWords descriptionMode="tooltip" grouped />
|
||||||
<AppendTrailingSpace descriptionMode="tooltip" grouped={true} />
|
<AppendTrailingSpace descriptionMode="tooltip" grouped={true} />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,26 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MicrophoneSelector } from "../MicrophoneSelector";
|
import { MicrophoneSelector } from "../MicrophoneSelector";
|
||||||
import { LanguageSelector } from "../LanguageSelector";
|
|
||||||
import { ShortcutInput } from "../ShortcutInput";
|
import { ShortcutInput } from "../ShortcutInput";
|
||||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||||
import { OutputDeviceSelector } from "../OutputDeviceSelector";
|
import { OutputDeviceSelector } from "../OutputDeviceSelector";
|
||||||
import { PushToTalk } from "../PushToTalk";
|
import { PushToTalk } from "../PushToTalk";
|
||||||
import { AudioFeedback } from "../AudioFeedback";
|
import { AudioFeedback } from "../AudioFeedback";
|
||||||
import { useSettings } from "../../../hooks/useSettings";
|
import { useSettings } from "../../../hooks/useSettings";
|
||||||
import { useModelStore } from "../../../stores/modelStore";
|
|
||||||
import { VolumeSlider } from "../VolumeSlider";
|
import { VolumeSlider } from "../VolumeSlider";
|
||||||
import { MuteWhileRecording } from "../MuteWhileRecording";
|
import { MuteWhileRecording } from "../MuteWhileRecording";
|
||||||
|
import { ModelSettingsCard } from "./ModelSettingsCard";
|
||||||
|
|
||||||
export const GeneralSettings: React.FC = () => {
|
export const GeneralSettings: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { audioFeedbackEnabled } = useSettings();
|
const { audioFeedbackEnabled } = useSettings();
|
||||||
const { currentModel, getModelInfo } = useModelStore();
|
|
||||||
const currentModelInfo = getModelInfo(currentModel);
|
|
||||||
const showLanguageSelector = currentModelInfo?.engine_type === "Whisper";
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||||
<SettingsGroup title={t("settings.general.title")}>
|
<SettingsGroup title={t("settings.general.title")}>
|
||||||
<ShortcutInput shortcutId="transcribe" grouped={true} />
|
<ShortcutInput shortcutId="transcribe" grouped={true} />
|
||||||
{showLanguageSelector && (
|
|
||||||
<LanguageSelector descriptionMode="tooltip" grouped={true} />
|
|
||||||
)}
|
|
||||||
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
<ModelSettingsCard />
|
||||||
<SettingsGroup title={t("settings.sound.title")}>
|
<SettingsGroup title={t("settings.sound.title")}>
|
||||||
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
||||||
<MuteWhileRecording descriptionMode="tooltip" grouped={true} />
|
<MuteWhileRecording descriptionMode="tooltip" grouped={true} />
|
||||||
|
|
|
||||||
39
src/components/settings/general/ModelSettingsCard.tsx
Normal file
39
src/components/settings/general/ModelSettingsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -273,7 +273,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleSaved}
|
onClick={onToggleSaved}
|
||||||
className={`p-2 rounded transition-colors cursor-pointer ${
|
className={`p-2 rounded-md transition-colors cursor-pointer ${
|
||||||
entry.saved
|
entry.saved
|
||||||
? "text-logo-primary hover:text-logo-primary/80"
|
? "text-logo-primary hover:text-logo-primary/80"
|
||||||
: "text-text/50 hover:text-logo-primary"
|
: "text-text/50 hover:text-logo-primary"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export { DebugSettings } from "./debug/DebugSettings";
|
||||||
export { HistorySettings } from "./history/HistorySettings";
|
export { HistorySettings } from "./history/HistorySettings";
|
||||||
export { AboutSettings } from "./about/AboutSettings";
|
export { AboutSettings } from "./about/AboutSettings";
|
||||||
export { PostProcessingSettings } from "./post-processing/PostProcessingSettings";
|
export { PostProcessingSettings } from "./post-processing/PostProcessingSettings";
|
||||||
|
export { ModelsSettings } from "./models/ModelsSettings";
|
||||||
|
|
||||||
// Individual setting components
|
// Individual setting components
|
||||||
export { MicrophoneSelector } from "./MicrophoneSelector";
|
export { MicrophoneSelector } from "./MicrophoneSelector";
|
||||||
|
|
|
||||||
360
src/components/settings/models/ModelsSettings.tsx
Normal file
360
src/components/settings/models/ModelsSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/components/settings/models/index.ts
Normal file
1
src/components/settings/models/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { ModelsSettings } from "./ModelsSettings";
|
||||||
|
|
@ -344,7 +344,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCreating && !selectedPrompt && (
|
{!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">
|
<p className="text-sm text-mid-gray">
|
||||||
{hasPrompts
|
{hasPrompts
|
||||||
? t("settings.postProcessing.prompts.selectToEdit")
|
? t("settings.postProcessing.prompts.selectToEdit")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
||||||
|
|
||||||
interface BadgeProps {
|
interface BadgeProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
variant?: "primary";
|
variant?: "primary" | "success" | "secondary";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13,6 +13,8 @@ const Badge: React.FC<BadgeProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
primary: "bg-logo-primary",
|
primary: "bg-logo-primary",
|
||||||
|
success: "bg-green-500/20 text-green-400",
|
||||||
|
secondary: "bg-mid-gray/20 text-text/70",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
variant?:
|
||||||
|
| "primary"
|
||||||
|
| "primary-soft"
|
||||||
|
| "secondary"
|
||||||
|
| "danger"
|
||||||
|
| "danger-ghost"
|
||||||
|
| "ghost";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13,15 +19,19 @@ export const Button: React.FC<ButtonProps> = ({
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const baseClasses =
|
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 = {
|
const variantClasses = {
|
||||||
primary:
|
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",
|
"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:
|
secondary:
|
||||||
"bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary focus:outline-none",
|
"bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary focus:outline-none",
|
||||||
danger:
|
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",
|
"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:
|
ghost:
|
||||||
"text-current border-transparent hover:bg-mid-gray/10 hover:border-logo-primary focus:bg-mid-gray/20",
|
"text-current border-transparent hover:bg-mid-gray/10 hover:border-logo-primary focus:bg-mid-gray/20",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
type="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
|
disabled
|
||||||
? "opacity-50 cursor-not-allowed"
|
? "opacity-50 cursor-not-allowed"
|
||||||
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
|
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
|
||||||
|
|
@ -86,7 +86,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{isOpen && !disabled && (
|
{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 ? (
|
{options.length === 0 ? (
|
||||||
<div className="px-2 py-1 text-sm text-mid-gray">
|
<div className="px-2 py-1 text-sm text-mid-gray">
|
||||||
{t("common.noOptionsFound")}
|
{t("common.noOptionsFound")}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const Input: React.FC<InputProps> = ({
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const baseClasses =
|
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
|
const interactiveClasses = disabled
|
||||||
? "opacity-60 cursor-not-allowed bg-mid-gray/10 border-mid-gray/40"
|
? "opacity-60 cursor-not-allowed bg-mid-gray/10 border-mid-gray/40"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export const PathDisplay: React.FC<PathDisplayProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<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}
|
{path}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const ResetButton: React.FC<ResetButtonProps> = React.memo(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={ariaLabel}
|
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
|
disabled
|
||||||
? "opacity-50 cursor-not-allowed text-text/40"
|
? "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"
|
: "hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] hover:cursor-pointer hover:border-logo-primary text-text/80"
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export const TextDisplay: React.FC<TextDisplayProps> = ({
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<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}
|
{displayValue}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -63,7 +63,7 @@ export const TextDisplay: React.FC<TextDisplayProps> = ({
|
||||||
{copyable && value && (
|
{copyable && value && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
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"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
{showCopied ? (
|
{showCopied ? (
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const Textarea: React.FC<TextareaProps> = ({
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const baseClasses =
|
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 = {
|
const variantClasses = {
|
||||||
default: "px-3 py-2 min-h-[100px]",
|
default: "px-3 py-2 min-h-[100px]",
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,15 @@
|
||||||
"postProcessing": "معالجة لاحقة",
|
"postProcessing": "معالجة لاحقة",
|
||||||
"history": "السجل",
|
"history": "السجل",
|
||||||
"debug": "تصحيح الأخطاء",
|
"debug": "تصحيح الأخطاء",
|
||||||
"about": "حول"
|
"about": "حول",
|
||||||
|
"models": "النماذج"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"subtitle": "للبدء، اختر نموذج التفريغ الصوتي",
|
"subtitle": "للبدء، اختر نموذج التفريغ الصوتي",
|
||||||
"recommended": "موصى به",
|
"recommended": "موصى به",
|
||||||
"download": "تنزيل",
|
"download": "تنزيل",
|
||||||
"downloading": "...جاري التنزيل",
|
"downloading": "...جاري التنزيل",
|
||||||
|
"downloadFailed": "فشل التنزيل. يرجى المحاولة مرة أخرى.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "الدقة",
|
"accuracy": "الدقة",
|
||||||
"speed": "السرعة"
|
"speed": "السرعة"
|
||||||
|
|
@ -79,14 +81,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"modelSelector": {
|
||||||
"welcome": "مرحباً بك في Handy!",
|
|
||||||
"downloadPrompt": ".قم بتنزيل نموذج أدناه للبدء في التفريغ الصوتي",
|
|
||||||
"availableModels": "النماذج المتاحة",
|
|
||||||
"downloadModels": "تنزيل النماذج",
|
|
||||||
"chooseModel": "اختر نموذجاً",
|
|
||||||
"active": "نشط",
|
"active": "نشط",
|
||||||
"download": "تنزيل",
|
|
||||||
"downloadSize": "حجم التنزيل",
|
|
||||||
"noModelsAvailable": "لا توجد نماذج متاحة",
|
"noModelsAvailable": "لا توجد نماذج متاحة",
|
||||||
"extracting": "...جاري استخراج {{modelName}}",
|
"extracting": "...جاري استخراج {{modelName}}",
|
||||||
"extractingMultiple": "...جاري استخراج {{count}} من النماذج",
|
"extractingMultiple": "...جاري استخراج {{count}} من النماذج",
|
||||||
|
|
@ -99,7 +94,19 @@
|
||||||
"modelError": "خطأ في النموذج",
|
"modelError": "خطأ في النموذج",
|
||||||
"modelUnloaded": "تم إلغاء تحميل النموذج",
|
"modelUnloaded": "تم إلغاء تحميل النموذج",
|
||||||
"noModelDownloadRequired": "لا يوجد نموذج - التنزيل مطلوب",
|
"noModelDownloadRequired": "لا يوجد نموذج - التنزيل مطلوب",
|
||||||
"deleteModel": "حذف {{modelName}}"
|
"deleteModel": "حذف {{modelName}}",
|
||||||
|
"switching": "جارٍ التبديل...",
|
||||||
|
"downloadSpeed": "{{speed}} ميجابايت/ث",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"cancelDownload": "إلغاء التنزيل",
|
||||||
|
"capabilities": {
|
||||||
|
"languageSelection": "يدعم اختيار اللغة",
|
||||||
|
"singleLanguage": "يدعم هذه اللغة فقط",
|
||||||
|
"multiLanguage": "متعدد اللغات",
|
||||||
|
"languageOnly": "{{language}} فقط",
|
||||||
|
"translation": "يدعم الترجمة",
|
||||||
|
"translate": "ترجمة"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"general": {
|
"general": {
|
||||||
|
|
@ -119,6 +126,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "اختصار الإلغاء",
|
"name": "اختصار الإلغاء",
|
||||||
"description": ".اختصار لوحة المفاتيح لإلغاء التسجيل الحالي"
|
"description": ".اختصار لوحة المفاتيح لإلغاء التسجيل الحالي"
|
||||||
|
},
|
||||||
|
"transcribe_with_post_process": {
|
||||||
|
"name": "مفتاح المعالجة اللاحقة",
|
||||||
|
"description": "اختياري: مفتاح اختصار مخصص يطبق دائماً المعالجة اللاحقة بالذكاء الاصطناعي على التفريغ الصوتي."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|
@ -241,6 +252,9 @@
|
||||||
},
|
},
|
||||||
"postProcessing": {
|
"postProcessing": {
|
||||||
"title": "معالجة لاحقة",
|
"title": "معالجة لاحقة",
|
||||||
|
"hotkey": {
|
||||||
|
"title": "مفتاح الاختصار"
|
||||||
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"title": "API (متوافق مع OpenAI)",
|
"title": "API (متوافق مع OpenAI)",
|
||||||
"provider": {
|
"provider": {
|
||||||
|
|
@ -373,6 +387,10 @@
|
||||||
"appData": "بيانات التطبيق:",
|
"appData": "بيانات التطبيق:",
|
||||||
"models": "النماذج:",
|
"models": "النماذج:",
|
||||||
"settings": "الإعدادات:"
|
"settings": "الإعدادات:"
|
||||||
|
},
|
||||||
|
"pasteDelay": {
|
||||||
|
"title": "تأخير اللصق",
|
||||||
|
"description": "التأخير قبل إرسال ضغطة مفتاح اللصق (بالمللي ثانية). قم بزيادتها إذا تم لصق نص خاطئ."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|
@ -403,6 +421,28 @@
|
||||||
"details": ".يستخدم Handy برنامج Whisper.cpp لمعالجة سريعة ومحلية لتحويل الكلام إلى نص. شكراً للعمل الرائع الذي قام به Georgi Gerganov والمساهمون"
|
"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": {
|
"footer": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Obecné",
|
"general": "Obecné",
|
||||||
|
"models": "Modely",
|
||||||
"advanced": "Pokročilé",
|
"advanced": "Pokročilé",
|
||||||
"postProcessing": "Následné zpracování",
|
"postProcessing": "Následné zpracování",
|
||||||
"history": "Historie",
|
"history": "Historie",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Doporučeno",
|
"recommended": "Doporučeno",
|
||||||
"download": "Stáhnout",
|
"download": "Stáhnout",
|
||||||
"downloading": "Stahování...",
|
"downloading": "Stahování...",
|
||||||
|
"downloadFailed": "Stahování se nezdařilo. Zkuste to prosím znovu.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "přesnost",
|
"accuracy": "přesnost",
|
||||||
"speed": "rychlost"
|
"speed": "rychlost"
|
||||||
|
|
@ -56,17 +58,30 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadModels": "Nepodařilo se načíst dostupné modely",
|
"loadModels": "Nepodařilo se načíst dostupné modely",
|
||||||
"downloadModel": "Nepodařilo se stáhnout model: {{error}}"
|
"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": {
|
"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í",
|
"active": "Aktivní",
|
||||||
"download": "Stáhnout",
|
|
||||||
"downloadSize": "Velikost stažení",
|
|
||||||
"noModelsAvailable": "Nejsou dostupné žádné modely",
|
"noModelsAvailable": "Nejsou dostupné žádné modely",
|
||||||
"extracting": "Rozbaluji {{modelName}}...",
|
"extracting": "Rozbaluji {{modelName}}...",
|
||||||
"extractingMultiple": "Rozbaluji {{count}} modelů...",
|
"extractingMultiple": "Rozbaluji {{count}} modelů...",
|
||||||
|
|
@ -79,9 +94,43 @@
|
||||||
"modelError": "Chyba modelu",
|
"modelError": "Chyba modelu",
|
||||||
"modelUnloaded": "Model uvolněn",
|
"modelUnloaded": "Model uvolněn",
|
||||||
"noModelDownloadRequired": "Žádný model - je nutné stáhnout",
|
"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": {
|
"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": {
|
"general": {
|
||||||
"title": "Obecné",
|
"title": "Obecné",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -99,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Zkratka zrušení",
|
"name": "Zkratka zrušení",
|
||||||
"description": "Klávesová zkratka pro zrušení aktuálního nahrávání."
|
"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": {
|
"errors": {
|
||||||
|
|
@ -221,6 +274,9 @@
|
||||||
},
|
},
|
||||||
"postProcessing": {
|
"postProcessing": {
|
||||||
"title": "Následné zpracování",
|
"title": "Následné zpracování",
|
||||||
|
"hotkey": {
|
||||||
|
"title": "Klávesová zkratka"
|
||||||
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"title": "API (kompatibilní s OpenAI)",
|
"title": "API (kompatibilní s OpenAI)",
|
||||||
"provider": {
|
"provider": {
|
||||||
|
|
@ -344,10 +400,19 @@
|
||||||
"label": "Přidat koncovou mezeru",
|
"label": "Přidat koncovou mezeru",
|
||||||
"description": "Přidat mezeru po vloženém přepisu"
|
"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": {
|
"paths": {
|
||||||
"appData": "Data aplikace:",
|
"appData": "Data aplikace:",
|
||||||
"models": "Modely:",
|
"models": "Modely:",
|
||||||
"settings": "Nastavení:"
|
"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": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
|
"models": "Modelle",
|
||||||
"advanced": "Erweitert",
|
"advanced": "Erweitert",
|
||||||
"postProcessing": "Nachbearbeitung",
|
"postProcessing": "Nachbearbeitung",
|
||||||
"history": "Verlauf",
|
"history": "Verlauf",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Empfohlen",
|
"recommended": "Empfohlen",
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloading": "Wird heruntergeladen...",
|
"downloading": "Wird heruntergeladen...",
|
||||||
|
"downloadFailed": "Download fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "Genauigkeit",
|
"accuracy": "Genauigkeit",
|
||||||
"speed": "Geschwindigkeit"
|
"speed": "Geschwindigkeit"
|
||||||
|
|
@ -71,18 +73,16 @@
|
||||||
"grant": "Berechtigung erteilen",
|
"grant": "Berechtigung erteilen",
|
||||||
"granted": "Erteilt",
|
"granted": "Erteilt",
|
||||||
"waiting": "Warten...",
|
"waiting": "Warten...",
|
||||||
"allGranted": "Alles bereit!"
|
"allGranted": "Alles bereit!",
|
||||||
|
"errors": {
|
||||||
|
"checkFailed": "Berechtigungsprüfung fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"requestFailed": "Berechtigungsanfrage fehlgeschlagen. Bitte erneut versuchen."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"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",
|
"active": "Aktiv",
|
||||||
"download": "Herunterladen",
|
"switching": "Wechseln...",
|
||||||
"downloadSize": "Downloadgröße",
|
|
||||||
"noModelsAvailable": "Keine Modelle verfügbar",
|
"noModelsAvailable": "Keine Modelle verfügbar",
|
||||||
"extracting": "Entpacke {{modelName}}...",
|
"extracting": "Entpacke {{modelName}}...",
|
||||||
"extractingMultiple": "Entpacke {{count}} Modelle...",
|
"extractingMultiple": "Entpacke {{count}} Modelle...",
|
||||||
|
|
@ -95,9 +95,42 @@
|
||||||
"modelError": "Modellfehler",
|
"modelError": "Modellfehler",
|
||||||
"modelUnloaded": "Modell entladen",
|
"modelUnloaded": "Modell entladen",
|
||||||
"noModelDownloadRequired": "Kein Modell - Download erforderlich",
|
"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": {
|
"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": {
|
"general": {
|
||||||
"title": "Allgemein",
|
"title": "Allgemein",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -115,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Abbrechen-Tastenkürzel",
|
"name": "Abbrechen-Tastenkürzel",
|
||||||
"description": "Das Tastenkürzel zum Abbrechen der aktuellen Aufnahme."
|
"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": {
|
"errors": {
|
||||||
|
|
@ -363,10 +400,19 @@
|
||||||
"label": "Leerzeichen anhängen",
|
"label": "Leerzeichen anhängen",
|
||||||
"description": "Leerzeichen nach eingefügter Transkription hinzufügen"
|
"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": {
|
"paths": {
|
||||||
"appData": "App-Daten:",
|
"appData": "App-Daten:",
|
||||||
"models": "Modelle:",
|
"models": "Modelle:",
|
||||||
"settings": "Einstellungen:"
|
"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": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
|
"models": "Models",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced",
|
||||||
"postProcessing": "Post Process",
|
"postProcessing": "Post Process",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Recommended",
|
"recommended": "Recommended",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloading": "Downloading...",
|
"downloading": "Downloading...",
|
||||||
|
"downloadFailed": "Download failed. Please try again.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "accuracy",
|
"accuracy": "accuracy",
|
||||||
"speed": "speed"
|
"speed": "speed"
|
||||||
|
|
@ -79,14 +81,8 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"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",
|
"active": "Active",
|
||||||
"download": "Download",
|
"switching": "Switching...",
|
||||||
"downloadSize": "Download size",
|
|
||||||
"noModelsAvailable": "No models available",
|
"noModelsAvailable": "No models available",
|
||||||
"extracting": "Extracting {{modelName}}...",
|
"extracting": "Extracting {{modelName}}...",
|
||||||
"extractingMultiple": "Extracting {{count}} models...",
|
"extractingMultiple": "Extracting {{count}} models...",
|
||||||
|
|
@ -99,9 +95,24 @@
|
||||||
"modelError": "Model Error",
|
"modelError": "Model Error",
|
||||||
"modelUnloaded": "Model Unloaded",
|
"modelUnloaded": "Model Unloaded",
|
||||||
"noModelDownloadRequired": "No Model - Download Required",
|
"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": {
|
"settings": {
|
||||||
|
"modelSettings": {
|
||||||
|
"title": "{{model}} Settings",
|
||||||
|
"noSettingsNeeded": "This model works automatically with no configuration needed."
|
||||||
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "General",
|
"title": "General",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -144,6 +155,24 @@
|
||||||
"description": "Hold to record, release to stop"
|
"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": {
|
"sound": {
|
||||||
"title": "Sound",
|
"title": "Sound",
|
||||||
"microphone": {
|
"microphone": {
|
||||||
|
|
@ -384,6 +413,10 @@
|
||||||
"appData": "App Data:",
|
"appData": "App Data:",
|
||||||
"models": "Models:",
|
"models": "Models:",
|
||||||
"settings": "Settings:"
|
"settings": "Settings:"
|
||||||
|
},
|
||||||
|
"pasteDelay": {
|
||||||
|
"title": "Paste Delay",
|
||||||
|
"description": "Delay before sending paste keystroke (in milliseconds). Increase if wrong text is being pasted."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
|
"models": "Modelos",
|
||||||
"advanced": "Avanzado",
|
"advanced": "Avanzado",
|
||||||
"postProcessing": "Post Proceso",
|
"postProcessing": "Post Proceso",
|
||||||
"history": "Historial",
|
"history": "Historial",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Recomendado",
|
"recommended": "Recomendado",
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"downloading": "Descargando...",
|
"downloading": "Descargando...",
|
||||||
|
"downloadFailed": "La descarga falló. Por favor, inténtalo de nuevo.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "precisión",
|
"accuracy": "precisión",
|
||||||
"speed": "velocidad"
|
"speed": "velocidad"
|
||||||
|
|
@ -71,18 +73,16 @@
|
||||||
"grant": "Conceder Permiso",
|
"grant": "Conceder Permiso",
|
||||||
"granted": "Concedido",
|
"granted": "Concedido",
|
||||||
"waiting": "Esperando...",
|
"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": {
|
"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",
|
"active": "Activo",
|
||||||
"download": "Descargar",
|
"switching": "Cambiando...",
|
||||||
"downloadSize": "Tamaño de descarga",
|
|
||||||
"noModelsAvailable": "No hay modelos disponibles",
|
"noModelsAvailable": "No hay modelos disponibles",
|
||||||
"extracting": "Extrayendo {{modelName}}...",
|
"extracting": "Extrayendo {{modelName}}...",
|
||||||
"extractingMultiple": "Extrayendo {{count}} modelos...",
|
"extractingMultiple": "Extrayendo {{count}} modelos...",
|
||||||
|
|
@ -95,9 +95,42 @@
|
||||||
"modelError": "Error del Modelo",
|
"modelError": "Error del Modelo",
|
||||||
"modelUnloaded": "Modelo Descargado",
|
"modelUnloaded": "Modelo Descargado",
|
||||||
"noModelDownloadRequired": "Sin Modelo - Descarga Requerida",
|
"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": {
|
"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": {
|
"general": {
|
||||||
"title": "General",
|
"title": "General",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -115,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Atajo de Cancelar",
|
"name": "Atajo de Cancelar",
|
||||||
"description": "El atajo de teclado para cancelar la grabación actual."
|
"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": {
|
"errors": {
|
||||||
|
|
@ -363,10 +400,19 @@
|
||||||
"label": "Agregar Espacio Final",
|
"label": "Agregar Espacio Final",
|
||||||
"description": "Agregar un espacio después de la transcripción pegada"
|
"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": {
|
"paths": {
|
||||||
"appData": "Datos de la Aplicación:",
|
"appData": "Datos de la Aplicación:",
|
||||||
"models": "Modelos:",
|
"models": "Modelos:",
|
||||||
"settings": "Configuración:"
|
"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": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"_comment": "French translation for Handy. Contribute at: https://github.com/cjpais/Handy",
|
|
||||||
"tray": {
|
"tray": {
|
||||||
"settings": "Paramètres...",
|
"settings": "Paramètres...",
|
||||||
"checkUpdates": "Rechercher des mises à jour...",
|
"checkUpdates": "Rechercher des mises à jour...",
|
||||||
|
|
@ -9,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
|
"models": "Modèles",
|
||||||
"advanced": "Avancé",
|
"advanced": "Avancé",
|
||||||
"postProcessing": "Post-traitement",
|
"postProcessing": "Post-traitement",
|
||||||
"history": "Historique",
|
"history": "Historique",
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"recommended": "Recommandé",
|
"recommended": "Recommandé",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"downloading": "Téléchargement...",
|
"downloading": "Téléchargement...",
|
||||||
|
"downloadFailed": "Échec du téléchargement. Veuillez réessayer.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "précision",
|
"accuracy": "précision",
|
||||||
"speed": "vitesse"
|
"speed": "vitesse"
|
||||||
|
|
@ -72,18 +73,16 @@
|
||||||
"grant": "Accorder l'autorisation",
|
"grant": "Accorder l'autorisation",
|
||||||
"granted": "Accordé",
|
"granted": "Accordé",
|
||||||
"waiting": "En attente...",
|
"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": {
|
"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",
|
"active": "Actif",
|
||||||
"download": "Télécharger",
|
"switching": "Changement...",
|
||||||
"downloadSize": "Taille du téléchargement",
|
|
||||||
"noModelsAvailable": "Aucun modèle disponible",
|
"noModelsAvailable": "Aucun modèle disponible",
|
||||||
"extracting": "Extraction de {{modelName}}...",
|
"extracting": "Extraction de {{modelName}}...",
|
||||||
"extractingMultiple": "Extraction de {{count}} modèles...",
|
"extractingMultiple": "Extraction de {{count}} modèles...",
|
||||||
|
|
@ -96,9 +95,42 @@
|
||||||
"modelError": "Erreur du Modèle",
|
"modelError": "Erreur du Modèle",
|
||||||
"modelUnloaded": "Modèle Déchargé",
|
"modelUnloaded": "Modèle Déchargé",
|
||||||
"noModelDownloadRequired": "Aucun Modèle - Téléchargement Requis",
|
"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": {
|
"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": {
|
"general": {
|
||||||
"title": "Général",
|
"title": "Général",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -116,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Raccourci d'Annulation",
|
"name": "Raccourci d'Annulation",
|
||||||
"description": "Le raccourci clavier pour annuler l'enregistrement en cours."
|
"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": {
|
"errors": {
|
||||||
|
|
@ -364,10 +400,19 @@
|
||||||
"label": "Ajouter un espace final",
|
"label": "Ajouter un espace final",
|
||||||
"description": "Ajouter un espace après la transcription collée"
|
"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": {
|
"paths": {
|
||||||
"appData": "Données de l'application :",
|
"appData": "Données de l'application :",
|
||||||
"models": "Modèles :",
|
"models": "Modèles :",
|
||||||
"settings": "Paramètres :"
|
"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": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Generale",
|
"general": "Generale",
|
||||||
|
"models": "Modelli",
|
||||||
"advanced": "Avanzate",
|
"advanced": "Avanzate",
|
||||||
"postProcessing": "Post-Elaborazione",
|
"postProcessing": "Post-Elaborazione",
|
||||||
"history": "Cronologia",
|
"history": "Cronologia",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Raccomandato",
|
"recommended": "Raccomandato",
|
||||||
"download": "Scaricare",
|
"download": "Scaricare",
|
||||||
"downloading": "Download in corso...",
|
"downloading": "Download in corso...",
|
||||||
|
"downloadFailed": "Download fallito. Riprova.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "accuratezza",
|
"accuracy": "accuratezza",
|
||||||
"speed": "velocità"
|
"speed": "velocità"
|
||||||
|
|
@ -71,18 +73,16 @@
|
||||||
"grant": "Concedi Permesso",
|
"grant": "Concedi Permesso",
|
||||||
"granted": "Concesso",
|
"granted": "Concesso",
|
||||||
"waiting": "In attesa...",
|
"waiting": "In attesa...",
|
||||||
"allGranted": "Tutto pronto!"
|
"allGranted": "Tutto pronto!",
|
||||||
|
"errors": {
|
||||||
|
"checkFailed": "Impossibile verificare i permessi. Riprova.",
|
||||||
|
"requestFailed": "Impossibile richiedere il permesso. Riprova."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"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",
|
"active": "Attivo",
|
||||||
"download": "Scarica",
|
"switching": "Cambio...",
|
||||||
"downloadSize": "Dimensione download",
|
|
||||||
"noModelsAvailable": "Nessun modello disponibile",
|
"noModelsAvailable": "Nessun modello disponibile",
|
||||||
"extracting": "Estrazione di {{modelName}}...",
|
"extracting": "Estrazione di {{modelName}}...",
|
||||||
"extractingMultiple": "Estrazione di {{count}} modelli...",
|
"extractingMultiple": "Estrazione di {{count}} modelli...",
|
||||||
|
|
@ -95,9 +95,42 @@
|
||||||
"modelError": "Errore del Modello",
|
"modelError": "Errore del Modello",
|
||||||
"modelUnloaded": "Modello Disattivato",
|
"modelUnloaded": "Modello Disattivato",
|
||||||
"noModelDownloadRequired": "Nessun Modello - Download Richiesto",
|
"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": {
|
"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": {
|
"general": {
|
||||||
"title": "Generale",
|
"title": "Generale",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -115,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Scorciatoia Annulla",
|
"name": "Scorciatoia Annulla",
|
||||||
"description": "La scorciatoia da tastiera per annullare la registrazione in corso."
|
"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": {
|
"errors": {
|
||||||
|
|
@ -363,10 +400,19 @@
|
||||||
"label": "Aggiungi Spazio Finale",
|
"label": "Aggiungi Spazio Finale",
|
||||||
"description": "Aggiungi uno spazio dopo la trascrizione incollata"
|
"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": {
|
"paths": {
|
||||||
"appData": "Dati App:",
|
"appData": "Dati App:",
|
||||||
"models": "Modelli:",
|
"models": "Modelli:",
|
||||||
"settings": "Impostazioni:"
|
"settings": "Impostazioni:"
|
||||||
|
},
|
||||||
|
"pasteDelay": {
|
||||||
|
"title": "Ritardo incolla",
|
||||||
|
"description": "Ritardo prima dell'invio del tasto incolla (in millisecondi). Aumentare se viene incollato il testo sbagliato."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "一般",
|
"general": "一般",
|
||||||
|
"models": "モデル",
|
||||||
"advanced": "詳細設定",
|
"advanced": "詳細設定",
|
||||||
"postProcessing": "後処理",
|
"postProcessing": "後処理",
|
||||||
"history": "履歴",
|
"history": "履歴",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "おすすめ",
|
"recommended": "おすすめ",
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"downloading": "ダウンロード中...",
|
"downloading": "ダウンロード中...",
|
||||||
|
"downloadFailed": "ダウンロードに失敗しました。もう一度お試しください。",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "精度",
|
"accuracy": "精度",
|
||||||
"speed": "速度"
|
"speed": "速度"
|
||||||
|
|
@ -71,18 +73,16 @@
|
||||||
"grant": "権限を許可",
|
"grant": "権限を許可",
|
||||||
"granted": "許可済み",
|
"granted": "許可済み",
|
||||||
"waiting": "待機中...",
|
"waiting": "待機中...",
|
||||||
"allGranted": "準備完了!"
|
"allGranted": "準備完了!",
|
||||||
|
"errors": {
|
||||||
|
"checkFailed": "権限の確認に失敗しました。もう一度お試しください。",
|
||||||
|
"requestFailed": "権限のリクエストに失敗しました。もう一度お試しください。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"modelSelector": {
|
||||||
"welcome": "Handyへようこそ!",
|
|
||||||
"downloadPrompt": "文字起こしを開始するには、下からモデルをダウンロードしてください。",
|
|
||||||
"availableModels": "利用可能なモデル",
|
|
||||||
"downloadModels": "モデルをダウンロード",
|
|
||||||
"chooseModel": "モデルを選択",
|
|
||||||
"active": "アクティブ",
|
"active": "アクティブ",
|
||||||
"download": "ダウンロード",
|
"switching": "切替中...",
|
||||||
"downloadSize": "ダウンロードサイズ",
|
|
||||||
"noModelsAvailable": "利用可能なモデルがありません",
|
"noModelsAvailable": "利用可能なモデルがありません",
|
||||||
"extracting": "{{modelName}}を展開中...",
|
"extracting": "{{modelName}}を展開中...",
|
||||||
"extractingMultiple": "{{count}}個のモデルを展開中...",
|
"extractingMultiple": "{{count}}個のモデルを展開中...",
|
||||||
|
|
@ -95,9 +95,42 @@
|
||||||
"modelError": "モデルエラー",
|
"modelError": "モデルエラー",
|
||||||
"modelUnloaded": "モデルがアンロードされました",
|
"modelUnloaded": "モデルがアンロードされました",
|
||||||
"noModelDownloadRequired": "モデルなし - ダウンロードが必要",
|
"noModelDownloadRequired": "モデルなし - ダウンロードが必要",
|
||||||
"deleteModel": "{{modelName}}を削除"
|
"deleteModel": "{{modelName}}を削除",
|
||||||
|
"downloadSpeed": "{{speed}} MB/s",
|
||||||
|
"capabilities": {
|
||||||
|
"languageSelection": "複数の入力言語をサポート",
|
||||||
|
"multiLanguage": "多言語",
|
||||||
|
"translation": "英語への翻訳が可能",
|
||||||
|
"translate": "英語に翻訳",
|
||||||
|
"singleLanguage": "この言語のみ対応",
|
||||||
|
"languageOnly": "{{language}}のみ"
|
||||||
|
},
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"cancelDownload": "ダウンロードをキャンセル"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"modelSettings": {
|
||||||
|
"title": "{{model}} 設定",
|
||||||
|
"noSettingsNeeded": "このモデルは設定不要で自動的に動作します。"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"title": "転写モデル",
|
||||||
|
"description": "転写モデルを選択するか、追加のモデルをダウンロードします。異なるモデルは精度と速度のレベルが異なります。",
|
||||||
|
"downloaded": "ダウンロード済み",
|
||||||
|
"available": "ダウンロード可能",
|
||||||
|
"deleteConfirm": "{{modelName}}を削除してもよろしいですか?再度使用するにはダウンロードが必要です。",
|
||||||
|
"deleteActiveConfirm": "{{modelName}}は現在使用中のモデルです。削除すると、新しいモデルを選択するまで文字起こしが停止します。本当に削除しますか?",
|
||||||
|
"deleteTitle": "モデルを削除",
|
||||||
|
"filters": {
|
||||||
|
"all": "すべて",
|
||||||
|
"multiLanguage": "多言語",
|
||||||
|
"translation": "翻訳",
|
||||||
|
"allLanguages": "すべての言語"
|
||||||
|
},
|
||||||
|
"noModelsMatch": "このフィルターに一致するモデルがありません。",
|
||||||
|
"yourModels": "ダウンロード済みモデル",
|
||||||
|
"availableModels": "ダウンロード可能"
|
||||||
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "一般",
|
"title": "一般",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -115,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "キャンセルショートカット",
|
"name": "キャンセルショートカット",
|
||||||
"description": "現在の録音をキャンセルするためのキーボードショートカット。"
|
"description": "現在の録音をキャンセルするためのキーボードショートカット。"
|
||||||
|
},
|
||||||
|
"transcribe_with_post_process": {
|
||||||
|
"name": "後処理ホットキー",
|
||||||
|
"description": "オプション:文字起こしに常にAI後処理を適用する専用ホットキー。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|
@ -363,10 +400,19 @@
|
||||||
"label": "末尾にスペースを追加",
|
"label": "末尾にスペースを追加",
|
||||||
"description": "貼り付けた文字起こしの後にスペースを追加"
|
"description": "貼り付けた文字起こしの後にスペースを追加"
|
||||||
},
|
},
|
||||||
|
"keyboardImplementation": {
|
||||||
|
"title": "Keyboard Implementation",
|
||||||
|
"description": "Choose the keyboard shortcut backend.",
|
||||||
|
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
|
||||||
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"appData": "アプリデータ:",
|
"appData": "アプリデータ:",
|
||||||
"models": "モデル:",
|
"models": "モデル:",
|
||||||
"settings": "設定:"
|
"settings": "設定:"
|
||||||
|
},
|
||||||
|
"pasteDelay": {
|
||||||
|
"title": "貼り付け遅延",
|
||||||
|
"description": "貼り付けキー送信前の遅延(ミリ秒)。間違ったテキストが貼り付けられる場合は増やしてください。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "일반",
|
"general": "일반",
|
||||||
|
"models": "모델",
|
||||||
"advanced": "고급",
|
"advanced": "고급",
|
||||||
"postProcessing": "후처리",
|
"postProcessing": "후처리",
|
||||||
"history": "히스토리",
|
"history": "히스토리",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "추천",
|
"recommended": "추천",
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"downloading": "다운로드 중...",
|
"downloading": "다운로드 중...",
|
||||||
|
"downloadFailed": "다운로드에 실패했습니다. 다시 시도해주세요.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "정확도",
|
"accuracy": "정확도",
|
||||||
"speed": "속도"
|
"speed": "속도"
|
||||||
|
|
@ -79,14 +81,8 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"modelSelector": {
|
||||||
"welcome": "Handy에 오신 것을 환영합니다!",
|
|
||||||
"downloadPrompt": "음성 인식을 시작하려면 아래에서 모델을 다운로드하세요.",
|
|
||||||
"availableModels": "사용 가능한 모델",
|
|
||||||
"downloadModels": "모델 다운로드",
|
|
||||||
"chooseModel": "모델 선택",
|
|
||||||
"active": "활성",
|
"active": "활성",
|
||||||
"download": "다운로드",
|
"switching": "전환 중...",
|
||||||
"downloadSize": "다운로드 크기",
|
|
||||||
"noModelsAvailable": "사용 가능한 모델이 없습니다",
|
"noModelsAvailable": "사용 가능한 모델이 없습니다",
|
||||||
"extracting": "{{modelName}} 압축 해제 중...",
|
"extracting": "{{modelName}} 압축 해제 중...",
|
||||||
"extractingMultiple": "{{count}}개 모델 압축 해제 중...",
|
"extractingMultiple": "{{count}}개 모델 압축 해제 중...",
|
||||||
|
|
@ -99,9 +95,24 @@
|
||||||
"modelError": "모델 오류",
|
"modelError": "모델 오류",
|
||||||
"modelUnloaded": "모델 언로드됨",
|
"modelUnloaded": "모델 언로드됨",
|
||||||
"noModelDownloadRequired": "모델 없음 - 다운로드 필요",
|
"noModelDownloadRequired": "모델 없음 - 다운로드 필요",
|
||||||
"deleteModel": "{{modelName}} 삭제"
|
"deleteModel": "{{modelName}} 삭제",
|
||||||
|
"downloadSpeed": "{{speed}} MB/s",
|
||||||
|
"cancel": "취소",
|
||||||
|
"cancelDownload": "다운로드 취소",
|
||||||
|
"capabilities": {
|
||||||
|
"languageSelection": "여러 입력 언어를 지원합니다",
|
||||||
|
"singleLanguage": "이 언어만 지원합니다",
|
||||||
|
"multiLanguage": "다국어",
|
||||||
|
"languageOnly": "{{language}} 전용",
|
||||||
|
"translation": "영어로 번역 가능",
|
||||||
|
"translate": "영어로 번역"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"modelSettings": {
|
||||||
|
"title": "{{model}} 설정",
|
||||||
|
"noSettingsNeeded": "이 모델은 별도의 설정 없이 자동으로 작동합니다."
|
||||||
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "일반",
|
"title": "일반",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -119,6 +130,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "취소 단축키",
|
"name": "취소 단축키",
|
||||||
"description": "현재 녹음을 취소하는 키보드 단축키입니다."
|
"description": "현재 녹음을 취소하는 키보드 단축키입니다."
|
||||||
|
},
|
||||||
|
"transcribe_with_post_process": {
|
||||||
|
"name": "후처리 단축키",
|
||||||
|
"description": "선택 사항: 항상 AI 후처리를 적용하는 전용 단축키입니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|
@ -163,6 +178,24 @@
|
||||||
"description": "오디오 피드백 사운드의 볼륨 조절"
|
"description": "오디오 피드백 사운드의 볼륨 조절"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"models": {
|
||||||
|
"title": "음성 인식 모델",
|
||||||
|
"description": "음성 인식 모델을 선택하거나 추가 모델을 다운로드하세요. 모델마다 정확도와 속도가 다릅니다.",
|
||||||
|
"yourModels": "다운로드된 모델",
|
||||||
|
"availableModels": "다운로드 가능",
|
||||||
|
"downloaded": "다운로드됨",
|
||||||
|
"available": "다운로드 가능",
|
||||||
|
"deleteConfirm": "{{modelName}}을(를) 삭제하시겠습니까? 다시 사용하려면 다운로드해야 합니다.",
|
||||||
|
"deleteActiveConfirm": "{{modelName}}은(는) 현재 활성 모델입니다. 삭제하면 새 모델을 선택할 때까지 전사가 중지됩니다. 정말 삭제하시겠습니까?",
|
||||||
|
"deleteTitle": "모델 삭제",
|
||||||
|
"filters": {
|
||||||
|
"all": "전체",
|
||||||
|
"multiLanguage": "다국어",
|
||||||
|
"translation": "번역",
|
||||||
|
"allLanguages": "모든 언어"
|
||||||
|
},
|
||||||
|
"noModelsMatch": "이 필터에 맞는 모델이 없습니다."
|
||||||
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"title": "고급",
|
"title": "고급",
|
||||||
"groups": {
|
"groups": {
|
||||||
|
|
@ -241,6 +274,9 @@
|
||||||
},
|
},
|
||||||
"postProcessing": {
|
"postProcessing": {
|
||||||
"title": "후처리",
|
"title": "후처리",
|
||||||
|
"hotkey": {
|
||||||
|
"title": "단축키"
|
||||||
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"title": "API (OpenAI 호환)",
|
"title": "API (OpenAI 호환)",
|
||||||
"provider": {
|
"provider": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Ogólne",
|
"general": "Ogólne",
|
||||||
|
"models": "Modele",
|
||||||
"advanced": "Zaawansowane",
|
"advanced": "Zaawansowane",
|
||||||
"postProcessing": "Postproces",
|
"postProcessing": "Postproces",
|
||||||
"history": "Historia",
|
"history": "Historia",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Polecane",
|
"recommended": "Polecane",
|
||||||
"download": "Pobierz",
|
"download": "Pobierz",
|
||||||
"downloading": "Pobieranie...",
|
"downloading": "Pobieranie...",
|
||||||
|
"downloadFailed": "Pobieranie nie powiodło się. Spróbuj ponownie.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "dokładność",
|
"accuracy": "dokładność",
|
||||||
"speed": "szybkość"
|
"speed": "szybkość"
|
||||||
|
|
@ -71,18 +73,16 @@
|
||||||
"grant": "Przyznaj uprawnienie",
|
"grant": "Przyznaj uprawnienie",
|
||||||
"granted": "Przyznano",
|
"granted": "Przyznano",
|
||||||
"waiting": "Oczekiwanie...",
|
"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": {
|
"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",
|
"active": "Aktywny",
|
||||||
"download": "Pobierz",
|
"switching": "Przełączanie...",
|
||||||
"downloadSize": "Rozmiar pobierania",
|
|
||||||
"noModelsAvailable": "Brak dostępnych modeli",
|
"noModelsAvailable": "Brak dostępnych modeli",
|
||||||
"extracting": "Rozpakowywanie {{modelName}}...",
|
"extracting": "Rozpakowywanie {{modelName}}...",
|
||||||
"extractingMultiple": "Rozpakowywanie {{count}} modeli...",
|
"extractingMultiple": "Rozpakowywanie {{count}} modeli...",
|
||||||
|
|
@ -95,9 +95,42 @@
|
||||||
"modelError": "Błąd modelu",
|
"modelError": "Błąd modelu",
|
||||||
"modelUnloaded": "Model wyładowany",
|
"modelUnloaded": "Model wyładowany",
|
||||||
"noModelDownloadRequired": "Brak modelu – wymagane pobranie",
|
"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": {
|
"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": {
|
"general": {
|
||||||
"title": "Ogólne",
|
"title": "Ogólne",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -115,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Skrót anulowania",
|
"name": "Skrót anulowania",
|
||||||
"description": "Skrót klawiaturowy do anulowania bieżącego nagrywania."
|
"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": {
|
"errors": {
|
||||||
|
|
@ -363,10 +400,19 @@
|
||||||
"label": "Dodaj spację na końcu",
|
"label": "Dodaj spację na końcu",
|
||||||
"description": "Dodaj spację po wklejonej transkrypcji"
|
"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": {
|
"paths": {
|
||||||
"appData": "Dane aplikacji:",
|
"appData": "Dane aplikacji:",
|
||||||
"models": "Modele:",
|
"models": "Modele:",
|
||||||
"settings": "Ustawienia:"
|
"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": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
|
"models": "Modelos",
|
||||||
"advanced": "Avançado",
|
"advanced": "Avançado",
|
||||||
"postProcessing": "Pós-Processamento",
|
"postProcessing": "Pós-Processamento",
|
||||||
"history": "Histórico",
|
"history": "Histórico",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Recomendado",
|
"recommended": "Recomendado",
|
||||||
"download": "Baixar",
|
"download": "Baixar",
|
||||||
"downloading": "Baixando...",
|
"downloading": "Baixando...",
|
||||||
|
"downloadFailed": "Falha no download. Por favor, tente novamente.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "precisão",
|
"accuracy": "precisão",
|
||||||
"speed": "velocidade"
|
"speed": "velocidade"
|
||||||
|
|
@ -79,14 +81,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"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",
|
"active": "Ativo",
|
||||||
"download": "Baixar",
|
|
||||||
"downloadSize": "Tamanho do download",
|
|
||||||
"noModelsAvailable": "Nenhum modelo disponível",
|
"noModelsAvailable": "Nenhum modelo disponível",
|
||||||
"extracting": "Extraindo {{modelName}}...",
|
"extracting": "Extraindo {{modelName}}...",
|
||||||
"extractingMultiple": "Extraindo {{count}} modelos...",
|
"extractingMultiple": "Extraindo {{count}} modelos...",
|
||||||
|
|
@ -99,9 +94,25 @@
|
||||||
"modelError": "Erro no Modelo",
|
"modelError": "Erro no Modelo",
|
||||||
"modelUnloaded": "Modelo Descarregado",
|
"modelUnloaded": "Modelo Descarregado",
|
||||||
"noModelDownloadRequired": "Sem Modelo - Download Necessário",
|
"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": {
|
"settings": {
|
||||||
|
"modelSettings": {
|
||||||
|
"title": "Configurações de {{model}}",
|
||||||
|
"noSettingsNeeded": "Este modelo funciona automaticamente sem necessidade de configuração."
|
||||||
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "Geral",
|
"title": "Geral",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -119,6 +130,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Atalho de Cancelar",
|
"name": "Atalho de Cancelar",
|
||||||
"description": "O atalho de teclado para cancelar a gravação atual."
|
"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": {
|
"errors": {
|
||||||
|
|
@ -140,6 +155,24 @@
|
||||||
"description": "Segure para gravar, solte para parar"
|
"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": {
|
"sound": {
|
||||||
"title": "Som",
|
"title": "Som",
|
||||||
"microphone": {
|
"microphone": {
|
||||||
|
|
@ -367,10 +400,19 @@
|
||||||
"label": "Adicionar Espaço Final",
|
"label": "Adicionar Espaço Final",
|
||||||
"description": "Adicionar um espaço após a transcrição colada"
|
"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": {
|
"paths": {
|
||||||
"appData": "Dados do App:",
|
"appData": "Dados do App:",
|
||||||
"models": "Modelos:",
|
"models": "Modelos:",
|
||||||
"settings": "Configurações:"
|
"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": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Общие",
|
"general": "Общие",
|
||||||
|
"models": "Модели",
|
||||||
"advanced": "Продвинутые",
|
"advanced": "Продвинутые",
|
||||||
"postProcessing": "Постобработка",
|
"postProcessing": "Постобработка",
|
||||||
"history": "История",
|
"history": "История",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Рекомендуется",
|
"recommended": "Рекомендуется",
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"downloading": "Загрузка...",
|
"downloading": "Загрузка...",
|
||||||
|
"downloadFailed": "Загрузка не удалась. Пожалуйста, попробуйте снова.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "точность",
|
"accuracy": "точность",
|
||||||
"speed": "скорость"
|
"speed": "скорость"
|
||||||
|
|
@ -71,18 +73,16 @@
|
||||||
"grant": "Предоставить разрешение",
|
"grant": "Предоставить разрешение",
|
||||||
"granted": "Предоставлено",
|
"granted": "Предоставлено",
|
||||||
"waiting": "Ожидание...",
|
"waiting": "Ожидание...",
|
||||||
"allGranted": "Всё готово!"
|
"allGranted": "Всё готово!",
|
||||||
|
"errors": {
|
||||||
|
"checkFailed": "Не удалось проверить разрешения. Пожалуйста, попробуйте снова.",
|
||||||
|
"requestFailed": "Не удалось запросить разрешение. Пожалуйста, попробуйте снова."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"modelSelector": {
|
||||||
"welcome": "Добро пожаловать в Handy!",
|
|
||||||
"downloadPrompt": "Загрузите модель ниже, чтобы начать транскрипцию.",
|
|
||||||
"availableModels": "Доступные модели",
|
|
||||||
"downloadModels": "Скачать модели",
|
|
||||||
"chooseModel": "Выберите модель",
|
|
||||||
"active": "Активный",
|
"active": "Активный",
|
||||||
"download": "Скачать",
|
"switching": "Переключение...",
|
||||||
"downloadSize": "Размер загрузки",
|
|
||||||
"noModelsAvailable": "Нет доступных моделей",
|
"noModelsAvailable": "Нет доступных моделей",
|
||||||
"extracting": "Извлечение {{modelName}}...",
|
"extracting": "Извлечение {{modelName}}...",
|
||||||
"extractingMultiple": "Извлечение моделей {{count}}...",
|
"extractingMultiple": "Извлечение моделей {{count}}...",
|
||||||
|
|
@ -95,9 +95,42 @@
|
||||||
"modelError": "Ошибка модели",
|
"modelError": "Ошибка модели",
|
||||||
"modelUnloaded": "Модель выгружена",
|
"modelUnloaded": "Модель выгружена",
|
||||||
"noModelDownloadRequired": "Нет модели – требуется загрузка",
|
"noModelDownloadRequired": "Нет модели – требуется загрузка",
|
||||||
"deleteModel": "Удалить {{modelName}}"
|
"deleteModel": "Удалить {{modelName}}",
|
||||||
|
"downloadSpeed": "{{speed}} МБ/с",
|
||||||
|
"capabilities": {
|
||||||
|
"languageSelection": "Поддерживает несколько языков ввода",
|
||||||
|
"multiLanguage": "Многоязычный",
|
||||||
|
"translation": "Может переводить на английский",
|
||||||
|
"translate": "Перевести на английский",
|
||||||
|
"singleLanguage": "Поддерживает только этот язык",
|
||||||
|
"languageOnly": "Только {{language}}"
|
||||||
|
},
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"cancelDownload": "Отменить загрузку"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"modelSettings": {
|
||||||
|
"title": "Настройки {{model}}",
|
||||||
|
"noSettingsNeeded": "Эта модель работает автоматически без необходимости настройки."
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"title": "Модели транскрипции",
|
||||||
|
"description": "Выберите модель транскрипции или загрузите дополнительные модели. Разные модели предлагают разные уровни точности и скорости.",
|
||||||
|
"downloaded": "Загружено",
|
||||||
|
"available": "Доступно для загрузки",
|
||||||
|
"deleteConfirm": "Вы уверены, что хотите удалить {{modelName}}? Вам нужно будет загрузить её снова, чтобы использовать.",
|
||||||
|
"deleteActiveConfirm": "{{modelName}} — ваша активная модель. Удаление остановит транскрипцию, пока вы не выберете новую модель. Вы уверены?",
|
||||||
|
"deleteTitle": "Удалить модель",
|
||||||
|
"filters": {
|
||||||
|
"all": "Все",
|
||||||
|
"multiLanguage": "Многоязычные",
|
||||||
|
"translation": "Перевод",
|
||||||
|
"allLanguages": "Все языки"
|
||||||
|
},
|
||||||
|
"noModelsMatch": "Нет моделей, соответствующих этому фильтру.",
|
||||||
|
"yourModels": "Загруженные модели",
|
||||||
|
"availableModels": "Доступны для загрузки"
|
||||||
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "Общие",
|
"title": "Общие",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -115,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Горячая клавиша отмены",
|
"name": "Горячая клавиша отмены",
|
||||||
"description": "Сочетание клавиш для отмены текущей записи."
|
"description": "Сочетание клавиш для отмены текущей записи."
|
||||||
|
},
|
||||||
|
"transcribe_with_post_process": {
|
||||||
|
"name": "Горячая клавиша постобработки",
|
||||||
|
"description": "Необязательно: Специальная горячая клавиша, которая всегда применяет AI-постобработку к вашей транскрипции."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|
@ -363,10 +400,19 @@
|
||||||
"label": "Добавить конечный пробел",
|
"label": "Добавить конечный пробел",
|
||||||
"description": "Добавить пробел после вставленной транскрипции"
|
"description": "Добавить пробел после вставленной транскрипции"
|
||||||
},
|
},
|
||||||
|
"keyboardImplementation": {
|
||||||
|
"title": "Keyboard Implementation",
|
||||||
|
"description": "Choose the keyboard shortcut backend.",
|
||||||
|
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
|
||||||
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"appData": "Данные приложения:",
|
"appData": "Данные приложения:",
|
||||||
"models": "Модели:",
|
"models": "Модели:",
|
||||||
"settings": "Настройки:"
|
"settings": "Настройки:"
|
||||||
|
},
|
||||||
|
"pasteDelay": {
|
||||||
|
"title": "Задержка вставки",
|
||||||
|
"description": "Задержка перед отправкой нажатия клавиши вставки (в миллисекундах). Увеличьте, если вставляется неправильный текст."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Genel",
|
"general": "Genel",
|
||||||
|
"models": "Modeller",
|
||||||
"advanced": "Gelişmiş",
|
"advanced": "Gelişmiş",
|
||||||
"postProcessing": "Son İşlem",
|
"postProcessing": "Son İşlem",
|
||||||
"history": "Geçmiş",
|
"history": "Geçmiş",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Önerilen",
|
"recommended": "Önerilen",
|
||||||
"download": "İndir",
|
"download": "İndir",
|
||||||
"downloading": "İndiriliyor...",
|
"downloading": "İndiriliyor...",
|
||||||
|
"downloadFailed": "İndirme başarısız oldu. Lütfen tekrar deneyin.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "doğruluk",
|
"accuracy": "doğruluk",
|
||||||
"speed": "hız"
|
"speed": "hız"
|
||||||
|
|
@ -79,14 +81,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"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",
|
"active": "Aktif",
|
||||||
"download": "İndir",
|
|
||||||
"downloadSize": "İndirme boyutu",
|
|
||||||
"noModelsAvailable": "Kullanılabilir model yok",
|
"noModelsAvailable": "Kullanılabilir model yok",
|
||||||
"extracting": "{{modelName}} çıkarılıyor...",
|
"extracting": "{{modelName}} çıkarılıyor...",
|
||||||
"extractingMultiple": "{{count}} model çıkarılıyor...",
|
"extractingMultiple": "{{count}} model çıkarılıyor...",
|
||||||
|
|
@ -99,9 +94,43 @@
|
||||||
"modelError": "Model Hatası",
|
"modelError": "Model Hatası",
|
||||||
"modelUnloaded": "Model Boşaltıldı",
|
"modelUnloaded": "Model Boşaltıldı",
|
||||||
"noModelDownloadRequired": "Model Yok – İndirme Gerekli",
|
"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": {
|
"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": {
|
"general": {
|
||||||
"title": "Genel",
|
"title": "Genel",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -119,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "İptal Kısayolu",
|
"name": "İptal Kısayolu",
|
||||||
"description": "Mevcut kaydı iptal etmek için klavye 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": {
|
"errors": {
|
||||||
|
|
@ -241,6 +274,9 @@
|
||||||
},
|
},
|
||||||
"postProcessing": {
|
"postProcessing": {
|
||||||
"title": "Son İşlem",
|
"title": "Son İşlem",
|
||||||
|
"hotkey": {
|
||||||
|
"title": "Kısayol Tuşu"
|
||||||
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"title": "API (OpenAI Uyumlu)",
|
"title": "API (OpenAI Uyumlu)",
|
||||||
"provider": {
|
"provider": {
|
||||||
|
|
@ -364,10 +400,19 @@
|
||||||
"label": "Sonuna Boşluk Ekle",
|
"label": "Sonuna Boşluk Ekle",
|
||||||
"description": "Yapıştırılan transkripsiyondan sonra boşluk ekler"
|
"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": {
|
"paths": {
|
||||||
"appData": "Uygulama Verileri:",
|
"appData": "Uygulama Verileri:",
|
||||||
"models": "Modeller:",
|
"models": "Modeller:",
|
||||||
"settings": "Ayarlar:"
|
"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": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Загальні",
|
"general": "Загальні",
|
||||||
|
"models": "Моделі",
|
||||||
"advanced": "Розширені",
|
"advanced": "Розширені",
|
||||||
"postProcessing": "Постобробка",
|
"postProcessing": "Постобробка",
|
||||||
"history": "Історія",
|
"history": "Історія",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "Рекомендовано",
|
"recommended": "Рекомендовано",
|
||||||
"download": "Завантажити",
|
"download": "Завантажити",
|
||||||
"downloading": "Завантаження...",
|
"downloading": "Завантаження...",
|
||||||
|
"downloadFailed": "Завантаження не вдалося. Будь ласка, спробуйте ще раз.",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "точність",
|
"accuracy": "точність",
|
||||||
"speed": "швидкість"
|
"speed": "швидкість"
|
||||||
|
|
@ -71,18 +73,15 @@
|
||||||
"grant": "Надати дозвіл",
|
"grant": "Надати дозвіл",
|
||||||
"granted": "Надано",
|
"granted": "Надано",
|
||||||
"waiting": "Очікування...",
|
"waiting": "Очікування...",
|
||||||
"allGranted": "Все готово!"
|
"allGranted": "Все готово!",
|
||||||
|
"errors": {
|
||||||
|
"checkFailed": "Не вдалося перевірити дозволи. Будь ласка, спробуйте ще раз.",
|
||||||
|
"requestFailed": "Не вдалося запросити дозвіл. Будь ласка, спробуйте ще раз."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"modelSelector": {
|
||||||
"welcome": "Ласкаво просимо до Handy!",
|
|
||||||
"downloadPrompt": "Завантажте модель нижче, щоб почати транскрипцію.",
|
|
||||||
"availableModels": "Доступні моделі",
|
|
||||||
"downloadModels": "Завантажити моделі",
|
|
||||||
"chooseModel": "Оберіть модель",
|
|
||||||
"active": "Активна",
|
"active": "Активна",
|
||||||
"download": "Завантажити",
|
|
||||||
"downloadSize": "Розмір завантаження",
|
|
||||||
"noModelsAvailable": "Немає доступних моделей",
|
"noModelsAvailable": "Немає доступних моделей",
|
||||||
"extracting": "Розпакування {{modelName}}...",
|
"extracting": "Розпакування {{modelName}}...",
|
||||||
"extractingMultiple": "Розпакування {{count}} моделей...",
|
"extractingMultiple": "Розпакування {{count}} моделей...",
|
||||||
|
|
@ -95,9 +94,25 @@
|
||||||
"modelError": "Помилка моделі",
|
"modelError": "Помилка моделі",
|
||||||
"modelUnloaded": "Модель вивантажена",
|
"modelUnloaded": "Модель вивантажена",
|
||||||
"noModelDownloadRequired": "Немає моделі - потрібно завантажити",
|
"noModelDownloadRequired": "Немає моделі - потрібно завантажити",
|
||||||
"deleteModel": "Видалити {{modelName}}"
|
"deleteModel": "Видалити {{modelName}}",
|
||||||
|
"switching": "Перемикання...",
|
||||||
|
"downloadSpeed": "{{speed}} MB/s",
|
||||||
|
"capabilities": {
|
||||||
|
"languageSelection": "Підтримує кілька мов введення",
|
||||||
|
"multiLanguage": "Багатомовна",
|
||||||
|
"translation": "Може перекладати на англійську",
|
||||||
|
"translate": "Перекласти на англійську",
|
||||||
|
"singleLanguage": "Підтримує лише цю мову",
|
||||||
|
"languageOnly": "Лише {{language}}"
|
||||||
|
},
|
||||||
|
"cancel": "Скасувати",
|
||||||
|
"cancelDownload": "Скасувати завантаження"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"modelSettings": {
|
||||||
|
"title": "Налаштування {{model}}",
|
||||||
|
"noSettingsNeeded": "Ця модель працює автоматично без потреби в налаштуванні."
|
||||||
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "Загальні",
|
"title": "Загальні",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -115,6 +130,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Гаряча клавіша скасування",
|
"name": "Гаряча клавіша скасування",
|
||||||
"description": "Комбінація клавіш для скасування поточного запису."
|
"description": "Комбінація клавіш для скасування поточного запису."
|
||||||
|
},
|
||||||
|
"transcribe_with_post_process": {
|
||||||
|
"name": "Гаряча клавіша постобробки",
|
||||||
|
"description": "Необов'язково: Спеціальна гаряча клавіша, яка завжди застосовує AI-постобробку до вашої транскрипції."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|
@ -136,6 +155,24 @@
|
||||||
"description": "Утримуйте для запису, відпустіть для зупинки"
|
"description": "Утримуйте для запису, відпустіть для зупинки"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"models": {
|
||||||
|
"title": "Моделі транскрипції",
|
||||||
|
"description": "Виберіть модель транскрипції або завантажте додаткові моделі. Різні моделі пропонують різні рівні точності та швидкості.",
|
||||||
|
"downloaded": "Завантажено",
|
||||||
|
"available": "Доступні для завантаження",
|
||||||
|
"deleteConfirm": "Ви впевнені, що хочете видалити {{modelName}}? Вам потрібно буде завантажити її знову, щоб використовувати.",
|
||||||
|
"deleteActiveConfirm": "{{modelName}} — ваша активна модель. Видалення зупинить транскрипцію, доки ви не оберете нову модель. Ви впевнені?",
|
||||||
|
"deleteTitle": "Видалити модель",
|
||||||
|
"filters": {
|
||||||
|
"all": "Усі",
|
||||||
|
"multiLanguage": "Багатомовні",
|
||||||
|
"translation": "Переклад",
|
||||||
|
"allLanguages": "Усі мови"
|
||||||
|
},
|
||||||
|
"noModelsMatch": "Жодна модель не відповідає цьому фільтру.",
|
||||||
|
"yourModels": "Завантажені моделі",
|
||||||
|
"availableModels": "Доступні для завантаження"
|
||||||
|
},
|
||||||
"sound": {
|
"sound": {
|
||||||
"title": "Звук",
|
"title": "Звук",
|
||||||
"microphone": {
|
"microphone": {
|
||||||
|
|
@ -363,10 +400,19 @@
|
||||||
"label": "Додавати пробіл в кінці",
|
"label": "Додавати пробіл в кінці",
|
||||||
"description": "Додавати пробіл після вставленої транскрипції"
|
"description": "Додавати пробіл після вставленої транскрипції"
|
||||||
},
|
},
|
||||||
|
"keyboardImplementation": {
|
||||||
|
"title": "Keyboard Implementation",
|
||||||
|
"description": "Choose the keyboard shortcut backend.",
|
||||||
|
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
|
||||||
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"appData": "Дані програми:",
|
"appData": "Дані програми:",
|
||||||
"models": "Моделі:",
|
"models": "Моделі:",
|
||||||
"settings": "Налаштування:"
|
"settings": "Налаштування:"
|
||||||
|
},
|
||||||
|
"pasteDelay": {
|
||||||
|
"title": "Затримка вставки",
|
||||||
|
"description": "Затримка перед надсиланням натискання клавіші вставки (у мілісекундах). Збільшіть, якщо вставляється неправильний текст."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"_comment": "Vietnamese translation for Handy. Contribute at: https://github.com/cjpais/Handy",
|
|
||||||
"tray": {
|
"tray": {
|
||||||
"settings": "Cài đặt...",
|
"settings": "Cài đặt...",
|
||||||
"checkUpdates": "Kiểm tra cập nhật...",
|
"checkUpdates": "Kiểm tra cập nhật...",
|
||||||
|
|
@ -9,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "Chung",
|
"general": "Chung",
|
||||||
|
"models": "Mô hình",
|
||||||
"advanced": "Nâng cao",
|
"advanced": "Nâng cao",
|
||||||
"postProcessing": "Xử lý sau",
|
"postProcessing": "Xử lý sau",
|
||||||
"history": "Lịch sử",
|
"history": "Lịch sử",
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"recommended": "Đề xuất",
|
"recommended": "Đề xuất",
|
||||||
"download": "Tải xuống",
|
"download": "Tải xuống",
|
||||||
"downloading": "Đang 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": {
|
"modelCard": {
|
||||||
"accuracy": "độ chính xác",
|
"accuracy": "độ chính xác",
|
||||||
"speed": "tốc độ"
|
"speed": "tốc độ"
|
||||||
|
|
@ -72,18 +73,16 @@
|
||||||
"grant": "Cấp quyền",
|
"grant": "Cấp quyền",
|
||||||
"granted": "Đã cấp",
|
"granted": "Đã cấp",
|
||||||
"waiting": "Đang chờ...",
|
"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": {
|
"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",
|
"active": "Đang hoạt động",
|
||||||
"download": "Tải xuống",
|
"switching": "Đang chuyển...",
|
||||||
"downloadSize": "Kích thước tải xuống",
|
|
||||||
"noModelsAvailable": "Không có mô hình nào",
|
"noModelsAvailable": "Không có mô hình nào",
|
||||||
"extracting": "Đang giải nén {{modelName}}...",
|
"extracting": "Đang giải nén {{modelName}}...",
|
||||||
"extractingMultiple": "Đang giải nén {{count}} mô hình...",
|
"extractingMultiple": "Đang giải nén {{count}} mô hình...",
|
||||||
|
|
@ -96,9 +95,42 @@
|
||||||
"modelError": "Lỗi Mô Hình",
|
"modelError": "Lỗi Mô Hình",
|
||||||
"modelUnloaded": "Mô Hình Đã Gỡ",
|
"modelUnloaded": "Mô Hình Đã Gỡ",
|
||||||
"noModelDownloadRequired": "Chưa Có Mô Hình - Cần Tải Xuống",
|
"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": {
|
"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": {
|
"general": {
|
||||||
"title": "Chung",
|
"title": "Chung",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -116,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "Phím tắt hủy",
|
"name": "Phím tắt hủy",
|
||||||
"description": "Phím tắt để hủy bản ghi hiện tại."
|
"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": {
|
"errors": {
|
||||||
|
|
@ -364,10 +400,19 @@
|
||||||
"label": "Thêm dấu cách cuối",
|
"label": "Thêm dấu cách cuối",
|
||||||
"description": "Thêm một dấu cách sau bản ghi đã dán"
|
"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": {
|
"paths": {
|
||||||
"appData": "Dữ liệu ứng dụng:",
|
"appData": "Dữ liệu ứng dụng:",
|
||||||
"models": "Mô hình:",
|
"models": "Mô hình:",
|
||||||
"settings": "Cài đặt:"
|
"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": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
|
"models": "模型",
|
||||||
"advanced": "高级",
|
"advanced": "高级",
|
||||||
"postProcessing": "后处理",
|
"postProcessing": "后处理",
|
||||||
"history": "历史记录",
|
"history": "历史记录",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"recommended": "推荐",
|
"recommended": "推荐",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloading": "下载中...",
|
"downloading": "下载中...",
|
||||||
|
"downloadFailed": "下载失败。请重试。",
|
||||||
"modelCard": {
|
"modelCard": {
|
||||||
"accuracy": "准确度",
|
"accuracy": "准确度",
|
||||||
"speed": "速度"
|
"speed": "速度"
|
||||||
|
|
@ -71,18 +73,16 @@
|
||||||
"grant": "授予权限",
|
"grant": "授予权限",
|
||||||
"granted": "已授予",
|
"granted": "已授予",
|
||||||
"waiting": "等待中...",
|
"waiting": "等待中...",
|
||||||
"allGranted": "全部完成!"
|
"allGranted": "全部完成!",
|
||||||
|
"errors": {
|
||||||
|
"checkFailed": "检查权限失败。请重试。",
|
||||||
|
"requestFailed": "请求权限失败。请重试。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelSelector": {
|
"modelSelector": {
|
||||||
"welcome": "欢迎使用 Handy!",
|
|
||||||
"downloadPrompt": "请下载以下模型以开始转录。",
|
|
||||||
"availableModels": "可用模型",
|
|
||||||
"downloadModels": "下载模型",
|
|
||||||
"chooseModel": "选择模型",
|
|
||||||
"active": "活跃",
|
"active": "活跃",
|
||||||
"download": "下载",
|
"switching": "切换中...",
|
||||||
"downloadSize": "下载大小",
|
|
||||||
"noModelsAvailable": "没有可用的模型",
|
"noModelsAvailable": "没有可用的模型",
|
||||||
"extracting": "正在解压 {{modelName}}...",
|
"extracting": "正在解压 {{modelName}}...",
|
||||||
"extractingMultiple": "正在解压 {{count}} 个模型...",
|
"extractingMultiple": "正在解压 {{count}} 个模型...",
|
||||||
|
|
@ -95,9 +95,42 @@
|
||||||
"modelError": "模型错误",
|
"modelError": "模型错误",
|
||||||
"modelUnloaded": "模型已卸载",
|
"modelUnloaded": "模型已卸载",
|
||||||
"noModelDownloadRequired": "无模型 - 需要下载",
|
"noModelDownloadRequired": "无模型 - 需要下载",
|
||||||
"deleteModel": "删除 {{modelName}}"
|
"deleteModel": "删除 {{modelName}}",
|
||||||
|
"downloadSpeed": "{{speed}} MB/s",
|
||||||
|
"capabilities": {
|
||||||
|
"languageSelection": "支持多种输入语言",
|
||||||
|
"multiLanguage": "多语言",
|
||||||
|
"translation": "可翻译为英语",
|
||||||
|
"translate": "翻译为英语",
|
||||||
|
"singleLanguage": "仅支持此语言",
|
||||||
|
"languageOnly": "仅 {{language}}"
|
||||||
|
},
|
||||||
|
"cancel": "取消",
|
||||||
|
"cancelDownload": "取消下载"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"modelSettings": {
|
||||||
|
"title": "{{model}} 设置",
|
||||||
|
"noSettingsNeeded": "此模型自动运行,无需配置。"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"title": "转录模型",
|
||||||
|
"description": "选择转录模型或下载其他模型。不同模型提供不同的准确度和速度。",
|
||||||
|
"downloaded": "已下载",
|
||||||
|
"available": "可下载",
|
||||||
|
"deleteConfirm": "确定要删除 {{modelName}} 吗?您需要重新下载才能使用。",
|
||||||
|
"deleteActiveConfirm": "{{modelName}} 是您当前使用的模型。删除后将停止转录,直到您选择新的模型。确定要删除吗?",
|
||||||
|
"deleteTitle": "删除模型",
|
||||||
|
"filters": {
|
||||||
|
"all": "全部",
|
||||||
|
"multiLanguage": "多语言",
|
||||||
|
"translation": "翻译",
|
||||||
|
"allLanguages": "所有语言"
|
||||||
|
},
|
||||||
|
"noModelsMatch": "没有符合此筛选条件的模型。",
|
||||||
|
"yourModels": "已下载的模型",
|
||||||
|
"availableModels": "可供下载"
|
||||||
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "通用",
|
"title": "通用",
|
||||||
"shortcut": {
|
"shortcut": {
|
||||||
|
|
@ -115,6 +148,10 @@
|
||||||
"cancel": {
|
"cancel": {
|
||||||
"name": "取消快捷键",
|
"name": "取消快捷键",
|
||||||
"description": "用于取消当前录制的键盘快捷键。"
|
"description": "用于取消当前录制的键盘快捷键。"
|
||||||
|
},
|
||||||
|
"transcribe_with_post_process": {
|
||||||
|
"name": "后处理快捷键",
|
||||||
|
"description": "可选:一个专用快捷键,始终对您的转录应用 AI 后处理。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|
@ -363,10 +400,19 @@
|
||||||
"label": "追加尾部空格",
|
"label": "追加尾部空格",
|
||||||
"description": "在粘贴的转录后添加空格"
|
"description": "在粘贴的转录后添加空格"
|
||||||
},
|
},
|
||||||
|
"keyboardImplementation": {
|
||||||
|
"title": "Keyboard Implementation",
|
||||||
|
"description": "Choose the keyboard shortcut backend.",
|
||||||
|
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
|
||||||
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"appData": "应用数据:",
|
"appData": "应用数据:",
|
||||||
"models": "模型:",
|
"models": "模型:",
|
||||||
"settings": "设置:"
|
"settings": "设置:"
|
||||||
|
},
|
||||||
|
"pasteDelay": {
|
||||||
|
"title": "粘贴延迟",
|
||||||
|
"description": "发送粘贴按键前的延迟(毫秒)。如果粘贴了错误的文本,请增加此值。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|
|
||||||
|
|
@ -57,4 +57,5 @@ export const LANGUAGES: Language[] = [
|
||||||
{ value: "et", label: "Estonian" },
|
{ value: "et", label: "Estonian" },
|
||||||
{ value: "mk", label: "Macedonian" },
|
{ value: "mk", label: "Macedonian" },
|
||||||
{ value: "br", label: "Breton" },
|
{ value: "br", label: "Breton" },
|
||||||
|
{ value: "mt", label: "Maltese" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { subscribeWithSelector } from "zustand/middleware";
|
import { subscribeWithSelector } from "zustand/middleware";
|
||||||
|
import { produce } from "immer";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { commands, type ModelInfo } from "@/bindings";
|
import { commands, type ModelInfo } from "@/bindings";
|
||||||
|
|
||||||
|
|
@ -10,16 +11,26 @@ interface DownloadProgress {
|
||||||
percentage: number;
|
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[];
|
models: ModelInfo[];
|
||||||
currentModel: string;
|
currentModel: string;
|
||||||
downloadingModels: Set<string>;
|
downloadingModels: Record<string, true>;
|
||||||
extractingModels: Set<string>;
|
extractingModels: Record<string, true>;
|
||||||
downloadProgress: Map<string, DownloadProgress>;
|
downloadProgress: Record<string, DownloadProgress>;
|
||||||
|
downloadStats: Record<string, DownloadStats>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
hasAnyModels: boolean;
|
hasAnyModels: boolean;
|
||||||
isFirstRun: boolean;
|
isFirstRun: boolean;
|
||||||
|
initialized: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
|
|
@ -28,9 +39,8 @@ interface ModelStore {
|
||||||
checkFirstRun: () => Promise<boolean>;
|
checkFirstRun: () => Promise<boolean>;
|
||||||
selectModel: (modelId: string) => Promise<boolean>;
|
selectModel: (modelId: string) => Promise<boolean>;
|
||||||
downloadModel: (modelId: string) => Promise<boolean>;
|
downloadModel: (modelId: string) => Promise<boolean>;
|
||||||
|
cancelDownload: (modelId: string) => Promise<boolean>;
|
||||||
deleteModel: (modelId: string) => Promise<boolean>;
|
deleteModel: (modelId: string) => Promise<boolean>;
|
||||||
|
|
||||||
// Getters
|
|
||||||
getModelInfo: (modelId: string) => ModelInfo | undefined;
|
getModelInfo: (modelId: string) => ModelInfo | undefined;
|
||||||
isModelDownloading: (modelId: string) => boolean;
|
isModelDownloading: (modelId: string) => boolean;
|
||||||
isModelExtracting: (modelId: string) => boolean;
|
isModelExtracting: (modelId: string) => boolean;
|
||||||
|
|
@ -38,85 +48,61 @@ interface ModelStore {
|
||||||
|
|
||||||
// Internal setters
|
// Internal setters
|
||||||
setModels: (models: ModelInfo[]) => void;
|
setModels: (models: ModelInfo[]) => void;
|
||||||
setCurrentModel: (model: string) => void;
|
setCurrentModel: (modelId: string) => void;
|
||||||
setLoading: (loading: boolean) => void;
|
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
setHasAnyModels: (hasAny: boolean) => void;
|
setLoading: (loading: 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useModelStore = create<ModelStore>()(
|
export const useModelStore = create<ModelsStore>()(
|
||||||
subscribeWithSelector((set, get) => ({
|
subscribeWithSelector((set, get) => ({
|
||||||
models: [],
|
models: [],
|
||||||
currentModel: "",
|
currentModel: "",
|
||||||
downloadingModels: new Set(),
|
downloadingModels: {},
|
||||||
extractingModels: new Set(),
|
extractingModels: {},
|
||||||
downloadProgress: new Map(),
|
downloadProgress: {},
|
||||||
|
downloadStats: {},
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
hasAnyModels: false,
|
hasAnyModels: false,
|
||||||
isFirstRun: false,
|
isFirstRun: false,
|
||||||
|
initialized: false,
|
||||||
|
|
||||||
// Internal setters
|
// Internal setters
|
||||||
setModels: (models) => set({ models }),
|
setModels: (models) => set({ models }),
|
||||||
setCurrentModel: (currentModel) => set({ currentModel }),
|
setCurrentModel: (currentModel) => set({ currentModel }),
|
||||||
setLoading: (loading) => set({ loading }),
|
|
||||||
setError: (error) => set({ error }),
|
setError: (error) => set({ error }),
|
||||||
setHasAnyModels: (hasAnyModels) => set({ hasAnyModels }),
|
setLoading: (loading) => set({ loading }),
|
||||||
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
|
|
||||||
addDownloadingModel: (modelId) =>
|
|
||||||
set((state) => ({
|
|
||||||
downloadingModels: new Set(state.downloadingModels).add(modelId),
|
|
||||||
})),
|
|
||||||
removeDownloadingModel: (modelId) =>
|
|
||||||
set((state) => {
|
|
||||||
const next = new Set(state.downloadingModels);
|
|
||||||
next.delete(modelId);
|
|
||||||
return { downloadingModels: next };
|
|
||||||
}),
|
|
||||||
addExtractingModel: (modelId) =>
|
|
||||||
set((state) => ({
|
|
||||||
extractingModels: new Set(state.extractingModels).add(modelId),
|
|
||||||
})),
|
|
||||||
removeExtractingModel: (modelId) =>
|
|
||||||
set((state) => {
|
|
||||||
const next = new Set(state.extractingModels);
|
|
||||||
next.delete(modelId);
|
|
||||||
return { extractingModels: next };
|
|
||||||
}),
|
|
||||||
setDownloadProgress: (modelId, progress) =>
|
|
||||||
set((state) => ({
|
|
||||||
downloadProgress: new Map(state.downloadProgress).set(
|
|
||||||
modelId,
|
|
||||||
progress,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
removeDownloadProgress: (modelId) =>
|
|
||||||
set((state) => {
|
|
||||||
const next = new Map(state.downloadProgress);
|
|
||||||
next.delete(modelId);
|
|
||||||
return { downloadProgress: next };
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
getModelInfo: (modelId) =>
|
|
||||||
get().models.find((model) => model.id === modelId),
|
|
||||||
isModelDownloading: (modelId) => get().downloadingModels.has(modelId),
|
|
||||||
isModelExtracting: (modelId) => get().extractingModels.has(modelId),
|
|
||||||
getDownloadProgress: (modelId) => get().downloadProgress.get(modelId),
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadModels: async () => {
|
loadModels: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await commands.getAvailableModels();
|
const result = await commands.getAvailableModels();
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
set({ models: result.data, error: null });
|
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 {
|
} else {
|
||||||
set({ error: `Failed to load models: ${result.error}` });
|
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 {
|
try {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
const result = await commands.setActiveModel(modelId);
|
const result = await commands.setActiveModel(modelId);
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
set({ currentModel: modelId, isFirstRun: false, hasAnyModels: true });
|
set({
|
||||||
|
currentModel: modelId,
|
||||||
|
isFirstRun: false,
|
||||||
|
hasAnyModels: true,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
set({ error: `Failed to switch to model: ${result.error}` });
|
set({ error: `Failed to switch to model: ${result.error}` });
|
||||||
|
|
@ -170,33 +160,76 @@ export const useModelStore = create<ModelStore>()(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadModel: async (modelId) => {
|
downloadModel: async (modelId: string) => {
|
||||||
const { addDownloadingModel, removeDownloadingModel } = get();
|
|
||||||
try {
|
try {
|
||||||
set({ error: null });
|
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);
|
const result = await commands.downloadModel(modelId);
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
set({ error: `Failed to download model: ${result.error}` });
|
set({ error: `Failed to download model: ${result.error}` });
|
||||||
removeDownloadingModel(modelId);
|
set(
|
||||||
|
produce((state) => {
|
||||||
|
delete state.downloadingModels[modelId];
|
||||||
|
}),
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: `Failed to download model: ${err}` });
|
set({ error: `Failed to download model: ${err}` });
|
||||||
removeDownloadingModel(modelId);
|
set(
|
||||||
|
produce((state) => {
|
||||||
|
delete state.downloadingModels[modelId];
|
||||||
|
}),
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteModel: async (modelId) => {
|
cancelDownload: async (modelId: string) => {
|
||||||
const { loadModels } = get();
|
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 {
|
try {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
const result = await commands.deleteModel(modelId);
|
const result = await commands.deleteModel(modelId);
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
await loadModels();
|
await get().loadModels();
|
||||||
|
await get().loadCurrentModel();
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
set({ error: `Failed to delete model: ${result.error}` });
|
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 () => {
|
initialize: async () => {
|
||||||
|
if (get().initialized) return;
|
||||||
|
|
||||||
const { loadModels, loadCurrentModel, checkFirstRun } = get();
|
const { loadModels, loadCurrentModel, checkFirstRun } = get();
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
await Promise.all([loadModels(), loadCurrentModel(), checkFirstRun()]);
|
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}`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue