feat(i18n): localize configure command

This commit is contained in:
Xu, Jingrong 2026-01-30 19:51:47 +08:00
parent badf499e2b
commit 5f6dc2d89b
7 changed files with 200 additions and 113 deletions

View File

@ -12,6 +12,7 @@ import {
import { guardCancel } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
import { loadConfig } from "../config/config.js";
import { t } from "../wizard/i18n.js";
export async function maybeInstallDaemon(params: {
runtime: RuntimeEnv;
@ -27,11 +28,11 @@ export async function maybeInstallDaemon(params: {
if (loaded) {
const action = guardCancel(
await select({
message: "Gateway service already installed",
message: t("configure.daemon.alreadyInstalled"),
options: [
{ value: "restart", label: "Restart" },
{ value: "reinstall", label: "Reinstall" },
{ value: "skip", label: "Skip" },
{ value: "restart", label: t("configure.daemon.restart") },
{ value: "reinstall", label: t("configure.daemon.reinstall") },
{ value: "skip", label: t("configure.daemon.skip") },
],
}),
params.runtime,
@ -40,12 +41,12 @@ export async function maybeInstallDaemon(params: {
await withProgress(
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
async (progress) => {
progress.setLabel("Restarting Gateway service…");
progress.setLabel(t("configure.daemon.restarting"));
await service.restart({
env: process.env,
stdout: process.stdout,
});
progress.setLabel("Gateway service restarted.");
progress.setLabel(t("configure.daemon.restarted"));
},
);
shouldCheckLinger = true;
@ -56,9 +57,9 @@ export async function maybeInstallDaemon(params: {
await withProgress(
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
async (progress) => {
progress.setLabel("Uninstalling Gateway service…");
progress.setLabel(t("configure.daemon.uninstalling"));
await service.uninstall({ env: process.env, stdout: process.stdout });
progress.setLabel("Gateway service uninstalled.");
progress.setLabel(t("configure.daemon.uninstalled"));
},
);
}
@ -72,7 +73,7 @@ export async function maybeInstallDaemon(params: {
} else {
daemonRuntime = guardCancel(
await select({
message: "Gateway service runtime",
message: t("configure.daemon.selectRuntime"),
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
}),
@ -83,7 +84,7 @@ export async function maybeInstallDaemon(params: {
await withProgress(
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
async (progress) => {
progress.setLabel("Preparing Gateway service…");
progress.setLabel(t("configure.daemon.preparing"));
const cfg = loadConfig();
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
@ -95,7 +96,7 @@ export async function maybeInstallDaemon(params: {
config: cfg,
});
progress.setLabel("Installing Gateway service…");
progress.setLabel(t("configure.daemon.installing"));
try {
await service.install({
env: process.env,
@ -104,15 +105,15 @@ export async function maybeInstallDaemon(params: {
workingDirectory,
environment,
});
progress.setLabel("Gateway service installed.");
progress.setLabel(t("configure.daemon.installed"));
} catch (err) {
installError = err instanceof Error ? err.message : String(err);
progress.setLabel("Gateway service install failed.");
progress.setLabel(t("configure.daemon.installFailed"));
}
},
);
if (installError) {
note("Gateway service install failed: " + installError, "Gateway");
note(t("configure.daemon.installFailedNote") + installError, "Gateway");
note(gatewayInstallErrorHint(), "Gateway");
return;
}
@ -127,7 +128,7 @@ export async function maybeInstallDaemon(params: {
note,
},
reason:
"Linux installs use a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
t("configure.daemon.lingerReason"),
requireConfirm: true,
});
}

View File

@ -11,6 +11,7 @@ import {
promptDefaultModel,
promptModelAllowlist,
} from "./model-picker.js";
import { t } from "../wizard/i18n.js";
type GatewayAuthChoice = "token" | "password";
@ -80,7 +81,7 @@ export async function promptAuthConfig(
prompter,
allowedKeys: anthropicOAuth ? ANTHROPIC_OAUTH_MODEL_KEYS : undefined,
initialSelections: anthropicOAuth ? ["anthropic/claude-opus-4-5"] : undefined,
message: anthropicOAuth ? "Anthropic OAuth models" : undefined,
message: anthropicOAuth ? t("configure.auth.anthropicOAuthModels") : undefined,
});
if (allowlistSelection.models) {
next = applyModelAllowlist(next, allowlistSelection.models);

View File

@ -4,6 +4,7 @@ import { findTailscaleBinary } from "../infra/tailscale.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
import { t } from "../wizard/i18n.js";
import { confirm, select, text } from "./configure.shared.js";
import { guardCancel, randomToken } from "./onboard-helpers.js";
@ -19,9 +20,9 @@ export async function promptGatewayConfig(
}> {
const portRaw = guardCancel(
await text({
message: "Gateway port",
message: t("onboarding.gatewayConfig.port"),
initialValue: String(resolveGatewayPort(cfg)),
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
validate: (value) => (Number.isFinite(Number(value)) ? undefined : t("onboarding.gatewayConfig.invalidPort")),
}),
runtime,
);
@ -29,31 +30,31 @@ export async function promptGatewayConfig(
let bind = guardCancel(
await select({
message: "Gateway bind mode",
message: t("onboarding.gatewayConfig.bind"),
options: [
{
value: "loopback",
label: "Loopback (Local only)",
hint: "Bind to 127.0.0.1 - secure, local-only access",
label: t("onboarding.gateway.bindLoopback"),
hint: t("onboarding.gatewayConfig.bindLoopbackHint") || "Bind to 127.0.0.1 - secure, local-only access",
},
{
value: "tailnet",
label: "Tailnet (Tailscale IP)",
hint: "Bind to your Tailscale IP only (100.x.x.x)",
label: t("onboarding.gateway.bindTailnet"),
hint: t("onboarding.gatewayConfig.bindTailnetHint") || "Bind to your Tailscale IP only (100.x.x.x)",
},
{
value: "auto",
label: "Auto (Loopback → LAN)",
label: t("onboarding.gateway.bindAuto"),
hint: "Prefer loopback; fall back to all interfaces if unavailable",
},
{
value: "lan",
label: "LAN (All interfaces)",
hint: "Bind to 0.0.0.0 - accessible from anywhere on your network",
label: t("onboarding.gateway.bindLan"),
hint: t("onboarding.gatewayConfig.bindLanHint") || "Bind to 0.0.0.0 - accessible from anywhere on your network",
},
{
value: "custom",
label: "Custom IP",
label: t("onboarding.gateway.bindCustom"),
hint: "Specify a specific IP address, with 0.0.0.0 fallback if unavailable",
},
],
@ -65,13 +66,13 @@ export async function promptGatewayConfig(
if (bind === "custom") {
const input = guardCancel(
await text({
message: "Custom IP address",
message: t("onboarding.gatewayConfig.customIp"),
placeholder: "192.168.1.100",
validate: (value) => {
if (!value) return "IP address is required for custom bind mode";
if (!value) return t("onboarding.gatewayConfig.customIpRequired");
const trimmed = value.trim();
const parts = trimmed.split(".");
if (parts.length !== 4) return "Invalid IPv4 address (e.g., 192.168.1.100)";
if (parts.length !== 4) return t("onboarding.gatewayConfig.invalidIp") + " (e.g., 192.168.1.100)";
if (
parts.every((part) => {
const n = parseInt(part, 10);
@ -79,7 +80,7 @@ export async function promptGatewayConfig(
})
)
return undefined;
return "Invalid IPv4 address (each octet must be 0-255)";
return t("onboarding.gatewayConfig.invalidIpOctet");
},
}),
runtime,
@ -89,10 +90,10 @@ export async function promptGatewayConfig(
let authMode = guardCancel(
await select({
message: "Gateway auth",
message: t("onboarding.gatewayConfig.auth"),
options: [
{ value: "token", label: "Token", hint: "Recommended default" },
{ value: "password", label: "Password" },
{ value: "token", label: t("onboarding.gatewayConfig.authToken"), hint: t("onboarding.gatewayConfig.authTokenHint") },
{ value: "password", label: t("onboarding.gatewayConfig.authPassword") },
],
initialValue: "token",
}),
@ -101,18 +102,18 @@ export async function promptGatewayConfig(
const tailscaleMode = guardCancel(
await select({
message: "Tailscale exposure",
message: t("onboarding.gatewayConfig.tsExposure"),
options: [
{ value: "off", label: "Off", hint: "No Tailscale exposure" },
{ value: "off", label: t("onboarding.gatewayConfig.tsOff"), hint: t("onboarding.gatewayConfig.tsOffHint") },
{
value: "serve",
label: "Serve",
hint: "Private HTTPS for your tailnet (devices on Tailscale)",
label: t("onboarding.gatewayConfig.tsServe"),
hint: t("onboarding.gatewayConfig.tsServeHint"),
},
{
value: "funnel",
label: "Funnel",
hint: "Public HTTPS via Tailscale Funnel (internet)",
label: t("onboarding.gatewayConfig.tsFunnel"),
hint: t("onboarding.gatewayConfig.tsFunnelHint"),
},
],
}),
@ -124,14 +125,8 @@ export async function promptGatewayConfig(
const tailscaleBin = await findTailscaleBinary();
if (!tailscaleBin) {
note(
[
"Tailscale binary not found in PATH or /Applications.",
"Ensure Tailscale is installed from:",
" https://tailscale.com/download/mac",
"",
"You can continue setup, but serve/funnel will fail at runtime.",
].join("\n"),
"Tailscale Warning",
t("onboarding.gatewayConfig.tsNotFound"),
t("onboarding.gatewayConfig.tsWarningTitle"),
);
}
}
@ -147,7 +142,7 @@ export async function promptGatewayConfig(
tailscaleResetOnExit = Boolean(
guardCancel(
await confirm({
message: "Reset Tailscale serve/funnel on exit?",
message: t("onboarding.gatewayConfig.tsResetConfirm"),
initialValue: false,
}),
runtime,
@ -156,12 +151,12 @@ export async function promptGatewayConfig(
}
if (tailscaleMode !== "off" && bind !== "loopback") {
note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
note(t("onboarding.gatewayConfig.tsAdjustBind"), "Note");
bind = "loopback";
}
if (tailscaleMode === "funnel" && authMode !== "password") {
note("Tailscale funnel requires password auth.", "Note");
note(t("onboarding.gatewayConfig.tsFunnelAuth"), "Note");
authMode = "password";
}
@ -172,7 +167,7 @@ export async function promptGatewayConfig(
if (authMode === "token") {
const tokenInput = guardCancel(
await text({
message: "Gateway token (blank to generate)",
message: t("onboarding.gatewayConfig.tokenPlaceholder"),
initialValue: randomToken(),
}),
runtime,
@ -183,8 +178,8 @@ export async function promptGatewayConfig(
if (authMode === "password") {
const password = guardCancel(
await text({
message: "Gateway password",
validate: (value) => (value?.trim() ? undefined : "Required"),
message: t("onboarding.gatewayConfig.passwordLabel"),
validate: (value) => (value?.trim() ? undefined : t("onboarding.gatewayConfig.passwordRequired")),
}),
runtime,
);

View File

@ -7,6 +7,7 @@ import {
} from "@clack/prompts";
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
import { t } from "../wizard/i18n.js";
export const CONFIGURE_WIZARD_SECTIONS = [
"workspace",
@ -33,27 +34,27 @@ export const CONFIGURE_SECTION_OPTIONS: Array<{
label: string;
hint: string;
}> = [
{ value: "workspace", label: "Workspace", hint: "Set workspace + sessions" },
{ value: "model", label: "Model", hint: "Pick provider + credentials" },
{ value: "web", label: "Web tools", hint: "Configure Brave search + fetch" },
{ value: "gateway", label: "Gateway", hint: "Port, bind, auth, tailscale" },
{
value: "daemon",
label: "Daemon",
hint: "Install/manage the background service",
},
{
value: "channels",
label: "Channels",
hint: "Link WhatsApp/Telegram/etc and defaults",
},
{ value: "skills", label: "Skills", hint: "Install/enable workspace skills" },
{
value: "health",
label: "Health check",
hint: "Run gateway + channel checks",
},
];
{ value: "workspace", label: t("configure.sections.workspace"), hint: t("configure.sections.workspaceHint") },
{ value: "model", label: t("configure.sections.model"), hint: t("configure.sections.modelHint") },
{ value: "web", label: t("configure.sections.web"), hint: t("configure.sections.webHint") },
{ value: "gateway", label: t("configure.sections.gateway"), hint: t("configure.sections.gatewayHint") },
{
value: "daemon",
label: t("configure.sections.daemon"),
hint: t("configure.sections.daemonHint"),
},
{
value: "channels",
label: t("configure.sections.channels"),
hint: t("configure.sections.channelsHint"),
},
{ value: "skills", label: t("configure.sections.skills"), hint: t("configure.sections.skillsHint") },
{
value: "health",
label: t("configure.sections.health"),
hint: t("configure.sections.healthHint"),
},
];
export const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? message);
export const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? message);

View File

@ -9,6 +9,7 @@ import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import { WizardCancelledError } from "../wizard/prompts.js";
import { t } from "../wizard/i18n.js";
import { removeChannelConfigWizard } from "./configure.channels.js";
import { maybeInstallDaemon } from "./configure.daemon.js";
import { promptGatewayConfig } from "./configure.gateway.js";
@ -51,13 +52,13 @@ async function promptConfigureSection(
): Promise<ConfigureSectionChoice> {
return guardCancel(
await select<ConfigureSectionChoice>({
message: "Select sections to configure",
message: t("configure.sections.title"),
options: [
...CONFIGURE_SECTION_OPTIONS,
{
value: "__continue",
label: "Continue",
hint: hasSelection ? "Done" : "Skip for now",
label: t("configure.sections.continue"),
hint: hasSelection ? t("configure.sections.continueHint") : t("configure.sections.skipHint"),
},
],
initialValue: CONFIGURE_SECTION_OPTIONS[0]?.value,
@ -69,17 +70,17 @@ async function promptConfigureSection(
async function promptChannelMode(runtime: RuntimeEnv): Promise<ChannelsWizardMode> {
return guardCancel(
await select({
message: "Channels",
message: t("configure.sections.channels"),
options: [
{
value: "configure",
label: "Configure/link",
hint: "Add/update channels; disable unselected accounts",
label: t("configure.channels.configure"),
hint: t("configure.channels.configureHint"),
},
{
value: "remove",
label: "Remove channel config",
hint: "Delete channel tokens/settings from openclaw.json",
label: t("configure.channels.remove"),
hint: t("configure.channels.removeHint"),
},
],
initialValue: "configure",
@ -107,7 +108,7 @@ async function promptWebToolsConfig(
const enableSearch = guardCancel(
await confirm({
message: "Enable web_search (Brave Search)?",
message: t("configure.web.enableSearch"),
initialValue: existingSearch?.enabled ?? hasSearchKey,
}),
runtime,
@ -122,9 +123,9 @@ async function promptWebToolsConfig(
const keyInput = guardCancel(
await text({
message: hasSearchKey
? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)"
: "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)",
placeholder: hasSearchKey ? "Leave blank to keep current" : "BSA...",
? t("configure.web.keyPrompt")
: t("configure.web.keyPromptEmpty"),
placeholder: hasSearchKey ? t("configure.web.placeholderKey") : t("configure.web.placeholderKeyEmpty"),
}),
runtime,
);
@ -133,19 +134,15 @@ async function promptWebToolsConfig(
nextSearch = { ...nextSearch, apiKey: key };
} else if (!hasSearchKey) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set BRAVE_API_KEY in the Gateway environment.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
t("configure.web.noKeyWarning"),
t("configure.sections.web"),
);
}
}
const enableFetch = guardCancel(
await confirm({
message: "Enable web_fetch (keyless HTTP fetch)?",
message: t("configure.web.enableFetch"),
initialValue: existingFetch?.enabled ?? true,
}),
runtime,
@ -175,7 +172,7 @@ export async function runConfigureWizard(
) {
try {
printWizardHeader(runtime);
intro(opts.command === "update" ? "OpenClaw update wizard" : "OpenClaw configure");
intro(opts.command === "update" ? t("configure.updateTitle") : t("configure.title"));
const prompter = createClackPrompter();
const snapshot = await readConfigFileSnapshot();
@ -212,30 +209,30 @@ export async function runConfigureWizard(
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
const remoteProbe = remoteUrl
? await probeGatewayReachable({
url: remoteUrl,
token: baseConfig.gateway?.remote?.token,
})
url: remoteUrl,
token: baseConfig.gateway?.remote?.token,
})
: null;
const mode = guardCancel(
await select({
message: "Where will the Gateway run?",
message: t("configure.gateway.modeSelect"),
options: [
{
value: "local",
label: "Local (this machine)",
label: t("configure.gateway.local"),
hint: localProbe.ok
? `Gateway reachable (${localUrl})`
: `No gateway detected (${localUrl})`,
? t("configure.gateway.localHintReachable", { url: localUrl })
: t("configure.gateway.localHintMissing", { url: localUrl }),
},
{
value: "remote",
label: "Remote (info-only)",
label: t("configure.gateway.remote"),
hint: !remoteUrl
? "No remote URL configured yet"
? t("configure.gateway.remoteHintNoUrl")
: remoteProbe?.ok
? `Gateway reachable (${remoteUrl})`
: `Configured but unreachable (${remoteUrl})`,
? t("configure.gateway.remoteHintReachable", { url: remoteUrl })
: t("configure.gateway.remoteHintConfigured", { url: remoteUrl }),
},
],
}),
@ -250,7 +247,7 @@ export async function runConfigureWizard(
});
await writeConfigFile(remoteConfig);
logConfigUpdated(runtime);
outro("Remote gateway configured.");
outro(t("configure.gateway.remoteConfigured"));
return;
}
@ -288,7 +285,7 @@ export async function runConfigureWizard(
if (opts.sections) {
const selected = opts.sections;
if (!selected || selected.length === 0) {
outro("No changes selected.");
outro(t("configure.gateway.noChanges"));
return;
}
@ -530,10 +527,10 @@ export async function runConfigureWizard(
if (!ranSection) {
if (didSetGatewayMode) {
await persistConfig();
outro("Gateway mode set to local.");
outro(t("configure.gateway.modeSetLocal"));
return;
}
outro("No changes selected.");
outro(t("configure.gateway.noChanges"));
return;
}
}
@ -582,7 +579,7 @@ export async function runConfigureWizard(
"Control UI",
);
outro("Configure complete.");
outro(t("configure.gateway.configureComplete"));
} catch (err) {
if (err instanceof WizardCancelledError) {
runtime.exit(0);

View File

@ -6,7 +6,7 @@ const locales: Record<string, any> = {
"zh-CN": zhCN,
};
export function t(key: string): string {
export function t(key: string, args?: Record<string, string | number>): string {
const keys = key.split(".");
let value = locales[currentLocale];
for (const k of keys) {
@ -16,5 +16,11 @@ export function t(key: string): string {
return key;
}
}
return typeof value === "string" ? value : key;
let str = typeof value === "string" ? value : key;
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(new RegExp(`{${k}}`, "g"), String(v));
}
}
return str;
}

View File

@ -209,6 +209,92 @@ export const zhCN = {
passwordRequired: "必须填写密码",
}
},
configure: {
title: "OpenClaw 配置",
updateTitle: "OpenClaw 更新向导",
auth: {
anthropicOAuthModels: "Anthropic OAuth 模型",
},
sections: {
title: "选择要配置的部分",
continue: "继续",
continueHint: "完成",
skipHint: "暂时跳过",
workspace: "工作区 (Workspace)",
workspaceHint: "设置工作区 + 会话",
model: "模型 (Model)",
modelHint: "选择提供商 + 凭证",
web: "Web 工具",
webHint: "配置 Brave 搜索 + 用于抓取",
gateway: "网关 (Gateway)",
gatewayHint: "端口、绑定、认证、Tailscale",
daemon: "后台服务 (Daemon)",
daemonHint: "安装/管理后台服务",
channels: "渠道 (Channels)",
channelsHint: "连接 WhatsApp/Telegram 等",
skills: "技能 (Skills)",
skillsHint: "安装/启用工作区技能",
health: "健康检查 (Health check)",
healthHint: "运行网关 + 渠道检查",
},
gateway: {
modeSelect: "网关将在哪里运行?",
local: "本地 (这台机器)",
localHintReachable: "网关可达 ({url})",
localHintMissing: "未检测到网关 ({url})",
remote: "远程 (仅信息)",
remoteHintNoUrl: "尚未配置远程 URL",
remoteHintReachable: "网关可达 ({url})",
remoteHintConfigured: "已配置但无法连接 ({url})",
remoteConfigured: "远程网关已配置。",
modeSetLocal: "网关模式已设置为本地。",
noChanges: "未选择任何更改。",
configureComplete: "配置完成。",
},
channels: {
modeTitle: "渠道配置模式",
configure: "配置/连接",
configureHint: "添加/更新渠道;禁用未选中的账户",
remove: "移除渠道配置",
removeHint: "从 openclaw.json 中删除渠道令牌/设置",
},
web: {
title: "网页搜索",
desc: [
"网页搜索允许您的 Agent 使用 `web_search` 工具在线查找信息。",
"它需要 Brave Search API 密钥(您可以将其存储在配置中或在网关环境中设置 BRAVE_API_KEY。",
"文档: https://docs.openclaw.ai/tools/web",
].join("\n"),
enableSearch: "启用 web_search (Brave Search)?",
keyPrompt: "Brave Search API 密钥 (留空保留当前值或使用 BRAVE_API_KEY)",
keyPromptEmpty: "Brave Search API 密钥 (粘贴到这里;留空使用 BRAVE_API_KEY)",
placeholderKey: "留空以保留当前设置",
placeholderKeyEmpty: "BSA...",
noKeyWarning: [
"尚未存储密钥,因此 web_search 将不可用。",
"请在此处存储密钥或在网关环境中设置 BRAVE_API_KEY。",
"文档: https://docs.openclaw.ai/tools/web",
].join("\n"),
enableFetch: "启用 web_fetch (无密钥 HTTP 抓取)?",
},
daemon: {
alreadyInstalled: "网关服务已安装",
restart: "重启 (Restart)",
reinstall: "重新安装 (Reinstall)",
skip: "跳过 (Skip)",
restarting: "正在重启网关服务…",
restarted: "网关服务已重启。",
uninstalling: "正在卸载网关服务…",
uninstalled: "网关服务已卸载。",
selectRuntime: "网关服务运行时",
preparing: "正在准备网关服务…",
installing: "正在安装网关服务…",
installed: "网关服务已安装。",
installFailed: "网关服务安装失败。",
installFailedNote: "网关服务安装失败: ",
lingerReason: "Linux 安装使用 systemd 用户服务。如果不启用 lingeringsystemd 会在注销/空闲时停止用户会话并终止网关。",
}
},
common: {
configUpdated: "配置已更新。",
}