mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-17 13:10:23 -04:00
Adds end-to-end internationalization to the React UI with five seed
languages (English, Italian, Spanish, German, Simplified Chinese) and
a sidebar-footer language switcher next to the existing theme toggle.
Library: react-i18next + i18next + i18next-http-backend +
i18next-browser-languagedetector. The detector caches the user's
choice in localStorage (key `localai-language`, mirroring the existing
`localai-theme` convention) and updates the `<html lang>` attribute on
change. fallbackLng is `en`, so any missing translation in another
locale falls back transparently.
Translation files live under `public/locales/<lng>/<ns>.json`. They
ride along with the existing `//go:embed react-ui/dist/*` directive,
but the previous SPA route in core/http/app.go only exposed
`/assets/*` from the embedded React build. This commit generalizes
the asset handler into a `serveReactSubdir(subdir)` helper and adds a
matching `/locales/*` route so i18next-http-backend can fetch the
JSONs at runtime. The http-backend `loadPath` is built via the
existing `apiUrl()` helper so instances served under a sub-path (e.g.
`<base href="/ui/">`) resolve correctly.
Namespaces (13): common, nav, errors, auth, home, models, importModel,
chat, agents, skills, collections, media, admin. Translated UI surfaces
include the sidebar/header/footer chrome, login + account flows, the
Home dashboard (incl. the manage-by-chat assistant CTA), the model
gallery + import flow, the chat experience (Chat.jsx + ChatsMenu),
agents/skills/collections list pages, the studio media tabs (Image,
Video, TTS), and the admin page-headers (Settings incl. its section
nav, Manage, Backends, Traces, Nodes, P2P, Users, Usage). Shared
components (ConfirmDialog, Toast) take their default labels from the
common namespace so callers don't need to pass strings explicitly.
Tooling for incremental adoption is included:
- `i18next-parser.config.js` + `npm run i18n:extract` to sweep `t()`
keys into the JSON skeletons.
- `scripts/translate-locales.mjs` (one-off helper) to bootstrap
non-English locales from English source via OpenAI or Anthropic
APIs, with --copy mode as a placeholder fallback. Idempotent;
preserves existing translations unless --overwrite is passed.
Larger config-driven pages (ModelEditor, Settings deep field forms,
AgentChat/AgentCreate, SkillEdit, CollectionDetails, Talk, Sound,
biometrics, FineTune/Quantize, Users modals, Nodes/P2P install
pickers, BackendLogs, Traces deep filters, Explorer) intentionally
keep their inner content untranslated for now — they fall back to
English via fallbackLng so functionality is unaffected, and the
extracted-strings pattern + the bootstrap script make follow-up
extraction straightforward.
The initial Suspense fallback at the root in main.jsx covers the
first JSON fetch on cold load. A simple `.app-boot-spinner` styled
in App.css provides a non-empty paint while the first namespace
loads.
Assisted-by: Claude:claude-opus-4-7 [Bash Read Edit Write Agent]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
181 lines
6.4 KiB
JavaScript
181 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Bootstrap non-English locale JSON files from the English source.
|
|
//
|
|
// Usage:
|
|
// # Fill missing keys in non-English locales by copying the English value
|
|
// # (placeholder — translators / community can refine afterwards).
|
|
// node scripts/translate-locales.mjs --copy
|
|
//
|
|
// # Translate via OpenAI (default provider).
|
|
// OPENAI_API_KEY=sk-... node scripts/translate-locales.mjs --translate
|
|
//
|
|
// # Translate via Anthropic.
|
|
// ANTHROPIC_API_KEY=sk-ant-... node scripts/translate-locales.mjs --translate --provider=anthropic
|
|
//
|
|
// # Dry-run to see what would change.
|
|
// node scripts/translate-locales.mjs --translate --dry-run
|
|
//
|
|
// Behavior:
|
|
// - Reads public/locales/en/*.json as source of truth.
|
|
// - For each other locale (it, es, de, zh-CN), opens the matching file
|
|
// (or creates it). Walks the source object; for each leaf string:
|
|
// * If the target already has a non-empty translation, leave it.
|
|
// * If --copy mode, fill with the English value.
|
|
// * If --translate mode, send the value to the LLM with the key path
|
|
// and locale name as context.
|
|
// - Writes the updated file with sorted keys, 2-space indent.
|
|
//
|
|
// The script is idempotent: existing translations are preserved unless
|
|
// --overwrite is passed. Run it whenever new keys are added in en/.
|
|
|
|
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'node:fs'
|
|
import { join, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT = join(__dirname, '..')
|
|
const LOCALES_DIR = join(ROOT, 'public', 'locales')
|
|
const SOURCE_LOCALE = 'en'
|
|
const TARGET_LOCALES = ['it', 'es', 'de', 'zh-CN']
|
|
|
|
const LANGUAGE_NAMES = {
|
|
it: 'Italian',
|
|
es: 'Spanish',
|
|
de: 'German',
|
|
'zh-CN': 'Simplified Chinese',
|
|
}
|
|
|
|
const argv = process.argv.slice(2)
|
|
const args = new Set(argv)
|
|
const MODE = args.has('--translate') ? 'translate' : 'copy'
|
|
const DRY_RUN = args.has('--dry-run')
|
|
const OVERWRITE = args.has('--overwrite')
|
|
const providerArg = argv.find(a => a.startsWith('--provider='))?.split('=')[1]
|
|
const PROVIDER = providerArg || (process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY ? 'anthropic' : 'openai')
|
|
|
|
function readJson(path) {
|
|
return JSON.parse(readFileSync(path, 'utf8'))
|
|
}
|
|
|
|
function writeJson(path, data) {
|
|
if (DRY_RUN) {
|
|
console.log(`[dry-run] would write ${path}`)
|
|
return
|
|
}
|
|
const dir = dirname(path)
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8')
|
|
}
|
|
|
|
function listNamespaces() {
|
|
return readdirSync(join(LOCALES_DIR, SOURCE_LOCALE))
|
|
.filter((f) => f.endsWith('.json'))
|
|
.map((f) => f.replace(/\.json$/, ''))
|
|
}
|
|
|
|
async function translateString(value, locale, keyPath) {
|
|
if (MODE === 'copy') return value
|
|
const language = LANGUAGE_NAMES[locale] || locale
|
|
const prompt = `Translate the following UI string from English to ${language}.
|
|
Preserve {{interpolation}} placeholders exactly. Preserve trailing punctuation
|
|
and ellipses. Do not add quotes around the result. Reply with the translation only.
|
|
|
|
Key: ${keyPath}
|
|
String: ${value}`
|
|
|
|
if (PROVIDER === 'anthropic') {
|
|
const apiKey = process.env.ANTHROPIC_API_KEY
|
|
if (!apiKey) throw new Error('ANTHROPIC_API_KEY required for --provider=anthropic')
|
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
body: JSON.stringify({
|
|
model: process.env.TRANSLATE_MODEL || 'claude-haiku-4-5-20251001',
|
|
max_tokens: 1024,
|
|
system: 'You are a professional UI string translator. Reply with the translation only, no preamble.',
|
|
messages: [{ role: 'user', content: prompt }],
|
|
}),
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.text()
|
|
throw new Error(`Anthropic API ${res.status}: ${body}`)
|
|
}
|
|
const data = await res.json()
|
|
return (data.content?.[0]?.text || '').trim()
|
|
}
|
|
|
|
const apiKey = process.env.OPENAI_API_KEY
|
|
if (!apiKey) throw new Error('OPENAI_API_KEY required for --provider=openai')
|
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: process.env.TRANSLATE_MODEL || 'gpt-4o-mini',
|
|
temperature: 0.2,
|
|
messages: [
|
|
{ role: 'system', content: 'You are a professional UI string translator.' },
|
|
{ role: 'user', content: prompt },
|
|
],
|
|
}),
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.text()
|
|
throw new Error(`OpenAI API ${res.status}: ${body}`)
|
|
}
|
|
const data = await res.json()
|
|
return data.choices[0].message.content.trim()
|
|
}
|
|
|
|
async function walk(source, target, locale, prefix = '') {
|
|
for (const key of Object.keys(source)) {
|
|
const path = prefix ? `${prefix}.${key}` : key
|
|
const sv = source[key]
|
|
if (sv && typeof sv === 'object' && !Array.isArray(sv)) {
|
|
target[key] = target[key] && typeof target[key] === 'object' ? target[key] : {}
|
|
await walk(sv, target[key], locale, path)
|
|
} else if (typeof sv === 'string') {
|
|
const existing = target[key]
|
|
const hasTranslation = typeof existing === 'string' && existing.trim().length > 0
|
|
if (hasTranslation && !OVERWRITE) continue
|
|
try {
|
|
target[key] = await translateString(sv, locale, path)
|
|
process.stdout.write('.')
|
|
} catch (err) {
|
|
process.stdout.write('!')
|
|
console.error(`\n failed at ${locale}/${path}: ${err.message}`)
|
|
target[key] = existing ?? ''
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const namespaces = listNamespaces()
|
|
console.log(`mode=${MODE} provider=${PROVIDER} locales=${TARGET_LOCALES.join(',')} namespaces=${namespaces.join(',')}`)
|
|
for (const ns of namespaces) {
|
|
const sourcePath = join(LOCALES_DIR, SOURCE_LOCALE, `${ns}.json`)
|
|
const source = readJson(sourcePath)
|
|
for (const locale of TARGET_LOCALES) {
|
|
const targetPath = join(LOCALES_DIR, locale, `${ns}.json`)
|
|
const target = existsSync(targetPath) ? readJson(targetPath) : {}
|
|
process.stdout.write(`${locale}/${ns} `)
|
|
await walk(source, target, locale)
|
|
process.stdout.write('\n')
|
|
writeJson(targetPath, target)
|
|
}
|
|
}
|
|
console.log('done.')
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|