diff --git a/.github/workflows/control-ui-locale-refresh.yml b/.github/workflows/control-ui-locale-refresh.yml index 2e1b61750556..f3b046d1e9c6 100644 --- a/.github/workflows/control-ui-locale-refresh.yml +++ b/.github/workflows/control-ui-locale-refresh.yml @@ -14,6 +14,14 @@ on: - scripts/control-ui-i18n.ts - scripts/control-ui-i18n-verify.ts - scripts/lib/control-ui-i18n-catalog.ts + - scripts/lib/control-ui-i18n-catalog-values.ts + - ui/src/i18n/lib/config-hint-translation.ts + - ui/src/lib/fnv1a.ts + - src/config/schema*.ts + - src/config/zod-schema*.ts + - src/config/media-audio-field-metadata.ts + - src/config/talk-defaults.ts + - src/config/channel-config-keys.ts - scripts/lib/control-ui-i18n-config.json - scripts/lib/control-ui-i18n-config.ts - scripts/lib/control-ui-i18n-raw-copy.ts @@ -281,6 +289,14 @@ jobs: scripts/control-ui-i18n.ts scripts/control-ui-i18n-verify.ts scripts/lib/control-ui-i18n-catalog.ts + scripts/lib/control-ui-i18n-catalog-values.ts + ui/src/i18n/lib/config-hint-translation.ts + ui/src/lib/fnv1a.ts + src/config/schema*.ts + src/config/zod-schema*.ts + src/config/media-audio-field-metadata.ts + src/config/talk-defaults.ts + src/config/channel-config-keys.ts scripts/lib/control-ui-i18n-config.json scripts/lib/control-ui-i18n-config.ts scripts/lib/control-ui-i18n-raw-copy.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index b921003ccf93..a64fc74475f6 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3784,7 +3784,7 @@ ui/src/components/theme-mode-toggle.ts 1 ui/src/components/web-awesome.ts 2 ui/src/components/wizard-step-controls.ts 4 ui/src/i18n/lib/registry.ts 2 -ui/src/i18n/lib/translate.ts 3 +ui/src/i18n/lib/translate.ts 1 ui/src/lib/agents/display.ts 6 ui/src/lib/board/gateway-provider.ts 2 ui/src/lib/board/widget-bridge.ts 3 diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index cfc794574ef3..b8d0e04d2620 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -112,8 +112,9 @@ const WINDOWS_WORKER_BUNDLE_SCOPE_RE = const WINDOWS_WORKER_WORKSPACE_SCOPE_RE = /^src\/(?:infra\/git-exec(?:\.test)?|agents\/worktrees\/(?:git|base-ref)(?:\.test)?|node-host\/node-worker-transfer-client(?:\.test)?|gateway\/worker-environments\/(?:node-worker-tunnel(?:\.test)?|workspace-result-(?:git(?:\.test)?|staging|ref-mutation\.test)|session-repository-checkpoints(?:\.test)?|workspace-sync-(?:scripts|manifest\.test)))\.ts$/; const CONTROL_UI_I18N_SCOPE_RE = - /^(ui\/src\/i18n\/|ui\/config\/control-ui-locales\.ts$|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:(?:catalog|config|raw-copy|sync-plan)\.ts|config\.json))$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/; -const CONTROL_UI_RAW_COPY_SOURCE_RE = /^ui\/src\/(?:app|components|lib|pages)\/.*\.tsx?$/; + /^(ui\/src\/i18n\/|ui\/config\/control-ui-locales\.ts$|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:(?:catalog(?:-values)?|config|raw-copy|sync-plan)\.ts|config\.json))$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/; +const CONTROL_UI_I18N_PRODUCTION_SOURCE_RE = + /^(?:ui\/src\/(?:app|components|lib|pages)\/.*\.tsx?|src\/config\/(?:schema[^/]*|zod-schema[^/]*|media-audio-field-metadata|talk-defaults|channel-config-keys)\.ts)$/; const CONTROL_UI_HARD_GENERATED_I18N_RE = /^ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks\.json|[^/]+\.(?:meta\.json|tm\.jsonl))$/; const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/; @@ -286,7 +287,7 @@ export function detectChangedScope(changedPaths) { if ( CONTROL_UI_I18N_SCOPE_RE.test(path) || - (CONTROL_UI_RAW_COPY_SOURCE_RE.test(path) && !facts.isTestOnly) + (CONTROL_UI_I18N_PRODUCTION_SOURCE_RE.test(path) && !facts.isTestOnly) ) { runControlUiI18n = true; } @@ -386,6 +387,7 @@ function isControlUiCanonicalMemoryMigration(changedPaths, generatedPaths) { "scripts/control-ui-i18n.ts", "scripts/control-ui-i18n-verify.ts", "scripts/lib/control-ui-i18n-catalog.ts", + "scripts/lib/control-ui-i18n-catalog-values.ts", "scripts/lib/control-ui-i18n-sync-plan.ts", "ui/AGENTS.md", "ui/config/control-ui-locales.ts", diff --git a/scripts/control-ui-i18n-verify.ts b/scripts/control-ui-i18n-verify.ts index 3d6bf3c5a469..9993e2322ea9 100644 --- a/scripts/control-ui-i18n-verify.ts +++ b/scripts/control-ui-i18n-verify.ts @@ -5,9 +5,11 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as ts from "typescript"; import { - loadControlUiSourceCatalog, loadControlUiTranslationMemory, materializeControlUiLocaleCatalog, +} from "./lib/control-ui-i18n-catalog-values.ts"; +import { + loadControlUiSourceCatalog, readControlUiSourceCatalog, } from "./lib/control-ui-i18n-catalog.ts"; import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts"; diff --git a/scripts/control-ui-i18n.ts b/scripts/control-ui-i18n.ts index 2abd014f9db6..43aab5a5c4b2 100644 --- a/scripts/control-ui-i18n.ts +++ b/scripts/control-ui-i18n.ts @@ -19,9 +19,11 @@ import { import { isStrictAffirmativeValue } from "./lib/arg-utils.mts"; import { hashControlUiTranslationText, - loadControlUiSourceCatalog, loadControlUiTranslationMemory, materializeControlUiLocaleCatalog, +} from "./lib/control-ui-i18n-catalog-values.ts"; +import { + loadControlUiSourceCatalog, readControlUiSourceCatalog, } from "./lib/control-ui-i18n-catalog.ts"; import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts"; diff --git a/scripts/lib/control-ui-i18n-catalog-values.ts b/scripts/lib/control-ui-i18n-catalog-values.ts new file mode 100644 index 000000000000..d20f61ae3e9a --- /dev/null +++ b/scripts/lib/control-ui-i18n-catalog-values.ts @@ -0,0 +1,91 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import type { TranslationMap, TranslationMemoryEntry } from "./control-ui-i18n-sync-plan.ts"; + +export function hashControlUiTranslationText(text: string): string { + return createHash("sha256").update(text.trim().split(/\s+/).join(" ")).digest("hex"); +} + +export function mergeControlUiTranslationMaps( + ...maps: ReadonlyArray +): TranslationMap { + const merged: TranslationMap = {}; + const mergeInto = (target: TranslationMap, source: TranslationMap): void => { + for (const [key, value] of Object.entries(source)) { + if (typeof value === "string") { + target[key] = value; + continue; + } + const existing = target[key]; + const nested = typeof existing === "object" ? existing : {}; + target[key] = nested; + mergeInto(nested, value); + } + }; + for (const map of maps) { + mergeInto(merged, map); + } + return merged; +} + +export function loadControlUiTranslationMemory( + filePath: string, +): Map { + const entries = new Map(); + if (!existsSync(filePath)) { + return entries; + } + for (const line of readFileSync(filePath, "utf8").split("\n")) { + if (!line.trim()) { + continue; + } + const entry = JSON.parse(line) as TranslationMemoryEntry; + if (entry.cache_key && entry.translated.trim()) { + entries.set(entry.cache_key, entry); + } + } + return entries; +} + +export function setControlUiCatalogValue( + catalog: TranslationMap, + key: string, + value: string, +): void { + const parts = key.split("."); + let current = catalog; + for (const part of parts.slice(0, -1)) { + const existing = current[part]; + if (!existing || typeof existing === "string") { + current[part] = {}; + } + current = current[part] as TranslationMap; + } + current[parts[parts.length - 1]!] = value; +} + +export function materializeControlUiLocaleCatalog( + sourceFlat: ReadonlyMap, + memory: ReadonlyMap, +): TranslationMap { + const translations = new Map(); + + for (const entry of memory.values()) { + for (const key of [entry.segment_id, ...(entry.segment_ids ?? [])]) { + const source = sourceFlat.get(key); + if (source === undefined || entry.text_hash !== hashControlUiTranslationText(source)) { + continue; + } + translations.set(key, entry.translated); + } + } + + const catalog: TranslationMap = {}; + for (const key of sourceFlat.keys()) { + const translated = translations.get(key); + if (translated !== undefined) { + setControlUiCatalogValue(catalog, key, translated); + } + } + return catalog; +} diff --git a/scripts/lib/control-ui-i18n-catalog.ts b/scripts/lib/control-ui-i18n-catalog.ts index 5e9de1bb0c8b..7d9b862facfe 100644 --- a/scripts/lib/control-ui-i18n-catalog.ts +++ b/scripts/lib/control-ui-i18n-catalog.ts @@ -1,8 +1,8 @@ -import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { buildBaseHints } from "../../src/config/schema.hints.js"; +import { configHintTranslationKey } from "../../ui/src/i18n/lib/config-hint-translation.ts"; import { registerActivityEnglish } from "../../ui/src/i18n/locales/en-activity.ts"; import { registerDebugEnglish } from "../../ui/src/i18n/locales/en-debug.ts"; import { registerDesktopEnglish } from "../../ui/src/i18n/locales/en-desktop.ts"; @@ -19,10 +19,14 @@ import { registerSkillLibraryEnglish } from "../../ui/src/i18n/locales/en-skill- import { registerTranscriptsEnglish } from "../../ui/src/i18n/locales/en-transcripts.ts"; import { registerUpdateActionsEnglish } from "../../ui/src/i18n/locales/en-update-actions.ts"; import { en } from "../../ui/src/i18n/locales/en.ts"; -import type { TranslationMap, TranslationMemoryEntry } from "./control-ui-i18n-sync-plan.ts"; +import { + mergeControlUiTranslationMaps, + setControlUiCatalogValue, +} from "./control-ui-i18n-catalog-values.ts"; +import type { TranslationMap } from "./control-ui-i18n-sync-plan.ts"; -// Host-only owner for generation, verification and Vite. Static imports let Vite -// track every English dependency (including en.ts's en-agents.ts import). +// Host-only source composition for generation, verification, and Vite. The locale +// loader tracks these imports and reloads them in an isolated namespace. const localesDir = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../ui/src/i18n/locales", @@ -70,6 +74,7 @@ export function loadControlUiSourceCatalog(): TranslationMap { registerSettingsEnglish.catalog, registerUpdateActionsEnglish.catalog, registerTranscriptsEnglish.catalog, + loadControlUiCoreHintCatalog(), ); } @@ -77,88 +82,19 @@ export async function readControlUiSourceCatalog(): Promise { const sources = await Promise.all( sourceFiles.map((fileName) => readFile(path.join(localesDir, fileName), "utf8")), ); - return sources.join("\n"); + return [...sources, JSON.stringify(loadControlUiCoreHintCatalog())].join("\n"); } -export function hashControlUiTranslationText(text: string): string { - return createHash("sha256").update(text.trim().split(/\s+/).join(" ")).digest("hex"); -} - -export function mergeControlUiTranslationMaps( - ...maps: ReadonlyArray -): TranslationMap { - const merged: TranslationMap = {}; - const mergeInto = (target: TranslationMap, source: TranslationMap): void => { - for (const [key, value] of Object.entries(source)) { - if (typeof value === "string") { - target[key] = value; - continue; - } - const existing = target[key]; - const nested = typeof existing === "object" ? existing : {}; - target[key] = nested; - mergeInto(nested, value); - } - }; - for (const map of maps) { - mergeInto(merged, map); - } - return merged; -} - -export function loadControlUiTranslationMemory( - filePath: string, -): Map { - const entries = new Map(); - if (!existsSync(filePath)) { - return entries; - } - for (const line of readFileSync(filePath, "utf8").split("\n")) { - if (!line.trim()) { - continue; - } - const entry = JSON.parse(line) as TranslationMemoryEntry; - if (entry.cache_key && entry.translated.trim()) { - entries.set(entry.cache_key, entry); - } - } - return entries; -} - -function setControlUiCatalogValue(catalog: TranslationMap, key: string, value: string): void { - const parts = key.split("."); - let current = catalog; - for (const part of parts.slice(0, -1)) { - const existing = current[part]; - if (!existing || typeof existing === "string") { - current[part] = {}; - } - current = current[part] as TranslationMap; - } - current[parts[parts.length - 1]!] = value; -} - -export function materializeControlUiLocaleCatalog( - sourceFlat: ReadonlyMap, - memory: ReadonlyMap, -): TranslationMap { - const translations = new Map(); - - for (const entry of memory.values()) { - for (const key of [entry.segment_id, ...(entry.segment_ids ?? [])]) { - const source = sourceFlat.get(key); - if (source === undefined || entry.text_hash !== hashControlUiTranslationText(source)) { - continue; - } - translations.set(key, entry.translated); - } - } - +function loadControlUiCoreHintCatalog(): TranslationMap { const catalog: TranslationMap = {}; - for (const key of sourceFlat.keys()) { - const translated = translations.get(key); - if (translated !== undefined) { - setControlUiCatalogValue(catalog, key, translated); + for (const [hintPath, hint] of Object.entries(buildBaseHints()).toSorted(([left], [right]) => + left.localeCompare(right), + )) { + for (const field of ["label", "help"] as const) { + const text = hint[field]; + if (text) { + setControlUiCatalogValue(catalog, configHintTranslationKey(hintPath, field, text), text); + } } } return catalog; diff --git a/src/scripts/ci-changed-scope.control-ui.test.ts b/src/scripts/ci-changed-scope.control-ui.test.ts index 1b54700fae77..81e847eb5058 100644 --- a/src/scripts/ci-changed-scope.control-ui.test.ts +++ b/src/scripts/ci-changed-scope.control-ui.test.ts @@ -16,10 +16,16 @@ it("skips control-ui localization checks for test-only UI source", () => { ); }); -it("runs control-ui localization checks for the canonical locale config", () => { - expect(detectChangedScope(["scripts/lib/control-ui-i18n-config.json"]).runControlUiI18n).toBe( - true, - ); +it.each([ + "scripts/lib/control-ui-i18n-config.json", + "scripts/lib/control-ui-i18n-catalog-values.ts", + "src/config/schema.labels.ts", + "src/config/zod-schema.cloud-workers.ts", + "src/config/media-audio-field-metadata.ts", + "src/config/talk-defaults.ts", + "ui/src/lib/fnv1a.ts", +])("runs control-ui localization checks for %s", (file) => { + expect(detectChangedScope([file]).runControlUiI18n).toBe(true); }); it.each([ diff --git a/test/scripts/control-ui-i18n-sync-plan.test.ts b/test/scripts/control-ui-i18n-sync-plan.test.ts index dcfe0e1f34a6..869a41915c11 100644 --- a/test/scripts/control-ui-i18n-sync-plan.test.ts +++ b/test/scripts/control-ui-i18n-sync-plan.test.ts @@ -3,7 +3,7 @@ import { hashControlUiTranslationText, materializeControlUiLocaleCatalog, mergeControlUiTranslationMaps, -} from "../../scripts/lib/control-ui-i18n-catalog.ts"; +} from "../../scripts/lib/control-ui-i18n-catalog-values.ts"; import { createControlUiLocaleSyncPlan, flattenTranslations, diff --git a/test/scripts/control-ui-i18n.test.ts b/test/scripts/control-ui-i18n.test.ts index 7867369d9724..c0680b741be7 100644 --- a/test/scripts/control-ui-i18n.test.ts +++ b/test/scripts/control-ui-i18n.test.ts @@ -27,7 +27,10 @@ import { runProcess, translateNativeEntries, } from "../../scripts/control-ui-i18n.ts"; +import { loadControlUiSourceCatalog } from "../../scripts/lib/control-ui-i18n-catalog.ts"; import { collectControlUiRawCopyFromSource } from "../../scripts/lib/control-ui-i18n-raw-copy.ts"; +import { flattenTranslations } from "../../scripts/lib/control-ui-i18n-sync-plan.ts"; +import { configHintTranslationKey } from "../../ui/src/i18n/lib/config-hint-translation.ts"; import { registerTranscriptsEnglish } from "../../ui/src/i18n/locales/en-transcripts.ts"; import { waitForChildClose, waitForPidFile } from "../helpers/process-wait.js"; import { createTempDirTracker } from "../helpers/temp-dir.js"; @@ -287,6 +290,19 @@ describe("translation provider privacy and fallback", () => { }); }); +describe("control-ui config hint source catalog", () => { + it("includes core config labels and help under collision-safe keys", () => { + const source = flattenTranslations(loadControlUiSourceCatalog()); + + const label = "Gateway Token"; + expect(source.get(configHintTranslationKey("gateway.auth.token", "label", label))).toBe(label); + const helpEntry = [...source].find(([key]) => + key.startsWith("configHints.gateway%2Eauth%2Etoken.help."), + ); + expect(helpEntry?.[1]).toBeTypeOf("string"); + }); +}); + describe("control-ui-i18n generated ownership", () => { it("includes lazy transcript copy and shared search labels in the generator catalog", () => { const result = spawnSync( @@ -382,6 +398,7 @@ describe("control-ui-i18n generated ownership", () => { "scripts/control-ui-i18n.ts", "scripts/control-ui-i18n-verify.ts", "scripts/lib/control-ui-i18n-catalog.ts", + "scripts/lib/control-ui-i18n-catalog-values.ts", "scripts/lib/control-ui-i18n-sync-plan.ts", "ui/AGENTS.md", "ui/config/control-ui-locales.ts", diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index a1debf9c0b4d..1dad52a0226b 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -7,7 +7,12 @@ "noUnusedParameters": true, "tsBuildInfoFile": ".artifacts/tsgo-cache/scripts.tsbuildinfo" }, - "include": ["scripts/**/*", "src/**/*.d.ts", "packages/**/*.d.ts"], + "include": [ + "scripts/**/*", + "src/**/*.d.ts", + "packages/**/*.d.ts", + "ui/src/i18n/virtual-locale.d.ts" + ], "exclude": [ "node_modules", "dist", diff --git a/ui/config/control-ui-locales.ts b/ui/config/control-ui-locales.ts index 5bcba58ec998..dea0625e7aa0 100644 --- a/ui/config/control-ui-locales.ts +++ b/ui/config/control-ui-locales.ts @@ -1,14 +1,16 @@ +import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { register } from "tsx/esm/api"; import type { Plugin } from "vite"; import { - loadControlUiSourceCatalog, loadControlUiTranslationMemory, materializeControlUiLocaleCatalog, -} from "../../scripts/lib/control-ui-i18n-catalog.ts"; +} from "../../scripts/lib/control-ui-i18n-catalog-values.ts"; import { CONTROL_UI_LOCALE_ENTRIES } from "../../scripts/lib/control-ui-i18n-config.ts"; import { flattenTranslations } from "../../scripts/lib/control-ui-i18n-sync-plan.ts"; +import type { TranslationMap } from "../../scripts/lib/control-ui-i18n-sync-plan.ts"; const localeModulePrefix = "virtual:openclaw-control-ui-locale/"; const resolvedLocaleModulePrefix = `\0${localeModulePrefix}`; @@ -18,7 +20,35 @@ const i18nAssetsDir = path.resolve( "../src/i18n/.i18n", ); const locales = new Set(CONTROL_UI_LOCALE_ENTRIES.map(({ locale }) => locale)); -const sourceCatalog = loadControlUiSourceCatalog(); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const sourceCatalogUrl = pathToFileURL( + path.join(repoRoot, "scripts/lib/control-ui-i18n-catalog.ts"), +).href; + +async function loadCurrentSourceCatalog(): Promise<{ + catalog: TranslationMap; + watchFiles: Set; +}> { + const watchFiles = new Set(); + const loader = register({ + namespace: `openclaw-control-ui-source-catalog-${randomUUID()}`, + onImport(url) { + if (url.startsWith("file:")) { + watchFiles.add(fileURLToPath(url)); + } + }, + tsconfig: path.join(repoRoot, "tsconfig.json"), + }); + try { + const module = (await loader.import( + sourceCatalogUrl, + import.meta.url, + )) as typeof import("../../scripts/lib/control-ui-i18n-catalog.ts"); + return { catalog: module.loadControlUiSourceCatalog(), watchFiles }; + } finally { + await loader.unregister(); + } +} export function controlUiLocaleModulesPlugin(): Plugin { return { @@ -30,7 +60,7 @@ export function controlUiLocaleModulesPlugin(): Plugin { } return null; }, - load(id) { + async load(id) { if (!id.startsWith(resolvedLocaleModulePrefix)) { return null; } @@ -39,12 +69,16 @@ export function controlUiLocaleModulesPlugin(): Plugin { return null; } const memoryPath = path.join(i18nAssetsDir, `${locale}.tm.jsonl`); + const { catalog: sourceCatalog, watchFiles } = await loadCurrentSourceCatalog(); + for (const watchFile of watchFiles) { + this.addWatchFile(watchFile); + } + this.addWatchFile(memoryPath); // Source PRs omit generated memory until the post-merge refresh runs. // Existing empty or malformed memory stays fatal below so drift cannot hide. if (!existsSync(memoryPath)) { return `export default ${JSON.stringify(sourceCatalog)};`; } - this.addWatchFile(memoryPath); const memory = loadControlUiTranslationMemory(memoryPath); if (memory.size === 0) { throw new Error(`Control UI ${locale} translation memory is missing or empty`); diff --git a/ui/src/app/vite-config.node.test.ts b/ui/src/app/vite-config.node.test.ts index fee8ed49eed3..6303e13c48a8 100644 --- a/ui/src/app/vite-config.node.test.ts +++ b/ui/src/app/vite-config.node.test.ts @@ -7,8 +7,11 @@ import { brotliDecompressSync, gunzipSync } from "node:zlib"; import { describe, expect, it, vi } from "vitest"; import { hashControlUiTranslationText, - loadControlUiSourceCatalog, loadControlUiTranslationMemory, + materializeControlUiLocaleCatalog, +} from "../../../scripts/lib/control-ui-i18n-catalog-values.ts"; +import { + loadControlUiSourceCatalog, readControlUiSourceCatalog, } from "../../../scripts/lib/control-ui-i18n-catalog.ts"; import { flattenTranslations } from "../../../scripts/lib/control-ui-i18n-sync-plan.ts"; @@ -21,6 +24,7 @@ import { resolveSourcePackageAliasesForVite, resolveTsconfigPathAliasesForVite, } from "../../vite.config.ts"; +import { configHintTranslationKey } from "../i18n/lib/config-hint-translation.ts"; import { en } from "../i18n/locales/en.ts"; const childProcessMocks = vi.hoisted(() => ({ execFileSync: vi.fn() })); @@ -500,6 +504,64 @@ describe("Control UI Vite config", () => { ); }); + it("materializes translated config hints from the current source catalog", () => { + const text = "Gateway Token"; + const key = configHintTranslationKey("gateway.auth.token", "label", text); + const translated = materializeControlUiLocaleCatalog( + flattenTranslations(loadControlUiSourceCatalog()), + new Map([ + [ + "config-hint", + { + cache_key: "config-hint", + model: "test", + provider: "test", + segment_id: key, + source_path: "test", + src_lang: "en", + text, + text_hash: hashControlUiTranslationText(text), + tgt_lang: "tr", + translated: "Ağ geçidi belirteci", + updated_at: "2026-09-03T00:00:00.000Z", + }, + ], + ]), + ); + + expect(flattenTranslations(translated).get(key)).toBe("Ağ geçidi belirteci"); + }); + + it("cannot serve a stale config-hint translation under the current content-addressed key", () => { + const oldText = "Old Gateway Token"; + const oldKey = configHintTranslationKey("gateway.auth.token", "label", oldText); + const currentKey = configHintTranslationKey("gateway.auth.token", "label", "Gateway Token"); + const translated = materializeControlUiLocaleCatalog( + flattenTranslations(loadControlUiSourceCatalog()), + new Map([ + [ + "stale-config-hint", + { + cache_key: "stale-config-hint", + model: "test", + provider: "test", + segment_id: oldKey, + source_path: "test", + src_lang: "en", + text: oldText, + text_hash: hashControlUiTranslationText(oldText), + tgt_lang: "tr", + translated: "Eski ağ geçidi belirteci", + updated_at: "2026-09-03T00:00:00.000Z", + }, + ], + ]), + ); + + expect(flattenTranslations(translated).get(oldKey)).toBeUndefined(); + expect(flattenTranslations(translated).get(currentKey)).toBeUndefined(); + }); + it("includes every English dependency in the raw source-hash input", async () => { const localesDir = path.join(repoRoot, "ui/src/i18n/locales"); const sourceRaw = await readControlUiSourceCatalog(); @@ -548,6 +610,7 @@ describe("Control UI Vite config", () => { expect(catalog.common.health).toBe(healthEntry?.translated); expect(catalog.activity.title).toBeTypeOf("string"); expect(addWatchFile).toHaveBeenCalledWith(memoryPath); + expect(addWatchFile).toHaveBeenCalledWith(path.join(repoRoot, "src/config/schema.hints.ts")); }); it("bootstraps only an absent locale memory from the English catalog", async () => { @@ -570,14 +633,16 @@ describe("Control UI Vite config", () => { expect([...flattenTranslations(catalog)]).toEqual([ ...flattenTranslations(loadControlUiSourceCatalog()), ]); - expect(addWatchFile).not.toHaveBeenCalled(); + expect(addWatchFile).toHaveBeenCalledWith( + path.join(repoRoot, "src/config/schema.hints.ts"), + ); }, ); await fsMocks.readFileSync.withImplementation( () => "", async () => { - expect(() => load.call({ addWatchFile } as never, id, {} as never)).toThrow( + await expect(load.call({ addWatchFile } as never, id, {} as never)).rejects.toThrow( "Control UI fr translation memory is missing or empty", ); }, @@ -585,17 +650,23 @@ describe("Control UI Vite config", () => { await fsMocks.readFileSync.withImplementation( () => "{", async () => { - expect(() => load.call({ addWatchFile } as never, id, {} as never)).toThrow(SyntaxError); + await expect(load.call({ addWatchFile } as never, id, {} as never)).rejects.toThrow( + SyntaxError, + ); }, ); }); - it("omits stale and missing Settings translations so runtime English can resolve them", async () => { + it("omits stale config and Settings translations so runtime English can resolve them", async () => { const loadHook = controlUiLocaleModulesPlugin().load; const load = typeof loadHook === "function" ? loadHook : loadHook?.handler; if (!load) { throw new Error("Expected locale module loader"); } + const currentHintText = "Gateway Token"; + const currentHintKey = configHintTranslationKey("gateway.auth.token", "label", currentHintText); + const staleHintText = "Old Gateway Token"; + const staleHintKey = configHintTranslationKey("gateway.auth.token", "label", staleHintText); const memory = [ { cache_key: "current", @@ -609,6 +680,20 @@ describe("Control UI Vite config", () => { text_hash: hashControlUiTranslationText("Retired update introduction"), translated: "Obsolete", }, + { + cache_key: "current-hint", + segment_id: currentHintKey, + text: currentHintText, + text_hash: hashControlUiTranslationText(currentHintText), + translated: "Ağ geçidi belirteci", + }, + { + cache_key: "stale-hint", + segment_id: staleHintKey, + text: staleHintText, + text_hash: hashControlUiTranslationText(staleHintText), + translated: "Eski ağ geçidi belirteci", + }, ] .map((entry) => JSON.stringify(entry)) .join("\n"); @@ -627,9 +712,12 @@ describe("Control UI Vite config", () => { if (typeof result !== "string") { throw new Error("Expected locale module loader to return generated source"); } - expect(JSON.parse(result.replace(/^export default /, "").replace(/;$/, ""))).toEqual({ - configView: { chatPrefs: { title: "Discussion" } }, - }); + const catalog = JSON.parse(result.replace(/^export default /, "").replace(/;$/, "")); + const flat = flattenTranslations(catalog); + expect(flat.get("configView.chatPrefs.title")).toBe("Discussion"); + expect(flat.get(currentHintKey)).toBe("Ağ geçidi belirteci"); + expect(flat.has("updates.page.intro")).toBe(false); + expect(flat.has(staleHintKey)).toBe(false); }, ); }, diff --git a/ui/src/components/config-form.browser.test.ts b/ui/src/components/config-form.browser.test.ts index f0c9727a6a72..d2eff33b931f 100644 --- a/ui/src/components/config-form.browser.test.ts +++ b/ui/src/components/config-form.browser.test.ts @@ -1,6 +1,8 @@ // Control UI tests cover config form behavior. import { render } from "lit"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "../i18n/index.ts"; +import { configHintTranslationKey } from "../i18n/lib/config-hint-translation.ts"; import { analyzeConfigSchema, renderConfigForm as renderConfigFormBase } from "./config-form.ts"; function renderConfigForm( @@ -60,7 +62,56 @@ function selectSegmented(control: HTMLElement) { group.dispatchEvent(new Event("change", { bubbles: true })); } +afterEach(async () => { + await i18n.setLocale("en"); +}); + describe("config form renderer", () => { + it("renders translated schema hint labels and help with English fallback", async () => { + const label = "Gateway Token"; + const help = "Token used to authenticate with the Gateway."; + const labelHash = configHintTranslationKey("gateway.auth.token", "label", label) + .split(".") + .at(-1)!; + const helpHash = configHintTranslationKey("gateway.auth.token", "help", help) + .split(".") + .at(-1)!; + i18n.registerTranslation("tr", { + configHints: { + "gateway%2Eauth%2Etoken": { + label: { [labelHash]: "Ağ geçidi belirteci" }, + help: { [helpHash]: "Ağ geçidi kimlik doğrulamasında kullanılan belirteç." }, + }, + }, + }); + await i18n.setLocale("tr"); + const container = document.createElement("div"); + const props = { + schema: rootAnalysis.schema, + uiHints: { + "gateway.auth.token": { + label, + help, + }, + }, + unsupportedPaths: rootAnalysis.unsupportedPaths, + value: {}, + onPatch: vi.fn(), + }; + + render(renderConfigForm(props), container); + + expect(container.querySelector("input")?.getAttribute("aria-label")).toBe( + "Ağ geçidi belirteci", + ); + expect(container.textContent).toContain("Ağ geçidi kimlik doğrulamasında kullanılan belirteç."); + + await i18n.setLocale("en"); + render(renderConfigForm(props), container); + expect(container.querySelector("input")?.getAttribute("aria-label")).toBe("Gateway Token"); + expect(container.textContent).toContain("Token used to authenticate with the Gateway."); + }); + it("conceals core-classified encryption, private-key, and local service env values", () => { const container = document.createElement("div"); const analysis = analyzeConfigSchema({ diff --git a/ui/src/components/config-form.render.ts b/ui/src/components/config-form.render.ts index 6a492f0e8985..fd0c524ec6f2 100644 --- a/ui/src/components/config-form.render.ts +++ b/ui/src/components/config-form.render.ts @@ -6,7 +6,13 @@ import "./web-awesome-popover.ts"; import { SECTION_META } from "./config-form.meta.ts"; import { renderNode } from "./config-form.node.ts"; import { matchesConfigSectionSearch, parseConfigSearchQuery } from "./config-form.search.ts"; -import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; +import { + hintForPath, + humanize, + localizedHintForPath, + schemaType, + type JsonSchema, +} from "./config-form.shared.ts"; import { splitConfigSchemaByTier } from "./config-form.tiers.ts"; import { renderLearnMoreLink, @@ -308,7 +314,7 @@ export function renderConfigForm(props: ConfigFormProps) { subsectionContext ? (() => { const { sectionKey, subsectionKey, schema: node } = subsectionContext; - const hint = hintForPath([sectionKey, subsectionKey], props.uiHints); + const hint = localizedHintForPath([sectionKey, subsectionKey], props.uiHints); const label = hint?.label ?? node.title ?? humanize(subsectionKey); const description = hint?.help ?? node.description ?? ""; const sectionValue = value[sectionKey]; diff --git a/ui/src/components/config-form.search.ts b/ui/src/components/config-form.search.ts index 1d4d411ab280..f48a2144f68b 100644 --- a/ui/src/components/config-form.search.ts +++ b/ui/src/components/config-form.search.ts @@ -1,6 +1,11 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { ConfigUiHints } from "../api/types.ts"; -import { hintForPath, humanize, schemaType, type JsonSchema } from "../lib/config-form-utils.ts"; +import { + localizedHintForPath, + humanize, + schemaType, + type JsonSchema, +} from "../lib/config-form-utils.ts"; import { arrayItemSchema, arrayItemSchemaIndexes } from "./config-form.array-items.ts"; export type ConfigSearchCriteria = { @@ -66,7 +71,7 @@ export function resolveConfigFieldMeta( schema: JsonSchema, hints: ConfigUiHints, ): ConfigFieldMeta { - const hint = hintForPath(path, hints); + const hint = localizedHintForPath(path, hints); const fallbackSegment = path.findLast((segment) => typeof segment === "string") ?? path.at(-1); const label = hint?.label ?? schema.title ?? humanize(String(fallbackSegment)); const help = hint?.help ?? schema.description; diff --git a/ui/src/components/config-form.shared.ts b/ui/src/components/config-form.shared.ts index 22b4242d4a81..553205fd026c 100644 --- a/ui/src/components/config-form.shared.ts +++ b/ui/src/components/config-form.shared.ts @@ -6,6 +6,7 @@ import { hintForPath, pathKey } from "../lib/config-form-utils.ts"; export { hintForPath, humanize, + localizedHintForPath, pathKey, schemaMayAcceptString, schemaType, diff --git a/ui/src/i18n/lib/config-hint-translation.ts b/ui/src/i18n/lib/config-hint-translation.ts new file mode 100644 index 000000000000..f0106124d743 --- /dev/null +++ b/ui/src/i18n/lib/config-hint-translation.ts @@ -0,0 +1,20 @@ +import { fnv1aUtf16 } from "../../lib/fnv1a.ts"; + +export type ConfigHintTranslationField = "label" | "help"; + +function encodeConfigHintPath(hintPath: string): string { + return encodeURIComponent(hintPath).replaceAll(".", "%2E"); +} + +function configHintTranslationDigest(sourceText: string): string { + const normalizedSource = sourceText.trim().split(/\s+/).join(" "); + return `v1-${fnv1aUtf16(normalizedSource).toString(36)}-${normalizedSource.length.toString(36)}`; +} + +export function configHintTranslationKey( + hintPath: string, + field: ConfigHintTranslationField, + sourceText: string, +): string { + return `configHints.${encodeConfigHintPath(hintPath)}.${field}.${configHintTranslationDigest(sourceText)}`; +} diff --git a/ui/src/i18n/lib/translate.test.ts b/ui/src/i18n/lib/translate.test.ts index dbe2f000fe9a..2142fd055142 100644 --- a/ui/src/i18n/lib/translate.test.ts +++ b/ui/src/i18n/lib/translate.test.ts @@ -115,6 +115,15 @@ describe("I18nManager pending locale retry", () => { expect(loadTranslation).not.toHaveBeenCalled(); }); + it("looks up only the active locale when a caller owns its fallback", async () => { + const { manager } = createManager(); + manager.registerTranslation("de", german); + await manager.setLocale("de"); + + expect(manager.translateActive("common.health")).toBe("Gesundheit"); + expect(manager.translateActive("common.connected")).toBeUndefined(); + }); + it("deduplicates an in-flight target and permits retry after the shared load settles", async () => { const { internals, loadTranslation, manager } = createManager(); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts index 81b4218150d9..56a261343a95 100644 --- a/ui/src/i18n/lib/translate.ts +++ b/ui/src/i18n/lib/translate.ts @@ -10,6 +10,17 @@ import { } from "./registry.ts"; import type { Locale, TranslationMap } from "./types.ts"; +function lookupTranslation(map: TranslationMap | undefined, keys: readonly string[]): unknown { + let value: unknown = map; + for (const key of keys) { + if (!value || typeof value !== "object") { + return undefined; + } + value = Reflect.get(value, key); + } + return value; +} + type Subscriber = (locale: Locale) => void; type LocaleLoadRecovery = { isUnrecoverableError: (error: unknown) => boolean; @@ -233,30 +244,19 @@ class I18nManager { this.subscribers.forEach((sub) => sub(this.locale)); } + public translateActive(key: string): string | undefined { + const value = lookupTranslation(this.translations[this.locale], key.split(".")); + return typeof value === "string" ? value : undefined; + } + public t(key: string, params?: Record): string { const keys = key.split("."); - let value: unknown = this.translations[this.locale] || this.translations[DEFAULT_LOCALE]; - - for (const k of keys) { - if (value && typeof value === "object") { - value = (value as Record)[k]; - } else { - value = undefined; - break; - } - } - - // Fallback to English. + let value = lookupTranslation( + this.translations[this.locale] || this.translations[DEFAULT_LOCALE], + keys, + ); if (value === undefined && this.locale !== DEFAULT_LOCALE) { - value = this.translations[DEFAULT_LOCALE]; - for (const k of keys) { - if (value && typeof value === "object") { - value = (value as Record)[k]; - } else { - value = undefined; - break; - } - } + value = lookupTranslation(this.translations[DEFAULT_LOCALE], keys); } if (typeof value !== "string") { @@ -275,6 +275,7 @@ class I18nManager { export const i18n = new I18nManager(); export const t = (key: string, params?: Record) => i18n.t(key, params); +export const translateActive = (key: string) => i18n.translateActive(key); if (typeof process !== "undefined" && (process.env?.VITEST || process.env?.NODE_ENV === "test")) { (globalThis as Record)[Symbol.for("openclaw.i18nManagerTestApi")] = { diff --git a/ui/src/lib/config-form-utils.node.test.ts b/ui/src/lib/config-form-utils.node.test.ts index 69c30cea99c6..472ef2fe30ed 100644 --- a/ui/src/lib/config-form-utils.node.test.ts +++ b/ui/src/lib/config-form-utils.node.test.ts @@ -1,8 +1,11 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; +import { i18n } from "../i18n/index.ts"; +import { configHintTranslationKey } from "../i18n/lib/config-hint-translation.ts"; import { cloneConfigObject, hintForPath, + localizedHintForPath, removePathValue, sanitizeRedactedFormForSubmit, serializeConfigForm, @@ -10,6 +13,48 @@ import { } from "./config-form-utils.ts"; describe("hintForPath", () => { + it("localizes the matched wildcard while preserving direct precedence and metadata", async () => { + const wildcard = { + label: "Plugin Enabled", + help: "Enable this plugin", + advanced: true, + order: 7, + }; + const direct = { label: "Specific plugin", sensitive: true }; + const hints = { + "plugins.entries.*.enabled": wildcard, + "plugins.entries.specific.enabled": direct, + }; + const key = configHintTranslationKey("plugins.entries.*.enabled", "label", wildcard.label); + i18n.registerTranslation("tr", { + configHints: { + "plugins%2Eentries%2E*%2Eenabled": { label: { [key.split(".").at(-1)!]: "Eklenti etkin" } }, + }, + }); + await i18n.setLocale("tr"); + try { + expect(localizedHintForPath(["plugins", "entries", "demo", "enabled"], hints)).toEqual({ + ...wildcard, + label: "Eklenti etkin", + }); + expect(localizedHintForPath(["plugins", "entries", "specific", "enabled"], hints)).toEqual( + direct, + ); + expect(hintForPath(["plugins", "entries", "demo", "enabled"], hints)).toBe(wildcard); + expect(localizedHintForPath(["missing"], hints)).toBeUndefined(); + expect( + localizedHintForPath(["plugins", "entries", "demo", "enabled"], { + "plugins.entries.*.enabled": { label: "Enable this plugin now" }, + })?.label, + ).toBe("Enable this plugin now"); + } finally { + await i18n.setLocale("en"); + } + expect(localizedHintForPath(["plugins", "entries", "demo", "enabled"], hints)).toEqual( + wildcard, + ); + }); + it("does not rescan wildcard hints for each path lookup", () => { let catalogScans = 0; const hints = new Proxy( diff --git a/ui/src/lib/config-form-utils.ts b/ui/src/lib/config-form-utils.ts index 4a4b0a3768ad..0dc41d724246 100644 --- a/ui/src/lib/config-form-utils.ts +++ b/ui/src/lib/config-form-utils.ts @@ -1,6 +1,8 @@ // Control UI controller manages form utils gateway state. import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { ConfigUiHint, ConfigUiHints } from "../api/types.ts"; +import { configHintTranslationKey } from "../i18n/lib/config-hint-translation.ts"; +import { translateActive } from "../i18n/lib/translate.ts"; export type JsonSchema = { type?: string | string[]; @@ -71,16 +73,23 @@ export function pathKey(path: Array): string { const wildcardHintCache = new WeakMap>(); -export function hintForPath(path: Array, hints: ConfigUiHints) { - const direct = hints[pathKey(path)]; +type ResolvedConfigUiHint = { + hint: ConfigUiHint; + hintPath: string; +}; + +function resolveHintForPath( + path: Array, + hints: ConfigUiHints, +): ResolvedConfigUiHint | undefined { + const directPath = pathKey(path); + const direct = hints[directPath]; if (direct) { - return direct; + return { hint: direct, hintPath: directPath }; } const segments = path.map(String); let wildcardHints = wildcardHintCache.get(hints); if (!wildcardHints) { - // Schema reloads replace the hints object, so identity safely owns this index. - // Reuse it across recursive form and search lookups instead of rescanning the catalog. wildcardHints = Object.entries(hints).flatMap(([hintKey, hint]) => hintKey.includes("*") ? [[hintKey.split("."), hint]] : [], ); @@ -91,12 +100,33 @@ export function hintForPath(path: Array, hints: ConfigUiHints) hintSegments.length === segments.length && hintSegments.every((segment, index) => segment === "*" || segment === segments[index]) ) { - return hint; + return { hint, hintPath: hintSegments.join(".") }; } } return undefined; } +export function hintForPath(path: Array, hints: ConfigUiHints) { + return resolveHintForPath(path, hints)?.hint; +} + +export function localizedHintForPath(path: Array, hints: ConfigUiHints) { + const resolved = resolveHintForPath(path, hints); + if (!resolved) { + return undefined; + } + const { hint, hintPath } = resolved; + return { + ...hint, + label: hint.label + ? (translateActive(configHintTranslationKey(hintPath, "label", hint.label)) ?? hint.label) + : hint.label, + help: hint.help + ? (translateActive(configHintTranslationKey(hintPath, "help", hint.help)) ?? hint.help) + : hint.help, + }; +} + export function humanize(raw: string) { return raw .replace(/_/g, " ") diff --git a/ui/src/pages/chat/chat-composer-undo-redo.browser.test.ts b/ui/src/pages/chat/chat-composer-undo-redo.browser.test.ts index 9c1c4a8da464..1e18ef2fbaa0 100644 --- a/ui/src/pages/chat/chat-composer-undo-redo.browser.test.ts +++ b/ui/src/pages/chat/chat-composer-undo-redo.browser.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node // Composer native undo/redo regression: the controlled `.value` binding must // not re-apply the textarea value after native input, which clobbers the // browser's undo/redo bookkeeping (#131708). diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 95e9405d0bdb..2ab8ee6078c0 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node // Control UI tests cover chat responsive behavior. import { mkdir } from "node:fs/promises"; import path from "node:path"; diff --git a/ui/src/pages/chat/tool-stream.node.test.ts b/ui/src/pages/chat/tool-stream.node.test.ts index 7456fc2c5a6e..c6823205f136 100644 --- a/ui/src/pages/chat/tool-stream.node.test.ts +++ b/ui/src/pages/chat/tool-stream.node.test.ts @@ -535,35 +535,39 @@ describe("app-tool-stream result blocks", () => { it("emits a result block for completed tools with empty output", () => { useToolStreamFakeTimers(); - const host = createHost(); + try { + const host = createHost(); - handleAgentEvent(host, { - runId: "run-1", - seq: 1, - stream: "tool", - ts: TOOL_STREAM_TEST_NOW, - sessionKey: "main", - data: { phase: "start", name: "bash", toolCallId: "call-1", args: { command: "true" } }, - }); - handleAgentEvent(host, { - runId: "run-1", - seq: 2, - stream: "tool", - ts: TOOL_STREAM_TEST_NOW + 1, - sessionKey: "main", - data: { phase: "result", name: "bash", toolCallId: "call-1", result: "" }, - }); + handleAgentEvent(host, { + runId: "run-1", + seq: 1, + stream: "tool", + ts: TOOL_STREAM_TEST_NOW, + sessionKey: "main", + data: { phase: "start", name: "bash", toolCallId: "call-1", args: { command: "true" } }, + }); + handleAgentEvent(host, { + runId: "run-1", + seq: 2, + stream: "tool", + ts: TOOL_STREAM_TEST_NOW + 1, + sessionKey: "main", + data: { phase: "result", name: "bash", toolCallId: "call-1", result: "" }, + }); - const entry = host.toolStreamById.get( - buildToolStreamIdentity("run-1", "call-1"), - ) as ToolStreamEntry; - expect(entry.resultReceived).toBe(true); - expect(entry.receivedAt).toBe(TOOL_STREAM_TEST_NOW); - expect(entry.message["__openclawToolStreamReceivedAt"]).toBe(TOOL_STREAM_TEST_NOW); - const content = entry.message.content as Array>; - // The empty-output result block marks the call as finished so the UI does - // not keep it in a running state for the rest of the run. - expect(content.some((block) => block.type === "toolresult" && block.text === "")).toBe(true); + const entry = host.toolStreamById.get( + buildToolStreamIdentity("run-1", "call-1"), + ) as ToolStreamEntry; + expect(entry.resultReceived).toBe(true); + expect(entry.receivedAt).toBe(TOOL_STREAM_TEST_NOW); + expect(entry.message["__openclawToolStreamReceivedAt"]).toBe(TOOL_STREAM_TEST_NOW); + const content = entry.message.content as Array>; + // The empty-output result block marks the call as finished so the UI does + // not keep it in a running state for the rest of the run. + expect(content.some((block) => block.type === "toolresult" && block.text === "")).toBe(true); + } finally { + vi.useRealTimers(); + } }); it.each([ diff --git a/ui/src/pages/config/setup.ts b/ui/src/pages/config/setup.ts index 1efef0483ee4..d0180ffd52f0 100644 --- a/ui/src/pages/config/setup.ts +++ b/ui/src/pages/config/setup.ts @@ -1,7 +1,11 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing } from "lit"; import { renderNode } from "../../components/config-form.node.ts"; -import { hintForPath, humanize, type JsonSchema } from "../../components/config-form.shared.ts"; +import { + humanize, + localizedHintForPath, + type JsonSchema, +} from "../../components/config-form.shared.ts"; import { renderSettingsGroup, renderSettingsPage, @@ -46,7 +50,7 @@ export function renderSetupSection(schema: JsonSchema, props: ConfigProps, disab if (typeof value !== "string" || !value) { return []; } - const hint = hintForPath(["wizard", key], props.uiHints); + const hint = localizedHintForPath(["wizard", key], props.uiHints); return [ renderSettingsRow({ title: hint?.label ?? schema.properties?.[key]?.title ?? humanize(key), diff --git a/ui/src/pages/config/view-navigation.ts b/ui/src/pages/config/view-navigation.ts index 1932052849df..e61157cd3ac0 100644 --- a/ui/src/pages/config/view-navigation.ts +++ b/ui/src/pages/config/view-navigation.ts @@ -1,7 +1,11 @@ import { html, nothing, type TemplateResult } from "lit"; import { isKernelOwnedChannelConfigKey } from "../../../../src/config/channel-config-keys.js"; import type { ConfigUiHints } from "../../api/types.ts"; -import { hintForPath, humanize, type JsonSchema } from "../../components/config-form.shared.ts"; +import { + humanize, + localizedHintForPath, + type JsonSchema, +} from "../../components/config-form.shared.ts"; import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; import type { ConfigProps } from "./view-types.ts"; @@ -12,7 +16,7 @@ export function getChannelConfigGroups(schema: JsonSchema, hints: ConfigUiHints) .filter(([key]) => !isKernelOwnedChannelConfigKey(key)) .map(([key, node]) => ({ key, - label: hintForPath(["channels", key], hints)?.label ?? node.title ?? humanize(key), + label: localizedHintForPath(["channels", key], hints)?.label ?? node.title ?? humanize(key), keys: [key], })) .toSorted((a, b) => a.label.localeCompare(b.label) || a.key.localeCompare(b.key));