feat(ui): add control ui locale sync pipeline

This commit is contained in:
Vincent Koc
2026-04-05 12:48:59 +01:00
parent 21d5f7a915
commit ee4fe4fb1e
8 changed files with 1242 additions and 3 deletions

View File

@@ -743,6 +743,11 @@ jobs:
continue-on-error: true
run: pnpm lint:ui:no-raw-window-open
- name: Check control UI locale sync
id: control_ui_i18n
continue-on-error: true
run: pnpm ui:i18n:check
- name: Run gateway watch regression harness
id: gateway_watch_regression
continue-on-error: true
@@ -775,6 +780,7 @@ jobs:
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome }}
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
run: |
failures=0
@@ -795,6 +801,7 @@ jobs:
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
name="${result%%|*}"
outcome="${result#*|}"

View File

@@ -0,0 +1,66 @@
name: Control UI Locale Refresh
on:
release:
types:
- published
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: control-ui-locale-refresh
cancel-in-progress: false
jobs:
refresh:
if: github.repository == 'openclaw/openclaw'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: true
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Ensure translation provider secrets exist
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_CONTROL_UI_I18N_OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.OPENCLAW_CONTROL_UI_I18N_ANTHROPIC_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo "Missing control UI i18n provider secret."
exit 1
fi
- name: Refresh control UI locale files
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_CONTROL_UI_I18N_OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.OPENCLAW_CONTROL_UI_I18N_ANTHROPIC_API_KEY }}
run: pnpm ui:i18n:sync
- name: Commit and push locale updates
env:
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if git diff --quiet; then
echo "No control UI locale changes."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add ui/src/i18n package.json scripts/control-ui-i18n.ts
git commit -m "chore(ui): refresh control UI locale files"
git push origin HEAD:"${TARGET_BRANCH}"

View File

@@ -1072,7 +1072,7 @@
"test:startup:bench:smoke": "node --import tsx scripts/bench-cli-startup.ts --preset real --case gatewayStatusJson --runs 1 --warmup 0 --output .artifacts/cli-startup-bench-smoke.json",
"test:startup:bench:update": "node scripts/test-update-cli-startup-bench.mjs",
"test:startup:memory": "node scripts/check-cli-startup-memory.mjs",
"test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test",
"test:ui": "pnpm ui:i18n:check && pnpm lint:ui:no-raw-window-open && pnpm --dir ui test",
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/run-vitest.mjs --config vitest.config.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
@@ -1083,6 +1083,8 @@
"tui:dev": "OPENCLAW_PROFILE=dev node scripts/run-node.mjs --dev tui",
"ui:build": "node scripts/ui.js build",
"ui:dev": "node scripts/ui.js dev",
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
"ui:install": "node scripts/ui.js install"
},
"dependencies": {

1092
scripts/control-ui-i18n.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,16 @@ type LazyLocaleRegistration = {
export const DEFAULT_LOCALE: Locale = "en";
const LAZY_LOCALES: readonly LazyLocale[] = ["zh-CN", "zh-TW", "pt-BR", "de", "es"];
const LAZY_LOCALES: readonly LazyLocale[] = [
"zh-CN",
"zh-TW",
"pt-BR",
"de",
"es",
"ja-JP",
"ko",
"fr",
];
const LAZY_LOCALE_REGISTRY: Record<LazyLocale, LazyLocaleRegistration> = {
"zh-CN": {
@@ -33,6 +42,18 @@ const LAZY_LOCALE_REGISTRY: Record<LazyLocale, LazyLocaleRegistration> = {
exportName: "es",
loader: () => import("../locales/es.ts"),
},
"ja-JP": {
exportName: "ja_JP",
loader: () => import("../locales/ja-JP.ts"),
},
ko: {
exportName: "ko",
loader: () => import("../locales/ko.ts"),
},
fr: {
exportName: "fr",
loader: () => import("../locales/fr.ts"),
},
};
export const SUPPORTED_LOCALES: ReadonlyArray<Locale> = [DEFAULT_LOCALE, ...LAZY_LOCALES];
@@ -58,6 +79,15 @@ export function resolveNavigatorLocale(navLang: string): Locale {
if (navLang.startsWith("es")) {
return "es";
}
if (navLang.startsWith("ja")) {
return "ja-JP";
}
if (navLang.startsWith("ko")) {
return "ko";
}
if (navLang.startsWith("fr")) {
return "fr";
}
return DEFAULT_LOCALE;
}

View File

@@ -1,6 +1,6 @@
export type TranslationMap = { [key: string]: string | TranslationMap };
export type Locale = "en" | "zh-CN" | "zh-TW" | "pt-BR" | "de" | "es";
export type Locale = "en" | "zh-CN" | "zh-TW" | "pt-BR" | "de" | "es" | "ja-JP" | "ko" | "fr";
export interface I18nConfig {
locale: Locale;

View File

@@ -399,6 +399,9 @@ export const en: TranslationMap = {
ptBR: "Português (Brazilian Portuguese)",
de: "Deutsch (German)",
es: "Español (Spanish)",
jaJP: "日本語 (Japanese)",
ko: "한국어 (Korean)",
fr: "Français (French)",
},
cron: {
summary: {

View File

@@ -1,11 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorageMock } from "../../test-helpers/storage.ts";
import * as translate from "../lib/translate.ts";
import { de } from "../locales/de.ts";
import { en } from "../locales/en.ts";
import { es } from "../locales/es.ts";
import { fr } from "../locales/fr.ts";
import { ja_JP } from "../locales/ja-JP.ts";
import { ko } from "../locales/ko.ts";
import { pt_BR } from "../locales/pt-BR.ts";
import { zh_CN } from "../locales/zh-CN.ts";
import { zh_TW } from "../locales/zh-TW.ts";
describe("i18n", () => {
function flatten(value: Record<string, string | Record<string, unknown>>, prefix = ""): string[] {
return Object.entries(value).flatMap(([key, nested]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof nested === "string") {
return [fullKey];
}
return flatten(nested as Record<string, string | Record<string, unknown>>, fullKey);
});
}
beforeEach(async () => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
@@ -80,8 +96,31 @@ describe("i18n", () => {
});
it("keeps the version label available in shipped locales", () => {
expect((de.common as { version?: string }).version).toBeTruthy();
expect((es.common as { version?: string }).version).toBeTruthy();
expect((fr.common as { version?: string }).version).toBeTruthy();
expect((ja_JP.common as { version?: string }).version).toBeTruthy();
expect((ko.common as { version?: string }).version).toBeTruthy();
expect((pt_BR.common as { version?: string }).version).toBeTruthy();
expect((zh_CN.common as { version?: string }).version).toBeTruthy();
expect((zh_TW.common as { version?: string }).version).toBeTruthy();
});
it("keeps shipped locales structurally aligned with English", () => {
const englishKeys = flatten(en);
for (const [locale, value] of Object.entries({
de,
es,
fr,
ja_JP,
ko,
pt_BR,
zh_CN,
zh_TW,
})) {
expect(flatten(value as Record<string, string | Record<string, unknown>>), locale).toEqual(
englishKeys,
);
}
});
});