mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-16 04:38:50 -04:00
feat(react-ui): localize models and fix 'Import' typo (#10341)
* feat(react-ui): localize SearchableSelect component Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com> * feat(react-ui): localize ModelSelector component Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com> * fix(react-ui): dynamically localize back navigation caption to match page title Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com> * feat(react-ui): localize back navigation state on Models page Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com> * feat(react-ui): localize ModelEditor page Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com> * fix(react-ui): fix Indonesian typo 'Import' to 'Impor' in importModel locale Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com> --------- Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com> Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
"type": "Type",
|
||||
"value": "Value",
|
||||
"search": "Search...",
|
||||
"selectPlaceholder": "Select an option..."
|
||||
"selectPlaceholder": "Select an option...",
|
||||
"noMatch": "No matches"
|
||||
},
|
||||
"time": {
|
||||
"now": "now",
|
||||
|
||||
38
core/http/react-ui/public/locales/en/modelEditor.json
Normal file
38
core/http/react-ui/public/locales/en/modelEditor.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
core/http/react-ui/public/locales/id/modelEditor.json
Normal file
38
core/http/react-ui/public/locales/id/modelEditor.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
<div style={{ padding: '6px 10px', fontSize: '0.8125rem', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||
No matches
|
||||
{t('forms.noMatch')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 <div className="page page--medium" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
|
||||
if (metaError) return <div className="page page--medium"><div className="empty-state"><p className="empty-state-text">Failed to load config metadata: {metaError}</p></div></div>
|
||||
|
||||
const backPage = isCreateMode && selectedTemplate ? t('actions.templates')
|
||||
: backState ? backState.fromLabel
|
||||
: isCreateMode ? t('actions.models') : t('actions.system')
|
||||
|
||||
return (
|
||||
<FormContextProvider formData={values}>
|
||||
<div className="page page--medium" style={{ padding: 0 }}>
|
||||
@@ -406,10 +412,10 @@ export default function ModelEditor() {
|
||||
padding: 'var(--spacing-lg) var(--spacing-lg) var(--spacing-md)',
|
||||
}}>
|
||||
<div>
|
||||
<h1 className="page-title">{isCreateMode ? 'Add Model' : 'Model Editor'}</h1>
|
||||
<h1 className="page-title">{isCreateMode ? t('title.add') : t('title.edit')}</h1>
|
||||
<p className="page-subtitle">
|
||||
{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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -419,20 +425,16 @@ export default function ModelEditor() {
|
||||
else if (backState) navigate(backState.from)
|
||||
else navigate(isCreateMode ? '/app/models' : '/app/manage')
|
||||
}}>
|
||||
<i className="fas fa-arrow-left" /> Back to {
|
||||
isCreateMode && selectedTemplate ? 'Templates'
|
||||
: backState ? backState.fromLabel
|
||||
: isCreateMode ? 'Models' : 'Manage'
|
||||
}
|
||||
<i className="fas fa-arrow-left" /> {t('actions.backTo', {page: backPage})}
|
||||
</button>
|
||||
{!showTemplateSelector && tab === 'interactive' && (
|
||||
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleInteractiveSave} disabled={saving || !isDirty}>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isCreateMode ? 'Create Model' : (isDirty ? 'Save Changes' : 'Saved')}</>}
|
||||
{saving ? <><LoadingSpinner size="sm" /> {t('actions.saving')}</> : <><i className="fas fa-save" /> {isCreateMode ? t('actions.createModel') : (isDirty ? t('actions.saveChanges') : t('actions.saved'))}</>}
|
||||
</button>
|
||||
)}
|
||||
{!showTemplateSelector && tab === 'yaml' && (
|
||||
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleYamlSave} disabled={saving || !isDirty}>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isCreateMode ? 'Create Model' : (isDirty ? 'Save Changes' : 'Saved')}</>}
|
||||
{saving ? <><LoadingSpinner size="sm" /> {t('actions.saving')}</> : <><i className="fas fa-save" /> {isCreateMode ? t('actions.createModel') : (isDirty ? t('actions.saveChanges') : t('actions.saved'))}</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<button
|
||||
key={t}
|
||||
key={tb}
|
||||
onClick={() => {
|
||||
if (active) return
|
||||
if (blocked) { setTabSwitchWarning(true); return }
|
||||
setTabSwitchWarning(false)
|
||||
setTab(t)
|
||||
setTab(tb)
|
||||
}}
|
||||
style={{
|
||||
padding: 'var(--spacing-sm) var(--spacing-md)', border: 'none',
|
||||
@@ -471,8 +473,8 @@ export default function ModelEditor() {
|
||||
transition: 'all 150ms',
|
||||
}}
|
||||
>
|
||||
<i className={`fas ${t === 'interactive' ? 'fa-sliders' : 'fa-code'}`} style={{ marginRight: 6 }} />
|
||||
{t === 'interactive' ? 'Interactive' : 'YAML'}
|
||||
<i className={`fas ${tb === 'interactive' ? 'fa-sliders' : 'fa-code'}`} style={{ marginRight: 6 }} />
|
||||
{tb === 'interactive' ? t('tabs.interactive') : t('tabs.yaml')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -485,7 +487,7 @@ export default function ModelEditor() {
|
||||
background: 'var(--color-warning-light, rgba(245, 158, 11, 0.08))',
|
||||
}}>
|
||||
<i className="fas fa-exclamation-triangle" />
|
||||
<span>Save or discard changes before switching tabs.</span>
|
||||
<span>{t('actions.switchWarning')}</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ marginLeft: 'auto', padding: '2px 10px', fontSize: '0.75rem' }}
|
||||
@@ -500,7 +502,7 @@ export default function ModelEditor() {
|
||||
setTab(tab === 'yaml' ? 'interactive' : 'yaml')
|
||||
}}
|
||||
>
|
||||
Discard & Switch
|
||||
{t('actions.discardAndSwitch')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -512,7 +514,7 @@ export default function ModelEditor() {
|
||||
<div style={{ padding: '0 var(--spacing-lg) var(--spacing-lg)' }}>
|
||||
{isCreateMode && (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-sm)' }}>
|
||||
Edit the YAML directly. The model name must be set in the YAML for create to work.
|
||||
{t('tabs.yamlDescription')}
|
||||
</p>
|
||||
)}
|
||||
<CodeEditor
|
||||
@@ -533,18 +535,18 @@ export default function ModelEditor() {
|
||||
<div className="card" style={{ padding: 'var(--spacing-md)' }}>
|
||||
<label className="form-label" style={{ fontWeight: 600 }}>
|
||||
<i className="fas fa-tag" style={{ marginRight: '6px', color: 'var(--color-primary)' }} />
|
||||
Model Name
|
||||
{t('forms.modelName.label')}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={values['name'] || ''}
|
||||
onChange={e => handleFieldChange('name', e.target.value)}
|
||||
placeholder="my-model-name"
|
||||
placeholder={t('forms.modelName.placeholder')}
|
||||
style={{ maxWidth: 400 }}
|
||||
/>
|
||||
<p style={{ marginTop: 'var(--spacing-xs)', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
|
||||
Use letters, numbers, hyphens, underscores, and dots only.
|
||||
{t('forms.modelName.hint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -596,7 +598,7 @@ export default function ModelEditor() {
|
||||
))}
|
||||
{activeSections.length === 0 && (
|
||||
<div style={{ padding: '12px', fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
||||
Use the search bar above to add fields
|
||||
{t('forms.empty.nav')}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
@@ -610,9 +612,9 @@ export default function ModelEditor() {
|
||||
{activeSections.length === 0 && (
|
||||
<div className="card" style={{ padding: 'var(--spacing-xl)', textAlign: 'center' }}>
|
||||
<i className="fas fa-sliders" style={{ fontSize: '2rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-md)' }} />
|
||||
<h3 style={{ marginBottom: 'var(--spacing-sm)' }}>No fields configured</h3>
|
||||
<h3 style={{ marginBottom: 'var(--spacing-sm)' }}>{t('forms.empty.title')}</h3>
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Use the search bar above to find and add configuration fields.
|
||||
{t('forms.empty.text')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -288,7 +288,7 @@ export default function Models() {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, 'Models') })}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, t('models')) })}>
|
||||
<i className="fas fa-plus" /> {t('actions.addModel')}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
|
||||
|
||||
Reference in New Issue
Block a user