Skip to content

Commit ef24cfe

Browse files
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 <[email protected]>
1 parent 665558b commit ef24cfe

54 files changed

Lines changed: 2498 additions & 913 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/lint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,8 @@ jobs:
1414
- name: Install dependencies
1515
run: bun install --frozen-lockfile
1616

17+
- name: Check translation consistency
18+
run: bun run check:translations
19+
1720
- name: Run ESLint
1821
run: bun run lint

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
"format:frontend": "prettier --write .",
1616
"format:backend": "cd src-tauri && cargo fmt",
1717
"test:playwright": "playwright test",
18-
"test:playwright:ui": "playwright test --ui"
18+
"test:playwright:ui": "playwright test --ui",
19+
"check:translations": "bun scripts/check-translations.ts"
1920
},
2021
"dependencies": {
2122
"@tailwindcss/vite": "^4.1.16",
2223
"@tauri-apps/api": "^2.9.0",
2324
"@tauri-apps/plugin-autostart": "~2.5.1",
2425
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
26+
"@tauri-apps/plugin-dialog": "~2",
2527
"@tauri-apps/plugin-fs": "~2.4.4",
2628
"@tauri-apps/plugin-global-shortcut": "~2.3.1",
2729
"@tauri-apps/plugin-opener": "^2.5.2",
@@ -30,16 +32,16 @@
3032
"@tauri-apps/plugin-sql": "~2.3.1",
3133
"@tauri-apps/plugin-store": "~2.4.1",
3234
"@tauri-apps/plugin-updater": "~2.9.0",
33-
"react-select": "^5.8.0",
34-
"tauri-plugin-macos-permissions-api": "2.3.0",
3535
"i18next": "^25.7.2",
3636
"immer": "^11.1.3",
3737
"lucide-react": "^0.542.0",
3838
"react": "^18.3.1",
3939
"react-dom": "^18.3.1",
4040
"react-i18next": "^16.4.1",
41+
"react-select": "^5.8.0",
4142
"sonner": "^2.0.7",
4243
"tailwindcss": "^4.1.16",
44+
"tauri-plugin-macos-permissions-api": "2.3.0",
4345
"zod": "^3.25.76",
4446
"zustand": "^5.0.8"
4547
},

scripts/check-translations.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { fileURLToPath } from "url";
4+
5+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
6+
7+
// Configuration
8+
const LOCALES_DIR = path.join(__dirname, "..", "src", "i18n", "locales");
9+
const REFERENCE_LANG = "en";
10+
11+
type TranslationData = Record<string, unknown>;
12+
13+
interface ValidationResult {
14+
valid: boolean;
15+
missing: string[][];
16+
extra: string[][];
17+
}
18+
19+
function getLanguages(): string[] {
20+
const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true });
21+
return entries
22+
.filter((entry) => entry.isDirectory() && entry.name !== REFERENCE_LANG)
23+
.map((entry) => entry.name)
24+
.sort();
25+
}
26+
27+
const LANGUAGES = getLanguages();
28+
29+
// Colors for terminal output
30+
const colors: Record<string, string> = {
31+
reset: "\x1b[0m",
32+
red: "\x1b[31m",
33+
green: "\x1b[32m",
34+
yellow: "\x1b[33m",
35+
blue: "\x1b[34m",
36+
};
37+
38+
function colorize(text: string, color: string): string {
39+
return `${colors[color]}${text}${colors.reset}`;
40+
}
41+
42+
function getAllKeyPaths(
43+
obj: TranslationData,
44+
prefix: string[] = [],
45+
): string[][] {
46+
let paths: string[][] = [];
47+
for (const key in obj) {
48+
if (!Object.hasOwn(obj, key)) continue;
49+
50+
const currentPath = prefix.concat([key]);
51+
const value = obj[key];
52+
53+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
54+
paths = paths.concat(
55+
getAllKeyPaths(value as TranslationData, currentPath),
56+
);
57+
} else {
58+
paths.push(currentPath);
59+
}
60+
}
61+
return paths;
62+
}
63+
64+
function hasKeyPath(obj: TranslationData, keyPath: string[]): boolean {
65+
let current: unknown = obj;
66+
for (const key of keyPath) {
67+
if (
68+
typeof current !== "object" ||
69+
current === null ||
70+
(current as Record<string, unknown>)[key] === undefined
71+
) {
72+
return false;
73+
}
74+
current = (current as Record<string, unknown>)[key];
75+
}
76+
return true;
77+
}
78+
79+
function loadTranslationFile(lang: string): TranslationData | null {
80+
const filePath = path.join(LOCALES_DIR, lang, "translation.json");
81+
82+
try {
83+
const content = fs.readFileSync(filePath, "utf8");
84+
return JSON.parse(content) as TranslationData;
85+
} catch (error) {
86+
console.error(colorize(`✗ Error loading ${lang}/translation.json:`, "red"));
87+
console.error(` ${(error as Error).message}`);
88+
return null;
89+
}
90+
}
91+
92+
function validateTranslations(): void {
93+
console.log(colorize("\n🌍 Translation Consistency Check\n", "blue"));
94+
95+
// Load reference file
96+
console.log(`Loading reference language: ${REFERENCE_LANG}`);
97+
const referenceData = loadTranslationFile(REFERENCE_LANG);
98+
99+
if (!referenceData) {
100+
console.error(
101+
colorize(`\n✗ Failed to load reference file (${REFERENCE_LANG})`, "red"),
102+
);
103+
process.exit(1);
104+
}
105+
106+
// Get all key paths from reference
107+
const referenceKeyPaths = getAllKeyPaths(referenceData);
108+
console.log(`Reference has ${referenceKeyPaths.length} keys\n`);
109+
110+
// Track validation results
111+
let hasErrors = false;
112+
const results: Record<string, ValidationResult> = {};
113+
114+
// Validate each language
115+
for (const lang of LANGUAGES) {
116+
const langData = loadTranslationFile(lang);
117+
118+
if (!langData) {
119+
hasErrors = true;
120+
results[lang] = { valid: false, missing: [], extra: [] };
121+
continue;
122+
}
123+
124+
// Find missing keys
125+
const missing = referenceKeyPaths.filter(
126+
(keyPath) => !hasKeyPath(langData, keyPath),
127+
);
128+
129+
// Find extra keys (keys in language but not in reference)
130+
const langKeyPaths = getAllKeyPaths(langData);
131+
const extra = langKeyPaths.filter(
132+
(keyPath) => !hasKeyPath(referenceData, keyPath),
133+
);
134+
135+
results[lang] = {
136+
valid: missing.length === 0 && extra.length === 0,
137+
missing,
138+
extra,
139+
};
140+
141+
if (missing.length > 0 || extra.length > 0) {
142+
hasErrors = true;
143+
}
144+
}
145+
146+
// Print results
147+
console.log(colorize("Results:", "blue"));
148+
console.log("─".repeat(60));
149+
150+
for (const lang of LANGUAGES) {
151+
const result = results[lang];
152+
153+
if (result.valid) {
154+
console.log(
155+
colorize(`✓ ${lang.toUpperCase()}: All keys present`, "green"),
156+
);
157+
} else {
158+
console.log(colorize(`✗ ${lang.toUpperCase()}: Issues found`, "red"));
159+
160+
if (result.missing.length > 0) {
161+
console.log(
162+
colorize(` Missing ${result.missing.length} keys:`, "yellow"),
163+
);
164+
result.missing.slice(0, 10).forEach((keyPath) => {
165+
console.log(` - ${keyPath.join(".")}`);
166+
});
167+
if (result.missing.length > 10) {
168+
console.log(
169+
colorize(
170+
` ... and ${result.missing.length - 10} more`,
171+
"yellow",
172+
),
173+
);
174+
}
175+
}
176+
177+
if (result.extra.length > 0) {
178+
console.log(
179+
colorize(
180+
` Extra ${result.extra.length} keys (not in reference):`,
181+
"yellow",
182+
),
183+
);
184+
result.extra.slice(0, 10).forEach((keyPath) => {
185+
console.log(` - ${keyPath.join(".")}`);
186+
});
187+
if (result.extra.length > 10) {
188+
console.log(
189+
colorize(` ... and ${result.extra.length - 10} more`, "yellow"),
190+
);
191+
}
192+
}
193+
194+
console.log("");
195+
}
196+
}
197+
198+
console.log("─".repeat(60));
199+
200+
// Summary
201+
const validCount = Object.values(results).filter((r) => r.valid).length;
202+
const totalCount = LANGUAGES.length;
203+
204+
if (hasErrors) {
205+
console.log(
206+
colorize(
207+
`\n✗ Validation failed: ${validCount}/${totalCount} languages passed`,
208+
"red",
209+
),
210+
);
211+
process.exit(1);
212+
} else {
213+
console.log(
214+
colorize(
215+
`\n✓ All ${totalCount} languages have complete translations!`,
216+
"green",
217+
),
218+
);
219+
process.exit(0);
220+
}
221+
}
222+
223+
// Run validation
224+
validateTranslations();

0 commit comments

Comments
 (0)