mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-14 11:49:33 -04:00
182 lines
6.4 KiB
JavaScript
182 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, id), 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', 'id']
|
|
|
|
const LANGUAGE_NAMES = {
|
|
it: 'Italian',
|
|
es: 'Spanish',
|
|
de: 'German',
|
|
'zh-CN': 'Simplified Chinese',
|
|
id: 'Indonesian',
|
|
}
|
|
|
|
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)
|
|
})
|