import { spawn } from "node:child_process" ;
import { createHash } from "node:crypto" ;
import { existsSync } from "node:fs" ;
import { mkdir, readFile, stat, writeFile } from "node:fs/promises" ;
import { homedir } from "node:os" ;
import path from "node:path" ;
import { createInterface } from "node:readline" ;
import { fileURLToPath, pathToFileURL } from "node:url" ;
import { formatErrorMessage } from "../src/infra/errors.ts" ;
interface TranslationMap {
[key: string]: string | TranslationMap;
}
type LocaleEntry = {
exportName: string;
fileName: string;
languageKey: string;
locale: string;
};
type GlossaryEntry = {
source: string;
target: string;
};
type TranslationMemoryEntry = {
cache_key: string;
model: string;
provider: string;
segment_id: string;
source_path: string;
src_lang: string;
text: string;
text_hash: string;
tgt_lang: string;
translated: string;
updated_at: string;
};
type LocaleMeta = {
fallbackKeys: string[];
generatedAt: string;
locale: string;
model: string;
provider: string;
sourceHash: string;
totalKeys: number;
translatedKeys: number;
workflow: number;
};
type TranslationBatchItem = {
cacheKey: string;
key: string;
text: string;
textHash: string;
};
const CONTROL_UI_I18N_WORKFLOW = 1 ;
const DEFAULT_OPENAI_MODEL = "gpt-5.4" ;
const DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-6" ;
const DEFAULT_PROVIDER = "openai" ;
const DEFAULT_PI_PACKAGE_VERSION = "0.58.3" ;
const HERE = path.dirname(fileURLToPath(import .meta.url));
const ROOT = path.resolve(HERE, ".." );
const LOCALES_DIR = path.join(ROOT, "ui" , "src" , "i18n" , "locales" );
const I18N_ASSETS_DIR = path.join(ROOT, "ui" , "src" , "i18n" , ".i18n" );
const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts" );
const SOURCE_LOCALE = "en" ;
const MAX_BATCH_ITEMS = 20 ;
const DEFAULT_BATCH_CHAR_BUDGET = 2 _000 ;
const TRANSLATE_MAX_ATTEMPTS = 2 ;
const TRANSLATE_BASE_DELAY_MS = 15 _000 ;
const DEFAULT_PROMPT_TIMEOUT_MS = 120 _000 ;
const PROGRESS_HEARTBEAT_MS = 30 _000 ;
const ENV_PROVIDER = "OPENCLAW_CONTROL_UI_I18N_PROVIDER" ;
const ENV_MODEL = "OPENCLAW_CONTROL_UI_I18N_MODEL" ;
const ENV_THINKING = "OPENCLAW_CONTROL_UI_I18N_THINKING" ;
const ENV_PI_EXECUTABLE = "OPENCLAW_CONTROL_UI_I18N_PI_EXECUTABLE" ;
const ENV_PI_ARGS = "OPENCLAW_CONTROL_UI_I18N_PI_ARGS" ;
const ENV_PI_PACKAGE_VERSION = "OPENCLAW_CONTROL_UI_I18N_PI_PACKAGE_VERSION" ;
const ENV_BATCH_CHAR_BUDGET = "OPENCLAW_CONTROL_UI_I18N_BATCH_CHAR_BUDGET" ;
const ENV_PROMPT_TIMEOUT = "OPENCLAW_CONTROL_UI_I18N_PROMPT_TIMEOUT" ;
const LOCALE_ENTRIES: readonly LocaleEntry[] = [
{ locale: "zh-CN" , fileName: "zh-CN.ts" , exportName: "zh_CN" , languageKey: "zhCN" },
{ locale: "zh-TW" , fileName: "zh-TW.ts" , exportName: "zh_TW" , languageKey: "zhTW" },
{ locale: "pt-BR" , fileName: "pt-BR.ts" , exportName: "pt_BR" , languageKey: "ptBR" },
{ locale: "de" , fileName: "de.ts" , exportName: "de" , languageKey: "de" },
{ locale: "es" , fileName: "es.ts" , exportName: "es" , languageKey: "es" },
{ locale: "ja-JP" , fileName: "ja-JP.ts" , exportName: "ja_JP" , languageKey: "jaJP" },
{ locale: "ko" , fileName: "ko.ts" , exportName: "ko" , languageKey: "ko" },
{ locale: "fr" , fileName: "fr.ts" , exportName: "fr" , languageKey: "fr" },
{ locale: "tr" , fileName: "tr.ts" , exportName: "tr" , languageKey: "tr" },
{ locale: "uk" , fileName: "uk.ts" , exportName: "uk" , languageKey: "uk" },
{ locale: "id" , fileName: "id.ts" , exportName: "id" , languageKey: "id" },
{ locale: "pl" , fileName: "pl.ts" , exportName: "pl" , languageKey: "pl" },
{ locale: "th" , fileName: "th.ts" , exportName: "th" , languageKey: "th" },
];
const DEFAULT_GLOSSARY: readonly GlossaryEntry[] = [
{ source: "OpenClaw" , target: "OpenClaw" },
{ source: "Gateway" , target: "Gateway" },
{ source: "Control UI" , target: "Control UI" },
{ source: "Skills" , target: "Skills" },
{ source: "Tailscale" , target: "Tailscale" },
{ source: "WhatsApp" , target: "WhatsApp" },
{ source: "Telegram" , target: "Telegram" },
{ source: "Discord" , target: "Discord" },
{ source: "Signal" , target: "Signal" },
{ source: "iMessage" , target: "iMessage" },
];
function usage(): never {
console.error(
[
"Usage:" ,
" node --import tsx scripts/control-ui-i18n.ts check" ,
" node --import tsx scripts/control-ui-i18n.ts sync [--write] [--locale <code>] [--force]" ,
].join("\n" ),
);
process.exit(2 );
}
function parseArgs(argv: string[]) {
const [command, ...rest] = argv;
if (command !== "check" && command !== "sync" ) {
usage();
}
let localeFilter: string | null = null ;
let write = false ;
let force = false ;
for (let index = 0 ; index < rest.length; index += 1 ) {
const part = rest[index];
switch (part) {
case "--locale" :
localeFilter = rest[index + 1 ] ?? null ;
index += 1 ;
break ;
case "--write" :
write = true ;
break ;
case "--force" :
force = true ;
break ;
default :
usage();
}
}
if (command === "check" && write) {
usage();
}
return {
command,
force,
localeFilter,
write,
};
}
function prettyLanguageLabel(locale: string): string {
switch (locale) {
case "en" :
return "English" ;
case "zh-CN" :
return "Simplified Chinese" ;
case "zh-TW" :
return "Traditional Chinese" ;
case "pt-BR" :
return "Brazilian Portuguese" ;
case "ja-JP" :
return "Japanese" ;
case "ko" :
return "Korean" ;
case "fr" :
return "French" ;
case "tr" :
return "Turkish" ;
case "uk" :
return "Ukrainian" ;
case "id" :
return "Indonesian" ;
case "pl" :
return "Polish" ;
case "th" :
return "Thai" ;
case "de" :
return "German" ;
case "es" :
return "Spanish" ;
default :
return locale;
}
}
function resolveConfiguredProvider(): string {
const configured = process.env[ENV_PROVIDER]?.trim();
if (configured) {
return configured;
}
if (process.env.OPENAI_API_KEY?.trim()) {
return "openai" ;
}
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return "anthropic" ;
}
return DEFAULT_PROVIDER;
}
function resolveConfiguredModel(): string {
const configured = process.env[ENV_MODEL]?.trim();
if (configured) {
return configured;
}
return resolveConfiguredProvider() === "anthropic"
? DEFAULT_ANTHROPIC_MODEL
: DEFAULT_OPENAI_MODEL;
}
function hasTranslationProvider(): boolean {
return Boolean (process.env.OPENAI_API_KEY?.trim() || process.env.ANTHROPIC_API_KEY?.trim());
}
function normalizeText(text: string): string {
return text.trim().split(/\s+/).join(" " );
}
function sha256(input: string | Uint8Array): string {
return createHash("sha256" ).update(input).digest("hex" );
}
function hashText(text: string): string {
return sha256(normalizeText(text));
}
function cacheNamespace(): string {
return [
`wf=${CONTROL_UI_I18N_WORKFLOW}`,
"engine=pi" ,
`provider=${resolveConfiguredProvider()}`,
`model=${resolveConfiguredModel()}`,
].join("|" );
}
function cacheKey(segmentId: string, textHash: string, targetLocale: string): string {
return sha256([cacheNamespace(), SOURCE_LOCALE, targetLocale, segmentId, textHash].join("|" ));
}
function localeFilePath(entry: LocaleEntry): string {
return path.join(LOCALES_DIR, entry.fileName);
}
function glossaryPath(entry: LocaleEntry): string {
return path.join(I18N_ASSETS_DIR, `glossary.${entry.locale}.json`);
}
function metaPath(entry: LocaleEntry): string {
return path.join(I18N_ASSETS_DIR, `${entry.locale}.meta.json`);
}
function tmPath(entry: LocaleEntry): string {
return path.join(I18N_ASSETS_DIR, `${entry.locale}.tm.jsonl`);
}
async function importLocaleModule<T>(filePath: string): Promise<T> {
const stats = await stat(filePath);
const href = `${pathToFileURL(filePath).href}?ts=${stats.mtimeMs}`;
return (await import (href)) as T;
}
async function loadLocaleMap(filePath: string, exportName: string): Promise<TranslationMap | null > {
if (!existsSync(filePath)) {
return null ;
}
const mod = await importLocaleModule<Record<string, TranslationMap>>(filePath);
return mod[exportName] ?? null ;
}
function flattenTranslations(value: TranslationMap, prefix = "" , out = new Map<string, string>()) {
for (const [key, nested] of Object.entries(value)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof nested === "string" ) {
out.set(fullKey, nested);
continue ;
}
flattenTranslations(nested, fullKey, out);
}
return out;
}
function setNestedValue(root: TranslationMap, dottedKey: string, value: string) {
const parts = dottedKey.split("." );
let cursor: TranslationMap = root;
for (let index = 0 ; index < parts.length - 1 ; index += 1 ) {
const key = parts[index];
const next = cursor[key];
if (!next || typeof next === "string" ) {
const replacement: TranslationMap = {};
cursor[key] = replacement;
cursor = replacement;
continue ;
}
cursor = next;
}
cursor[parts.at(-1 )!] = value;
}
function compareStringArrays(left: string[], right: string[]) {
if (left.length !== right.length) {
return false ;
}
return left.every((value, index) => value === right[index]);
}
function isIdentifier(value: string): boolean {
return /^[A-Za-z_$][A-Za-z0-9 _$]*$/.test(value);
}
function renderTranslationValue(value: TranslationValue, indent = 0 ): string {
if (typeof value === "string" ) {
return JSON.stringify(value);
}
const entries = Object.entries(value);
if (entries.length === 0 ) {
return "{}" ;
}
const pad = " " .repeat(indent);
const innerPad = " " .repeat(indent + 1 );
return `{\n${entries
.map(([key, nested]) => {
const renderedKey = isIdentifier(key) ? key : JSON.stringify(key);
return `${innerPad}${renderedKey}: ${renderTranslationValue(nested, indent + 1 )},`;
})
.join("\n" )}\n${pad}}`;
}
function renderLocaleModule(entry: LocaleEntry, value: TranslationMap): string {
return [
'import type { TranslationMap } from "../lib/types.ts";' ,
"" ,
"// Generated by scripts/control-ui-i18n.ts.",
`export const ${entry.exportName}: TranslationMap = ${renderTranslationValue(value)};`,
"" ,
].join("\n" );
}
async function loadGlossary(filePath: string): Promise<GlossaryEntry[]> {
if (!existsSync(filePath)) {
return [];
}
const raw = await readFile(filePath, "utf8" );
const parsed = JSON.parse(raw) as GlossaryEntry[];
return Array.isArray(parsed) ? parsed : [];
}
function renderGlossary(entries: readonly GlossaryEntry[]): string {
return `${JSON.stringify(entries, null , 2 )}\n`;
}
async function loadMeta(filePath: string): Promise<LocaleMeta | null > {
if (!existsSync(filePath)) {
return null ;
}
const raw = await readFile(filePath, "utf8" );
return JSON.parse(raw) as LocaleMeta;
}
function renderMeta(meta: LocaleMeta): string {
return `${JSON.stringify(meta, null , 2 )}\n`;
}
async function loadTranslationMemory(
filePath: string,
): Promise<Map<string, TranslationMemoryEntry>> {
const entries = new Map<string, TranslationMemoryEntry>();
if (!existsSync(filePath)) {
return entries;
}
const raw = await readFile(filePath, "utf8" );
for (const line of raw.split("\n" )) {
const trimmed = line.trim();
if (!trimmed) {
continue ;
}
const parsed = JSON.parse(trimmed) as TranslationMemoryEntry;
if (parsed.cache_key && parsed.translated.trim()) {
entries.set(parsed.cache_key, parsed);
}
}
return entries;
}
function renderTranslationMemory(entries: Map<string, TranslationMemoryEntry>): string {
const ordered = [...entries.values()].toSorted((left, right) =>
left.cache_key.localeCompare(right.cache_key),
);
if (ordered.length === 0 ) {
return "" ;
}
return `${ordered.map((entry) => JSON.stringify(entry)).join("\n" )}\n`;
}
function buildGlossaryPrompt(glossary: readonly GlossaryEntry[]): string {
if (glossary.length === 0 ) {
return "" ;
}
return [
"Required terminology (use exactly when the source term matches):" ,
...glossary
.filter((entry) => entry.source.trim() && entry.target.trim())
.map((entry) => `- ${entry.source} -> ${entry.target}`),
].join("\n" );
}
function buildSystemPrompt(targetLocale: string, glossary: readonly GlossaryEntry[]): string {
const glossaryBlock = buildGlossaryPrompt(glossary);
const lines = [
"You are a translation function, not a chat assistant." ,
`Translate UI strings from ${prettyLanguageLabel(SOURCE_LOCALE)} to ${prettyLanguageLabel(targetLocale)}.`,
"" ,
"Rules:" ,
"- Output ONLY valid JSON." ,
"- The JSON must be an object whose keys exactly match the provided ids." ,
"- Translate all English prose; keep code, URLs, product names, CLI commands, config keys, and env vars in English." ,
"- Preserve placeholders exactly, including {count}, {time}, {shown}, {total}, and similar tokens." ,
"- Preserve punctuation, ellipses, arrows, and casing when they are part of literal UI text." ,
"- Preserve Markdown, inline code, HTML tags, and slash commands when present." ,
"- Use fluent, neutral product UI language." ,
"- Do not add explanations, comments, or extra keys." ,
"- Never return an empty string for a key; if unsure, return the source text unchanged." ,
];
if (glossaryBlock) {
lines.push("" , glossaryBlock);
}
return lines.join("\n" );
}
function buildBatchPrompt(items: readonly TranslationBatchItem[]): string {
const payload = Object.fromEntries(items.map((item) => [item.key, item.text]));
return [
"Translate this JSON object." ,
"Return ONLY a JSON object with the same keys." ,
"" ,
JSON.stringify(payload, null , 2 ),
].join("\n" );
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatDuration(ms: number): string {
if (ms < 1 _000 ) {
return `${Math.round(ms)}ms`;
}
if (ms < 60 _000 ) {
return `${(ms / 1 _000 ).toFixed(ms < 10 _000 ? 1 : 0 )}s`;
}
const totalSeconds = Math.round(ms / 1 _000 );
const minutes = Math.floor(totalSeconds / 60 );
const seconds = totalSeconds % 60 ;
return `${minutes}m ${seconds}s`;
}
function logProgress(message: string) {
process.stdout.write(`control-ui-i18n: ${message}\n`);
}
function isPromptTimeoutError(error: Error): boolean {
return error.message.toLowerCase().includes("timed out" );
}
function resolvePromptTimeoutMs(): number {
const raw = process.env[ENV_PROMPT_TIMEOUT]?.trim();
if (!raw) {
return DEFAULT_PROMPT_TIMEOUT_MS;
}
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROMPT_TIMEOUT_MS;
}
function resolveThinkingLevel(): "low" | "high" {
return process.env[ENV_THINKING]?.trim().toLowerCase() === "high" ? "high" : "low" ;
}
function resolveBatchCharBudget(): number {
const raw = process.env[ENV_BATCH_CHAR_BUDGET]?.trim();
if (!raw) {
return DEFAULT_BATCH_CHAR_BUDGET;
}
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_BATCH_CHAR_BUDGET;
}
function estimateBatchChars(items: readonly TranslationBatchItem[]): number {
return items.reduce((total, item) => total + item.key.length + item.text.length + 8 , 2 );
}
type PiCommand = {
args: string[];
executable: string;
};
function resolvePiPackageVersion(): string {
return process.env[ENV_PI_PACKAGE_VERSION]?.trim() || DEFAULT_PI_PACKAGE_VERSION;
}
function getPiRuntimeDir() {
return path.join(
homedir(),
".cache" ,
"openclaw" ,
"control-ui-i18n" ,
"pi-runtime" ,
resolvePiPackageVersion(),
);
}
async function resolvePiCommand(): Promise<PiCommand> {
const explicitExecutable = process.env[ENV_PI_EXECUTABLE]?.trim();
if (explicitExecutable) {
return {
executable: explicitExecutable,
args: process.env[ENV_PI_ARGS]?.trim().split(/\s+/).filter(Boolean ) ?? [],
};
}
const pathEntries = (process.env.PATH ?? "" ).split(path.delimiter).filter(Boolean );
for (const entry of pathEntries) {
const candidate = path.join(entry, process.platform === "win32" ? "pi.cmd" : "pi" );
if (existsSync(candidate)) {
return { executable: candidate, args: [] };
}
}
const runtimeDir = getPiRuntimeDir();
const cliPath = path.join(
runtimeDir,
"node_modules" ,
"@mariozechner" ,
"pi-coding-agent" ,
"dist" ,
"cli.js" ,
);
if (!existsSync(cliPath)) {
await mkdir(runtimeDir, { recursive: true });
await runProcess(
"npm" ,
[
"install" ,
"--silent" ,
"--no-audit" ,
"--no-fund" ,
`@mariozechner/pi-coding-agent@${resolvePiPackageVersion()}`,
],
{
cwd: runtimeDir,
rejectOnFailure: true ,
},
);
}
return { executable: "node" , args: [cliPath] };
}
type RunProcessOptions = {
cwd?: string;
input?: string;
rejectOnFailure?: boolean ;
};
async function runProcess(
executable: string,
args: string[],
options: RunProcessOptions = {},
): Promise<{ code: number; stderr: string; stdout: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(executable, args, {
cwd: options.cwd ?? ROOT,
env: process.env,
stdio: ["pipe" , "pipe" , "pipe" ],
});
let stdout = "" ;
let stderr = "" ;
child.stdout.on("data" , (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data" , (chunk) => {
stderr += String(chunk);
});
child.once("error" , reject);
if (options.input !== undefined) {
child.stdin.end(options.input);
} else {
child.stdin.end();
}
child.once("close" , (code) => {
if ((code ?? 1 ) !== 0 && options.rejectOnFailure) {
reject(
new Error(`${executable} ${args.join(" " )} failed: ${stderr.trim() || stdout.trim()}`),
);
return ;
}
resolve({ code: code ?? 1 , stderr, stdout });
});
});
}
async function formatGeneratedTypeScript(filePath: string, source: string): Promise<string> {
const result = await runProcess(
"pnpm" ,
["exec" , "oxfmt" , "--stdin-filepath" , path.relative(ROOT, filePath)],
{
input: source,
rejectOnFailure: true ,
},
);
return result.stdout;
}
type PendingPrompt = {
id: string;
reject: (reason?: unknown) => void ;
resolve: (value: string) => void ;
responseReceived: boolean ;
};
type LocaleRunContext = {
localeCount: number;
localeIndex: number;
};
type TranslationBatchContext = LocaleRunContext & {
batchCount: number;
batchIndex: number;
locale: string;
splitDepth?: number;
segmentLabel?: string;
};
type ClientAccess = {
getClient: () => Promise<PiRpcClient>;
resetClient: () => Promise<void >;
};
function formatLocaleLabel(locale: string, context: LocaleRunContext): string {
return `[${context.localeIndex}/${context.localeCount}] ${locale}`;
}
function formatBatchLabel(context: TranslationBatchContext): string {
const suffix = context.segmentLabel ? `.${context.segmentLabel}` : "" ;
return `${formatLocaleLabel(context.locale, context)} batch ${context.batchIndex}/${context.batchCount}${suffix}`;
}
function buildTranslationBatches(items: readonly TranslationBatchItem[]): TranslationBatchItem[][] {
const batches: TranslationBatchItem[][] = [];
const budget = resolveBatchCharBudget();
let current: TranslationBatchItem[] = [];
let currentChars = 2 ;
for (const item of items) {
const itemChars = estimateBatchChars([item]);
const wouldOverflow = current.length > 0 && currentChars + itemChars > budget;
const reachedMaxItems = current.length >= MAX_BATCH_ITEMS;
if (wouldOverflow || reachedMaxItems) {
batches.push(current);
current = [];
currentChars = 2 ;
}
current.push(item);
currentChars += itemChars;
}
if (current.length > 0 ) {
batches.push(current);
}
return batches;
}
class PiRpcClient {
private readonly stderrChunks: string[] = [];
private closed = false ;
private pending: PendingPrompt | null = null ;
private readonly process;
private readonly stdin;
private requestCount = 0 ;
private sequence = Promise.resolve();
private constructor(processHandle: ReturnType<typeof spawn>) {
this .process = processHandle;
this .stdin = processHandle.stdin;
}
static async create(systemPrompt: string): Promise<PiRpcClient> {
const command = await resolvePiCommand();
const args = [
...command.args,
"--mode" ,
"rpc" ,
"--provider" ,
resolveConfiguredProvider(),
"--model" ,
resolveConfiguredModel(),
"--thinking" ,
resolveThinkingLevel(),
"--no-session" ,
"--system-prompt" ,
systemPrompt,
];
const child = spawn(command.executable, args, {
cwd: ROOT,
env: process.env,
stdio: ["pipe" , "pipe" , "pipe" ],
});
const client = new PiRpcClient(child);
client.bindProcess();
await client.waitForBoot();
return client;
}
private bindProcess() {
const stderr = createInterface({ input: this .process.stderr });
stderr.on("line" , (line) => {
this .stderrChunks.push(line);
});
const stdout = createInterface({ input: this .process.stdout });
stdout.on("line" , (line) => {
void this .handleStdoutLine(line);
});
this .process.once("error" , (error) => {
this .rejectPending(error);
});
this .process.once("close" , () => {
this .closed = true ;
this .rejectPending(
new Error(`pi process closed${this .stderr() ? ` (${this .stderr()})` : "" }`),
);
});
}
private async waitForBoot() {
await sleep(150 );
}
private stderr() {
return this .stderrChunks.join("\n" ).trim();
}
private rejectPending(error: Error) {
const pending = this .pending;
this .pending = null ;
if (pending) {
pending.reject(error);
}
}
private async handleStdoutLine(line: string) {
const trimmed = line.trim();
if (!trimmed) {
return ;
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
return ;
}
const pending = this .pending;
if (!pending) {
return ;
}
switch (parsed.type) {
case "response" : {
if (parsed.id !== pending.id) {
return ;
}
const success = parsed.success === true ;
if (!success) {
const errorText =
typeof parsed.error === "string" && parsed.error.trim()
? parsed.error.trim()
: "pi prompt failed" ;
this .pending = null ;
pending.reject(new Error(errorText));
return ;
}
pending.responseReceived = true ;
return ;
}
case "agent_end" : {
try {
const result = extractTranslationResult(parsed);
this .pending = null ;
pending.resolve(result);
} catch (error) {
this .pending = null ;
pending.reject(error);
}
}
}
}
async prompt(message: string, label: string): Promise<string> {
this .sequence = this .sequence.then(async () => {
if (this .closed) {
throw new Error(`pi process unavailable${this .stderr() ? ` (${this .stderr()})` : "" }`);
}
const id = `req-${++this .requestCount}`;
const payload = JSON.stringify({ type: "prompt" , id, message });
const timeoutMs = resolvePromptTimeoutMs();
const startedAt = Date.now();
return await new Promise<string>((resolve, reject) => {
const heartbeat = setInterval(() => {
const responseState = this .pending?.responseReceived
? "response=received"
: "response=pending" ;
logProgress(
`${label}: still waiting (${formatDuration(Date.now() - startedAt)} / ${formatDuration(timeoutMs)}, ${responseState})`,
);
}, PROGRESS_HEARTBEAT_MS);
const timer = setTimeout(() => {
if (this .pending?.id === id) {
this .pending = null ;
clearInterval(heartbeat);
void this .close();
const stderr = this .stderr();
reject(
new Error(
`${label}: translation prompt timed out after ${timeoutMs}ms${stderr ? ` (pi stderr: ${stderr})` : "" }`,
),
);
}
}, timeoutMs);
this .pending = {
id,
reject: (reason) => {
clearTimeout(timer);
clearInterval(heartbeat);
reject(reason);
},
resolve: (value) => {
clearTimeout(timer);
clearInterval(heartbeat);
resolve(value);
},
responseReceived: false ,
};
this .stdin.write(`${payload}\n`, (error) => {
if (!error) {
return ;
}
clearTimeout(timer);
clearInterval(heartbeat);
if (this .pending?.id === id) {
this .pending = null ;
}
reject(error);
});
});
});
return (await this .sequence) as string;
}
async close() {
if (this .closed) {
return ;
}
this .closed = true ;
this .stdin.end();
this .process.kill("SIGTERM" );
await sleep(150 );
if (!this .process.killed) {
this .process.kill("SIGKILL" );
}
}
}
function extractTranslationResult(payload: Record<string, unknown>): string {
const messages = Array.isArray(payload.messages) ? payload.messages : [];
for (let index = messages.length - 1 ; index >= 0 ; index -= 1 ) {
const message = messages[index];
if (!message || typeof message !== "object" ) {
continue ;
}
if ((message as { role?: string }).role !== "assistant" ) {
continue ;
}
const errorMessage = (message as { errorMessage?: string }).errorMessage;
const stopReason = (message as { stopReason?: string }).stopReason;
if (errorMessage || stopReason === "error" ) {
throw new Error(errorMessage?.trim() || "pi error" );
}
const content = (message as { content?: unknown }).content;
if (typeof content === "string" ) {
return content;
}
if (Array.isArray(content)) {
return content
.filter((block): block is { type?: string; text?: string } =>
Boolean (block && typeof block === "object" ),
)
.map((block) => (block.type === "text" && typeof block.text === "string" ? block.text : "" ))
.join("" );
}
}
throw new Error("assistant translation not found" );
}
async function translateBatch(
clientAccess: ClientAccess,
items: readonly TranslationBatchItem[],
context: TranslationBatchContext,
): Promise<Map<string, string>> {
const batchLabel = formatBatchLabel(context);
const splitDepth = context.splitDepth ?? 0 ;
let lastError: Error | null = null ;
for (let attempt = 0 ; attempt < TRANSLATE_MAX_ATTEMPTS; attempt += 1 ) {
const attemptNumber = attempt + 1 ;
const attemptLabel = `${batchLabel} attempt ${attemptNumber}/${TRANSLATE_MAX_ATTEMPTS}`;
const startedAt = Date.now();
logProgress(`${attemptLabel}: start keys=${items.length}`);
try {
const raw = await (
await clientAccess.getClient()
).prompt(buildBatchPrompt(items), attemptLabel);
const parsed = JSON.parse(raw) as Record<string, unknown>;
const translated = new Map<string, string>();
for (const item of items) {
const value = parsed[item.key];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`missing translation for ${item.key}`);
}
translated.set(item.key, value);
}
logProgress(`${attemptLabel}: done (${formatDuration(Date.now() - startedAt)})`);
return translated;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
await clientAccess.resetClient();
logProgress(
`${attemptLabel}: failed after ${formatDuration(Date.now() - startedAt)}: ${lastError.message}`,
);
if (isPromptTimeoutError(lastError) && items.length > 1 ) {
const midpoint = Math.ceil(items.length / 2 );
logProgress(
`${batchLabel}: splitting timed out batch into ${midpoint} + ${items.length - midpoint} keys`,
);
const left = await translateBatch(clientAccess, items.slice(0 , midpoint), {
...context,
splitDepth: splitDepth + 1 ,
segmentLabel: `${context.segmentLabel ?? "" }a`,
});
const right = await translateBatch(clientAccess, items.slice(midpoint), {
...context,
splitDepth: splitDepth + 1 ,
segmentLabel: `${context.segmentLabel ?? "" }b`,
});
return new Map([...left, ...right]);
}
if (isPromptTimeoutError(lastError)) {
break ;
}
if (attempt + 1 < TRANSLATE_MAX_ATTEMPTS) {
const delayMs = TRANSLATE_BASE_DELAY_MS * attemptNumber;
logProgress(`${attemptLabel}: retrying in ${formatDuration(delayMs)}`);
await sleep(delayMs);
}
}
}
throw lastError ?? new Error("translation failed" );
}
type SyncOutcome = {
changed: boolean ;
fallbackCount: number;
locale: string;
wrote: boolean ;
};
async function syncLocale(
entry: LocaleEntry,
options: { checkOnly: boolean ; force: boolean ; write: boolean },
context: LocaleRunContext,
) {
const localeLabel = formatLocaleLabel(entry.locale, context);
const localeStartedAt = Date.now();
const sourceRaw = await readFile(SOURCE_LOCALE_PATH, "utf8" );
const sourceHash = sha256(sourceRaw);
const sourceMap = (await loadLocaleMap(SOURCE_LOCALE_PATH, "en" )) ?? {};
const sourceFlat = flattenTranslations(sourceMap);
const existingPath = localeFilePath(entry);
const existingMap = (await loadLocaleMap(existingPath, entry.exportName)) ?? {};
const existingFlat = flattenTranslations(existingMap);
const previousMeta = await loadMeta(metaPath(entry));
const previousFallbackKeys = new Set(previousMeta?.fallbackKeys ?? []);
const glossaryFilePath = glossaryPath(entry);
const glossary = await loadGlossary(glossaryFilePath);
const tm = await loadTranslationMemory(tmPath(entry));
const allowTranslate = hasTranslationProvider();
const nextFlat = new Map<string, string>();
const pending: TranslationBatchItem[] = [];
const fallbackKeys: string[] = [];
for (const [key, text] of sourceFlat.entries()) {
const textHash = hashText(text);
const segmentCacheKey = cacheKey(key, textHash, entry.locale);
const cached = tm.get(segmentCacheKey);
const existing = existingFlat.get(key);
const shouldRefreshFallback = previousFallbackKeys.has(key);
if (cached && !(allowTranslate && shouldRefreshFallback)) {
nextFlat.set(key, cached.translated);
if (shouldRefreshFallback) {
fallbackKeys.push(key);
}
continue ;
}
if (existing !== undefined && !(allowTranslate && shouldRefreshFallback)) {
nextFlat.set(key, existing);
if (shouldRefreshFallback) {
fallbackKeys.push(key);
}
continue ;
}
pending.push({
cacheKey: segmentCacheKey,
key,
text,
textHash,
});
}
if (allowTranslate && pending.length > 0 ) {
const batches = buildTranslationBatches(pending);
const batchCount = batches.length;
logProgress(
`${localeLabel}: start keys=${sourceFlat.size} pending=${pending.length} batches=${batchCount} provider=${resolveConfiguredProvider()} model=${resolveConfiguredModel()} thinking=${resolveThinkingLevel()} timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`,
);
let client: PiRpcClient | null = null ;
const clientAccess: ClientAccess = {
async getClient() {
if (!client) {
client = await PiRpcClient.create(buildSystemPrompt(entry.locale, glossary));
}
return client;
},
async resetClient() {
if (!client) {
return ;
}
await client.close();
client = null ;
},
};
try {
for (const [batchIndex, batch] of batches.entries()) {
const translated = await translateBatch(clientAccess, batch, {
...context,
batchCount,
batchIndex: batchIndex + 1 ,
locale: entry.locale,
});
for (const item of batch) {
const value = translated.get(item.key);
if (!value) {
continue ;
}
nextFlat.set(item.key, value);
tm.set(item.cacheKey, {
cache_key: item.cacheKey,
model: resolveConfiguredModel(),
provider: resolveConfiguredProvider(),
segment_id: item.key,
source_path: `ui/src/i18n/locales/${entry.fileName}`,
src_lang: SOURCE_LOCALE,
text: item.text,
text_hash: item.textHash,
tgt_lang: entry.locale,
translated: value,
updated_at: new Date().toISOString(),
});
}
}
} finally {
await clientAccess.resetClient();
}
} else if (allowTranslate) {
logProgress(
`${localeLabel}: no translation work needed (all keys reused from cache or existing files)`,
);
} else {
logProgress(`${localeLabel}: no provider configured, using English fallback for pending keys`);
}
for (const item of pending) {
if (nextFlat.has(item.key)) {
continue ;
}
const existing = existingFlat.get(item.key);
if (existing !== undefined && !options.force) {
nextFlat.set(item.key, existing);
if (previousFallbackKeys.has(item.key)) {
fallbackKeys.push(item.key);
}
continue ;
}
nextFlat.set(item.key, item.text);
fallbackKeys.push(item.key);
}
// Do not infer fallback state from source-text equality alone.
// Product names, config keys, and other intentional carry-through strings may
// legitimately stay identical to English. Track fallback keys from actual
// fallback decisions and previous fallback metadata instead.
const nextMap: TranslationMap = {};
for (const [key, value] of sourceFlat.entries()) {
setNestedValue(nextMap, key, nextFlat.get(key) ?? value);
}
const nextProvider = allowTranslate
? resolveConfiguredProvider()
: (previousMeta?.provider ?? "" );
const nextModel = allowTranslate ? resolveConfiguredModel() : (previousMeta?.model ?? "" );
const sortedFallbackKeys = [...new Set(fallbackKeys)].toSorted((left, right) =>
left.localeCompare(right),
);
const translatedKeys = sourceFlat.size - sortedFallbackKeys.length;
const semanticMetaChanged =
!previousMeta ||
previousMeta.locale !== entry.locale ||
previousMeta.sourceHash !== sourceHash ||
previousMeta.provider !== nextProvider ||
previousMeta.model !== nextModel ||
previousMeta.totalKeys !== sourceFlat.size ||
previousMeta.translatedKeys !== translatedKeys ||
previousMeta.workflow !== CONTROL_UI_I18N_WORKFLOW ||
!compareStringArrays(previousMeta.fallbackKeys, sortedFallbackKeys);
const nextMeta: LocaleMeta = {
fallbackKeys: sortedFallbackKeys,
generatedAt: semanticMetaChanged ? new Date().toISOString() : previousMeta.generatedAt,
locale: entry.locale,
model: nextModel,
provider: nextProvider,
sourceHash,
totalKeys: sourceFlat.size,
translatedKeys,
workflow: CONTROL_UI_I18N_WORKFLOW,
};
const expectedLocale = await formatGeneratedTypeScript(
existingPath,
renderLocaleModule(entry, nextMap),
);
const expectedMeta = renderMeta(nextMeta);
const expectedGlossary = renderGlossary(glossary.length === 0 ? DEFAULT_GLOSSARY : glossary);
const expectedTm = renderTranslationMemory(tm);
const currentLocale = existsSync(existingPath) ? await readFile(existingPath, "utf8" ) : "" ;
const currentMeta = existsSync(metaPath(entry)) ? await readFile(metaPath(entry), "utf8" ) : "" ;
const currentGlossary = existsSync(glossaryFilePath)
? await readFile(glossaryFilePath, "utf8" )
: "" ;
const currentTm = existsSync(tmPath(entry)) ? await readFile(tmPath(entry), "utf8" ) : "" ;
const changed =
currentLocale !== expectedLocale ||
currentMeta !== expectedMeta ||
currentGlossary !== expectedGlossary ||
currentTm !== expectedTm;
if (
!changed ||
(previousMeta?.sourceHash === sourceHash &&
!options.force &&
!options.checkOnly &&
!options.write)
) {
logProgress(
`${localeLabel}: done changed=${changed} fallbacks=${nextMeta.fallbackKeys.length} elapsed=${formatDuration(Date.now() - localeStartedAt)}`,
);
return {
changed,
fallbackCount: nextMeta.fallbackKeys.length,
locale: entry.locale,
wrote: false ,
} satisfies SyncOutcome;
}
if (!options.checkOnly && options.write) {
await mkdir(LOCALES_DIR, { recursive: true });
await mkdir(I18N_ASSETS_DIR, { recursive: true });
await writeFile(existingPath, expectedLocale, "utf8" );
await writeFile(metaPath(entry), expectedMeta, "utf8" );
await writeFile(glossaryFilePath, expectedGlossary, "utf8" );
if (expectedTm) {
await writeFile(tmPath(entry), expectedTm, "utf8" );
} else if (existsSync(tmPath(entry))) {
await writeFile(tmPath(entry), "" , "utf8" );
}
}
logProgress(
`${localeLabel}: done changed=${changed} fallbacks=${nextMeta.fallbackKeys.length} elapsed=${formatDuration(Date.now() - localeStartedAt)}${!options.checkOnly && options.write && changed ? " wrote" : "" }`,
);
return {
changed,
fallbackCount: nextMeta.fallbackKeys.length,
locale: entry.locale,
wrote: !options.checkOnly && options.write && changed,
} satisfies SyncOutcome;
}
async function verifyRuntimeLocaleConfig() {
const registryRaw = await readFile(
path.join(ROOT, "ui" , "src" , "i18n" , "lib" , "registry.ts" ),
"utf8" ,
);
const typesRaw = await readFile(path.join(ROOT, "ui" , "src" , "i18n" , "lib" , "types.ts" ), "utf8" );
const expectedLocaleSnippets = LOCALE_ENTRIES.map((entry) => entry.locale);
for (const locale of expectedLocaleSnippets) {
if (!registryRaw.includes(`"${locale}" `) || !typesRaw.includes(`| "${locale}" `)) {
throw new Error(`runtime locale config is missing ${locale}`);
}
}
const enMap = (await loadLocaleMap(SOURCE_LOCALE_PATH, "en" )) ?? {};
const languageMap = enMap.languages;
const languageKeys =
languageMap && typeof languageMap === "object"
? Object.keys(languageMap).toSorted((left, right) => left.localeCompare(right))
: [];
const expectedLanguageKeys = ["en" , ...LOCALE_ENTRIES.map((entry) => entry.languageKey)].toSorted(
(left, right) => left.localeCompare(right),
);
if (!compareStringArrays(languageKeys, expectedLanguageKeys)) {
throw new Error(
`ui/src/i18n/locales/en.ts languages block is out of sync: expected ${expectedLanguageKeys.join(", " )}, got ${languageKeys.join(", " )}`,
);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2 ));
await verifyRuntimeLocaleConfig();
const entries = args.localeFilter
? LOCALE_ENTRIES.filter((entry) => entry.locale === args.localeFilter)
: [...LOCALE_ENTRIES];
if (entries.length === 0 ) {
throw new Error(`unknown locale: ${args.localeFilter}`);
}
logProgress(
`command=${args.command} locales=${entries.length} provider=${hasTranslationProvider() ? resolveConfiguredProvider() : "fallback-only" } model=${hasTranslationProvider() ? resolveConfiguredModel() : "n/a" } thinking=${hasTranslationProvider() ? resolveThinkingLevel() : "n/a" } timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`,
);
const outcomes: SyncOutcome[] = [];
for (const [index, entry] of entries.entries()) {
const outcome = await syncLocale(
entry,
{
checkOnly: args.command === "check" ,
force: args.force,
write: args.write,
},
{
localeCount: entries.length,
localeIndex: index + 1 ,
},
);
outcomes.push(outcome);
}
const changed = outcomes.filter((outcome) => outcome.changed);
const summary = outcomes
.map(
(outcome) =>
`${outcome.locale}: ${outcome.changed ? "dirty" : "clean" } (fallbacks=${outcome.fallbackCount}${outcome.wrote ? ", wrote" : "" })`,
)
.join("\n" );
process.stdout.write(`${summary}\n`);
if (args.command === "check" && changed.length > 0 ) {
throw new Error(
[
"control-ui-i18n drift detected." ,
"Run `node --import tsx scripts/control-ui-i18n.ts sync --write` and commit the results." ,
].join("\n" ),
);
}
if (args.command === "sync" && !args.write && changed.length > 0 ) {
process.stdout.write(
"dry-run only. re-run with `node --import tsx scripts/control-ui-i18n.ts sync --write` to update files.\n" ,
);
}
}
await main().catch ((error) => {
console.error(formatErrorMessage(error));
process.exit(1 );
});
Messung V0.5 in Prozent C=94 H=91 G=92
¤ Dauer der Verarbeitung: 0.21 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland