From 9ba8521e7eeb14d012bd79d24204382f1d09beda Mon Sep 17 00:00:00 2001 From: "Dedy F. Setyawan" Date: Mon, 15 Jun 2026 23:26:27 +0700 Subject: [PATCH] feat(react-ui): localize models and fix 'Import' typo (#10341) * feat(react-ui): localize SearchableSelect component Signed-off-by: Dedy F. Setyawan * feat(react-ui): localize ModelSelector component Signed-off-by: Dedy F. Setyawan * fix(react-ui): dynamically localize back navigation caption to match page title Signed-off-by: Dedy F. Setyawan * feat(react-ui): localize back navigation state on Models page Signed-off-by: Dedy F. Setyawan * feat(react-ui): localize ModelEditor page Signed-off-by: Dedy F. Setyawan * fix(react-ui): fix Indonesian typo 'Import' to 'Impor' in importModel locale Signed-off-by: Dedy F. Setyawan --------- Signed-off-by: Dedy F. Setyawan Co-authored-by: Ettore Di Giacinto --- .../e2e/model-editor-back-nav.spec.js | 6 +-- .../react-ui/public/locales/en/common.json | 3 +- .../public/locales/en/modelEditor.json | 38 ++++++++++++++ .../react-ui/public/locales/en/models.json | 7 +++ .../react-ui/public/locales/id/common.json | 5 +- .../public/locales/id/importModel.json | 4 +- .../public/locales/id/modelEditor.json | 38 ++++++++++++++ .../react-ui/public/locales/id/models.json | 9 +++- .../react-ui/src/components/ModelSelector.jsx | 6 ++- .../src/components/SearchableSelect.jsx | 4 +- core/http/react-ui/src/pages/Manage.jsx | 2 +- core/http/react-ui/src/pages/ModelEditor.jsx | 50 ++++++++++--------- core/http/react-ui/src/pages/Models.jsx | 2 +- 13 files changed, 136 insertions(+), 38 deletions(-) create mode 100644 core/http/react-ui/public/locales/en/modelEditor.json create mode 100644 core/http/react-ui/public/locales/id/modelEditor.json diff --git a/core/http/react-ui/e2e/model-editor-back-nav.spec.js b/core/http/react-ui/e2e/model-editor-back-nav.spec.js index 5ad085aea..973d93967 100644 --- a/core/http/react-ui/e2e/model-editor-back-nav.spec.js +++ b/core/http/react-ui/e2e/model-editor-back-nav.spec.js @@ -44,7 +44,7 @@ test.describe('Model Editor — Back navigation', () => { await mockEditorEndpoints(page) }) - test('Back returns to Manage with a "Back to Manage" caption', async ({ page }) => { + test('Back returns to Manage with a "Back to System" caption', async ({ page }) => { await page.goto('/app/manage') await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 }) @@ -55,7 +55,7 @@ test.describe('Model Editor — Back navigation', () => { await page.getByRole('menuitem', { name: 'Edit configuration' }).click() await expect(page).toHaveURL(/\/app\/model-editor\//) - const back = page.getByRole('button', { name: /Back to Manage/ }) + const back = page.getByRole('button', { name: /Back to System/ }) await expect(back).toBeVisible({ timeout: 10_000 }) await back.click() @@ -89,6 +89,6 @@ test.describe('Model Editor — Back navigation', () => { test('falls back to "Back to Manage" on a direct visit with no origin state', async ({ page }) => { await page.goto('/app/model-editor/mock-model') - await expect(page.getByRole('button', { name: /Back to Manage/ })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /Back to System/ })).toBeVisible({ timeout: 10_000 }) }) }) diff --git a/core/http/react-ui/public/locales/en/common.json b/core/http/react-ui/public/locales/en/common.json index a18ecdb3a..7767ebbae 100644 --- a/core/http/react-ui/public/locales/en/common.json +++ b/core/http/react-ui/public/locales/en/common.json @@ -86,7 +86,8 @@ "type": "Type", "value": "Value", "search": "Search...", - "selectPlaceholder": "Select an option..." + "selectPlaceholder": "Select an option...", + "noMatch": "No matches" }, "time": { "now": "now", diff --git a/core/http/react-ui/public/locales/en/modelEditor.json b/core/http/react-ui/public/locales/en/modelEditor.json new file mode 100644 index 000000000..ef4ddaa8e --- /dev/null +++ b/core/http/react-ui/public/locales/en/modelEditor.json @@ -0,0 +1,38 @@ +{ + "title": { + "add": "Add Model", + "edit": "Model Editor" + }, + "subtitle": { + "chooseModelType": "Choose a model type to get started", + "newModel": "New model" + }, + "actions": { + "backTo": "Back to {{page}}", + "system": "System", + "templates": "Templates", + "createModel": "Create Model", + "saveChanges": "Save Changes", + "saving": "Saving...", + "saved": "Saved", + "switchWarning": "Save or discard changes before switching tabs.", + "discardAndSwitch": "Discard & Switch" + }, + "tabs": { + "interactive": "Interactive", + "yaml": "YAML", + "yamlDescription": "Edit the YAML directly. The model name must be set in the YAML for create to work." + }, + "forms": { + "modelName": { + "label": "Model Name", + "placeholder": "my-model-name", + "hint": "Use letters, numbers, hyphens, underscores, and dots only." + }, + "empty": { + "nav": "Use the search bar above to add fields", + "title": "No fields configured", + "text": "Use the search bar above to find and add configuration fields." + } + } +} diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json index 603bd809c..13eb3592e 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -1,6 +1,7 @@ { "title": "Install Models", "subtitle": "Browse and install AI models from the gallery", + "models": "Models", "stats": { "available": "Available", "installed": "Installed" @@ -89,5 +90,11 @@ "loadFailed": "Failed to load models: {{message}}", "installFailed": "Failed to install: {{message}}", "deleteFailed": "Failed to delete: {{message}}" + }, + "selector": { + "loading": "Loading models...", + "selectModel": "Select model...", + "searchPlaceholder": "Search models...", + "noModels": "No models available" } } diff --git a/core/http/react-ui/public/locales/id/common.json b/core/http/react-ui/public/locales/id/common.json index b20eeb00a..80dc59c70 100644 --- a/core/http/react-ui/public/locales/id/common.json +++ b/core/http/react-ui/public/locales/id/common.json @@ -86,7 +86,8 @@ "type": "Tipe", "value": "Nilai", "search": "Cari...", - "selectPlaceholder": "Pilih opsi..." + "selectPlaceholder": "Pilih opsi...", + "noMatch": "Tidak ada yang cocok" }, "time": { "now": "baru saja", @@ -106,4 +107,4 @@ "gigabytes": "GB", "terabytes": "TB" } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/id/importModel.json b/core/http/react-ui/public/locales/id/importModel.json index 0e3a8c49d..a23333873 100644 --- a/core/http/react-ui/public/locales/id/importModel.json +++ b/core/http/react-ui/public/locales/id/importModel.json @@ -1,7 +1,7 @@ { "title": "Impor Model Baru", "subtitle": { - "simple": "Import model dari URI — deteksi otomatis memilih backend.", + "simple": "Impor model dari URI — deteksi otomatis memilih backend.", "powerYaml": "Tulis konfigurasi YAML lengkap untuk model.", "powerPrefs": "Preferensi impor tingkat lanjut." }, @@ -139,4 +139,4 @@ "local": "File konfigurasi YAML lokal" } } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/id/modelEditor.json b/core/http/react-ui/public/locales/id/modelEditor.json new file mode 100644 index 000000000..16869d1bd --- /dev/null +++ b/core/http/react-ui/public/locales/id/modelEditor.json @@ -0,0 +1,38 @@ +{ + "title": { + "add": "Tambah Model", + "edit": "Editor Model" + }, + "subtitle": { + "chooseModelType": "Pilih tipe model untuk memulai", + "newModel": "Model baru" + }, + "actions": { + "backTo": "Kembali ke {{page}}", + "system": "Sistem", + "templates": "Templat", + "createModel": "Buat Model", + "saveChanges": "Simpan Perubahan", + "saving": "Menyimpan...", + "saved": "Tersimpan", + "switchWarning": "Simpan atau buang perubahan sebelum beralih tab.", + "discardAndSwitch": "Buang & Beralih" + }, + "tabs": { + "interactive": "Interaktif", + "yaml": "YAML", + "yamlDescription": "Edit YAML secara langsung. Nama model harus diatur di YAML agar pembuatan berhasil." + }, + "forms": { + "modelName": { + "label": "Nama Model", + "placeholder": "nama-model-saya", + "hint": "Gunakan huruf, angka, tanda hubung, garis bawah, dan titik saja." + }, + "empty": { + "nav": "Gunakan kolom pencarian di atas untuk menambahkan field", + "title": "Tidak ada field yang dikonfigurasi", + "text": "Gunakan kolom pencarian di atas untuk menemukan dan menambahkan field konfigurasi." + } + } +} diff --git a/core/http/react-ui/public/locales/id/models.json b/core/http/react-ui/public/locales/id/models.json index d88a04116..a8c5404fa 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -1,6 +1,7 @@ { "title": "Instal Model", "subtitle": "Telusuri dan instal model AI dari galeri", + "models": "Model", "stats": { "available": "Tersedia", "installed": "Terinstal" @@ -89,5 +90,11 @@ "loadFailed": "Gagal memuat model: {{message}}", "installFailed": "Gagal menginstal: {{message}}", "deleteFailed": "Gagal menghapus: {{message}}" + }, + "selector": { + "loading": "Memuat model...", + "selectModel": "Pilih model...", + "searchPlaceholder": "Cari model...", + "noModels": "Model tidak tersedia" } -} \ No newline at end of file +} diff --git a/core/http/react-ui/src/components/ModelSelector.jsx b/core/http/react-ui/src/components/ModelSelector.jsx index 1974a4927..9009524ee 100644 --- a/core/http/react-ui/src/components/ModelSelector.jsx +++ b/core/http/react-ui/src/components/ModelSelector.jsx @@ -1,12 +1,14 @@ import { useEffect, useMemo } from 'react' import { useModels } from '../hooks/useModels' import SearchableSelect from './SearchableSelect' +import { useTranslation } from 'react-i18next' export default function ModelSelector({ value, onChange, capability, className = '', options: externalOptions, loading: externalLoading, disabled: externalDisabled, searchPlaceholder, style, }) { + const { t } = useTranslation('models') // Skip capability fetch when external options are provided (capability will be undefined) const { models: hookModels, loading: hookLoading } = useModels(externalOptions ? undefined : capability) @@ -28,8 +30,8 @@ export default function ModelSelector({ value={value || ''} onChange={onChange} options={modelNames} - placeholder={isLoading ? 'Loading models...' : (modelNames.length === 0 ? 'No models available' : 'Select model...')} - searchPlaceholder={searchPlaceholder || 'Search models...'} + placeholder={isLoading ? t('selector.loading') : (modelNames.length === 0 ? t('selector.noModels') : t('selector.selectModel'))} + searchPlaceholder={searchPlaceholder || t('selector.searchPlaceholder')} disabled={isDisabled} className={className} style={style} diff --git a/core/http/react-ui/src/components/SearchableSelect.jsx b/core/http/react-ui/src/components/SearchableSelect.jsx index 50324d262..52430adab 100644 --- a/core/http/react-ui/src/components/SearchableSelect.jsx +++ b/core/http/react-ui/src/components/SearchableSelect.jsx @@ -1,10 +1,12 @@ import { useState, useEffect, useRef, useMemo } from 'react' +import { useTranslation } from 'react-i18next' export default function SearchableSelect({ value, onChange, options, placeholder = 'Select...', allOption, searchPlaceholder = 'Search...', disabled = false, style, className = '', }) { + const { t } = useTranslation('common') const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [focusIndex, setFocusIndex] = useState(-1) @@ -226,7 +228,7 @@ export default function SearchableSelect({ })} {filtered.length === 0 && !allOption && (
- No matches + {t('forms.noMatch')}
)} diff --git a/core/http/react-ui/src/pages/Manage.jsx b/core/http/react-ui/src/pages/Manage.jsx index 79c57413f..10d684a6e 100644 --- a/core/http/react-ui/src/pages/Manage.jsx +++ b/core/http/react-ui/src/pages/Manage.jsx @@ -675,7 +675,7 @@ export default function Manage() { onClick: () => handleTogglePinned(model.id, model.pinned), disabled: pinningModels.has(model.id) || !!model.disabled }, { key: 'edit', icon: 'fa-pen-to-square', label: 'Edit configuration', - onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`, { state: fromState(location, 'Manage') }) }, + onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`, { state: fromState(location, t('manage.title')) }) }, { key: 'logs', icon: 'fa-terminal', label: 'Backend logs', onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) }, { divider: true }, diff --git a/core/http/react-ui/src/pages/ModelEditor.jsx b/core/http/react-ui/src/pages/ModelEditor.jsx index 362924923..83d53f0e6 100644 --- a/core/http/react-ui/src/pages/ModelEditor.jsx +++ b/core/http/react-ui/src/pages/ModelEditor.jsx @@ -12,6 +12,7 @@ import ConfigFieldRenderer from '../components/ConfigFieldRenderer' import { FormContextProvider } from '../contexts/FormContext' import TemplateSelector from '../components/TemplateSelector' import MODEL_TEMPLATES from '../utils/modelTemplates' +import { useTranslation } from 'react-i18next' const SECTION_ICONS = { general: 'fa-cog', llm: 'fa-microchip', parameters: 'fa-sliders', @@ -70,6 +71,7 @@ function defaultForType(uiType) { } export default function ModelEditor() { + const { t } = useTranslation('modelEditor') const { name } = useParams() const [searchParams] = useSearchParams() const navigate = useNavigate() @@ -397,6 +399,10 @@ export default function ModelEditor() { if (loading) return
if (metaError) return

Failed to load config metadata: {metaError}

+ const backPage = isCreateMode && selectedTemplate ? t('actions.templates') + : backState ? backState.fromLabel + : isCreateMode ? t('actions.models') : t('actions.system') + return (
@@ -406,10 +412,10 @@ export default function ModelEditor() { padding: 'var(--spacing-lg) var(--spacing-lg) var(--spacing-md)', }}>
-

{isCreateMode ? 'Add Model' : 'Model Editor'}

+

{isCreateMode ? t('title.add') : t('title.edit')}

{isCreateMode - ? (showTemplateSelector ? 'Choose a model type to get started' : `New model${selectedTemplate ? ` — ${selectedTemplate.label}` : ''}`) + ? (showTemplateSelector ? t('subtitle.chooseModelType') : `${t('subtitle.newModel')}${selectedTemplate ? ` — ${selectedTemplate.label}` : ''}`) : decodeURIComponent(name)}

@@ -419,20 +425,16 @@ export default function ModelEditor() { else if (backState) navigate(backState.from) else navigate(isCreateMode ? '/app/models' : '/app/manage') }}> - Back to { - isCreateMode && selectedTemplate ? 'Templates' - : backState ? backState.fromLabel - : isCreateMode ? 'Models' : 'Manage' - } + {t('actions.backTo', {page: backPage})} {!showTemplateSelector && tab === 'interactive' && ( )} {!showTemplateSelector && tab === 'yaml' && ( )}
@@ -448,17 +450,17 @@ export default function ModelEditor() { display: 'flex', gap: 0, padding: '0 var(--spacing-lg)', borderBottom: '1px solid var(--color-border)', }}> - {['interactive', 'yaml'].map(t => { - const active = tab === t + {['interactive', 'yaml'].map(tb => { + const active = tab === tb const blocked = !active && isDirty return ( ) })} @@ -485,7 +487,7 @@ export default function ModelEditor() { background: 'var(--color-warning-light, rgba(245, 158, 11, 0.08))', }}> - Save or discard changes before switching tabs. + {t('actions.switchWarning')} )} @@ -512,7 +514,7 @@ export default function ModelEditor() {
{isCreateMode && (

- Edit the YAML directly. The model name must be set in the YAML for create to work. + {t('tabs.yamlDescription')}

)} handleFieldChange('name', e.target.value)} - placeholder="my-model-name" + placeholder={t('forms.modelName.placeholder')} style={{ maxWidth: 400 }} />

- Use letters, numbers, hyphens, underscores, and dots only. + {t('forms.modelName.hint')}

@@ -596,7 +598,7 @@ export default function ModelEditor() { ))} {activeSections.length === 0 && (
- Use the search bar above to add fields + {t('forms.empty.nav')}
)} @@ -610,9 +612,9 @@ export default function ModelEditor() { {activeSections.length === 0 && (
-

No fields configured

+

{t('forms.empty.title')}

- Use the search bar above to find and add configuration fields. + {t('forms.empty.text')}

)} diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index be8b04ae2..7e2122801 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -288,7 +288,7 @@ export default function Models() { -