wgui: basic i18n support, refactoring: use LayoutState, translation framework (LLM-based generator)

This commit is contained in:
Aleksander
2025-08-02 23:31:23 +02:00
parent 4e46c45bcf
commit eaa81450b5
45 changed files with 916 additions and 223 deletions

View File

@@ -0,0 +1,37 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "llm_translator",
"dependencies": {
"ollama": "^0.5.14",
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^22.13.13",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
"@types/node": ["@types/node@22.17.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ=="],
"@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="],
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"ollama": ["ollama@0.5.16", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
}
}

View File

@@ -0,0 +1,13 @@
You are a translator for the VR application which translates strings from English to {TARGET_LANG} language.
Info: This program is intended to be used as an utility to easily access your desktop display from within VR.
Glossary:
wlx-overlay-s: The name of this software (also called WlxOverlay-S)
WayVR: A Wayland compositor intended to be used in VR
WayVR Dashboard: An application (and game) launcher which is displayed in front of the user
OpenVR: API made by Valve
OpenXR: API made by Khronos
OSC: OpenSoundControl
You will be given the input in the code blocks which needs to be translated to {TARGET_LANG} language. Write only the result in codeblocks, do not explain. Keep any newlines and other important formatting-required identifiers as-is.

186
scripts/translator/main.ts Normal file
View File

@@ -0,0 +1,186 @@
import { exit } from "process";
import * as fsp from "fs/promises";
import path from "path";
import * as fs from "fs";
import ollama from 'ollama'
const model_name = process.env["MODEL"] as string;
const template_name = process.env["TEMPLATE"] as string;
let lang_path = process.env["LANG_PATH"] as string;
if (model_name === undefined) {
console.log("MODEL not set");
exit(-1);
}
if (template_name === undefined) {
console.log("TEMPLATE not set");
exit(-1);
}
if (lang_path === undefined) {
console.log("LANG_PATH not set. Try \"LANG_PATH=../../uidev/assets/lang/ ./run.sh\"");
exit(-1);
}
lang_path = path.resolve(__dirname + "/" + lang_path);
if (lang_path === undefined || !fs.existsSync(lang_path)) {
console.log("Invalid or non-existent LANG_PATH");
exit(-1);
}
const current_path = path.resolve(__dirname);
const templates_path = path.resolve(__dirname + "/templates");
async function loop_object(obj: any, initial_str: string, callback: (key: string, value: string) => Promise<void>) {
for (var key in obj) {
let full_key = initial_str + key;
if (typeof obj[key] === "object" && obj[key] !== null) {
await loop_object(obj[key], full_key + ".", callback)
} else if (obj.hasOwnProperty(key)) {
await callback(full_key, obj[key])
}
}
}
function extract_backticks(str: string) {
const regex = /`([^`]+)`/g;
return str.match(regex)?.map(match => match.slice(1, -1).trim());
}
function set_i18n_key(obj: any, key: string, value: string | undefined) {
const parts = key.split('.');
let cur_level = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
if (!cur_level[part]) {
cur_level[part] = {};
}
cur_level = cur_level[part];
}
cur_level[parts[parts.length - 1]!] = value;
}
function key_exists(obj: any, key: string) {
const parts = key.split('.');
let level = obj;
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
if (!level || !level[part]) {
return false;
}
level = level[part];
}
return true;
};
interface Example {
key: string;
en: string;
translated: string;
}
interface Template {
full_name: string; // "Polish"
examples: Example[]
}
function gen_prompt(description: string, template: Template, key: string, english_translation: string) {
let num = 1;
for (const example of template.examples) {
description += "\nExample " + num + ":\n\n";
description += "Translate key `" + example.key + "` from English to " + template.full_name + ":\n\n";
description += "```\n";
description += example.en + "\n";
description += "```\n\n";
description += "Result:\n\n";
description += "```\n";
description += example.translated + "\n";
description += "```\n";
num += 1;
}
description += "\nEnd of examples.\n\n";
description += "Translate key `" + key + "` from English to " + template.full_name + ":\n\n";
description += "```\n";
description += english_translation + "\n";
description += "```\n";
return description;
}
async function run() {
const template = JSON.parse(await fsp.readFile(templates_path + "/" + template_name + ".json", "utf-8")) as Template;
let description_txt = await fsp.readFile(current_path + "/description.txt", "utf-8");
description_txt = description_txt.replaceAll("{TARGET_LANG}", template.full_name);
const orig_english_json = JSON.parse(await fsp.readFile(lang_path + "/en.json") as any);
let orig_translated_json = {};
try {
orig_translated_json = JSON.parse((await fsp.readFile(lang_path + "/" + template_name + ".json")).toString());
}
catch (_e) { }
let llm_translated_json = {};
const translated_json_path = lang_path + "/" + template_name + ".json";
if (await fsp.exists(translated_json_path)) {
llm_translated_json = JSON.parse((await fsp.readFile(translated_json_path)).toString());
}
let human = 0;
let llm = 0;
let total_count = 0;
await loop_object(orig_english_json, "", async () => {
total_count += 1;
});
await loop_object(llm_translated_json, "", async (key, _) => {
if (!key_exists(orig_english_json, key)) {
console.log("Removing key", key);
set_i18n_key(llm_translated_json, key, undefined);
fsp.writeFile(translated_json_path, JSON.stringify(llm_translated_json, undefined, 2));
}
});
await loop_object(orig_english_json, "", async (key, english_translation) => {
if (key_exists(orig_translated_json, key)) {
human += 1;
return;
}
if (key_exists(llm_translated_json, key)) {
llm += 1;
return;
}
console.log("Translating", key, "...");
llm++;
const prompt = gen_prompt(description_txt, template, key, english_translation);
const response = await ollama.chat({
model: model_name,
messages: [{ role: "user", content: prompt }],
})
const msg = extract_backticks(response.message.content);
if (msg === undefined || msg[0] === undefined) {
throw new Error("backticks failed. Raw content: " + response.message.content);
}
console.log(" >>>", msg);
set_i18n_key(llm_translated_json, key, msg[0]);
fsp.writeFile(translated_json_path, JSON.stringify(llm_translated_json, undefined, 2));
});
console.log("\"" + template_name + "\" translation finished,", human, "were already human translated,", llm, "llm-translated (" + Math.round((llm / total_count) * 100.0) + "% machine-translated)");
}
run().catch((e) => {
console.log("Fatal error:", e);
exit(-1);
});

108
scripts/translator/package-lock.json generated Normal file
View File

@@ -0,0 +1,108 @@
{
"name": "llm_translator",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "llm_translator",
"dependencies": {
"ollama": "^0.5.14"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^22.13.13"
},
"peerDependencies": {
"typescript": "^5"
}
},
"node_modules/@types/bun": {
"version": "1.2.19",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.19.tgz",
"integrity": "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.2.19"
}
},
"node_modules/@types/node": {
"version": "22.17.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz",
"integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/bun-types": {
"version": "1.2.19",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.19.tgz",
"integrity": "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"peerDependencies": {
"@types/react": "^19"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/ollama": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.16.tgz",
"integrity": "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w==",
"license": "MIT",
"dependencies": {
"whatwg-fetch": "^3.6.20"
}
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^22.13.13"
},
"name": "llm_translator",
"module": "index.ts",
"type": "module",
"private": true,
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"ollama": "^0.5.14"
}
}

12
scripts/translator/run.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
bun install
export MODEL="gemma3:12b"
TEMPLATE="pl" bun main.ts
TEMPLATE="de" bun main.ts
TEMPLATE="ja" bun main.ts
TEMPLATE="es" bun main.ts

View File

@@ -0,0 +1,20 @@
{
"full_name": "German",
"examples": [
{
"key": "BAR.OPACITY",
"en": "Opacity",
"translated": "Undurchsichtigkeit"
},
{
"key": "PANEL.CLOSE",
"en": "Close panel",
"translated": "Schließen Sie das Panel"
},
{
"key": "SETTINGS.LEFT_HANDED_MODE",
"en": "Left-handed mode",
"translated": "Linkshändige Modus"
}
]
}

View File

@@ -0,0 +1,20 @@
{
"full_name": "Spanish",
"examples": [
{
"key": "BAR.OPACITY",
"en": "Opacity",
"translated": "Opacidad"
},
{
"key": "PANEL.CLOSE",
"en": "Close panel",
"translated": "Cerrar panel"
},
{
"key": "SETTINGS.LEFT_HANDED_MODE",
"en": "Left-handed mode",
"translated": "Modo para zurdos"
}
]
}

View File

@@ -0,0 +1,20 @@
{
"full_name": "Japanese",
"examples": [
{
"key": "BAR.OPACITY",
"en": "Opacity",
"translated": "不透明度"
},
{
"key": "PANEL.CLOSE",
"en": "Close panel",
"translated": "パネルを閉じる"
},
{
"key": "SETTINGS.LEFT_HANDED_MODE",
"en": "Left-handed mode",
"translated": "左利きモード"
}
]
}

View File

@@ -0,0 +1,20 @@
{
"full_name": "Polish",
"examples": [
{
"key": "BAR.OPACITY",
"en": "Opacity",
"translated": "Przezroczystość"
},
{
"key": "PANEL.CLOSE",
"en": "Close panel",
"translated": "Zamknij panel"
},
{
"key": "SETTINGS.LEFT_HANDED_MODE",
"en": "Left-handed mode",
"translated": "Tryb leworęczny"
}
]
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"lib": ["esnext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}