diff --git a/README.md b/README.md
index 1fd5e074c..f4c1786cc 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# 🦞 OpenClaw — Personal AI Assistant
+English | [简体中文](README.zh-CN.md)
+
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 8d1a1f1ca..4d2eb2c97 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -56,7 +56,7 @@
### 环境要求
- **Node.js** ≥ 22
-- **操作系统**: macOS, Linux, Windows (via WSL2)
+- **操作系统**: macOS, Linux, Windows (推荐 WSL2)
### 安装
diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts
index 774893213..aba1441e6 100644
--- a/src/commands/onboard-helpers.ts
+++ b/src/commands/onboard-helpers.ts
@@ -27,10 +27,11 @@ import {
} from "../utils.js";
import { VERSION } from "../version.js";
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
+import { t } from "../wizard/i18n.js";
export function guardCancel(value: T | symbol, runtime: RuntimeEnv): T {
if (isCancel(value)) {
- cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled.");
+ cancel(stylePromptTitle(t("onboarding.helpers.cancelled")) ?? t("onboarding.helpers.cancelled"));
runtime.exit(0);
}
return value as T;
@@ -55,7 +56,7 @@ export function summarizeExistingConfig(config: OpenClawConfig): string {
if (config.skills?.install?.nodeManager) {
rows.push(shortenHomeInString(`skills.nodeManager: ${config.skills.install.nodeManager}`));
}
- return rows.length ? rows.join("\n") : "No key settings detected.";
+ return rows.length ? rows.join("\n") : t("onboarding.helpers.noSettings");
}
export function randomToken(): string {
@@ -172,7 +173,7 @@ export function formatControlUiSshHint(params: {
const authedUrl = params.token ? `${localUrl}${tokenParam}` : undefined;
const sshTarget = resolveSshTargetHint();
return [
- "No GUI detected. Open from your computer:",
+ t("onboarding.helpers.sshHint"),
`ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`,
"Then open:",
localUrl,
@@ -242,10 +243,10 @@ export async function ensureWorkspaceAndSessions(
dir: workspaceDir,
ensureBootstrapFiles: !options?.skipBootstrap,
});
- runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`);
+ runtime.log(`${t("onboarding.helpers.workspaceOk")}: ${shortenHomePath(ws.dir)}`);
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
await fs.mkdir(sessionsDir, { recursive: true });
- runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`);
+ runtime.log(`${t("onboarding.helpers.sessionsOk")}: ${shortenHomePath(sessionsDir)}`);
}
export function resolveNodeManagerOptions(): Array<{
@@ -268,9 +269,9 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
}
try {
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
- runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
+ runtime.log(`${t("onboarding.helpers.trashOk")}: ${shortenHomePath(pathname)}`);
} catch {
- runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
+ runtime.log(`${t("onboarding.helpers.trashFail")}: ${shortenHomePath(pathname)}`);
}
}
diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts
index 915097d83..73dfaa32f 100644
--- a/src/commands/onboard.ts
+++ b/src/commands/onboard.ts
@@ -8,6 +8,7 @@ import { runInteractiveOnboarding } from "./onboard-interactive.js";
import { runNonInteractiveOnboarding } from "./onboard-non-interactive.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OnboardOptions } from "./onboard-types.js";
+import { t } from "../wizard/i18n.js";
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
assertSupportedRuntime(runtime);
@@ -21,18 +22,18 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
if (opts.nonInteractive && (authChoice === "claude-cli" || authChoice === "codex-cli")) {
runtime.error(
[
- `Auth choice "${authChoice}" is deprecated.`,
- 'Use "--auth-choice token" (Anthropic setup-token) or "--auth-choice openai-codex".',
+ t("onboarding.cli.deprecatedAuth").replace("{authChoice}", authChoice),
+ t("onboarding.cli.useAuthToken"),
].join("\n"),
);
runtime.exit(1);
return;
}
if (authChoice === "claude-cli") {
- runtime.log('Auth choice "claude-cli" is deprecated; using setup-token flow instead.');
+ runtime.log(t("onboarding.cli.authTokenFlow"));
}
if (authChoice === "codex-cli") {
- runtime.log('Auth choice "codex-cli" is deprecated; using OpenAI Codex OAuth instead.');
+ runtime.log(t("onboarding.cli.authCodexFlow"));
}
const flow = opts.flow === "manual" ? ("advanced" as const) : opts.flow;
const normalizedOpts =
@@ -43,8 +44,7 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
runtime.error(
[
- "Non-interactive onboarding requires explicit risk acknowledgement.",
- "Read: https://docs.openclaw.ai/security",
+ t("onboarding.cli.nonInteractiveRisk"),
`Re-run with: ${formatCliCommand("openclaw onboard --non-interactive --accept-risk ...")}`,
].join("\n"),
);
@@ -61,13 +61,7 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
}
if (process.platform === "win32") {
- runtime.log(
- [
- "Windows detected.",
- "WSL2 is strongly recommended; native Windows is untested and more problematic.",
- "Guide: https://docs.openclaw.ai/windows",
- ].join("\n"),
- );
+ runtime.log(t("onboarding.cli.winWarning"));
}
if (normalizedOpts.nonInteractive) {
diff --git a/src/wizard/i18n.ts b/src/wizard/i18n.ts
new file mode 100644
index 000000000..c4472b6fd
--- /dev/null
+++ b/src/wizard/i18n.ts
@@ -0,0 +1,20 @@
+
+import { zhCN } from "./locales/zh-CN.js";
+
+const currentLocale = "zh-CN"; // Default to Chinese
+const locales: Record = {
+ "zh-CN": zhCN,
+};
+
+export function t(key: string): string {
+ const keys = key.split(".");
+ let value = locales[currentLocale];
+ for (const k of keys) {
+ if (value && typeof value === "object") {
+ value = value[k];
+ } else {
+ return key;
+ }
+ }
+ return typeof value === "string" ? value : key;
+}
diff --git a/src/wizard/locales/zh-CN.ts b/src/wizard/locales/zh-CN.ts
new file mode 100644
index 000000000..fcd8d13fe
--- /dev/null
+++ b/src/wizard/locales/zh-CN.ts
@@ -0,0 +1,215 @@
+
+export const zhCN = {
+ onboarding: {
+ title: "OpenClaw 引导安装",
+ intro: "欢迎使用 OpenClaw 引导安装",
+ security: {
+ title: "安全警告",
+ note: [
+ "安全警告 — 请仔细阅读。",
+ "",
+ "OpenClaw 是一个个人爱好项目,目前处于 Beta 阶段。请做好遇到问题的心理准备。",
+ "如果启用了技能工具,机器人可以读取您的文件并执行操作。",
+ "恶意提示(Prompt Injection)可能会诱使机器人执行不安全的操作。",
+ "",
+ "如果您对基础安全和访问控制感到不放心,请不要运行 OpenClaw。",
+ "在启用工具或将其暴露在互联网之前,请先向有经验的人寻求帮助。",
+ "",
+ "推荐的安全基准:",
+ "- 开启配对/白名单机制 + 提及触发(Mention Gating)。",
+ "- 在沙箱中运行 + 最小权限原则。",
+ "- 不要让代理程序能接触到敏感的系统密钥和凭证。",
+ "- 对拥有工具权限或监听不信任渠道的机器人,务必使用最强大的模型。",
+ "",
+ "定期运行审计命令:",
+ "openclaw security audit --deep",
+ "openclaw security audit --fix",
+ "",
+ "必读文档: https://docs.openclaw.ai/gateway/security",
+ ].join("\n"),
+ confirm: "我理解这是非常强大的工具,并且具有内在风险。是否继续?",
+ cancelled: "未接受安全风险,已取消。",
+ },
+ config: {
+ invalid: "配置文件无效",
+ issues: "配置问题提示",
+ repair: "配置无效。请运行 `openclaw doctor` 进行修复,然后重新运行引导安装。",
+ },
+ flow: {
+ modeSelect: "选择安装模式",
+ quickstart: "快速上手 (QuickStart)",
+ quickstartHint: "稍后可以通过 `openclaw configure` 进行详细调整。",
+ manual: "手动配置 (Manual)",
+ manualHint: "详细配置端口、网络、Tailscale 及认证选项。",
+ invalidFlow: "无效的 --flow 参数(请使用 quickstart, manual 或 advanced)。",
+ remoteSwitch: "快速上手模式仅支持本地网关。正在切换到手动模式。",
+ },
+ existingConfig: {
+ title: "检测到现有配置",
+ action: "配置处理方式",
+ keep: "使用现有值",
+ modify: "更新配置值",
+ reset: "重置 (Reset)",
+ resetScope: "重置范围",
+ scopeConfig: "仅重置基本配置",
+ scopeConfigCreds: "重置基本配置 + 凭证 + 会话",
+ scopeFull: "完整重置 (配置 + 凭证 + 会话 + 工作区)",
+ },
+ gateway: {
+ keepSettings: "保留当前的网关设置:",
+ port: "网关端口",
+ bind: "网关绑定",
+ auth: "网关认证",
+ tailscale: "Tailscale 暴露",
+ chatChannels: "直接前往聊天渠道配置。",
+ bindLoopback: "本地回环 (127.0.0.1)",
+ bindLan: "局域网 (LAN)",
+ bindCustom: "自定义 IP",
+ bindTailnet: "Tailnet (Tailscale IP)",
+ bindAuto: "自动",
+ authToken: "令牌 Token (默认)",
+ authPassword: "密码 Password",
+ tsOff: "关闭",
+ tsServe: "Serve 模式",
+ tsFunnel: "Funnel 模式",
+ },
+ setup: {
+ question: "您想设置什么?",
+ local: "本地网关 (Local gateway - 当前机器)",
+ localOk: "网关可达",
+ localFail: "未检测到网关",
+ remote: "远程网关 (Remote gateway - 仅配置信息)",
+ remoteNoUrl: "尚未配置远程 URL",
+ remoteOk: "远程网关可达",
+ remoteFail: "已配置但无法连接",
+ remoteDone: "远程网关配置完成。",
+ workspaceDir: "工作区目录",
+ skippingChannels: "跳过渠道设置。",
+ skills: "技能 (Skills)",
+ skippingSkills: "跳过技能设置。",
+ },
+ cli: {
+ winWarning: [
+ "检测到 Windows 系统。",
+ "强烈建议使用 WSL2;原生 Windows 环境未经充分测试,可能存在兼容性问题。",
+ "指南: https://docs.openclaw.ai/windows",
+ ].join("\n"),
+ nonInteractiveRisk: [
+ "非交互式安装需要明确的技术风险说明(--accept-risk)。",
+ "详情请阅读: https://docs.openclaw.ai/security",
+ ].join("\n"),
+ deprecatedAuth: '身份验证选项 "{authChoice}" 已弃用。',
+ useAuthToken: '请使用 "--auth-choice token" (Anthropic) 或 "--auth-choice openai-codex"。',
+ authTokenFlow: '身份验证选项 "claude-cli" 已弃用,将使用令牌(token)流程。',
+ authCodexFlow: '身份验证选项 "codex-cli" 已弃用,将使用 OpenAI Codex 会话流程。',
+ },
+ helpers: {
+ cancelled: "设置已取消。",
+ noSettings: "未检测到关键配置。",
+ workspaceOk: "工作区确认",
+ sessionsOk: "会话目录确认",
+ trashOk: "已移至回收站",
+ trashFail: "移至回收站失败 (请手动删除)",
+ sshHint: "未检测到 GUI 环境。请从您的电脑上访问:",
+ },
+ finalize: {
+ systemdNote: "检测到 Linux,但当前用户似乎无法使用 Systemd。这可能会影响服务安装。",
+ systemdLinger: "为确保 OpenClaw 服务在您登出后继续运行,我们需要启用用户逗留 (Linger)。",
+ installService: "是否将 OpenClaw 安装为后台服务?",
+ serviceNoSystemd: "由于 Systemd不可用,跳过服务安装。您可以手动运行 OpenClaw。",
+ serviceInstalled: "服务管理",
+ serviceRuntime: "服务运行时 (Daemon Runtime)",
+ serviceRuntimeQuickstart: "快速启动模式下,我们将使用 Node.js 运行时。",
+ restarted: "服务已重启",
+ restarting: "正在重启服务...",
+ uninstalled: "服务已卸载",
+ uninstalling: "正在卸载服务...",
+ preparing: "正在准备安装...",
+ installing: "正在安装服务...",
+ installFail: "安装失败",
+ installSuccess: "安装成功",
+ healthHelp: "健康检查失败。请参考文档进行排查:",
+ healthDocsPrefix: "相关文档:",
+ optionalApps: "可选组件",
+ optionalAppsList: "OpenClaw 提供了 Web UI、TUI 等多种管理方式。",
+ controlUi: "控制面板 (Control UI)",
+ hatchTui: "启动 TUI (Moltbot)",
+ hatchTuiNote: [
+ "这是定义您的代理人的关键动作。",
+ "请花点时间。",
+ "您告诉它的信息越多,体验就越好。",
+ '我们将发送: "唤醒吧,我的朋友!"',
+ ].join("\n"),
+ hatchWeb: "打开 Web UI",
+ hatchLater: "以后再说",
+ hatchQuestion: "您想现在启动哪个界面?",
+ tokenNote: [
+ "网关令牌 (Token):用于网关和控制面板的共享身份验证。",
+ "存储位置:~/.openclaw/openclaw.json (gateway.auth.token) 或环境变量 OPENCLAW_GATEWAY_TOKEN。",
+ "网页 UI 会在浏览器本地存储中保存一份副本。",
+ "随时获取带令牌的链接:openclaw dashboard --no-open",
+ ].join("\n"),
+ webUiSeeded: "网页 UI 已在后台初始化。稍后可通过以下命令打开:",
+ dashboardReady: "仪表板已就绪",
+ dashboardOpened: "已在浏览器中打开。请保留该标签页以控制 OpenClaw。",
+ dashboardCopy: "请将此 URL 复制到本机的浏览器中以控制 OpenClaw。",
+ backupNote: "请定期备份您的工作区目录,它包含您的所有 Agent 数据。",
+ webSearchOptional: "网页搜索功能 (可选)",
+ webSearchEnabled: "网页搜索已成功启用!代理人可以在需要时在线查询信息。",
+ webSearchDisabled: [
+ "如果您希望代理人能够搜索网页,则需要一个 API 密钥。",
+ "",
+ "OpenClaw 使用 Brave Search 进行网页搜索。如果没有 API 密钥,该工具将无法工作。",
+ "",
+ "设置方法:",
+ "- 运行: openclaw configure --section web",
+ "- 启用 web_search 并粘贴您的 Brave Search API 密钥",
+ "",
+ "或者:在网关环境变量中设置 BRAVE_API_KEY。",
+ ].join("\n"),
+ webSearchKeyConfig: "已使用配置文件中的 API Key。",
+ webSearchKeyEnv: "已使用系统环境变量 BRAVE_API_KEY。",
+ whatNow: "接下来可以做什么?",
+ onboardingComplete: "OpenClaw 初始化完成!",
+ onboardingCompleteOpened: "OpenClaw 初始化完成,仪表板已随令牌打开;请保留该标签页以控制 OpenClaw。",
+ onboardingCompleteSeeded: "OpenClaw 初始化完成,网页 UI 已在后台初始化;随时使用上面的链接打开。",
+ },
+ gatewayConfig: {
+ port: "网关端口",
+ invalidPort: "无效的端口号",
+ bind: "网关绑定 (Bind)",
+ customIp: "自定义 IP 地址",
+ customIpRequired: "自定义绑定模式需要提供 IP 地址",
+ invalidIp: "无效的 IPv4 地址",
+ invalidIpOctet: "无效的 IPv4 地址 (每段必须是 0-255)",
+ auth: "网关身份验证",
+ authToken: "令牌 (Token)",
+ authTokenHint: "推荐的默认方式 (支持本地和远程)",
+ authPassword: "密码 (Password)",
+ tsExposure: "Tailscale 暴露",
+ tsOff: "关闭",
+ tsOffHint: "不进行 Tailscale 暴露",
+ tsServe: "Serve 模式",
+ tsServeHint: "为您的 Tailnet 提供私有 HTTPS (仅限 Tailscale 里的设备)",
+ tsFunnel: "Funnel 模式",
+ tsFunnelHint: "通过 Tailscale Funnel 提供公共 HTTPS (互联网可访问)",
+ tsWarningTitle: "Tailscale 警告",
+ tsNotFound: [
+ "未在 PATH 中找到 Tailscale 二进制文件。",
+ "请确保已安装 Tailscale。",
+ "",
+ "您可以继续设置,但 serve/funnel 在运行时会失败。",
+ ].join("\n"),
+ tsResetConfirm: "退出时重置 Tailscale serve/funnel?",
+ tsAdjustBind: "Tailscale 需要 bind=loopback。正在自动调整网关绑定为 loopback。",
+ tsFunnelAuth: "Tailscale Funnel 需要使用密码验证方式。",
+ tokenPlaceholder: "网关令牌 (留空则自动生成)",
+ tokenHint: "多机访问或非 127.0.0.1 访问时需要此令牌",
+ passwordLabel: "网关密码",
+ passwordRequired: "必须填写密码",
+ }
+ },
+ common: {
+ configUpdated: "配置已更新。",
+ }
+};
diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts
index c5b01d6bf..1f5e63e37 100644
--- a/src/wizard/onboarding.finalize.ts
+++ b/src/wizard/onboarding.finalize.ts
@@ -33,8 +33,9 @@ import {
} from "../commands/daemon-install-helpers.js";
import type { GatewayWizardSettings, WizardFlow } from "./onboarding.types.js";
import type { WizardPrompter } from "./prompts.js";
+import { t } from "./i18n.js";
-type FinalizeOnboardingOptions = {
+export type FinalizeOnboardingOptions = {
flow: WizardFlow;
opts: OnboardOptions;
baseConfig: OpenClawConfig;
@@ -65,7 +66,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
process.platform === "linux" ? await isSystemdUserServiceAvailable() : true;
if (process.platform === "linux" && !systemdAvailable) {
await prompter.note(
- "Systemd user services are unavailable. Skipping lingering checks and service install.",
+ t("onboarding.finalize.systemdNote"),
"Systemd",
);
}
@@ -78,8 +79,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
confirm: prompter.confirm,
note: prompter.note,
},
- reason:
- "Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
+ reason: t("onboarding.finalize.systemdLinger"),
requireConfirm: false,
});
}
@@ -95,15 +95,15 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
installDaemon = true;
} else {
installDaemon = await prompter.confirm({
- message: "Install Gateway service (recommended)",
+ message: t("onboarding.finalize.installService"),
initialValue: true,
});
}
if (process.platform === "linux" && !systemdAvailable && installDaemon) {
await prompter.note(
- "Systemd user services are unavailable; skipping service install. Use your container supervisor or `docker compose up -d`.",
- "Gateway service",
+ t("onboarding.finalize.serviceNoSystemd"),
+ t("onboarding.finalize.serviceInstalled"),
);
installDaemon = false;
}
@@ -113,33 +113,33 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
flow === "quickstart"
? (DEFAULT_GATEWAY_DAEMON_RUNTIME as GatewayDaemonRuntime)
: ((await prompter.select({
- message: "Gateway service runtime",
- options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
- initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
- })) as GatewayDaemonRuntime);
+ message: t("onboarding.finalize.serviceRuntime"),
+ options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
+ initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
+ })) as GatewayDaemonRuntime);
if (flow === "quickstart") {
await prompter.note(
- "QuickStart uses Node for the Gateway service (stable + supported).",
- "Gateway service runtime",
+ t("onboarding.finalize.serviceRuntimeQuickstart"),
+ t("onboarding.finalize.serviceRuntime"),
);
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
if (loaded) {
const action = (await prompter.select({
- message: "Gateway service already installed",
+ message: t("onboarding.finalize.serviceInstalled"),
options: [
- { value: "restart", label: "Restart" },
- { value: "reinstall", label: "Reinstall" },
- { value: "skip", label: "Skip" },
+ { value: "restart", label: "重启 (Restart)" },
+ { value: "reinstall", label: "重新安装 (Reinstall)" },
+ { value: "skip", label: "跳过 (Skip)" },
],
})) as "restart" | "reinstall" | "skip";
if (action === "restart") {
await withWizardProgress(
- "Gateway service",
- { doneMessage: "Gateway service restarted." },
+ t("onboarding.finalize.serviceInstalled"),
+ { doneMessage: t("onboarding.finalize.restarted") },
async (progress) => {
- progress.update("Restarting Gateway service…");
+ progress.update(t("onboarding.finalize.restarting"));
await service.restart({
env: process.env,
stdout: process.stdout,
@@ -148,10 +148,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
);
} else if (action === "reinstall") {
await withWizardProgress(
- "Gateway service",
- { doneMessage: "Gateway service uninstalled." },
+ t("onboarding.finalize.serviceInstalled"),
+ { doneMessage: t("onboarding.finalize.uninstalled") },
async (progress) => {
- progress.update("Uninstalling Gateway service…");
+ progress.update(t("onboarding.finalize.uninstalling"));
await service.uninstall({ env: process.env, stdout: process.stdout });
},
);
@@ -159,10 +159,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
}
if (!loaded || (loaded && (await service.isLoaded({ env: process.env })) === false)) {
- const progress = prompter.progress("Gateway service");
+ const progress = prompter.progress(t("onboarding.finalize.serviceInstalled"));
let installError: string | null = null;
try {
- progress.update("Preparing Gateway service…");
+ progress.update(t("onboarding.finalize.preparing"));
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port: settings.port,
@@ -172,7 +172,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
config: nextConfig,
});
- progress.update("Installing Gateway service…");
+ progress.update(t("onboarding.finalize.installing"));
await service.install({
env: process.env,
stdout: process.stdout,
@@ -184,11 +184,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
installError = err instanceof Error ? err.message : String(err);
} finally {
progress.stop(
- installError ? "Gateway service install failed." : "Gateway service installed.",
+ installError ? t("onboarding.finalize.installFail") : t("onboarding.finalize.installSuccess"),
);
}
if (installError) {
- await prompter.note(`Gateway service install failed: ${installError}`, "Gateway");
+ await prompter.note(`${t("onboarding.finalize.installFail")}: ${installError}`, "Gateway");
await prompter.note(gatewayInstallErrorHint(), "Gateway");
}
}
@@ -213,11 +213,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
runtime.error(formatHealthCheckFailure(err));
await prompter.note(
[
- "Docs:",
+ t("onboarding.finalize.healthDocsPrefix"),
"https://docs.openclaw.ai/gateway/health",
"https://docs.openclaw.ai/gateway/troubleshooting",
].join("\n"),
- "Health check help",
+ t("onboarding.finalize.healthHelp"),
);
}
}
@@ -232,13 +232,8 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
}
await prompter.note(
- [
- "Add nodes for extra features:",
- "- macOS app (system + notifications)",
- "- iOS app (camera/canvas)",
- "- Android app (camera/canvas)",
- ].join("\n"),
- "Optional apps",
+ t("onboarding.finalize.optionalAppsList"),
+ t("onboarding.finalize.optionalApps"),
);
const controlUiBasePath =
@@ -260,8 +255,8 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
password: settings.authMode === "password" ? nextConfig.gateway?.auth?.password : "",
});
const gatewayStatusLine = gatewayProbe.ok
- ? "Gateway: reachable"
- : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
+ ? t("onboarding.setup.localOk")
+ : `${t("onboarding.setup.localFail")}${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
const bootstrapPath = path.join(
resolveUserPath(options.workspaceDir),
DEFAULT_BOOTSTRAP_FILENAME,
@@ -274,14 +269,14 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
await prompter.note(
[
`Web UI: ${links.httpUrl}`,
- tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
+ tokenParam ? `Web UI (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}` : undefined,
`Gateway WS: ${links.wsUrl}`,
gatewayStatusLine,
"Docs: https://docs.openclaw.ai/web/control-ui",
]
.filter(Boolean)
.join("\n"),
- "Control UI",
+ t("onboarding.finalize.controlUi"),
);
let controlUiOpened = false;
@@ -292,32 +287,22 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
if (!opts.skipUi && gatewayProbe.ok) {
if (hasBootstrap) {
await prompter.note(
- [
- "This is the defining action that makes your agent you.",
- "Please take your time.",
- "The more you tell it, the better the experience will be.",
- 'We will send: "Wake up, my friend!"',
- ].join("\n"),
- "Start TUI (best option!)",
+ t("onboarding.finalize.hatchTuiNote"),
+ t("onboarding.finalize.hatchTui"),
);
}
await prompter.note(
- [
- "Gateway token: shared auth for the Gateway + Control UI.",
- "Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.",
- "Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).",
- `Get the tokenized link anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
- ].join("\n"),
- "Token",
+ t("onboarding.finalize.tokenNote"),
+ t("onboarding.gatewayConfig.authToken"),
);
hatchChoice = (await prompter.select({
- message: "How do you want to hatch your bot?",
+ message: t("onboarding.finalize.hatchQuestion"),
options: [
- { value: "tui", label: "Hatch in TUI (recommended)" },
- { value: "web", label: "Open the Web UI" },
- { value: "later", label: "Do this later" },
+ { value: "tui", label: t("onboarding.finalize.hatchTui") },
+ { value: "web", label: t("onboarding.finalize.hatchWeb") },
+ { value: "later", label: t("onboarding.finalize.hatchLater") },
],
initialValue: "tui",
})) as "tui" | "web" | "later";
@@ -336,7 +321,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
}
if (seededInBackground) {
await prompter.note(
- `Web UI seeded in the background. Open later with: ${formatCliCommand(
+ `${t("onboarding.finalize.webUiSeeded")} ${formatCliCommand(
"openclaw dashboard --no-open",
)}`,
"Web UI",
@@ -362,37 +347,37 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
}
await prompter.note(
[
- `Dashboard link (with token): ${authedUrl}`,
+ `${t("onboarding.finalize.dashboardReady")} (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}`,
controlUiOpened
- ? "Opened in your browser. Keep that tab to control OpenClaw."
- : "Copy/paste this URL in a browser on this machine to control OpenClaw.",
+ ? t("onboarding.finalize.dashboardOpened")
+ : t("onboarding.finalize.dashboardCopy"),
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
- "Dashboard ready",
+ t("onboarding.finalize.dashboardReady"),
);
} else {
await prompter.note(
- `When you're ready: ${formatCliCommand("openclaw dashboard --no-open")}`,
- "Later",
+ `${t("onboarding.finalize.hatchLater")}: ${formatCliCommand("openclaw dashboard --no-open")}`,
+ t("onboarding.finalize.hatchLater"),
);
}
} else if (opts.skipUi) {
- await prompter.note("Skipping Control UI/TUI prompts.", "Control UI");
+ await prompter.note("Skipping Control UI/TUI prompts.", t("onboarding.finalize.controlUi"));
}
await prompter.note(
[
- "Back up your agent workspace.",
+ t("onboarding.finalize.backupNote"),
"Docs: https://docs.openclaw.ai/concepts/agent-workspace",
].join("\n"),
- "Workspace backup",
+ t("onboarding.finalize.backupNote"),
);
await prompter.note(
"Running agents on your computer is risky — harden your setup: https://docs.openclaw.ai/security",
- "Security",
+ t("onboarding.security.title"),
);
const shouldOpenControlUi =
@@ -421,15 +406,15 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
await prompter.note(
[
- `Dashboard link (with token): ${authedUrl}`,
+ `${t("onboarding.finalize.dashboardReady")} (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}`,
controlUiOpened
- ? "Opened in your browser. Keep that tab to control OpenClaw."
- : "Copy/paste this URL in a browser on this machine to control OpenClaw.",
+ ? t("onboarding.finalize.dashboardOpened")
+ : t("onboarding.finalize.dashboardCopy"),
controlUiOpenHint,
]
.filter(Boolean)
.join("\n"),
- "Dashboard ready",
+ t("onboarding.finalize.dashboardReady"),
);
}
@@ -439,38 +424,32 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
await prompter.note(
hasWebSearchKey
? [
- "Web search is enabled, so your agent can look things up online when needed.",
- "",
- webSearchKey
- ? "API key: stored in config (tools.web.search.apiKey)."
- : "API key: provided via BRAVE_API_KEY env var (Gateway environment).",
- "Docs: https://docs.openclaw.ai/tools/web",
- ].join("\n")
+ t("onboarding.finalize.webSearchEnabled"),
+ "",
+ webSearchKey
+ ? t("onboarding.finalize.webSearchKeyConfig")
+ : t("onboarding.finalize.webSearchKeyEnv"),
+ "Docs: https://docs.openclaw.ai/tools/web",
+ ].join("\n")
: [
- "If you want your agent to be able to search the web, you’ll need an API key.",
- "",
- "OpenClaw uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search won’t work.",
- "",
- "Set it up interactively:",
- `- Run: ${formatCliCommand("openclaw configure --section web")}`,
- "- Enable web_search and paste your Brave Search API key",
- "",
- "Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).",
- "Docs: https://docs.openclaw.ai/tools/web",
- ].join("\n"),
- "Web search (optional)",
+ t("onboarding.finalize.webSearchDisabled"),
+ "",
+ `设置命令: ${formatCliCommand("openclaw configure --section web")}`,
+ "Docs: https://docs.openclaw.ai/tools/web",
+ ].join("\n"),
+ t("onboarding.finalize.webSearchOptional"),
);
await prompter.note(
- 'What now: https://openclaw.ai/showcase ("What People Are Building").',
- "What now",
+ 'Showcase: https://openclaw.ai/showcase',
+ t("onboarding.finalize.whatNow"),
);
await prompter.outro(
controlUiOpened
- ? "Onboarding complete. Dashboard opened with your token; keep that tab to control OpenClaw."
+ ? t("onboarding.finalize.onboardingCompleteOpened")
: seededInBackground
- ? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above."
- : "Onboarding complete. Use the tokenized dashboard link above to control OpenClaw.",
+ ? t("onboarding.finalize.onboardingCompleteSeeded")
+ : t("onboarding.finalize.onboardingComplete"),
);
}
diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts
index d7dceae24..f85e6a0b5 100644
--- a/src/wizard/onboarding.gateway-config.ts
+++ b/src/wizard/onboarding.gateway-config.ts
@@ -9,6 +9,7 @@ import type {
WizardFlow,
} from "./onboarding.types.js";
import type { WizardPrompter } from "./prompts.js";
+import { t } from "./i18n.js";
type ConfigureGatewayOptions = {
flow: WizardFlow;
@@ -35,29 +36,29 @@ export async function configureGatewayForOnboarding(
flow === "quickstart"
? quickstartGateway.port
: Number.parseInt(
- String(
- await prompter.text({
- message: "Gateway port",
- initialValue: String(localPort),
- validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
- }),
- ),
- 10,
- );
+ String(
+ await prompter.text({
+ message: t("onboarding.gatewayConfig.port"),
+ initialValue: String(localPort),
+ validate: (value) => (Number.isFinite(Number(value)) ? undefined : t("onboarding.gatewayConfig.invalidPort")),
+ }),
+ ),
+ 10,
+ );
let bind = (
flow === "quickstart"
? quickstartGateway.bind
: ((await prompter.select({
- message: "Gateway bind",
- options: [
- { value: "loopback", label: "Loopback (127.0.0.1)" },
- { value: "lan", label: "LAN (0.0.0.0)" },
- { value: "tailnet", label: "Tailnet (Tailscale IP)" },
- { value: "auto", label: "Auto (Loopback → LAN)" },
- { value: "custom", label: "Custom IP" },
- ],
- })) as "loopback" | "lan" | "auto" | "custom" | "tailnet")
+ message: t("onboarding.gatewayConfig.bind"),
+ options: [
+ { value: "loopback", label: t("onboarding.gateway.bindLoopback") },
+ { value: "lan", label: t("onboarding.gateway.bindLan") },
+ { value: "tailnet", label: t("onboarding.gateway.bindTailnet") },
+ { value: "auto", label: t("onboarding.gateway.bindAuto") },
+ { value: "custom", label: t("onboarding.gateway.bindCustom") },
+ ],
+ })) as "loopback" | "lan" | "auto" | "custom" | "tailnet")
) as "loopback" | "lan" | "auto" | "custom" | "tailnet";
let customBindHost = quickstartGateway.customBindHost;
@@ -65,14 +66,14 @@ export async function configureGatewayForOnboarding(
const needsPrompt = flow !== "quickstart" || !customBindHost;
if (needsPrompt) {
const input = await prompter.text({
- message: "Custom IP address",
+ message: t("onboarding.gatewayConfig.customIp"),
placeholder: "192.168.1.100",
initialValue: customBindHost ?? "",
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");
if (
parts.every((part) => {
const n = parseInt(part, 10);
@@ -80,7 +81,7 @@ export async function configureGatewayForOnboarding(
})
)
return undefined;
- return "Invalid IPv4 address (each octet must be 0-255)";
+ return t("onboarding.gatewayConfig.invalidIpOctet");
},
});
customBindHost = typeof input === "string" ? input.trim() : undefined;
@@ -91,38 +92,38 @@ export async function configureGatewayForOnboarding(
flow === "quickstart"
? quickstartGateway.authMode
: ((await prompter.select({
- message: "Gateway auth",
- options: [
- {
- value: "token",
- label: "Token",
- hint: "Recommended default (local + remote)",
- },
- { value: "password", label: "Password" },
- ],
- initialValue: "token",
- })) as GatewayAuthChoice)
+ message: t("onboarding.gatewayConfig.auth"),
+ options: [
+ {
+ value: "token",
+ label: t("onboarding.gatewayConfig.authToken"),
+ hint: t("onboarding.gatewayConfig.authTokenHint"),
+ },
+ { value: "password", label: t("onboarding.gatewayConfig.authPassword") },
+ ],
+ initialValue: "token",
+ })) as GatewayAuthChoice)
) as GatewayAuthChoice;
const tailscaleMode = (
flow === "quickstart"
? quickstartGateway.tailscaleMode
: ((await prompter.select({
- message: "Tailscale exposure",
- options: [
- { value: "off", label: "Off", hint: "No Tailscale exposure" },
- {
- value: "serve",
- label: "Serve",
- hint: "Private HTTPS for your tailnet (devices on Tailscale)",
- },
- {
- value: "funnel",
- label: "Funnel",
- hint: "Public HTTPS via Tailscale Funnel (internet)",
- },
- ],
- })) as "off" | "serve" | "funnel")
+ message: t("onboarding.gatewayConfig.tsExposure"),
+ options: [
+ { value: "off", label: t("onboarding.gatewayConfig.tsOff"), hint: t("onboarding.gatewayConfig.tsOffHint") },
+ {
+ value: "serve",
+ label: t("onboarding.gatewayConfig.tsServe"),
+ hint: t("onboarding.gatewayConfig.tsServeHint"),
+ },
+ {
+ value: "funnel",
+ label: t("onboarding.gatewayConfig.tsFunnel"),
+ hint: t("onboarding.gatewayConfig.tsFunnelHint"),
+ },
+ ],
+ })) as "off" | "serve" | "funnel")
) as "off" | "serve" | "funnel";
// Detect Tailscale binary before proceeding with serve/funnel setup.
@@ -130,14 +131,8 @@ export async function configureGatewayForOnboarding(
const tailscaleBin = await findTailscaleBinary();
if (!tailscaleBin) {
await prompter.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"),
);
}
}
@@ -145,14 +140,14 @@ export async function configureGatewayForOnboarding(
let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false;
if (tailscaleMode !== "off" && flow !== "quickstart") {
await prompter.note(
- ["Docs:", "https://docs.openclaw.ai/gateway/tailscale", "https://docs.openclaw.ai/web"].join(
+ [t("onboarding.finalize.healthDocsPrefix"), "https://docs.openclaw.ai/gateway/tailscale", "https://docs.openclaw.ai/web"].join(
"\n",
),
- "Tailscale",
+ t("onboarding.gateway.tailscale"),
);
tailscaleResetOnExit = Boolean(
await prompter.confirm({
- message: "Reset Tailscale serve/funnel on exit?",
+ message: t("onboarding.gatewayConfig.tsResetConfirm"),
initialValue: false,
}),
);
@@ -162,13 +157,13 @@ export async function configureGatewayForOnboarding(
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
// - Funnel requires password auth.
if (tailscaleMode !== "off" && bind !== "loopback") {
- await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
+ await prompter.note(t("onboarding.gatewayConfig.tsAdjustBind"), t("onboarding.gateway.tailscale"));
bind = "loopback";
customBindHost = undefined;
}
if (tailscaleMode === "funnel" && authMode !== "password") {
- await prompter.note("Tailscale funnel requires password auth.", "Note");
+ await prompter.note(t("onboarding.gatewayConfig.tsFunnelAuth"), t("onboarding.gateway.tailscale"));
authMode = "password";
}
@@ -178,8 +173,8 @@ export async function configureGatewayForOnboarding(
gatewayToken = quickstartGateway.token ?? randomToken();
} else {
const tokenInput = await prompter.text({
- message: "Gateway token (blank to generate)",
- placeholder: "Needed for multi-machine or non-loopback access",
+ message: t("onboarding.gatewayConfig.tokenPlaceholder"),
+ placeholder: t("onboarding.gatewayConfig.tokenHint"),
initialValue: quickstartGateway.token ?? "",
});
gatewayToken = String(tokenInput).trim() || randomToken();
@@ -191,9 +186,9 @@ export async function configureGatewayForOnboarding(
flow === "quickstart" && quickstartGateway.password
? quickstartGateway.password
: await prompter.text({
- message: "Gateway password",
- validate: (value) => (value?.trim() ? undefined : "Required"),
- });
+ message: t("onboarding.gatewayConfig.passwordLabel"),
+ validate: (value) => (value?.trim() ? undefined : t("onboarding.gatewayConfig.passwordRequired")),
+ });
nextConfig = {
...nextConfig,
gateway: {
diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts
index ef2e349c6..7989b3f00 100644
--- a/src/wizard/onboarding.ts
+++ b/src/wizard/onboarding.ts
@@ -38,6 +38,7 @@ import { logConfigUpdated } from "../config/logging.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
+import { t } from "./i18n.js";
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js";
@@ -50,33 +51,12 @@ async function requireRiskAcknowledgement(params: {
if (params.opts.acceptRisk === true) return;
await params.prompter.note(
- [
- "Security warning — please read.",
- "",
- "OpenClaw is a hobby project and still in beta. Expect sharp edges.",
- "This bot can read files and run actions if tools are enabled.",
- "A bad prompt can trick it into doing unsafe things.",
- "",
- "If you’re not comfortable with basic security and access control, don’t run OpenClaw.",
- "Ask someone experienced to help before enabling tools or exposing it to the internet.",
- "",
- "Recommended baseline:",
- "- Pairing/allowlists + mention gating.",
- "- Sandbox + least-privilege tools.",
- "- Keep secrets out of the agent’s reachable filesystem.",
- "- Use the strongest available model for any bot with tools or untrusted inboxes.",
- "",
- "Run regularly:",
- "openclaw security audit --deep",
- "openclaw security audit --fix",
- "",
- "Must read: https://docs.openclaw.ai/gateway/security",
- ].join("\n"),
- "Security",
+ t("onboarding.security.note"),
+ t("onboarding.security.title"),
);
const ok = await params.prompter.confirm({
- message: "I understand this is powerful and inherently risky. Continue?",
+ message: t("onboarding.security.confirm"),
initialValue: false,
});
if (!ok) {
@@ -90,14 +70,14 @@ export async function runOnboardingWizard(
prompter: WizardPrompter,
) {
printWizardHeader(runtime);
- await prompter.intro("OpenClaw onboarding");
+ await prompter.intro(t("onboarding.intro"));
await requireRiskAcknowledgement({ opts, prompter });
const snapshot = await readConfigFileSnapshot();
let baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {};
if (snapshot.exists && !snapshot.valid) {
- await prompter.note(summarizeExistingConfig(baseConfig), "Invalid config");
+ await prompter.note(summarizeExistingConfig(baseConfig), t("onboarding.config.invalid"));
if (snapshot.issues.length > 0) {
await prompter.note(
[
@@ -105,18 +85,18 @@ export async function runOnboardingWizard(
"",
"Docs: https://docs.openclaw.ai/gateway/configuration",
].join("\n"),
- "Config issues",
+ t("onboarding.config.issues"),
);
}
await prompter.outro(
- `Config invalid. Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run onboarding.`,
+ t("onboarding.config.repair"),
);
runtime.exit(1);
return;
}
- const quickstartHint = `Configure details later via ${formatCliCommand("openclaw configure")}.`;
- const manualHint = "Configure port, network, Tailscale, and auth options.";
+ const quickstartHint = t("onboarding.flow.quickstartHint");
+ const manualHint = t("onboarding.flow.manualHint");
const explicitFlowRaw = opts.flow?.trim();
const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw;
if (
@@ -124,7 +104,7 @@ export async function runOnboardingWizard(
normalizedExplicitFlow !== "quickstart" &&
normalizedExplicitFlow !== "advanced"
) {
- runtime.error("Invalid --flow (use quickstart, manual, or advanced).");
+ runtime.error(t("onboarding.flow.invalidFlow"));
runtime.exit(1);
return;
}
@@ -135,47 +115,47 @@ export async function runOnboardingWizard(
let flow: WizardFlow =
explicitFlow ??
((await prompter.select({
- message: "Onboarding mode",
+ message: t("onboarding.flow.modeSelect"),
options: [
- { value: "quickstart", label: "QuickStart", hint: quickstartHint },
- { value: "advanced", label: "Manual", hint: manualHint },
+ { value: "quickstart", label: t("onboarding.flow.quickstart"), hint: quickstartHint },
+ { value: "advanced", label: t("onboarding.flow.manual"), hint: manualHint },
],
initialValue: "quickstart",
})) as "quickstart" | "advanced");
if (opts.mode === "remote" && flow === "quickstart") {
await prompter.note(
- "QuickStart only supports local gateways. Switching to Manual mode.",
- "QuickStart",
+ t("onboarding.flow.remoteSwitch"),
+ t("onboarding.flow.quickstart"),
);
flow = "advanced";
}
if (snapshot.exists) {
- await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected");
+ await prompter.note(summarizeExistingConfig(baseConfig), t("onboarding.existingConfig.title"));
const action = (await prompter.select({
- message: "Config handling",
+ message: t("onboarding.existingConfig.action"),
options: [
- { value: "keep", label: "Use existing values" },
- { value: "modify", label: "Update values" },
- { value: "reset", label: "Reset" },
+ { value: "keep", label: t("onboarding.existingConfig.keep") },
+ { value: "modify", label: t("onboarding.existingConfig.modify") },
+ { value: "reset", label: t("onboarding.existingConfig.reset") },
],
})) as "keep" | "modify" | "reset";
if (action === "reset") {
const workspaceDefault = baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE;
const resetScope = (await prompter.select({
- message: "Reset scope",
+ message: t("onboarding.existingConfig.resetScope"),
options: [
- { value: "config", label: "Config only" },
+ { value: "config", label: t("onboarding.existingConfig.scopeConfig") },
{
value: "config+creds+sessions",
- label: "Config + creds + sessions",
+ label: t("onboarding.existingConfig.scopeConfigCreds"),
},
{
value: "full",
- label: "Full reset (config + creds + sessions + workspace)",
+ label: t("onboarding.existingConfig.scopeFull"),
},
],
})) as ResetScope;
@@ -197,10 +177,10 @@ export async function runOnboardingWizard(
const bindRaw = baseConfig.gateway?.bind;
const bind =
bindRaw === "loopback" ||
- bindRaw === "lan" ||
- bindRaw === "auto" ||
- bindRaw === "custom" ||
- bindRaw === "tailnet"
+ bindRaw === "lan" ||
+ bindRaw === "auto" ||
+ bindRaw === "custom" ||
+ bindRaw === "tailnet"
? bindRaw
: "loopback";
@@ -237,41 +217,41 @@ export async function runOnboardingWizard(
if (flow === "quickstart") {
const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => {
- if (value === "loopback") return "Loopback (127.0.0.1)";
- if (value === "lan") return "LAN";
- if (value === "custom") return "Custom IP";
- if (value === "tailnet") return "Tailnet (Tailscale IP)";
- return "Auto";
+ if (value === "loopback") return t("onboarding.gateway.bindLoopback");
+ if (value === "lan") return t("onboarding.gateway.bindLan");
+ if (value === "custom") return t("onboarding.gateway.bindCustom");
+ if (value === "tailnet") return t("onboarding.gateway.bindTailnet");
+ return t("onboarding.gateway.bindAuto");
};
const formatAuth = (value: GatewayAuthChoice) => {
- if (value === "token") return "Token (default)";
- return "Password";
+ if (value === "token") return t("onboarding.gateway.authToken");
+ return t("onboarding.gateway.authPassword");
};
const formatTailscale = (value: "off" | "serve" | "funnel") => {
- if (value === "off") return "Off";
- if (value === "serve") return "Serve";
- return "Funnel";
+ if (value === "off") return t("onboarding.gateway.tsOff");
+ if (value === "serve") return t("onboarding.gateway.tsServe");
+ return t("onboarding.gateway.tsFunnel");
};
const quickstartLines = quickstartGateway.hasExisting
? [
- "Keeping your current gateway settings:",
- `Gateway port: ${quickstartGateway.port}`,
- `Gateway bind: ${formatBind(quickstartGateway.bind)}`,
- ...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
- ? [`Gateway custom IP: ${quickstartGateway.customBindHost}`]
- : []),
- `Gateway auth: ${formatAuth(quickstartGateway.authMode)}`,
- `Tailscale exposure: ${formatTailscale(quickstartGateway.tailscaleMode)}`,
- "Direct to chat channels.",
- ]
+ t("onboarding.gateway.keepSettings"),
+ `${t("onboarding.gateway.port")}: ${quickstartGateway.port}`,
+ `${t("onboarding.gateway.bind")}: ${formatBind(quickstartGateway.bind)}`,
+ ...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
+ ? [`${t("onboarding.gateway.bindCustom")}: ${quickstartGateway.customBindHost}`]
+ : []),
+ `${t("onboarding.gateway.auth")}: ${formatAuth(quickstartGateway.authMode)}`,
+ `${t("onboarding.gateway.tailscale")}: ${formatTailscale(quickstartGateway.tailscaleMode)}`,
+ t("onboarding.gateway.chatChannels"),
+ ]
: [
- `Gateway port: ${DEFAULT_GATEWAY_PORT}`,
- "Gateway bind: Loopback (127.0.0.1)",
- "Gateway auth: Token (default)",
- "Tailscale exposure: Off",
- "Direct to chat channels.",
- ];
- await prompter.note(quickstartLines.join("\n"), "QuickStart");
+ `${t("onboarding.gateway.port")}: ${DEFAULT_GATEWAY_PORT}`,
+ `${t("onboarding.gateway.bind")}: ${t("onboarding.gateway.bindLoopback")}`,
+ `${t("onboarding.gateway.auth")}: ${t("onboarding.gateway.authToken")}`,
+ `${t("onboarding.gateway.tailscale")}: ${t("onboarding.gateway.tsOff")}`,
+ t("onboarding.gateway.chatChannels"),
+ ];
+ await prompter.note(quickstartLines.join("\n"), t("onboarding.flow.quickstart"));
}
const localPort = resolveGatewayPort(baseConfig);
@@ -284,9 +264,9 @@ export async function runOnboardingWizard(
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 =
@@ -294,33 +274,33 @@ export async function runOnboardingWizard(
(flow === "quickstart"
? "local"
: ((await prompter.select({
- message: "What do you want to set up?",
- options: [
- {
- value: "local",
- label: "Local gateway (this machine)",
- hint: localProbe.ok
- ? `Gateway reachable (${localUrl})`
- : `No gateway detected (${localUrl})`,
- },
- {
- value: "remote",
- label: "Remote gateway (info-only)",
- hint: !remoteUrl
- ? "No remote URL configured yet"
- : remoteProbe?.ok
- ? `Gateway reachable (${remoteUrl})`
- : `Configured but unreachable (${remoteUrl})`,
- },
- ],
- })) as OnboardMode));
+ message: t("onboarding.setup.question"),
+ options: [
+ {
+ value: "local",
+ label: t("onboarding.setup.local"),
+ hint: localProbe.ok
+ ? `${t("onboarding.setup.localOk")} (${localUrl})`
+ : `${t("onboarding.setup.localFail")} (${localUrl})`,
+ },
+ {
+ value: "remote",
+ label: t("onboarding.setup.remote"),
+ hint: !remoteUrl
+ ? t("onboarding.setup.remoteNoUrl")
+ : remoteProbe?.ok
+ ? `${t("onboarding.setup.remoteOk")} (${remoteUrl})`
+ : `${t("onboarding.setup.remoteFail")} (${remoteUrl})`,
+ },
+ ],
+ })) as OnboardMode));
if (mode === "remote") {
let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);
logConfigUpdated(runtime);
- await prompter.outro("Remote gateway configured.");
+ await prompter.outro(t("onboarding.setup.remoteDone"));
return;
}
@@ -329,9 +309,9 @@ export async function runOnboardingWizard(
(flow === "quickstart"
? (baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE)
: await prompter.text({
- message: "Workspace directory",
- initialValue: baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE,
- }));
+ message: t("onboarding.setup.workspaceDir"),
+ initialValue: baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE,
+ }));
const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_WORKSPACE);
@@ -403,13 +383,13 @@ export async function runOnboardingWizard(
const settings = gateway.settings;
if (opts.skipChannels ?? opts.skipProviders) {
- await prompter.note("Skipping channel setup.", "Channels");
+ await prompter.note(t("onboarding.setup.skippingChannels"), t("onboarding.gateway.chatChannels"));
} else {
const quickstartAllowFromChannels =
flow === "quickstart"
? listChannelPlugins()
- .filter((plugin) => plugin.meta.quickstartAllowFrom)
- .map((plugin) => plugin.id)
+ .filter((plugin) => plugin.meta.quickstartAllowFrom)
+ .map((plugin) => plugin.id)
: [];
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowSignalInstall: true,
@@ -427,7 +407,7 @@ export async function runOnboardingWizard(
});
if (opts.skipSkills) {
- await prompter.note("Skipping skills setup.", "Skills");
+ await prompter.note(t("onboarding.setup.skippingSkills"), t("onboarding.setup.skills"));
} else {
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
}
diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts
index 461195a7a..43701e304 100644
--- a/ui/src/ui/format.ts
+++ b/ui/src/ui/format.ts
@@ -1,26 +1,27 @@
+import { t } from "./i18n.js";
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
export function formatMs(ms?: number | null): string {
- if (!ms && ms !== 0) return "n/a";
+ if (!ms && ms !== 0) return t("format.na");
return new Date(ms).toLocaleString();
}
export function formatAgo(ms?: number | null): string {
- if (!ms && ms !== 0) return "n/a";
+ if (!ms && ms !== 0) return t("format.na");
const diff = Date.now() - ms;
- if (diff < 0) return "just now";
+ if (diff < 0) return t("format.justNow");
const sec = Math.round(diff / 1000);
- if (sec < 60) return `${sec}s ago`;
+ if (sec < 60) return t("format.agoSec", { count: sec });
const min = Math.round(sec / 60);
- if (min < 60) return `${min}m ago`;
+ if (min < 60) return t("format.agoMin", { count: min });
const hr = Math.round(min / 60);
- if (hr < 48) return `${hr}h ago`;
+ if (hr < 48) return t("format.agoHr", { count: hr });
const day = Math.round(hr / 24);
- return `${day}d ago`;
+ return t("format.agoDay", { count: day });
}
export function formatDurationMs(ms?: number | null): string {
- if (!ms && ms !== 0) return "n/a";
+ if (!ms && ms !== 0) return t("format.na");
if (ms < 1000) return `${ms}ms`;
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
@@ -33,7 +34,7 @@ export function formatDurationMs(ms?: number | null): string {
}
export function formatList(values?: Array): string {
- if (!values || values.length === 0) return "none";
+ if (!values || values.length === 0) return t("format.none");
return values.filter((v): v is string => Boolean(v && v.trim())).join(", ");
}
diff --git a/ui/src/ui/i18n.ts b/ui/src/ui/i18n.ts
index 2d48fb6e0..669b5d391 100644
--- a/ui/src/ui/i18n.ts
+++ b/ui/src/ui/i18n.ts
@@ -6,7 +6,7 @@ const locales: Record = {
"zh-CN": zhCN,
};
-export function t(key: string): string {
+export function t(key: string, params?: Record): string {
const keys = key.split(".");
let value: any = locales[currentLocale];
@@ -18,5 +18,11 @@ export function t(key: string): string {
}
}
- return typeof value === "string" ? value : key;
+ let str = typeof value === "string" ? value : key;
+ if (params) {
+ for (const [k, v] of Object.entries(params)) {
+ str = str.replace(`{${k}}`, String(v));
+ }
+ }
+ return str;
}
diff --git a/ui/src/ui/locales/zh-CN.ts b/ui/src/ui/locales/zh-CN.ts
index fb2f0a096..d9acfcfbf 100644
--- a/ui/src/ui/locales/zh-CN.ts
+++ b/ui/src/ui/locales/zh-CN.ts
@@ -32,58 +32,397 @@ export const zhCN = {
debug: "调试",
logs: "日志",
},
+ sidebarGroups: {
+ chat: "聊天",
+ control: "控制台",
+ agent: "代理",
+ settings: "设置",
+ },
overview: {
title: "概览",
- subtitle: "查看网关的运行状态和摘要",
+ subtitle: "网关状态与摘要",
+ gatewayAccess: "网关访问",
+ gatewayAccessSubtitle: "控制台连接地址及其身份验证方式。",
+ websocketUrl: "WebSocket 地址",
+ gatewayToken: "网关令牌 (Token)",
+ passwordLabel: "密码 (不存储)",
+ sessionKeyLabel: "默认会话密钥",
+ connect: "连接",
+ connectHint: "点击连接以应用连接更改。",
+ snapshotTitle: "快照",
+ snapshotSubtitle: "最新的网关握手信息。",
+ statusOk: "已连接",
+ statusErr: "已断开",
+ uptime: "运行时间",
+ tickInterval: "打点间隔",
+ lastChannelsRefresh: "上次渠道刷新",
+ channelsHint: "使用“渠道”连接 WhatsApp、Telegram、Discord、Signal 或 iMessage。",
+ instances: "实例",
+ instancesHint: "过去 5 分钟内的存在信号 (Presence)。",
+ sessions: "会话",
+ sessionsHint: "网关跟踪的最近会话密钥。",
+ cron: "定时任务 (Cron)",
+ nextWake: "下次唤醒 {run}",
+ notesTitle: "备注",
+ notesSubtitle: "远程控制设置的快速提示。",
+ tailscaleTitle: "Tailscale Serve",
+ tailscaleHint: "推荐使用 Serve 模式通过 Tailscale 身份验证锁定网关。",
+ hygieneTitle: "会话规范",
+ hygieneHint: "使用 /new 或 sessions.patch 重置上下文。",
+ cronRemindersTitle: "定时提醒",
+ cronRemindersHint: "为循环运行使用隔离的会话。",
+ authRequired: "网关需要身份验证。添加令牌或密码,然后点击连接。",
+ authFailed: "身份验证失败。请重新复制包含令牌的 URL 或更新令牌,然后点击连接。",
+ insecureContext: "当前页面为 HTTP,浏览器已禁用设备身份。请使用 HTTPS 或在网关主机上访问 localhost。",
},
channels: {
title: "渠道",
subtitle: "管理消息渠道连接",
+ healthTitle: "渠道健康状况",
+ healthSubtitle: "来自网关的渠道状态快照。",
+ noSnapshot: "暂无快照。",
+ genericSubtitle: "渠道状态与配置。",
+ configured: "已配置",
+ running: "运行中",
+ connected: "已连接",
+ lastInbound: "最近上行",
+ yes: "是",
+ no: "否",
+ active: "活跃",
+ lastStart: "最近启动",
+ lastProbe: "最近探测",
+ probe: "探测",
+ probeOk: "探测成功",
+ probeFailed: "探测失败",
+ discord: {
+ subtitle: "机器人状态与频道配置。",
+ },
+ googleChat: {
+ subtitle: "机器人状态与空间配置。",
+ credential: "凭据",
+ audience: "目标受众",
+ },
+ imessage: {
+ subtitle: "iMessage 状态与网关配置。",
+ },
+ nostr: {
+ subtitle: "通过 Nostr 中继 (NIP-04) 进行的去中心化私信。",
+ publicKey: "公钥",
+ profile: "简介",
+ editProfile: "编辑简介",
+ noProfileHint: "尚未设置简介。点击“编辑简介”来添加您的姓名、个人简介和头像。",
+ profileFields: {
+ username: "用户名",
+ usernameHelp: "简短的用户名 (例如 satoshi)",
+ displayName: "显示名称",
+ displayNameHelp: "您的完整显示名称",
+ bio: "个人简介",
+ bioHelp: "简短的个人介绍或描述",
+ avatarUrl: "头像 URL",
+ avatarUrlHelp: "头像图片的 HTTPS URL",
+ bannerUrl: "横幅 URL",
+ bannerUrlHelp: "横幅图片的 HTTPS URL",
+ website: "网站",
+ websiteHelp: "您的个人网站",
+ nip05: "NIP-05 标识符",
+ nip05Help: "可验证的标识符 (例如 you@domain.com)",
+ lud16: "闪电网络地址",
+ lud16Help: "用于打赏的闪电网络地址 (LUD-16)",
+ },
+ form: {
+ title: "编辑简介",
+ account: "账户: {accountId}",
+ saveAndPublish: "保存并发布",
+ saving: "正在保存...",
+ importFromRelays: "从中继导入",
+ importing: "正在导入...",
+ hideAdvanced: "隐藏高级选项",
+ showAdvanced: "显示高级选项",
+ unsavedChanges: "您有未保存的更改",
+ picturePreview: "头像预览",
+ advanced: "高级选项",
+ }
+ },
+ signal: {
+ subtitle: "signal-cli 状态与渠道配置。",
+ baseUrl: "基础 URL",
+ },
+ slack: {
+ subtitle: "Socket 模式状态与渠道配置。",
+ },
+ telegram: {
+ subtitle: "机器人状态与渠道配置。",
+ mode: "模式",
+ },
+ whatsapp: {
+ subtitle: "连接 WhatsApp Web 并监控连接健康状况。",
+ linked: "已链接",
+ lastConnect: "最近连接",
+ lastMessage: "最近消息",
+ authAge: "身份验证时长",
+ working: "正在处理...",
+ showQr: "显示二维码",
+ relink: "重新链接",
+ waitForScan: "等待扫描",
+ logout: "退出登录",
+ qrAlt: "WhatsApp 二维码",
+ },
+ shared: {
+ accounts: "账户 ({count})",
+ },
+ config: {
+ schemaUnavailable: "配置架构预览不可用。请使用原始数据 (Raw)。",
+ channelSchemaUnavailable: "渠道配置架构不可用。",
+ loadingSchema: "正在加载配置架构...",
+ saving: "正在保存...",
+ reload: "重新加载",
+ }
},
chat: {
title: "聊天",
subtitle: "与您的 AI 助手互动",
+ compacting: "正在压缩上下文...",
+ compacted: "上下文已压缩",
+ attachmentPreview: "附件预览",
+ removeAttachment: "移除附件",
+ placeholderCompose: "添加消息或粘贴图像...",
+ placeholderHint: "消息 (↩ 发送, Shift+↩ 换行, 可粘贴图像)",
+ placeholderConnect: "连接到网关以开始聊天...",
+ loading: "正在加载聊天...",
+ exitFocus: "退出专注模式",
+ queued: "已进入队列 ({count})",
+ imageAttachment: "图像 ({count})",
+ removeQueued: "移除队列消息",
+ labelMessage: "消息",
+ stop: "停止",
+ newSession: "新会话",
+ queue: "入队",
+ send: "发送",
+ historyNotice: "显示最近 {limit} 条消息 (隐藏了 {hidden} 条)。",
},
sessions: {
title: "会话",
- subtitle: "查看活跃的代理会话",
+ subtitle: "活跃会话密钥及按会话进行的行为覆盖。",
+ activeWithin: "活跃于(分钟)",
+ limit: "限制",
+ includeGlobal: "包含全局",
+ includeUnknown: "包含未知",
+ storePath: "存储路径: {path}",
+ table: {
+ key: "密钥",
+ label: "标签",
+ kind: "类型",
+ updated: "更新于",
+ tokens: "令牌 (Tokens)",
+ thinking: "思考",
+ verbose: "详细模式",
+ reasoning: "推理",
+ actions: "操作",
+ },
+ noSessions: "未发现会话。",
+ inherit: "继承",
+ offExplicit: "禁用 (显式)",
+ on: "启用",
+ stream: "流式",
+ delete: "删除",
+ optional: "(可选)",
+ thinkingLevels: {
+ off: "关闭",
+ minimal: "极简",
+ low: "低",
+ medium: "中",
+ high: "高",
+ }
},
cron: {
title: "定时任务",
- subtitle: "管理定时作业",
+ scheduler: "调度器",
+ schedulerSubtitle: "网关原生定时调度器状态。",
+ jobs: "任务数",
+ nextWake: "下次唤醒",
+ newJob: "新建任务",
+ newJobSubtitle: "创建定时唤醒或代理执行任务。",
+ name: "名称",
+ description: "描述",
+ agentId: "代理 ID",
+ enabled: "已启用",
+ schedule: "调度模式",
+ every: "每隔",
+ at: "在",
+ cron: "Cron 表达式",
+ session: "会话",
+ main: "主会话",
+ isolated: "隔离会话",
+ wakeMode: "唤醒模式",
+ nextHeartbeat: "下次心跳",
+ now: "立即",
+ payload: "载荷类型",
+ systemEvent: "系统事件",
+ agentTurn: "代理回合",
+ systemText: "系统文本",
+ agentMessage: "代理消息",
+ deliver: "投递",
+ channel: "渠道",
+ to: "发送至",
+ timeout: "超时 (秒)",
+ postToMainPrefix: "发布到主会话前缀",
+ addJob: "添加任务",
+ saving: "正在保存...",
+ jobsTitle: "任务列表",
+ jobsSubtitle: "网关中存储的所有定时任务。",
+ noJobs: "暂无任务。",
+ runHistory: "运行历史",
+ latestRuns: "最近运行记录: {id}",
+ selectJob: "(请选择一个任务)",
+ selectJobHint: "选择一个任务以查看运行历史。",
+ noRuns: "暂无运行记录。",
+ runAt: "运行时间",
+ unit: "单位",
+ minutes: "分钟",
+ hours: "小时",
+ days: "天",
+ expression: "表达式",
+ timezone: "时区 (可选)",
+ lastChannel: "最近渠道",
+ disable: "禁用",
+ enable: "启用",
+ run: "运行",
+ runs: "历史",
+ remove: "移除",
},
skills: {
title: "技能",
- subtitle: "管理代理能力",
- },
- nodes: {
- title: "节点",
- subtitle: "管理计算节点",
+ subtitle: "内置、托管及工作区技能。",
+ filter: "过滤",
+ searchPlaceholder: "搜索技能",
+ shown: "显示 {count} 个",
+ noSkills: "未发现技能。",
+ installing: "正在安装...",
+ eligible: "符合条件",
+ blocked: "已屏蔽",
+ disabled: "已禁用",
+ missing: "缺失:",
+ reason: "原因:",
+ apiKey: "API 密钥",
+ saveKey: "保存密钥",
+ reasonDisabled: "已禁用",
+ reasonAllowlist: "被白名单拦截",
+ enable: "启用",
+ disable: "禁用",
},
instances: {
- title: "实例",
- subtitle: "查看连接的客户端实例",
+ title: "已连接实例",
+ subtitle: "来自网关和客户端的存在感应信号 (Presence)。",
+ noInstances: "暂无实例报告。",
+ unknownHost: "未知主机",
+ lastInput: "最近输入",
+ ago: "{time}前",
+ scopes: "{count} 个作用域",
+ reason: "原因",
},
- config: {
- title: "配置",
- subtitle: "编辑网关设置",
+ gateway: {
+ changeTitle: "更改网关地址",
+ changeSubtitle: "这将连接到一个不同的网关服务器",
+ trustWarning: "仅在您信任此 URL 的情况下确认。恶意 URL 可能会危及您的系统安全。",
+ confirm: "确认",
+ cancel: "取消",
},
- debug: {
- title: "调试",
- subtitle: "高级工具和状态",
+ execApproval: {
+ title: "需要执行审批",
+ expiresIn: "{time} 后过期",
+ expired: "已过期",
+ pending: "{count} 个待处理",
+ allowOnce: "允许一次",
+ allowAlways: "总是允许",
+ deny: "拒绝",
+ host: "主机",
+ agent: "代理",
+ session: "会话",
+ cwd: "工作目录",
+ resolved: "解析路径",
+ security: "安全",
+ ask: "请求人",
},
- logs: {
- title: "日志",
- subtitle: "查看实时系统日志",
+ markdownSidebar: {
+ title: "工具输出",
+ close: "关闭侧边栏",
+ viewRaw: "查看原始文本",
+ noContent: "暂无内容",
+ },
+ configSections: {
+ env: { label: "环境变量", description: "传递给网关进程的环境变量" },
+ update: { label: "更新", description: "自动更新设置和发布渠道" },
+ agents: { label: "代理", description: "代理配置、模型和身份" },
+ auth: { label: "身份验证", description: "API 密钥和身份验证配置文件" },
+ channels: { label: "渠道", description: "消息渠道 (Telegram, Discord, Slack 等)" },
+ messages: { label: "消息", description: "消息处理和路由设置" },
+ commands: { label: "命令", description: "自定义斜杠命令" },
+ hooks: { label: "钩子", description: "Webhook 和事件钩子" },
+ skills: { label: "技能", description: "技能包和能力" },
+ tools: { label: "工具", description: "工具配置 (浏览器、搜索等)" },
+ gateway: { label: "网关", description: "网关服务器设置 (端口、身份验证、绑定)" },
+ wizard: { label: "设置向导", description: "设置向导状态和历史" },
+ meta: { label: "元数据", description: "网关元数据和版本信息" },
+ logging: { label: "日志", description: "日志级别和输出配置" },
+ browser: { label: "浏览器", description: "浏览器自动化设置" },
+ ui: { label: "界面", description: "用户界面偏好设置" },
+ models: { label: "模型", description: "AI 模型配置和提供商" },
+ bindings: { label: "绑定", description: "按键绑定和快捷键" },
+ broadcast: { label: "广播", description: "广播和通知设置" },
+ audio: { label: "音频", description: "音频输入/输出设置" },
+ session: { label: "会话", description: "会话管理和持久化" },
+ cron: { label: "定时任务", description: "计划任务和自动化" },
+ web: { label: "Web", description: "Web 服务器和 API 设置" },
+ discovery: { label: "发现", description: "服务发现和网络" },
+ canvasHost: { label: "画布主机", description: "画布渲染和显示" },
+ talk: { label: "语音", description: "语音和通话设置" },
+ plugins: { label: "插件", description: "插件管理和扩展" },
+ },
+ configErrors: {
+ noMatch: "没有匹配 \"{query}\" 的设置",
+ emptySection: "此章节没有设置",
+ schemaUnavailable: "配置结构不可用。",
+ unsupportedSchema: "不支持的配置结构。请使用原始数据 (Raw)。",
+ },
+ configNodes: {
+ unsupportedNode: "不支持的配置节点。请使用原始数据 (Raw)。",
+ defaultLabel: "默认值: {value}",
+ resetTitle: "重置为默认值",
+ selectPlaceholder: "请选择...",
+ itemsCount: "{count} 个项目",
+ addItem: "添加",
+ removeItem: "移除项目",
+ noItems: "暂无项目。点击“添加”来创建一个。",
+ customEntries: "自定义条目",
+ addEntry: "添加条目",
+ keyPlaceholder: "键",
+ jsonValuePlaceholder: "JSON 值",
+ removeEntry: "移除条目",
+ noCustomEntries: "暂无自定义条目。",
+ unsupportedType: "不支持的类型: {type}。请使用原始数据 (Raw)。",
+ },
+ format: {
+ na: "暂无",
+ justNow: "刚刚",
+ agoSec: "{count} 秒前",
+ agoMin: "{count} 分钟前",
+ agoHr: "{count} 小时前",
+ agoDay: "{count} 天前",
+ none: "无",
},
common: {
loading: "加载中...",
- error: "错误",
+ refresh: "刷新",
+ delete: "删除",
save: "保存",
cancel: "取消",
- delete: "删除",
+ na: "无",
+ inherit: "继承",
+ error: "错误",
edit: "编辑",
- refresh: "刷新",
disconnected: "与网关断开连接。",
+ valid: "有效",
+ invalid: "无效",
+ unknown: "未知",
}
};
diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts
index 567e9c608..1adff825b 100644
--- a/ui/src/ui/navigation.ts
+++ b/ui/src/ui/navigation.ts
@@ -2,13 +2,13 @@ import type { IconName } from "./icons.js";
import { t } from "./i18n.js";
export const TAB_GROUPS = [
- { label: "Chat", tabs: ["chat"] },
+ { label: t("sidebarGroups.chat"), tabs: ["chat"] },
{
- label: "Control",
+ label: t("sidebarGroups.control"),
tabs: ["overview", "channels", "instances", "sessions", "cron"],
},
- { label: "Agent", tabs: ["skills", "nodes"] },
- { label: "Settings", tabs: ["config", "debug", "logs"] },
+ { label: t("sidebarGroups.agent"), tabs: ["skills", "nodes"] },
+ { label: t("sidebarGroups.settings"), tabs: ["config", "debug", "logs"] },
] as const;
export type Tab =
@@ -155,7 +155,7 @@ export function titleForTab(tab: Tab) {
case "logs":
return t("tabs.logs");
default:
- return "Control";
+ return t("sidebarGroups.control");
}
}
diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts
index 1a5ec0731..d3f519316 100644
--- a/ui/src/ui/types.ts
+++ b/ui/src/ui/types.ts
@@ -313,6 +313,8 @@ export type PresenceEntry = {
lastInputSeconds?: number | null;
reason?: string | null;
text?: string | null;
+ roles?: string[] | null;
+ scopes?: string[] | null;
ts?: number | null;
};
@@ -398,23 +400,23 @@ export type CronWakeMode = "next-heartbeat" | "now";
export type CronPayload =
| { kind: "systemEvent"; text: string }
| {
- kind: "agentTurn";
- message: string;
- thinking?: string;
- timeoutSeconds?: number;
- deliver?: boolean;
- provider?:
- | "last"
- | "whatsapp"
- | "telegram"
- | "discord"
- | "slack"
- | "signal"
- | "imessage"
- | "msteams";
- to?: string;
- bestEffortDeliver?: boolean;
- };
+ kind: "agentTurn";
+ message: string;
+ thinking?: string;
+ timeoutSeconds?: number;
+ deliver?: boolean;
+ provider?:
+ | "last"
+ | "whatsapp"
+ | "telegram"
+ | "discord"
+ | "slack"
+ | "signal"
+ | "imessage"
+ | "msteams";
+ to?: string;
+ bestEffortDeliver?: boolean;
+ };
export type CronIsolation = {
postToMainPrefix?: string;
diff --git a/ui/src/ui/views/channels.config.ts b/ui/src/ui/views/channels.config.ts
index 3c4d2c7df..3b14fb42f 100644
--- a/ui/src/ui/views/channels.config.ts
+++ b/ui/src/ui/views/channels.config.ts
@@ -1,4 +1,5 @@
import { html } from "lit";
+import { t } from "../i18n";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
@@ -71,26 +72,26 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
- return html`Schema unavailable. Use Raw.
`;
+ return html`${t("channels.config.schemaUnavailable")}
`;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
- return html`Channel config schema unavailable.
`;
+ return html`${t("channels.config.channelSchemaUnavailable")}
`;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
return html`
${renderNode({
- schema: node,
- value,
- path: ["channels", props.channelId],
- hints: props.uiHints,
- unsupported: new Set(analysis.unsupportedPaths),
- disabled: props.disabled,
- showLabel: false,
- onPatch: props.onPatch,
- })}
+ schema: node,
+ value,
+ path: ["channels", props.channelId],
+ hints: props.uiHints,
+ unsupported: new Set(analysis.unsupportedPaths),
+ disabled: props.disabled,
+ showLabel: false,
+ onPatch: props.onPatch,
+ })}
`;
}
@@ -104,29 +105,29 @@ export function renderChannelConfigSection(params: {
return html`
${props.configSchemaLoading
- ? html`
Loading config schema…
`
- : renderChannelConfigForm({
- channelId,
- configValue: props.configForm,
- schema: props.configSchema,
- uiHints: props.configUiHints,
- disabled,
- onPatch: props.onConfigPatch,
- })}
+ ? html`
${t("channels.config.loadingSchema")}
`
+ : renderChannelConfigForm({
+ channelId,
+ configValue: props.configForm,
+ schema: props.configSchema,
+ uiHints: props.configUiHints,
+ disabled,
+ onPatch: props.onConfigPatch,
+ })}
diff --git a/ui/src/ui/views/channels.discord.ts b/ui/src/ui/views/channels.discord.ts
index 07890e969..90b27a499 100644
--- a/ui/src/ui/views/channels.discord.ts
+++ b/ui/src/ui/views/channels.discord.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
@@ -15,46 +16,46 @@ export function renderDiscordCard(params: {
return html`
Discord
-
Bot status and channel configuration.
+
${t("channels.discord.subtitle")}
${accountCountLabel}
- Configured
- ${discord?.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${discord?.configured ? t("channels.yes") : t("channels.no")}
- Running
- ${discord?.running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${discord?.running ? t("channels.yes") : t("channels.no")}
- Last start
- ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}
+ ${t("channels.lastStart")}
+ ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : t("common.na")}
- Last probe
- ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}
+ ${t("channels.lastProbe")}
+ ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : t("common.na")}
${discord?.lastError
- ? html`
+ ? html`
${discord.lastError}
`
- : nothing}
+ : nothing}
${discord?.probe
- ? html`
- Probe ${discord.probe.ok ? "ok" : "failed"} ·
+ ? html`
+ ${t("channels.probe")} ${discord.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
`
- : nothing}
+ : nothing}
${renderChannelConfigSection({ channelId: "discord", props })}
diff --git a/ui/src/ui/views/channels.googlechat.ts b/ui/src/ui/views/channels.googlechat.ts
index a014ac89e..85902bb2b 100644
--- a/ui/src/ui/views/channels.googlechat.ts
+++ b/ui/src/ui/views/channels.googlechat.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type { GoogleChatStatus } from "../types";
@@ -15,58 +16,58 @@ export function renderGoogleChatCard(params: {
return html`
Google Chat
-
Chat API webhook status and channel configuration.
+
${t("channels.googleChat.subtitle")}
${accountCountLabel}
- Configured
- ${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}
+ ${t("channels.configured")}
+ ${googleChat ? (googleChat.configured ? t("channels.yes") : t("channels.no")) : t("common.na")}
- Running
- ${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}
+ ${t("channels.running")}
+ ${googleChat ? (googleChat.running ? t("channels.yes") : t("channels.no")) : t("common.na")}
- Credential
- ${googleChat?.credentialSource ?? "n/a"}
+ ${t("channels.googleChat.credential")}
+ ${googleChat?.credentialSource ?? t("common.na")}
- Audience
+ ${t("channels.googleChat.audience")}
${googleChat?.audienceType
- ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
- : "n/a"}
+ ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
+ : t("common.na")}
- Last start
- ${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}
+ ${t("channels.lastStart")}
+ ${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : t("common.na")}
- Last probe
- ${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}
+ ${t("channels.lastProbe")}
+ ${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : t("common.na")}
${googleChat?.lastError
- ? html`
+ ? html`
${googleChat.lastError}
`
- : nothing}
+ : nothing}
${googleChat?.probe
- ? html`
- Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
+ ? html`
+ ${t("channels.probe")} ${googleChat.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
`
- : nothing}
+ : nothing}
${renderChannelConfigSection({ channelId: "googlechat", props })}
diff --git a/ui/src/ui/views/channels.imessage.ts b/ui/src/ui/views/channels.imessage.ts
index 85fd90d03..8884e3ff8 100644
--- a/ui/src/ui/views/channels.imessage.ts
+++ b/ui/src/ui/views/channels.imessage.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
@@ -15,46 +16,46 @@ export function renderIMessageCard(params: {
return html`
iMessage
-
macOS bridge status and channel configuration.
+
${t("channels.imessage.subtitle")}
${accountCountLabel}
- Configured
- ${imessage?.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${imessage?.configured ? t("channels.yes") : t("channels.no")}
- Running
- ${imessage?.running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${imessage?.running ? t("channels.yes") : t("channels.no")}
- Last start
- ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
+ ${t("channels.lastStart")}
+ ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : t("common.na")}
- Last probe
- ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
+ ${t("channels.lastProbe")}
+ ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : t("common.na")}
${imessage?.lastError
- ? html`
+ ? html`
${imessage.lastError}
`
- : nothing}
+ : nothing}
${imessage?.probe
- ? html`
- Probe ${imessage.probe.ok ? "ok" : "failed"} ·
+ ? html`
+ ${t("channels.probe")} ${imessage.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${imessage.probe.error ?? ""}
`
- : nothing}
+ : nothing}
${renderChannelConfigSection({ channelId: "imessage", props })}
diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/ui/views/channels.nostr-profile-form.ts
index 8565d8ef9..267090f4d 100644
--- a/ui/src/ui/views/channels.nostr-profile-form.ts
+++ b/ui/src/ui/views/channels.nostr-profile-form.ts
@@ -5,6 +5,7 @@
*/
import { html, nothing, type TemplateResult } from "lit";
+import { t } from "../i18n";
import type { NostrProfile as NostrProfileType } from "../types";
@@ -104,9 +105,9 @@ export function renderNostrProfileForm(params: {
rows="3"
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; resize: vertical; font-family: inherit;"
@input=${(e: InputEvent) => {
- const target = e.target as HTMLTextAreaElement;
- callbacks.onFieldChange(field, target.value);
- }}
+ const target = e.target as HTMLTextAreaElement;
+ callbacks.onFieldChange(field, target.value);
+ }}
?disabled=${state.saving}
>
${help ? html`
${help}
` : nothing}
@@ -128,9 +129,9 @@ export function renderNostrProfileForm(params: {
maxlength=${maxLength ?? 256}
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px;"
@input=${(e: InputEvent) => {
- const target = e.target as HTMLInputElement;
- callbacks.onFieldChange(field, target.value);
- }}
+ const target = e.target as HTMLInputElement;
+ callbacks.onFieldChange(field, target.value);
+ }}
?disabled=${state.saving}
/>
${help ? html`
${help}
` : nothing}
@@ -147,16 +148,16 @@ export function renderNostrProfileForm(params: {

{
- const img = e.target as HTMLImageElement;
- img.style.display = "none";
- }}
+ const img = e.target as HTMLImageElement;
+ img.style.display = "none";
+ }}
@load=${(e: Event) => {
- const img = e.target as HTMLImageElement;
- img.style.display = "block";
- }}
+ const img = e.target as HTMLImageElement;
+ img.style.display = "block";
+ }}
/>
`;
@@ -165,74 +166,74 @@ export function renderNostrProfileForm(params: {
return html`
-
Edit Profile
-
Account: ${accountId}
+
${t("channels.nostr.form.title")}
+
${t("channels.nostr.form.account", { accountId })}
${state.error
- ? html`
${state.error}
`
- : nothing}
+ ? html`
${state.error}
`
+ : nothing}
${state.success
- ? html`
${state.success}
`
- : nothing}
+ ? html`
${state.success}
`
+ : nothing}
${renderPicturePreview()}
- ${renderField("name", "Username", {
+ ${renderField("name", t("channels.nostr.profileFields.username"), {
placeholder: "satoshi",
maxLength: 256,
- help: "Short username (e.g., satoshi)",
+ help: t("channels.nostr.profileFields.usernameHelp"),
})}
- ${renderField("displayName", "Display Name", {
+ ${renderField("displayName", t("channels.nostr.profileFields.displayName"), {
placeholder: "Satoshi Nakamoto",
maxLength: 256,
- help: "Your full display name",
+ help: t("channels.nostr.profileFields.displayNameHelp"),
})}
- ${renderField("about", "Bio", {
+ ${renderField("about", t("channels.nostr.profileFields.bio"), {
type: "textarea",
placeholder: "Tell people about yourself...",
maxLength: 2000,
- help: "A brief bio or description",
+ help: t("channels.nostr.profileFields.bioHelp"),
})}
- ${renderField("picture", "Avatar URL", {
+ ${renderField("picture", t("channels.nostr.profileFields.avatarUrl"), {
type: "url",
placeholder: "https://example.com/avatar.jpg",
- help: "HTTPS URL to your profile picture",
+ help: t("channels.nostr.profileFields.avatarUrlHelp"),
})}
${state.showAdvanced
- ? html`
+ ? html`
-
Advanced
+
${t("channels.nostr.form.advanced")}
- ${renderField("banner", "Banner URL", {
- type: "url",
- placeholder: "https://example.com/banner.jpg",
- help: "HTTPS URL to a banner image",
- })}
+ ${renderField("banner", t("channels.nostr.profileFields.bannerUrl"), {
+ type: "url",
+ placeholder: "https://example.com/banner.jpg",
+ help: t("channels.nostr.profileFields.bannerUrlHelp"),
+ })}
- ${renderField("website", "Website", {
- type: "url",
- placeholder: "https://example.com",
- help: "Your personal website",
- })}
+ ${renderField("website", t("channels.nostr.profileFields.website"), {
+ type: "url",
+ placeholder: "https://example.com",
+ help: t("channels.nostr.profileFields.websiteHelp"),
+ })}
- ${renderField("nip05", "NIP-05 Identifier", {
- placeholder: "you@example.com",
- help: "Verifiable identifier (e.g., you@domain.com)",
- })}
+ ${renderField("nip05", t("channels.nostr.profileFields.nip05"), {
+ placeholder: "you@example.com",
+ help: t("channels.nostr.profileFields.nip05Help"),
+ })}
- ${renderField("lud16", "Lightning Address", {
- placeholder: "you@getalby.com",
- help: "Lightning address for tips (LUD-16)",
- })}
+ ${renderField("lud16", t("channels.nostr.profileFields.lud16"), {
+ placeholder: "you@getalby.com",
+ help: t("channels.nostr.profileFields.lud16Help"),
+ })}
`
- : nothing}
+ : nothing}
${isDirty
- ? html`
- You have unsaved changes
+ ? html`
+ ${t("channels.nostr.form.unsavedChanges")}
`
- : nothing}
+ : nothing}
`;
}
diff --git a/ui/src/ui/views/channels.nostr.ts b/ui/src/ui/views/channels.nostr.ts
index 05152d80b..7fa9e4ecf 100644
--- a/ui/src/ui/views/channels.nostr.ts
+++ b/ui/src/ui/views/channels.nostr.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
@@ -14,7 +15,7 @@ import {
* Truncate a pubkey for display (shows first and last 8 chars)
*/
function truncatePubkey(pubkey: string | null | undefined): string {
- if (!pubkey) return "n/a";
+ if (!pubkey) return t("common.na");
if (pubkey.length <= 20) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
@@ -64,26 +65,26 @@ export function renderNostrCard(params: {
- Running
- ${account.running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${account.running ? t("channels.yes") : t("channels.no")}
- Configured
- ${account.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${account.configured ? t("channels.yes") : t("channels.no")}
- Public Key
+ ${t("channels.nostr.publicKey")}
${truncatePubkey(publicKey)}
- Last inbound
- ${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}
+ ${t("channels.lastInbound")}
+ ${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}
${account.lastError
- ? html`
+ ? html`
${account.lastError}
`
- : nothing}
+ : nothing}
`;
@@ -102,14 +103,14 @@ export function renderNostrCard(params: {
const profile =
(primaryAccount as
| {
- profile?: {
- name?: string;
- displayName?: string;
- about?: string;
- picture?: string;
- nip05?: string;
- };
- }
+ profile?: {
+ name?: string;
+ displayName?: string;
+ about?: string;
+ picture?: string;
+ nip05?: string;
+ };
+ }
| undefined)?.profile ?? nostr?.profile;
const { name, displayName, about, picture, nip05 } = profile ?? {};
const hasAnyProfileData = name || displayName || about || picture || nip05;
@@ -117,49 +118,49 @@ export function renderNostrCard(params: {
return html`
-
Profile
+
${t("channels.nostr.profile")}
${summaryConfigured
- ? html`
+ ? html`
`
- : nothing}
+ : nothing}
${hasAnyProfileData
- ? html`
+ ? html`
${picture
- ? html`
+ ? html`

{
- (e.target as HTMLImageElement).style.display = "none";
- }}
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
/>
`
- : nothing}
- ${name ? html`
Name${name}
` : nothing}
+ : nothing}
+ ${name ? html`
${t("channels.nostr.profileFields.username")}${name}
` : nothing}
${displayName
- ? html`
Display Name${displayName}
`
- : nothing}
+ ? html`
${t("channels.nostr.profileFields.displayName")}${displayName}
`
+ : nothing}
${about
- ? html`
About${about}
`
- : nothing}
- ${nip05 ? html`
NIP-05${nip05}
` : nothing}
+ ? html`
${t("channels.nostr.profileFields.bio")}${about}
`
+ : nothing}
+ ${nip05 ? html`
${t("channels.nostr.profileFields.nip05")}${nip05}
` : nothing}
`
- : html`
+ : html`
- No profile set. Click "Edit Profile" to add your name, bio, and avatar.
+ ${t("channels.nostr.noProfileHint")}
`}
@@ -169,48 +170,48 @@ export function renderNostrCard(params: {
return html`
Nostr
-
Decentralized DMs via Nostr relays (NIP-04).
+
${t("channels.nostr.subtitle")}
${accountCountLabel}
${hasMultipleAccounts
- ? html`
+ ? html`
${nostrAccounts.map((account) => renderAccountCard(account))}
`
- : html`
+ : html`
- Configured
- ${summaryConfigured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${summaryConfigured ? t("channels.yes") : t("channels.no")}
- Running
- ${summaryRunning ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${summaryRunning ? t("channels.yes") : t("channels.no")}
- Public Key
+ ${t("channels.nostr.publicKey")}
${truncatePubkey(summaryPublicKey)}
- Last start
- ${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}
+ ${t("channels.lastStart")}
+ ${summaryLastStartAt ? formatAgo(summaryLastStartAt) : t("common.na")}
`}
${summaryLastError
- ? html`
${summaryLastError}
`
- : nothing}
+ ? html`
${summaryLastError}
`
+ : nothing}
${renderProfileSection()}
${renderChannelConfigSection({ channelId: "nostr", props })}
-
+
`;
diff --git a/ui/src/ui/views/channels.shared.ts b/ui/src/ui/views/channels.shared.ts
index 9af0c2ea1..8208df180 100644
--- a/ui/src/ui/views/channels.shared.ts
+++ b/ui/src/ui/views/channels.shared.ts
@@ -1,10 +1,11 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types";
export function formatDuration(ms?: number | null) {
- if (!ms && ms !== 0) return "n/a";
+ if (!ms && ms !== 0) return t("common.na");
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
@@ -41,5 +42,5 @@ export function renderChannelAccountCount(
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
- return html`
Accounts (${count})
`;
+ return html`
${t("channels.shared.accounts", { count })}
`;
}
diff --git a/ui/src/ui/views/channels.signal.ts b/ui/src/ui/views/channels.signal.ts
index 9d4f6c147..9fb5b5f73 100644
--- a/ui/src/ui/views/channels.signal.ts
+++ b/ui/src/ui/views/channels.signal.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
@@ -15,50 +16,50 @@ export function renderSignalCard(params: {
return html`
Signal
-
signal-cli status and channel configuration.
+
${t("channels.signal.subtitle")}
${accountCountLabel}
- Configured
- ${signal?.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${signal?.configured ? t("channels.yes") : t("channels.no")}
- Running
- ${signal?.running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${signal?.running ? t("channels.yes") : t("channels.no")}
- Base URL
- ${signal?.baseUrl ?? "n/a"}
+ ${t("channels.signal.baseUrl")}
+ ${signal?.baseUrl ?? t("common.na")}
- Last start
- ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}
+ ${t("channels.lastStart")}
+ ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : t("common.na")}
- Last probe
- ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}
+ ${t("channels.lastProbe")}
+ ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : t("common.na")}
${signal?.lastError
- ? html`
+ ? html`
${signal.lastError}
`
- : nothing}
+ : nothing}
${signal?.probe
- ? html`
- Probe ${signal.probe.ok ? "ok" : "failed"} ·
+ ? html`
+ ${t("channels.probe")} ${signal.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
`
- : nothing}
+ : nothing}
${renderChannelConfigSection({ channelId: "signal", props })}
diff --git a/ui/src/ui/views/channels.slack.ts b/ui/src/ui/views/channels.slack.ts
index eb93ac4c3..8f39d8c92 100644
--- a/ui/src/ui/views/channels.slack.ts
+++ b/ui/src/ui/views/channels.slack.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
@@ -15,46 +16,46 @@ export function renderSlackCard(params: {
return html`
Slack
-
Socket mode status and channel configuration.
+
${t("channels.slack.subtitle")}
${accountCountLabel}
- Configured
- ${slack?.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${slack?.configured ? t("channels.yes") : t("channels.no")}
- Running
- ${slack?.running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${slack?.running ? t("channels.yes") : t("channels.no")}
- Last start
- ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}
+ ${t("channels.lastStart")}
+ ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : t("common.na")}
- Last probe
- ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}
+ ${t("channels.lastProbe")}
+ ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : t("common.na")}
${slack?.lastError
- ? html`
+ ? html`
${slack.lastError}
`
- : nothing}
+ : nothing}
${slack?.probe
- ? html`
- Probe ${slack.probe.ok ? "ok" : "failed"} ·
+ ? html`
+ ${t("channels.probe")} ${slack.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
`
- : nothing}
+ : nothing}
${renderChannelConfigSection({ channelId: "slack", props })}
diff --git a/ui/src/ui/views/channels.telegram.ts b/ui/src/ui/views/channels.telegram.ts
index 498d98f87..baf66677b 100644
--- a/ui/src/ui/views/channels.telegram.ts
+++ b/ui/src/ui/views/channels.telegram.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
@@ -28,24 +29,24 @@ export function renderTelegramCard(params: {
- Running
- ${account.running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${account.running ? t("channels.yes") : t("channels.no")}
- Configured
- ${account.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${account.configured ? t("channels.yes") : t("channels.no")}
- Last inbound
- ${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}
+ ${t("channels.lastInbound")}
+ ${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}
${account.lastError
- ? html`
+ ? html`
${account.lastError}
`
- : nothing}
+ : nothing}
`;
@@ -54,58 +55,58 @@ export function renderTelegramCard(params: {
return html`
Telegram
-
Bot status and channel configuration.
+
${t("channels.telegram.subtitle")}
${accountCountLabel}
${hasMultipleAccounts
- ? html`
+ ? html`
${telegramAccounts.map((account) => renderAccountCard(account))}
`
- : html`
+ : html`
- Configured
- ${telegram?.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${telegram?.configured ? t("channels.yes") : t("channels.no")}
- Running
- ${telegram?.running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${telegram?.running ? t("channels.yes") : t("channels.no")}
- Mode
- ${telegram?.mode ?? "n/a"}
+ ${t("channels.telegram.mode")}
+ ${telegram?.mode ?? t("common.na")}
- Last start
- ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}
+ ${t("channels.lastStart")}
+ ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : t("common.na")}
- Last probe
- ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}
+ ${t("channels.lastProbe")}
+ ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : t("common.na")}
`}
${telegram?.lastError
- ? html`
+ ? html`
${telegram.lastError}
`
- : nothing}
+ : nothing}
${telegram?.probe
- ? html`
- Probe ${telegram.probe.ok ? "ok" : "failed"} ·
+ ? html`
+ ${t("channels.probe")} ${telegram.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
`
- : nothing}
+ : nothing}
${renderChannelConfigSection({ channelId: "telegram", props })}
diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts
index a0fce8f40..6f1abaaa4 100644
--- a/ui/src/ui/views/channels.ts
+++ b/ui/src/ui/views/channels.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type {
@@ -60,35 +61,35 @@ export function renderChannels(props: ChannelsProps) {
return html`
${orderedChannels.map((channel) =>
- renderChannel(channel.key, props, {
- whatsapp,
- telegram,
- discord,
- googlechat,
- slack,
- signal,
- imessage,
- nostr,
- channelAccounts: props.snapshot?.channelAccounts ?? null,
- }),
- )}
+ renderChannel(channel.key, props, {
+ whatsapp,
+ telegram,
+ discord,
+ googlechat,
+ slack,
+ signal,
+ imessage,
+ nostr,
+ channelAccounts: props.snapshot?.channelAccounts ?? null,
+ }),
+ )}
-
Channel health
-
Channel status snapshots from the gateway.
+
${t("channels.healthTitle")}
+
${t("channels.healthSubtitle")}
-
${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}
+
${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : t("common.na")}
${props.lastError
- ? html`
+ ? html`
${props.lastError}
`
- : nothing}
+ : nothing}
-${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
+${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : t("channels.noSnapshot")}
`;
@@ -145,7 +146,7 @@ function renderChannel(
case "googlechat":
return renderGoogleChatCard({
props,
- googlechat: data.googlechat,
+ googleChat: data.googlechat,
accountCountLabel,
});
case "slack":
@@ -176,12 +177,12 @@ function renderChannel(
props.nostrProfileAccountId === accountId ? props.nostrProfileFormState : null;
const profileFormCallbacks = showForm
? {
- onFieldChange: props.onNostrProfileFieldChange,
- onSave: props.onNostrProfileSave,
- onImport: props.onNostrProfileImport,
- onCancel: props.onNostrProfileCancel,
- onToggleAdvanced: props.onNostrProfileToggleAdvanced,
- }
+ onFieldChange: props.onNostrProfileFieldChange,
+ onSave: props.onNostrProfileSave,
+ onImport: props.onNostrProfileImport,
+ onCancel: props.onNostrProfileCancel,
+ onToggleAdvanced: props.onNostrProfileToggleAdvanced,
+ }
: null;
return renderNostrCard({
props,
@@ -215,37 +216,37 @@ function renderGenericChannelCard(
return html`
${label}
-
Channel status and configuration.
+
${t("channels.genericSubtitle")}
${accountCountLabel}
${accounts.length > 0
- ? html`
+ ? html`
${accounts.map((account) => renderGenericAccount(account))}
`
- : html`
+ : html`
- Configured
- ${configured == null ? "n/a" : configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${configured == null ? t("common.na") : configured ? t("channels.yes") : t("channels.no")}
- Running
- ${running == null ? "n/a" : running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${running == null ? t("common.na") : running ? t("channels.yes") : t("channels.no")}
- Connected
- ${connected == null ? "n/a" : connected ? "Yes" : "No"}
+ ${t("channels.connected")}
+ ${connected == null ? t("common.na") : connected ? t("channels.yes") : t("channels.no")}
`}
${lastError
- ? html`
+ ? html`
${lastError}
`
- : nothing}
+ : nothing}
${renderChannelConfigSection({ channelId: key, props })}
@@ -274,19 +275,19 @@ function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS;
}
-function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" {
- if (account.running) return "Yes";
+function deriveRunningStatus(account: ChannelAccountSnapshot): string {
+ if (account.running) return t("channels.yes");
// If we have recent inbound activity, the channel is effectively running
- if (hasRecentActivity(account)) return "Active";
- return "No";
+ if (hasRecentActivity(account)) return t("channels.active");
+ return t("channels.no");
}
-function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" {
- if (account.connected === true) return "Yes";
- if (account.connected === false) return "No";
+function deriveConnectedStatus(account: ChannelAccountSnapshot): string {
+ if (account.connected === true) return t("channels.yes");
+ if (account.connected === false) return t("channels.no");
// If connected is null/undefined but we have recent activity, show as active
- if (hasRecentActivity(account)) return "Active";
- return "n/a";
+ if (hasRecentActivity(account)) return t("channels.active");
+ return t("common.na");
}
function renderGenericAccount(account: ChannelAccountSnapshot) {
@@ -301,28 +302,28 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
- Running
+ ${t("channels.running")}
${runningStatus}
- Configured
- ${account.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${account.configured ? t("channels.yes") : t("channels.no")}
- Connected
+ ${t("channels.connected")}
${connectedStatus}
- Last inbound
- ${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}
+ ${t("channels.lastInbound")}
+ ${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}
${account.lastError
- ? html`
+ ? html`
${account.lastError}
`
- : nothing}
+ : nothing}
`;
diff --git a/ui/src/ui/views/channels.whatsapp.ts b/ui/src/ui/views/channels.whatsapp.ts
index eae3be695..fd8cf9623 100644
--- a/ui/src/ui/views/channels.whatsapp.ts
+++ b/ui/src/ui/views/channels.whatsapp.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatAgo } from "../format";
import type { WhatsAppStatus } from "../types";
@@ -16,67 +17,67 @@ export function renderWhatsAppCard(params: {
return html`
WhatsApp
-
Link WhatsApp Web and monitor connection health.
+
${t("channels.whatsapp.subtitle")}
${accountCountLabel}
- Configured
- ${whatsapp?.configured ? "Yes" : "No"}
+ ${t("channels.configured")}
+ ${whatsapp?.configured ? t("channels.yes") : t("channels.no")}
- Linked
- ${whatsapp?.linked ? "Yes" : "No"}
+ ${t("channels.whatsapp.linked")}
+ ${whatsapp?.linked ? t("channels.yes") : t("channels.no")}
- Running
- ${whatsapp?.running ? "Yes" : "No"}
+ ${t("channels.running")}
+ ${whatsapp?.running ? t("channels.yes") : t("channels.no")}
- Connected
- ${whatsapp?.connected ? "Yes" : "No"}
+ ${t("channels.connected")}
+ ${whatsapp?.connected ? t("channels.yes") : t("channels.no")}
- Last connect
+ ${t("channels.whatsapp.lastConnect")}
${whatsapp?.lastConnectedAt
- ? formatAgo(whatsapp.lastConnectedAt)
- : "n/a"}
+ ? formatAgo(whatsapp.lastConnectedAt)
+ : t("common.na")}
- Last message
+ ${t("channels.whatsapp.lastMessage")}
- ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
+ ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : t("common.na")}
- Auth age
+ ${t("channels.whatsapp.authAge")}
${whatsapp?.authAgeMs != null
- ? formatDuration(whatsapp.authAgeMs)
- : "n/a"}
+ ? formatDuration(whatsapp.authAgeMs)
+ : t("common.na")}
${whatsapp?.lastError
- ? html`
+ ? html`
${whatsapp.lastError}
`
- : nothing}
+ : nothing}
${props.whatsappMessage
- ? html`
+ ? html`
${props.whatsappMessage}
`
- : nothing}
+ : nothing}
${props.whatsappQrDataUrl
- ? html`
-

+ ? html`
+
`
- : nothing}
+ : nothing}
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index f5fb6e80b..55fc45836 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -16,6 +16,7 @@ import {
} from "../chat/grouped-render";
import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider";
+import { t } from "../i18n";
export type CompactionIndicatorStatus = {
active: boolean;
@@ -84,7 +85,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (status.active) {
return html`
- ${icons.loader} Compacting context...
+ ${icons.loader} ${t("chat.compacting")}
`;
}
@@ -95,7 +96,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
- ${icons.check} Context compacted
+ ${icons.check} ${t("chat.compacted")}
`;
}
@@ -153,29 +154,29 @@ function renderAttachmentPreview(props: ChatProps) {
return html`
${attachments.map(
- (att) => html`
+ (att) => html`
`,
- )}
+ )}
`;
}
@@ -197,9 +198,9 @@ export function renderChat(props: ChatProps) {
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected
? hasAttachments
- ? "Add a message or paste more images..."
- : "Message (↩ to send, Shift+↩ for line breaks, paste images)"
- : "Connect to the gateway to start chatting…";
+ ? t("chat.placeholderCompose")
+ : t("chat.placeholderHint")
+ : t("chat.placeholderConnect");
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
@@ -210,60 +211,60 @@ export function renderChat(props: ChatProps) {
aria-live="polite"
@scroll=${props.onChatScroll}
>
- ${props.loading ? html`
Loading chat…
` : nothing}
+ ${props.loading ? html`
${t("chat.loading")}
` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
- if (item.kind === "reading-indicator") {
- return renderReadingIndicatorGroup(assistantIdentity);
- }
+ if (item.kind === "reading-indicator") {
+ return renderReadingIndicatorGroup(assistantIdentity);
+ }
- if (item.kind === "stream") {
- return renderStreamingGroup(
- item.text,
- item.startedAt,
- props.onOpenSidebar,
- assistantIdentity,
- );
- }
+ if (item.kind === "stream") {
+ return renderStreamingGroup(
+ item.text,
+ item.startedAt,
+ props.onOpenSidebar,
+ assistantIdentity,
+ );
+ }
- if (item.kind === "group") {
- return renderMessageGroup(item, {
- onOpenSidebar: props.onOpenSidebar,
- showReasoning,
- assistantName: props.assistantName,
- assistantAvatar: assistantIdentity.avatar,
- });
- }
+ if (item.kind === "group") {
+ return renderMessageGroup(item, {
+ onOpenSidebar: props.onOpenSidebar,
+ showReasoning,
+ assistantName: props.assistantName,
+ assistantAvatar: assistantIdentity.avatar,
+ });
+ }
- return nothing;
- })}
+ return nothing;
+ })}
`;
return html`
${props.disabledReason
- ? html`${props.disabledReason}
`
- : nothing}
+ ? html`${props.disabledReason}
`
+ : nothing}
${props.error
- ? html`${props.error}
`
- : nothing}
+ ? html`${props.error}
`
+ : nothing}
${renderCompactionIndicator(props.compactionStatus)}
${props.focusMode
- ? html`
+ ? html`
`
- : nothing}
+ : nothing}
${props.queue.length
- ? html`
+ ? html`
-
Queued (${props.queue.length})
+
${t("chat.queued", { count: props.queue.length })}
${props.queue.map(
- (item) => html`
+ (item) => html`
${item.text ||
- (item.attachments?.length
- ? `Image (${item.attachments.length})`
- : "")}
+ (item.attachments?.length
+ ? t("chat.imageAttachment", { count: item.attachments.length })
+ : "")}
`,
- )}
+ )}
`
- : nothing}
+ : nothing}
${renderAttachmentPreview(props)}
@@ -425,7 +426,7 @@ function buildChatItems(props: ChatProps): Array {
key: "chat:history:notice",
message: {
role: "system",
- content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
+ content: t("chat.historyNotice", { limit: CHAT_HISTORY_RENDER_LIMIT, hidden: historyStart }),
timestamp: Date.now(),
},
});
diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts
index 17a182281..3416e98cb 100644
--- a/ui/src/ui/views/config-form.node.ts
+++ b/ui/src/ui/views/config-form.node.ts
@@ -1,4 +1,5 @@
import { html, nothing, type TemplateResult } from "lit";
+import { t } from "../i18n";
import type { ConfigUiHints } from "../types";
import {
defaultValue,
@@ -56,7 +57,7 @@ export function renderNode(params: {
if (unsupported.has(key)) {
return html`
${label}
-
Unsupported schema node. Use Raw mode.
+
${t("configNodes.unsupportedNode")}
`;
}
@@ -210,7 +211,7 @@ export function renderNode(params: {
return html`
${label}
-
Unsupported type: ${type}. Use Raw mode.
+
${t("configNodes.unsupportedType", { type })}
`;
}
@@ -233,7 +234,11 @@ function renderTextInput(params: {
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder =
hint?.placeholder ??
- (isSensitive ? "••••" : schema.default !== undefined ? `Default: ${schema.default}` : "");
+ (isSensitive
+ ? "••••"
+ : schema.default !== undefined
+ ? t("configNodes.defaultLabel", { value: String(schema.default) })
+ : "");
const displayValue = value ?? "";
return html`
@@ -248,29 +253,29 @@ function renderTextInput(params: {
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
- const raw = (e.target as HTMLInputElement).value;
- if (inputType === "number") {
- if (raw.trim() === "") {
- onPatch(path, undefined);
- return;
- }
- const parsed = Number(raw);
- onPatch(path, Number.isNaN(parsed) ? raw : parsed);
- return;
- }
- onPatch(path, raw);
- }}
+ const raw = (e.target as HTMLInputElement).value;
+ if (inputType === "number") {
+ if (raw.trim() === "") {
+ onPatch(path, undefined);
+ return;
+ }
+ const parsed = Number(raw);
+ onPatch(path, Number.isNaN(parsed) ? raw : parsed);
+ return;
+ }
+ onPatch(path, raw);
+ }}
@change=${(e: Event) => {
- if (inputType === "number") return;
- const raw = (e.target as HTMLInputElement).value;
- onPatch(path, raw.trim());
- }}
+ if (inputType === "number") return;
+ const raw = (e.target as HTMLInputElement).value;
+ onPatch(path, raw.trim());
+ }}
/>
${schema.default !== undefined ? html`
@@ -314,10 +319,10 @@ function renderNumberInput(params: {
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
- const raw = (e.target as HTMLInputElement).value;
- const parsed = raw === "" ? undefined : Number(raw);
- onPatch(path, parsed);
- }}
+ const raw = (e.target as HTMLInputElement).value;
+ const parsed = raw === "" ? undefined : Number(raw);
+ onPatch(path, parsed);
+ }}
/>
`;
@@ -203,75 +182,72 @@ export function renderConfigForm(props: ConfigFormProps) {
return html`
`;
}
diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts
index ff6f57f32..5a8e79b7d 100644
--- a/ui/src/ui/views/config.ts
+++ b/ui/src/ui/views/config.ts
@@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
+import { t } from "../i18n";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import {
hintForPath,
@@ -75,18 +76,18 @@ const sidebarIcons = {
// Section definitions
const SECTIONS: Array<{ key: string; label: string }> = [
- { key: "env", label: "Environment" },
- { key: "update", label: "Updates" },
- { key: "agents", label: "Agents" },
- { key: "auth", label: "Authentication" },
- { key: "channels", label: "Channels" },
- { key: "messages", label: "Messages" },
- { key: "commands", label: "Commands" },
- { key: "hooks", label: "Hooks" },
- { key: "skills", label: "Skills" },
- { key: "tools", label: "Tools" },
- { key: "gateway", label: "Gateway" },
- { key: "wizard", label: "Setup Wizard" },
+ { key: "env", label: t("config.sections.env") },
+ { key: "update", label: t("config.sections.update") },
+ { key: "agents", label: t("config.sections.agents") },
+ { key: "auth", label: t("config.sections.auth") },
+ { key: "channels", label: t("config.sections.channels") },
+ { key: "messages", label: t("config.sections.messages") },
+ { key: "commands", label: t("config.sections.commands") },
+ { key: "hooks", label: t("config.sections.hooks") },
+ { key: "skills", label: t("config.sections.skills") },
+ { key: "tools", label: t("config.sections.tools") },
+ { key: "gateway", label: t("config.sections.gateway") },
+ { key: "wizard", label: t("config.sections.wizard") },
];
type SubsectionEntry = {
@@ -210,10 +211,10 @@ export function renderConfig(props: ConfigProps) {
: null;
const subsections = props.activeSection
? resolveSubsections({
- key: props.activeSection,
- schema: activeSectionSchema,
- uiHints: props.uiHints,
- })
+ key: props.activeSection,
+ schema: activeSectionSchema,
+ uiHints: props.uiHints,
+ })
: [];
const allowSubnav =
props.formMode === "form" &&
@@ -255,8 +256,8 @@ export function renderConfig(props: ConfigProps) {
@@ -326,35 +327,35 @@ export function renderConfig(props: ConfigProps) {
${hasChanges ? html`
- ${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}
+ ${props.formMode === "raw" ? t("config.unsavedChanges") : t("config.unsavedChangeCount", { count: diff.length })}
` : html`
- No changes
+ ${t("config.noChanges")}
`}
- ${props.loading ? "Loading…" : "Reload"}
+ ${props.loading ? t("config.reloading") : t("config.reload")}
- ${props.saving ? "Saving…" : "Save"}
+ ${props.saving ? t("config.saving") : t("config.save")}
- ${props.applying ? "Applying…" : "Apply"}
+ ${props.applying ? t("config.applying") : t("config.apply")}
- ${props.updating ? "Updating…" : "Update"}
+ ${props.updating ? t("config.updating") : t("config.update")}
@@ -363,7 +364,7 @@ export function renderConfig(props: ConfigProps) {
${hasChanges && props.formMode === "form" ? html`
- View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}
+ ${t("config.viewChanges", { count: diff.length })}
@@ -384,89 +385,87 @@ export function renderConfig(props: ConfigProps) {
` : nothing}
${activeSectionMeta && props.formMode === "form"
- ? html`
+ ? html`
${getSectionIcon(props.activeSection ?? "")}
${activeSectionMeta.label}
${activeSectionMeta.description
- ? html`
${activeSectionMeta.description}
`
- : nothing}
+ ? html`
${activeSectionMeta.description}
`
+ : nothing}
`
- : nothing}
+ : nothing}
${allowSubnav
- ? html`
+ ? html`
props.onSubsectionChange(ALL_SUBSECTION)}
>
- All
+ ${t("config.allSubsections")}
${subsections.map(
- (entry) => html`
+ (entry) => html`
props.onSubsectionChange(entry.key)}
>
${entry.label}
`,
- )}
+ )}
`
- : nothing}
+ : nothing}
${props.formMode === "form"
- ? html`
+ ? html`
${props.schemaLoading
- ? html`
+ ? html`
-
Loading schema…
+
${t("config.loadingSchema")}
`
- : renderConfigForm({
- schema: analysis.schema,
- uiHints: props.uiHints,
- value: props.formValue,
- disabled: props.loading || !props.formValue,
- unsupportedPaths: analysis.unsupportedPaths,
- onPatch: props.onFormPatch,
- searchQuery: props.searchQuery,
- activeSection: props.activeSection,
- activeSubsection: effectiveSubsection,
- })}
+ : renderConfigForm({
+ schema: analysis.schema,
+ uiHints: props.uiHints,
+ value: props.formValue,
+ disabled: props.loading || !props.formValue,
+ unsupportedPaths: analysis.unsupportedPaths,
+ onPatch: props.onFormPatch,
+ searchQuery: props.searchQuery,
+ activeSection: props.activeSection,
+ activeSubsection: effectiveSubsection,
+ })}
${formUnsafe
- ? html`
- Form view can't safely edit some fields.
- Use Raw to avoid losing config entries.
+ ? html`
+ ${t("config.unsafeWarning")}
`
- : nothing}
+ : nothing}
`
- : html`
+ : html`
`}
${props.issues.length > 0
- ? html`
+ ? html`
${JSON.stringify(props.issues, null, 2)}
`
- : nothing}
+ : nothing}
`;
diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts
index d25e3eb45..744a0f6c7 100644
--- a/ui/src/ui/views/cron.ts
+++ b/ui/src/ui/views/cron.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatMs } from "../format";
import {
@@ -46,7 +47,7 @@ function buildChannelOptions(props: CronProps): string[] {
}
function resolveChannelLabel(props: CronProps, channel: string): string {
- if (channel === "last") return "last";
+ if (channel === "last") return t("cron.lastChannel");
const meta = props.channelMeta?.find((entry) => entry.id === channel);
if (meta?.label) return meta.label;
return props.channelLabels?.[channel] ?? channel;
@@ -57,223 +58,223 @@ export function renderCron(props: CronProps) {
return html`
-
Scheduler
-
Gateway-owned cron scheduler status.
+
${t("cron.scheduler")}
+
${t("cron.schedulerSubtitle")}
-
Enabled
+
${t("cron.enabled")}
${props.status
- ? props.status.enabled
- ? "Yes"
- : "No"
- : "n/a"}
+ ? props.status.enabled
+ ? t("channels.yes")
+ : t("channels.no")
+ : t("common.na")}
-
Jobs
-
${props.status?.jobs ?? "n/a"}
+
${t("cron.jobs")}
+
${props.status?.jobs ?? t("common.na")}
-
Next wake
+
${t("cron.nextWake")}
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
- ${props.loading ? "Refreshing…" : "Refresh"}
+ ${props.loading ? t("common.loading") : t("common.refresh")}
${props.error ? html`${props.error}` : nothing}
-
New Job
-
Create a scheduled wakeup or agent run.
+
${t("cron.newJob")}
+
${t("cron.newJobSubtitle")}
${renderScheduleFields(props)}
${props.form.payloadKind === "agentTurn"
- ? html`
+ ? html`
${props.form.sessionTarget === "isolated"
- ? html`
+ ? html`
`
- : nothing}
+ : nothing}
`
- : nothing}
+ : nothing}
- ${props.busy ? "Saving…" : "Add job"}
+ ${props.busy ? t("cron.saving") : t("cron.addJob")}
- Jobs
- All scheduled jobs stored in the gateway.
+ ${t("cron.jobsTitle")}
+ ${t("cron.jobsSubtitle")}
${props.jobs.length === 0
- ? html`No jobs yet.
`
- : html`
+ ? html`${t("cron.noJobs")}
`
+ : html`
${props.jobs.map((job) => renderJob(job, props))}
@@ -281,17 +282,17 @@ export function renderCron(props: CronProps) {
- Run history
- Latest runs for ${props.runsJobId ?? "(select a job)"}.
+ ${t("cron.runHistory")}
+ ${t("cron.latestRuns", { id: props.runsJobId ?? t("cron.selectJob") })}.
${props.runsJobId == null
- ? html`
+ ? html`
- Select a job to inspect run history.
+ ${t("cron.selectJobHint")}
`
- : props.runs.length === 0
- ? html`No runs yet.
`
- : html`
+ : props.runs.length === 0
+ ? html`${t("cron.noRuns")}
`
+ : html`
${props.runs.map((entry) => renderRun(entry))}
@@ -305,14 +306,14 @@ function renderScheduleFields(props: CronProps) {
if (form.scheduleKind === "at") {
return html`
`;
@@ -321,27 +322,27 @@ function renderScheduleFields(props: CronProps) {
return html`
@@ -350,19 +351,19 @@ function renderScheduleFields(props: CronProps) {
return html`
@@ -378,9 +379,9 @@ function renderJob(job: CronJob, props: CronProps) {
${job.name}
${formatCronSchedule(job)}
${formatCronPayload(job)}
- ${job.agentId ? html`Agent: ${job.agentId}
` : nothing}
+ ${job.agentId ? html`${t("cron.agentId")}: ${job.agentId}
` : nothing}
- ${job.enabled ? "enabled" : "disabled"}
+ ${job.enabled ? t("cron.enable") : t("cron.disable")}
${job.sessionTarget}
${job.wakeMode}
@@ -392,41 +393,41 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
- event.stopPropagation();
- props.onToggle(job, !job.enabled);
- }}
+ event.stopPropagation();
+ props.onToggle(job, !job.enabled);
+ }}
>
- ${job.enabled ? "Disable" : "Enable"}
+ ${job.enabled ? t("cron.disable") : t("cron.enable")}
{
- event.stopPropagation();
- props.onRun(job);
- }}
+ event.stopPropagation();
+ props.onRun(job);
+ }}
>
- Run
+ ${t("cron.run")}
{
- event.stopPropagation();
- props.onLoadRuns(job.id);
- }}
+ event.stopPropagation();
+ props.onLoadRuns(job.id);
+ }}
>
- Runs
+ ${t("cron.runs")}
{
- event.stopPropagation();
- props.onRemove(job);
- }}
+ event.stopPropagation();
+ props.onRemove(job);
+ }}
>
- Remove
+ ${t("cron.remove")}
diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts
index 5e35f0f64..562748311 100644
--- a/ui/src/ui/views/debug.ts
+++ b/ui/src/ui/views/debug.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatEventPayload } from "../presenter";
import type { EventLogEntry } from "../app-events";
@@ -32,85 +33,86 @@ export function renderDebug(props: DebugProps) {
const securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success";
const securityLabel =
critical > 0
- ? `${critical} critical`
+ ? t("debug.criticalIssues", { count: critical })
: warn > 0
- ? `${warn} warnings`
- : "No critical issues";
+ ? t("debug.warningIssues", { count: warn })
+ : t("debug.noCriticalIssues");
return html`
-
Snapshots
-
Status, health, and heartbeat data.
+
${t("debug.snapshotsTitle")}
+
${t("debug.snapshotsSubtitle")}
- ${props.loading ? "Refreshing…" : "Refresh"}
+ ${props.loading ? t("debug.refreshing") : t("common.refresh")}
-
Status
+
${t("debug.status")}
${securitySummary
- ? html`
- Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run
-
openclaw security audit --deep for details.
+ ? html`
+ ${t("debug.securityAudit", {
+ label: securityLabel + (info > 0 ? ` · ${t("debug.infoIssues", { count: info })}` : ""),
+ })}
`
- : nothing}
+ : nothing}
${JSON.stringify(props.status ?? {}, null, 2)}
-
Health
+
${t("debug.health")}
${JSON.stringify(props.health ?? {}, null, 2)}
-
Last heartbeat
+
${t("debug.lastHeartbeat")}
${JSON.stringify(props.heartbeat ?? {}, null, 2)}
-
Manual RPC
-
Send a raw gateway method with JSON params.
+
${t("debug.manualRpcTitle")}
+
${t("debug.manualRpcSubtitle")}
- Call
+ ${t("debug.call")}
${props.callError
- ? html`
+ ? html`
${props.callError}
`
- : nothing}
+ : nothing}
${props.callResult
- ? html`
${props.callResult}`
- : nothing}
+ ? html`
${props.callResult}`
+ : nothing}
- Models
- Catalog from models.list.
+ ${t("debug.modelsTitle")}
+ ${t("debug.modelsSubtitle")}
${JSON.stringify(
props.models ?? [],
null,
@@ -119,14 +121,14 @@ export function renderDebug(props: DebugProps) {
- Event Log
- Latest gateway events.
+ ${t("debug.eventLogTitle")}
+ ${t("debug.eventLogSubtitle")}
${props.eventLog.length === 0
- ? html`No events yet.
`
- : html`
+ ? html`${t("debug.noEvents")}
`
+ : html`
${props.eventLog.map(
- (evt) => html`
+ (evt) => html`
${evt.event}
@@ -137,7 +139,7 @@ export function renderDebug(props: DebugProps) {
`,
- )}
+ )}
`}
diff --git a/ui/src/ui/views/exec-approval.ts b/ui/src/ui/views/exec-approval.ts
index 548d56683..a876024b4 100644
--- a/ui/src/ui/views/exec-approval.ts
+++ b/ui/src/ui/views/exec-approval.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import type { AppViewState } from "../app-view-state";
@@ -22,54 +23,54 @@ export function renderExecApprovalPrompt(state: AppViewState) {
if (!active) return nothing;
const request = active.request;
const remainingMs = active.expiresAtMs - Date.now();
- const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired";
+ const remaining = remainingMs > 0 ? t("execApproval.expiresIn", { time: formatRemaining(remainingMs) }) : t("execApproval.expired");
const queueCount = state.execApprovalQueue.length;
return html`
${request.command}
- ${renderMetaRow("Host", request.host)}
- ${renderMetaRow("Agent", request.agentId)}
- ${renderMetaRow("Session", request.sessionKey)}
- ${renderMetaRow("CWD", request.cwd)}
- ${renderMetaRow("Resolved", request.resolvedPath)}
- ${renderMetaRow("Security", request.security)}
- ${renderMetaRow("Ask", request.ask)}
+ ${renderMetaRow(t("execApproval.host"), request.host)}
+ ${renderMetaRow(t("execApproval.agent"), request.agentId)}
+ ${renderMetaRow(t("execApproval.session"), request.sessionKey)}
+ ${renderMetaRow(t("execApproval.cwd"), request.cwd)}
+ ${renderMetaRow(t("execApproval.resolved"), request.resolvedPath)}
+ ${renderMetaRow(t("execApproval.security"), request.security)}
+ ${renderMetaRow(t("execApproval.ask"), request.ask)}
${state.execApprovalError
- ? html`
${state.execApprovalError}
`
- : nothing}
+ ? html`
${state.execApprovalError}
`
+ : nothing}
state.handleExecApprovalDecision("allow-once")}
>
- Allow once
+ ${t("execApproval.allowOnce")}
state.handleExecApprovalDecision("allow-always")}
>
- Always allow
+ ${t("execApproval.allowAlways")}
state.handleExecApprovalDecision("deny")}
>
- Deny
+ ${t("execApproval.deny")}
diff --git a/ui/src/ui/views/gateway-url-confirmation.ts b/ui/src/ui/views/gateway-url-confirmation.ts
index 7d48c4367..101327995 100644
--- a/ui/src/ui/views/gateway-url-confirmation.ts
+++ b/ui/src/ui/views/gateway-url-confirmation.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import type { AppViewState } from "../app-view-state";
@@ -11,26 +12,26 @@ export function renderGatewayUrlConfirmation(state: AppViewState) {
${pendingGatewayUrl}
- Only confirm if you trust this URL. Malicious URLs can compromise your system.
+ ${t("gateway.trustWarning")}
state.handleGatewayUrlConfirm()}
>
- Confirm
+ ${t("gateway.confirm")}
state.handleGatewayUrlCancel()}
>
- Cancel
+ ${t("gateway.cancel")}
diff --git a/ui/src/ui/views/instances.ts b/ui/src/ui/views/instances.ts
index 43a4f4191..ad602cc6a 100644
--- a/ui/src/ui/views/instances.ts
+++ b/ui/src/ui/views/instances.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { formatPresenceAge, formatPresenceSummary } from "../presenter";
import type { PresenceEntry } from "../types";
@@ -16,27 +17,27 @@ export function renderInstances(props: InstancesProps) {
-
Connected Instances
-
Presence beacons from the gateway and clients.
+
${t("instances.title")}
+
${t("instances.subtitle")}
- ${props.loading ? "Loading…" : "Refresh"}
+ ${props.loading ? t("common.loading") : t("common.refresh")}
${props.lastError
- ? html`
+ ? html`
${props.lastError}
`
- : nothing}
+ : nothing}
${props.statusMessage
- ? html`
+ ? html`
${props.statusMessage}
`
- : nothing}
+ : nothing}
${props.entries.length === 0
- ? html`
No instances reported yet.
`
- : props.entries.map((entry) => renderEntry(entry))}
+ ? html`
${t("instances.noInstances")}
`
+ : props.entries.map((entry) => renderEntry(entry))}
`;
@@ -45,21 +46,21 @@ export function renderInstances(props: InstancesProps) {
function renderEntry(entry: PresenceEntry) {
const lastInput =
entry.lastInputSeconds != null
- ? `${entry.lastInputSeconds}s ago`
- : "n/a";
+ ? t("instances.ago", { time: `${entry.lastInputSeconds}s` })
+ : t("common.na");
const mode = entry.mode ?? "unknown";
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
const scopesLabel =
scopes.length > 0
? scopes.length > 3
- ? `${scopes.length} scopes`
+ ? t("instances.scopes", { count: scopes.length })
: `scopes: ${scopes.join(", ")}`
: null;
return html`
-
${entry.host ?? "unknown host"}
+
${entry.host ?? t("instances.unknownHost")}
${formatPresenceSummary(entry)}
${mode}
@@ -67,18 +68,18 @@ function renderEntry(entry: PresenceEntry) {
${scopesLabel ? html`${scopesLabel}` : nothing}
${entry.platform ? html`${entry.platform}` : nothing}
${entry.deviceFamily
- ? html`${entry.deviceFamily}`
- : nothing}
+ ? html`${entry.deviceFamily}`
+ : nothing}
${entry.modelIdentifier
- ? html`${entry.modelIdentifier}`
- : nothing}
+ ? html`${entry.modelIdentifier}`
+ : nothing}
${entry.version ? html`${entry.version}` : nothing}
`;
diff --git a/ui/src/ui/views/logs.ts b/ui/src/ui/views/logs.ts
index 6da434dbe..189662f8b 100644
--- a/ui/src/ui/views/logs.ts
+++ b/ui/src/ui/views/logs.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import type { LogEntry, LogLevel } from "../types";
@@ -44,83 +45,84 @@ export function renderLogs(props: LogsProps) {
if (entry.level && !props.levelFilters[entry.level]) return false;
return matchesFilter(entry, needle);
});
- const exportLabel = needle || levelFiltered ? "filtered" : "visible";
+ const exportLabel = needle || levelFiltered ? t("logs.exportFiltered") : t("logs.exportVisible");
return html`
-
Logs
-
Gateway file logs (JSONL).
+
${t("logs.logsTitle")}
+
${t("logs.logsSubtitle")}
- ${props.loading ? "Loading…" : "Refresh"}
+ ${props.loading ? t("common.loading") : t("common.refresh")}
props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
>
- Export ${exportLabel}
+ ${t("logs.export", { label: exportLabel })}
${LEVELS.map(
- (level) => html`
+ (level) => html`
`,
- )}
+ )}
${props.file
- ? html`File: ${props.file}
`
- : nothing}
+ ? html`${t("logs.fileLabel")}: ${props.file}
`
+ : nothing}
${props.truncated
- ? html`
- Log output truncated; showing latest chunk.
+ ? html`
+ ${t("logs.truncatedWarn")}
`
- : nothing}
+ : nothing}
${props.error
- ? html`
${props.error}
`
- : nothing}
+ ? html`
${props.error}
`
+ : nothing}
${filtered.length === 0
- ? html`
No log entries.
`
- : filtered.map(
- (entry) => html`
+ ? html`
${t("logs.noEntries")}
`
+ : filtered.map(
+ (entry) => html`
${formatTime(entry.time)}
${entry.level ?? ""}
@@ -128,7 +130,7 @@ export function renderLogs(props: LogsProps) {
${entry.message ?? entry.raw}
`,
- )}
+ )}
`;
diff --git a/ui/src/ui/views/markdown-sidebar.ts b/ui/src/ui/views/markdown-sidebar.ts
index 828c524a3..5a966979a 100644
--- a/ui/src/ui/views/markdown-sidebar.ts
+++ b/ui/src/ui/views/markdown-sidebar.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../icons";
@@ -15,22 +16,22 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
return html`
`;
diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts
index 31beca988..43fb912d3 100644
--- a/ui/src/ui/views/nodes.ts
+++ b/ui/src/ui/views/nodes.ts
@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
+import { t } from "../i18n";
import { clampText, formatAgo, formatList } from "../format";
import type {
@@ -60,17 +61,17 @@ export function renderNodes(props: NodesProps) {
-
Nodes
-
Paired devices and live links.
+
${t("nodes.nodesTitle")}
+
${t("nodes.nodesSubtitle")}
- ${props.loading ? "Loading…" : "Refresh"}
+ ${props.loading ? t("common.loading") : t("common.refresh")}
${props.nodes.length === 0
- ? html`
No nodes found.
`
- : props.nodes.map((n) => renderNode(n))}
+ ? html`
${t("nodes.noNodesFound")}
`
+ : props.nodes.map((n) => renderNode(n))}
`;
@@ -84,32 +85,32 @@ function renderDevices(props: NodesProps) {
-
Devices
-
Pairing requests + role tokens.
+
${t("nodes.devicesTitle")}
+
${t("nodes.devicesSubtitle")}
- ${props.devicesLoading ? "Loading…" : "Refresh"}
+ ${props.devicesLoading ? t("common.loading") : t("common.refresh")}
${props.devicesError
- ? html`${props.devicesError}
`
- : nothing}
+ ? html`${props.devicesError}
`
+ : nothing}
${pending.length > 0
- ? html`
-
Pending
+ ? html`
+
${t("nodes.pending")}
${pending.map((req) => renderPendingDevice(req, props))}
`
- : nothing}
+ : nothing}
${paired.length > 0
- ? html`
-
Paired
+ ? html`
+
${t("nodes.paired")}
${paired.map((device) => renderPairedDevice(device, props))}
`
- : nothing}
+ : nothing}
${pending.length === 0 && paired.length === 0
- ? html`
No paired devices.
`
- : nothing}
+ ? html`
${t("nodes.noPairedDevices")}
`
+ : nothing}
`;
@@ -133,10 +134,10 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
@@ -157,9 +158,9 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
${device.deviceId}${ip}
${roles} · ${scopes}
${tokens.length === 0
- ? html`
Tokens: none
`
- : html`
-
Tokens
+ ? html`
${t("nodes.tokensNone")}
`
+ : html`
+
${t("nodes.tokens")}
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
@@ -170,7 +171,7 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
}
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
- const status = token.revokedAtMs ? "revoked" : "active";
+ const status = token.revokedAtMs ? t("nodes.revoked") : t("nodes.active");
const scopes = `scopes: ${formatList(token.scopes)}`;
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
return html`
@@ -181,16 +182,16 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
class="btn btn--sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
>
- Rotate
+ ${t("nodes.rotate")}
${token.revokedAtMs
- ? nothing
- : html`
+ ? nothing
+ : html`
props.onDeviceRevoke(deviceId, token.role)}
>
- Revoke
+ ${t("nodes.revoke")}
`}
@@ -274,15 +275,15 @@ type ExecApprovalsState = {
const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__";
const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [
- { value: "deny", label: "Deny" },
- { value: "allowlist", label: "Allowlist" },
- { value: "full", label: "Full" },
+ { value: "deny", label: t("nodes.securityOptions.deny") },
+ { value: "allowlist", label: t("nodes.securityOptions.allowlist") },
+ { value: "full", label: t("nodes.securityOptions.full") },
];
const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [
- { value: "off", label: "Off" },
- { value: "on-miss", label: "On miss" },
- { value: "always", label: "Always" },
+ { value: "off", label: t("nodes.askOptions.off") },
+ { value: "on-miss", label: t("nodes.askOptions.onMiss") },
+ { value: "always", label: t("nodes.askOptions.always") },
];
function resolveBindingsState(props: NodesProps): BindingState {
@@ -399,11 +400,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? ((form?.agents ?? {})[selectedScope] as Record | undefined) ??
- null
+ null
: null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
- [])
+ [])
: [];
return {
ready,
@@ -436,9 +437,9 @@ function renderBindings(state: BindingState) {
-
Exec node binding
+
${t("nodes.bindingTitle")}
- Pin agents to a specific node when using exec host=node.
+ ${t("nodes.bindingSubtitle")}
- ${state.configSaving ? "Saving…" : "Save"}
+ ${state.configSaving ? t("common.saving") : t("common.save")}
${state.formMode === "raw"
- ? html`