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: {
Profile picture preview { - 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: {
`; @@ -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`
Profile picture { - (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` ` - : 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``; + return html``; } 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: {
`; @@ -54,58 +55,58 @@ export function renderTelegramCard(params: { return html`
Telegram
-
Bot status and channel configuration.
+
${t("channels.telegram.subtitle")}
${accountCountLabel} ${hasMultipleAccounts - ? html` + ? html` ` - : 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` ` - : 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) {
`; 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`
- WhatsApp QR + ? html`
+ ${t("channels.whatsapp.qrAlt")}
` - : 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`
Attachment preview
`, - )} + )}
`; } @@ -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}
${sidebarOpen - ? html` + ? html` - props.onSplitRatioChange?.(e.detail.splitRatio)} + props.onSplitRatioChange?.(e.detail.splitRatio)} >
${renderMarkdownSidebar({ - content: props.sidebarContent ?? null, - error: props.sidebarError ?? null, - onClose: props.onCloseSidebar!, - onViewRawText: () => { - if (!props.sidebarContent || !props.onOpenSidebar) return; - props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``); - }, - })} + content: props.sidebarContent ?? null, + error: props.sidebarError ?? null, + onClose: props.onCloseSidebar!, + onViewRawText: () => { + if (!props.sidebarContent || !props.onOpenSidebar) return; + props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``); + }, + })}
` - : 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); + }} />
${help ? html`
${help}
` : nothing} ${arr.length === 0 ? html`
- No items yet. Click "Add" to create one. + ${t("configNodes.noItems")}
` : html`
@@ -533,28 +538,28 @@ function renderArray(params: {
${renderNode({ - schema: itemsSchema, - value: item, - path: [...path, idx], - hints, - unsupported, - disabled, - showLabel: false, - onPatch, - })} + schema: itemsSchema, + value: item, + path: [...path, idx], + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })}
`)} @@ -581,106 +586,106 @@ function renderMapField(params: { return html`
- Custom entries + ${t("configNodes.customEntries")}
${entries.length === 0 ? html` -
No custom entries.
+
${t("configNodes.noCustomEntries")}
` : html`
${entries.map(([key, entryValue]) => { - const valuePath = [...path, key]; - const fallback = jsonValue(entryValue); - return html` + const valuePath = [...path, key]; + const fallback = jsonValue(entryValue); + return html`
{ - const nextKey = (e.target as HTMLInputElement).value.trim(); - if (!nextKey || nextKey === key) return; - const next = { ...(value ?? {}) }; - if (nextKey in next) return; - next[nextKey] = next[key]; - delete next[key]; - onPatch(path, next); - }} + const nextKey = (e.target as HTMLInputElement).value.trim(); + if (!nextKey || nextKey === key) return; + const next = { ...(value ?? {}) }; + if (nextKey in next) return; + next[nextKey] = next[key]; + delete next[key]; + onPatch(path, next); + }} />
${anySchema - ? html` + ? html` ` - : renderNode({ - schema, - value: entryValue, - path: valuePath, - hints, - unsupported, - disabled, - showLabel: false, - onPatch, - })} + : renderNode({ + schema, + value: entryValue, + path: valuePath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })}
`; - })} + })}
`}
diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 2e7dc5f4e..1ceb815d3 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { t } from "../i18n"; import type { ConfigUiHints } from "../types"; import { icons } from "../icons"; import { @@ -54,37 +55,15 @@ const sectionIcons = { default: html``, }; -// Section metadata -export const SECTION_META: Record = { - env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" }, - update: { label: "Updates", description: "Auto-update settings and release channel" }, - agents: { label: "Agents", description: "Agent configurations, models, and identities" }, - auth: { label: "Authentication", description: "API keys and authentication profiles" }, - channels: { label: "Channels", description: "Messaging channels (Telegram, Discord, Slack, etc.)" }, - messages: { label: "Messages", description: "Message handling and routing settings" }, - commands: { label: "Commands", description: "Custom slash commands" }, - hooks: { label: "Hooks", description: "Webhooks and event hooks" }, - skills: { label: "Skills", description: "Skill packs and capabilities" }, - tools: { label: "Tools", description: "Tool configurations (browser, search, etc.)" }, - gateway: { label: "Gateway", description: "Gateway server settings (port, auth, binding)" }, - wizard: { label: "Setup Wizard", description: "Setup wizard state and history" }, - // Additional sections - meta: { label: "Metadata", description: "Gateway metadata and version information" }, - logging: { label: "Logging", description: "Log levels and output configuration" }, - browser: { label: "Browser", description: "Browser automation settings" }, - ui: { label: "UI", description: "User interface preferences" }, - models: { label: "Models", description: "AI model configurations and providers" }, - bindings: { label: "Bindings", description: "Key bindings and shortcuts" }, - broadcast: { label: "Broadcast", description: "Broadcast and notification settings" }, - audio: { label: "Audio", description: "Audio input/output settings" }, - session: { label: "Session", description: "Session management and persistence" }, - cron: { label: "Cron", description: "Scheduled tasks and automation" }, - web: { label: "Web", description: "Web server and API settings" }, - discovery: { label: "Discovery", description: "Service discovery and networking" }, - canvasHost: { label: "Canvas Host", description: "Canvas rendering and display" }, - talk: { label: "Talk", description: "Voice and speech settings" }, - plugins: { label: "Plugins", description: "Plugin management and extensions" }, -}; +// Section metadata is now retrieved via getSectionMeta(key) to support localization +function getSectionMeta(key: string): { label: string; description: string } { + return ( + (t(`configSections.${key}` as any) as any) ?? { + label: key.charAt(0).toUpperCase() + key.slice(1), + description: "", + } + ); +} function getSectionIcon(key: string) { return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default; @@ -93,7 +72,7 @@ function getSectionIcon(key: string) { function matchesSearch(key: string, schema: JsonSchema, query: string): boolean { if (!query) return true; const q = query.toLowerCase(); - const meta = SECTION_META[key]; + const meta = getSectionMeta(key); // Check key name if (key.toLowerCase().includes(q)) return true; @@ -142,12 +121,12 @@ function schemaMatches(schema: JsonSchema, query: string): boolean { export function renderConfigForm(props: ConfigFormProps) { if (!props.schema) { - return html`
Schema unavailable.
`; + return html`
${t("configErrors.schemaUnavailable")}
`; } const schema = props.schema; const value = props.value ?? {}; if (schemaType(schema) !== "object" || !schema.properties) { - return html`
Unsupported schema. Use Raw.
`; + return html`
${t("configErrors.unsupportedSchema")}
`; } const unsupported = new Set(props.unsupportedPaths ?? []); const properties = schema.properties; @@ -193,8 +172,8 @@ export function renderConfigForm(props: ConfigFormProps) {
${icons.search}
${searchQuery - ? `No settings match "${searchQuery}"` - : "No settings in this section"} + ? t("configErrors.noMatch", { query: searchQuery }) + : t("configErrors.emptySection")}
`; @@ -203,75 +182,72 @@ export function renderConfigForm(props: ConfigFormProps) { return html`
${subsectionContext - ? (() => { - const { sectionKey, subsectionKey, schema: node } = subsectionContext; - const hint = hintForPath([sectionKey, subsectionKey], props.uiHints); - const label = hint?.label ?? node.title ?? humanize(subsectionKey); - const description = hint?.help ?? node.description ?? ""; - const sectionValue = (value as Record)[sectionKey]; - const scopedValue = - sectionValue && typeof sectionValue === "object" - ? (sectionValue as Record)[subsectionKey] - : undefined; - const id = `config-section-${sectionKey}-${subsectionKey}`; - return html` + ? (() => { + const { sectionKey, subsectionKey, schema: node } = subsectionContext; + const hint = hintForPath([sectionKey, subsectionKey], props.uiHints); + const label = hint?.label ?? node.title ?? humanize(subsectionKey); + const description = hint?.help ?? node.description ?? ""; + const sectionValue = (value as Record)[sectionKey]; + const scopedValue = + sectionValue && typeof sectionValue === "object" + ? (sectionValue as Record)[subsectionKey] + : undefined; + const id = `config-section-${sectionKey}-${subsectionKey}`; + return html`
${getSectionIcon(sectionKey)}

${label}

${description - ? html`

${description}

` - : nothing} + ? html`

${description}

` + : nothing}
${renderNode({ - schema: node, - value: scopedValue, - path: [sectionKey, subsectionKey], - hints: props.uiHints, - unsupported, - disabled: props.disabled ?? false, - showLabel: false, - onPatch: props.onPatch, - })} + schema: node, + value: scopedValue, + path: [sectionKey, subsectionKey], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + showLabel: false, + onPatch: props.onPatch, + })}
`; - })() - : filteredEntries.map(([key, node]) => { - const meta = SECTION_META[key] ?? { - label: key.charAt(0).toUpperCase() + key.slice(1), - description: node.description ?? "", - }; + })() + : filteredEntries.map(([key, node]) => { + const meta = getSectionMeta(key); - return html` + return html`
${getSectionIcon(key)}

${meta.label}

${meta.description - ? html`

${meta.description}

` - : nothing} + ? html`

${meta.description}

` + : nothing}
${renderNode({ - schema: node, - value: (value as Record)[key], - path: [key], - hints: props.uiHints, - unsupported, - disabled: props.disabled ?? false, - showLabel: false, - onPatch: props.onPatch, - })} + schema: node, + value: (value as Record)[key], + path: [key], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + showLabel: false, + onPatch: props.onPatch, + })}
`; - })} + })}
`; } 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")} `}
@@ -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`
${subsections.map( - (entry) => html` + (entry) => html` `, - )} + )}
` - : 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.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}
-
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")}
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")}
-
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")}
- +
${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` @@ -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.formMode === "raw" - ? html`
- Switch the Config tab to Form mode to edit bindings here. + ? html`
+ ${t("nodes.bindingRawWarn")}
` - : nothing} + : nothing} ${!state.ready - ? html`
-
Load config to edit bindings.
+ ? html`
+
${t("nodes.loadConfigToEdit")}
` - : html` + : html`
-
Default binding
-
Used when agents do not override a node binding.
+
${t("nodes.defaultBinding")}
+
${t("nodes.defaultBindingHint")}
${!supportsBinding - ? html`
No nodes with system.run available.
` - : nothing} + ? html`
${t("nodes.noRunNodes")}
` + : nothing}
${state.agents.length === 0 - ? html`
No agents found.
` - : state.agents.map((agent) => - renderAgentBinding(agent, state), - )} + ? html`
No agents found.
` + : state.agents.map((agent) => + renderAgentBinding(agent, state), + )}
`}
@@ -517,9 +518,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
-
Exec approvals
+
${t("nodes.approvalsTitle")}
- Allowlist and approval policy for exec host=gateway/node. + ${t("nodes.approvalsSubtitle")}
${renderExecApprovalsTarget(state)} ${!ready - ? html`
-
Load exec approvals to edit allowlists.
+ ? html`
+
${t("nodes.loadApprovalsToEdit")}
` - : html` + : html` ${renderExecApprovalsTabs(state)} ${renderExecApprovalsPolicy(state)} ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE - ? nothing - : renderExecApprovalsAllowlist(state)} + ? nothing + : renderExecApprovalsAllowlist(state)} `}
`; @@ -558,62 +559,62 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
-
Target
+
${t("nodes.target")}
- Gateway edits local approvals; node edits the selected node. + ${t("nodes.targetHint")}
${state.target === "node" - ? html` + ? html` ` - : nothing} + : nothing}
${state.target === "node" && !hasNodes - ? html`
No nodes advertise exec approvals yet.
` - : nothing} + ? html`
${t("nodes.noApprovalsNodes")}
` + : nothing}
`; } @@ -621,17 +622,17 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) { function renderExecApprovalsTabs(state: ExecApprovalsState) { return html`
- Scope + ${t("nodes.scope")}
${state.agents.map((agent) => { - const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; - return html` + const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; + return html` `; - })} + })}
`; @@ -668,42 +669,42 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
-
Security
+
${t("nodes.security")}
${isDefaults - ? "Default security mode." - : `Default: ${defaults.security}.`} + ? t("nodes.securityDefaultHint") + : t("nodes.securityAgentHint", { security: defaults.security })}
@@ -711,40 +712,40 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
-
Ask
+
${t("nodes.ask")}
- ${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`} + ${isDefaults ? t("nodes.askDefaultHint") : t("nodes.securityAgentHint", { security: defaults.ask })}
@@ -752,42 +753,42 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
-
Ask fallback
+
${t("nodes.askFallback")}
${isDefaults - ? "Applied when the UI prompt is unavailable." - : `Default: ${defaults.askFallback}.`} + ? t("nodes.askFallbackHint") + : t("nodes.securityAgentHint", { security: defaults.askFallback })}
@@ -795,37 +796,37 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
-
Auto-allow skill CLIs
+
${t("nodes.autoAllowSkills")}
${isDefaults - ? "Allow skill executables listed by the Gateway." - : autoIsDefault - ? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).` - : `Override (${autoEffective ? "on" : "off"}).`} + ? t("nodes.autoAllowSkillsHint") + : autoIsDefault + ? t("nodes.useDefault", { security: defaults.autoAllowSkills ? t("nodes.askOptions.off") : t("nodes.askOptions.onMiss") }) // reusing askOptions for on/off if appropriate, or just using raw strings if better. Actually I'll use a specific logic or simplified strings. + : t("nodes.securityAgentHint", { security: autoEffective ? "开启" : "关闭" })}
${!isDefaults && !autoIsDefault - ? html`` - : nothing} + : nothing}
@@ -838,26 +839,26 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) { return html`
-
Allowlist
-
Case-insensitive glob patterns.
+
${t("nodes.allowlist")}
+
${t("nodes.allowlistHint")}
${entries.length === 0 - ? html`
No allowlist entries yet.
` - : entries.map((entry, index) => - renderAllowlistEntry(state, entry, index), - )} + ? html`
${t("nodes.noEntries")}
` + : entries.map((entry, index) => + renderAllowlistEntry(state, entry, index), + )}
`; } @@ -877,39 +878,39 @@ function renderAllowlistEntry( return html`
-
${entry.pattern?.trim() ? entry.pattern : "New pattern"}
-
Last used: ${lastUsed}
+
${entry.pattern?.trim() ? entry.pattern : t("nodes.addEntry")}
+
上次使用: ${lastUsed}
${lastCommand ? html`
${lastCommand}
` : nothing} ${lastPath ? html`
${lastPath}
` : nothing}
@@ -925,35 +926,35 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
${label}
- ${agent.isDefault ? "default agent" : "agent"} · + ${agent.isDefault ? "默认代理" : "代理"} · ${bindingValue === "__default__" - ? `uses default (${state.defaultBinding ?? "any"})` - : `override: ${agent.binding}`} + ? t("nodes.useDefault", { security: state.defaultBinding ?? t("nodes.anyNode") }) + : `${t("nodes.rotate")}: ${agent.binding}`}
@@ -1071,15 +1072,16 @@ function renderNode(node: Record) { ${typeof node.remoteIp === "string" ? ` · ${node.remoteIp}` : ""} ${typeof node.version === "string" ? ` · ${node.version}` : ""}
+
- ${paired ? "paired" : "unpaired"} + ${paired ? t("nodes.paired") : "未配对"} - ${connected ? "connected" : "offline"} + ${connected ? "已连接" : "离线"} ${caps.slice(0, 12).map((c) => html`${String(c)}`)} ${commands - .slice(0, 8) - .map((c) => html`${String(c)}`)} + .slice(0, 8) + .map((c) => html`${String(c)}`)}
diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index ca8b909b5..10049d69f 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -1,4 +1,5 @@ import { html } from "lit"; +import { t } from "../i18n"; import type { GatewayHelloOk } from "../gateway"; import { formatAgo, formatDurationMs } from "../format"; @@ -41,7 +42,7 @@ export function renderOverview(props: OverviewProps) { if (!hasToken && !hasPassword) { return html`
- This gateway requires auth. Add a token or password, then click Connect. + ${t("overview.authRequired")}
openclaw dashboard --no-open → tokenized URL
openclaw doctor --generate-gateway-token → set token @@ -61,9 +62,7 @@ export function renderOverview(props: OverviewProps) { } return html`
- Auth failed. Re-copy a tokenized URL with - openclaw dashboard --no-open, or update the token, - then click Connect. + ${t("overview.authFailed")}
- This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or - open http://127.0.0.1:18789 on the gateway host. + ${t("overview.insecureContext")}
- If you must stay on HTTP, set - gateway.controlUi.allowInsecureAuth: true (token-only). + 如果必须保留 HTTP,请设置 + gateway.controlUi.allowInsecureAuth: true (仅限令牌)。
-
Gateway Access
-
Where the dashboard connects and how it authenticates.
+
${t("overview.gatewayAccess")}
+
${t("overview.gatewayAccessSubtitle")}
- - - Click Connect to apply connection changes. + + + ${t("overview.connectHint")}
-
Snapshot
-
Latest gateway handshake information.
+
${t("overview.snapshotTitle")}
+
${t("overview.snapshotSubtitle")}
-
Status
+
${t("nodes.statusLabel")}
- ${props.connected ? "Connected" : "Disconnected"} + ${props.connected ? t("overview.statusOk") : t("overview.statusErr")}
-
Uptime
+
${t("overview.uptime")}
${uptime}
-
Tick Interval
+
${t("overview.tickInterval")}
${tick}
-
Last Channels Refresh
+
${t("overview.lastChannelsRefresh")}
${props.lastChannelsRefresh - ? formatAgo(props.lastChannelsRefresh) - : "n/a"} + ? formatAgo(props.lastChannelsRefresh) + : "n/a"}
${props.lastError - ? html`
+ ? html`
${props.lastError}
${authHint ?? ""} ${insecureContextHint ?? ""}
` - : html`
- Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage. + : html`
+ ${t("overview.channelsHint")}
`}
-
Instances
+
${t("overview.instances")}
${props.presenceCount}
-
Presence beacons in the last 5 minutes.
+
${t("overview.instancesHint")}
-
Sessions
+
${t("overview.sessions")}
${props.sessionsCount ?? "n/a"}
-
Recent session keys tracked by the gateway.
+
${t("overview.sessionsHint")}
-
Cron
+
${t("overview.cron")}
${props.cronEnabled == null - ? "n/a" - : props.cronEnabled - ? "Enabled" - : "Disabled"} + ? "n/a" + : props.cronEnabled + ? "已启用" + : "已禁用"}
-
Next wake ${formatNextRun(props.cronNext)}
+
${t("overview.nextWake", { run: formatNextRun(props.cronNext) })}
-
Notes
-
Quick reminders for remote control setups.
+
${t("overview.notesTitle")}
+
${t("overview.notesSubtitle")}
-
Tailscale serve
+
${t("overview.tailscaleTitle")}
- Prefer serve mode to keep the gateway on loopback with tailnet auth. + ${t("overview.tailscaleHint")}
-
Session hygiene
-
Use /new or sessions.patch to reset context.
+
${t("overview.hygieneTitle")}
+
${t("overview.hygieneHint")}
-
Cron reminders
-
Use isolated sessions for recurring runs.
+
${t("overview.cronRemindersTitle")}
+
${t("overview.cronRemindersHint")}
diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 7b5e97eb7..526a40538 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { t } from "../i18n"; import { formatAgo } from "../format"; import { formatSessionTokens } from "../presenter"; @@ -36,11 +37,16 @@ export type SessionsProps = { const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const; const BINARY_THINK_LEVELS = ["", "off", "on"] as const; const VERBOSE_LEVELS = [ - { value: "", label: "inherit" }, - { value: "off", label: "off (explicit)" }, - { value: "on", label: "on" }, + { value: "", label: "common.inherit" }, + { value: "off", label: "sessions.offExplicit" }, + { value: "on", label: "sessions.on" }, +] as const; +const REASONING_LEVELS = [ + { value: "", label: "common.inherit" }, + { value: "off", label: "sessions.offExplicit" }, + { value: "on", label: "sessions.on" }, + { value: "stream", label: "sessions.stream" }, ] as const; -const REASONING_LEVELS = ["", "off", "on", "stream"] as const; function normalizeProviderId(provider?: string | null): string { if (!provider) return ""; @@ -76,96 +82,96 @@ export function renderSessions(props: SessionsProps) {
-
Sessions
-
Active session keys and per-session overrides.
+
${t("sessions.title")}
+
${t("sessions.subtitle")}
${props.error - ? html`
${props.error}
` - : nothing} + ? html`
${props.error}
` + : nothing}
- ${props.result ? `Store: ${props.result.path}` : ""} + ${props.result ? t("sessions.storePath", { path: props.result.path }) : ""}
-
Key
-
Label
-
Kind
-
Updated
-
Tokens
-
Thinking
-
Verbose
-
Reasoning
-
Actions
+
${t("sessions.table.key")}
+
${t("sessions.table.label")}
+
${t("sessions.table.kind")}
+
${t("sessions.table.updated")}
+
${t("sessions.table.tokens")}
+
${t("sessions.table.thinking")}
+
${t("sessions.table.verbose")}
+
${t("sessions.table.reasoning")}
+
${t("sessions.table.actions")}
${rows.length === 0 - ? html`
No sessions found.
` - : rows.map((row) => - renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading), - )} + ? html`
${t("sessions.noSessions")}
` + : rows.map((row) => + renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading), + )}
`; @@ -194,17 +200,17 @@ function renderRow( return html`
+ ? html`${displayName}` + : displayName}
{ - const value = (e.target as HTMLInputElement).value.trim(); - onPatch(row.key, { label: value || null }); - }} + const value = (e.target as HTMLInputElement).value.trim(); + onPatch(row.key, { label: value || null }); + }} />
${row.kind}
@@ -215,15 +221,15 @@ function renderRow( .value=${thinking} ?disabled=${disabled} @change=${(e: Event) => { - const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { - thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking), - }); - }} + const value = (e.target as HTMLSelectElement).value; + onPatch(row.key, { + thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking), + }); + }} > ${thinkLevels.map((level) => - html``, - )} + html``, + )}
@@ -231,13 +237,13 @@ function renderRow( .value=${verbose} ?disabled=${disabled} @change=${(e: Event) => { - const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { verboseLevel: value || null }); - }} + const value = (e.target as HTMLSelectElement).value; + onPatch(row.key, { verboseLevel: value || null }); + }} > ${VERBOSE_LEVELS.map( - (level) => html``, - )} + (level) => html``, + )}
@@ -245,18 +251,18 @@ function renderRow( .value=${reasoning} ?disabled=${disabled} @change=${(e: Event) => { - const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { reasoningLevel: value || null }); - }} + const value = (e.target as HTMLSelectElement).value; + onPatch(row.key, { reasoningLevel: value || null }); + }} > ${REASONING_LEVELS.map((level) => - html``, - )} + html``, + )}
diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index cfc024cb1..513dcc8d2 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { t } from "../i18n"; import { clampText } from "../format"; import type { SkillStatusEntry, SkillStatusReport } from "../types"; @@ -25,45 +26,45 @@ export function renderSkills(props: SkillsProps) { const filter = props.filter.trim().toLowerCase(); const filtered = filter ? skills.filter((skill) => - [skill.name, skill.description, skill.source] - .join(" ") - .toLowerCase() - .includes(filter), - ) + [skill.name, skill.description, skill.source] + .join(" ") + .toLowerCase() + .includes(filter), + ) : skills; return html`
-
Skills
-
Bundled, managed, and workspace skills.
+
${t("skills.title")}
+
${t("skills.subtitle")}
-
${filtered.length} shown
+
${t("skills.shown", { count: filtered.length })}
${props.error - ? html`
${props.error}
` - : nothing} + ? html`
${props.error}
` + : nothing} ${filtered.length === 0 - ? html`
No skills found.
` - : html` + ? html`
${t("skills.noSkills")}
` + : html`
${filtered.map((skill) => renderSkill(skill, props))}
@@ -85,8 +86,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { ...skill.missing.os.map((o) => `os:${o}`), ]; const reasons: string[] = []; - if (skill.disabled) reasons.push("disabled"); - if (skill.blockedByAllowlist) reasons.push("blocked by allowlist"); + if (skill.disabled) reasons.push(t("skills.reasonDisabled")); + if (skill.blockedByAllowlist) reasons.push(t("skills.reasonAllowlist")); return html`
@@ -97,24 +98,24 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
${skill.source} - ${skill.eligible ? "eligible" : "blocked"} + ${skill.eligible ? t("skills.eligible") : t("skills.blocked")} - ${skill.disabled ? html`disabled` : nothing} + ${skill.disabled ? html`${t("skills.disabled")}` : nothing}
${missing.length > 0 - ? html` + ? html`
- Missing: ${missing.join(", ")} + ${t("skills.missing")} ${missing.join(", ")}
` - : nothing} + : nothing} ${reasons.length > 0 - ? html` + ? html`
- Reason: ${reasons.join(", ")} + ${t("skills.reason")} ${reasons.join(", ")}
` - : nothing} + : nothing}
@@ -123,40 +124,39 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { ?disabled=${busy} @click=${() => props.onToggle(skill.skillKey, skill.disabled)} > - ${skill.disabled ? "Enable" : "Disable"} + ${skill.disabled ? t("skills.enable") : t("skills.disable")} ${canInstall - ? html`` - : nothing} + : nothing}
${message - ? html`
${message.message}
` - : nothing} + : nothing} ${skill.primaryEnv - ? html` + ? html`
- API key + ${t("skills.apiKey")} - props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)} + props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)} />
` - : nothing} + : nothing}
`;