feat: 完成 Web UI 和 CLI 的全面中文化汉化,并添加 DeepSeek 支持

This commit is contained in:
Xu, Jingrong 2026-01-30 15:46:47 +08:00
parent fc955e85ad
commit 5ee5e7f643
41 changed files with 2186 additions and 1644 deletions

View File

@ -1,5 +1,7 @@
# 🦞 OpenClaw — Personal AI Assistant # 🦞 OpenClaw — Personal AI Assistant
English | [简体中文](README.zh-CN.md)
<p align="center"> <p align="center">
<picture> <picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png"> <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">

View File

@ -56,7 +56,7 @@
### 环境要求 ### 环境要求
- **Node.js** ≥ 22 - **Node.js** ≥ 22
- **操作系统**: macOS, Linux, Windows (via WSL2) - **操作系统**: macOS, Linux, Windows (推荐 WSL2)
### 安装 ### 安装

View File

@ -27,10 +27,11 @@ import {
} from "../utils.js"; } from "../utils.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js"; import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
import { t } from "../wizard/i18n.js";
export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T { export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
if (isCancel(value)) { if (isCancel(value)) {
cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled."); cancel(stylePromptTitle(t("onboarding.helpers.cancelled")) ?? t("onboarding.helpers.cancelled"));
runtime.exit(0); runtime.exit(0);
} }
return value as T; return value as T;
@ -55,7 +56,7 @@ export function summarizeExistingConfig(config: OpenClawConfig): string {
if (config.skills?.install?.nodeManager) { if (config.skills?.install?.nodeManager) {
rows.push(shortenHomeInString(`skills.nodeManager: ${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 { export function randomToken(): string {
@ -172,7 +173,7 @@ export function formatControlUiSshHint(params: {
const authedUrl = params.token ? `${localUrl}${tokenParam}` : undefined; const authedUrl = params.token ? `${localUrl}${tokenParam}` : undefined;
const sshTarget = resolveSshTargetHint(); const sshTarget = resolveSshTargetHint();
return [ return [
"No GUI detected. Open from your computer:", t("onboarding.helpers.sshHint"),
`ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`, `ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`,
"Then open:", "Then open:",
localUrl, localUrl,
@ -242,10 +243,10 @@ export async function ensureWorkspaceAndSessions(
dir: workspaceDir, dir: workspaceDir,
ensureBootstrapFiles: !options?.skipBootstrap, ensureBootstrapFiles: !options?.skipBootstrap,
}); });
runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`); runtime.log(`${t("onboarding.helpers.workspaceOk")}: ${shortenHomePath(ws.dir)}`);
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId); const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
await fs.mkdir(sessionsDir, { recursive: true }); await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`); runtime.log(`${t("onboarding.helpers.sessionsOk")}: ${shortenHomePath(sessionsDir)}`);
} }
export function resolveNodeManagerOptions(): Array<{ export function resolveNodeManagerOptions(): Array<{
@ -268,9 +269,9 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
} }
try { try {
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 }); await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`); runtime.log(`${t("onboarding.helpers.trashOk")}: ${shortenHomePath(pathname)}`);
} catch { } catch {
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`); runtime.log(`${t("onboarding.helpers.trashFail")}: ${shortenHomePath(pathname)}`);
} }
} }

View File

@ -8,6 +8,7 @@ import { runInteractiveOnboarding } from "./onboard-interactive.js";
import { runNonInteractiveOnboarding } from "./onboard-non-interactive.js"; import { runNonInteractiveOnboarding } from "./onboard-non-interactive.js";
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import type { OnboardOptions } from "./onboard-types.js"; import type { OnboardOptions } from "./onboard-types.js";
import { t } from "../wizard/i18n.js";
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) { export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
assertSupportedRuntime(runtime); assertSupportedRuntime(runtime);
@ -21,18 +22,18 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
if (opts.nonInteractive && (authChoice === "claude-cli" || authChoice === "codex-cli")) { if (opts.nonInteractive && (authChoice === "claude-cli" || authChoice === "codex-cli")) {
runtime.error( runtime.error(
[ [
`Auth choice "${authChoice}" is deprecated.`, t("onboarding.cli.deprecatedAuth").replace("{authChoice}", authChoice),
'Use "--auth-choice token" (Anthropic setup-token) or "--auth-choice openai-codex".', t("onboarding.cli.useAuthToken"),
].join("\n"), ].join("\n"),
); );
runtime.exit(1); runtime.exit(1);
return; return;
} }
if (authChoice === "claude-cli") { 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") { 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 flow = opts.flow === "manual" ? ("advanced" as const) : opts.flow;
const normalizedOpts = const normalizedOpts =
@ -43,8 +44,7 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) { if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
runtime.error( runtime.error(
[ [
"Non-interactive onboarding requires explicit risk acknowledgement.", t("onboarding.cli.nonInteractiveRisk"),
"Read: https://docs.openclaw.ai/security",
`Re-run with: ${formatCliCommand("openclaw onboard --non-interactive --accept-risk ...")}`, `Re-run with: ${formatCliCommand("openclaw onboard --non-interactive --accept-risk ...")}`,
].join("\n"), ].join("\n"),
); );
@ -61,13 +61,7 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
} }
if (process.platform === "win32") { if (process.platform === "win32") {
runtime.log( runtime.log(t("onboarding.cli.winWarning"));
[
"Windows detected.",
"WSL2 is strongly recommended; native Windows is untested and more problematic.",
"Guide: https://docs.openclaw.ai/windows",
].join("\n"),
);
} }
if (normalizedOpts.nonInteractive) { if (normalizedOpts.nonInteractive) {

20
src/wizard/i18n.ts Normal file
View File

@ -0,0 +1,20 @@
import { zhCN } from "./locales/zh-CN.js";
const currentLocale = "zh-CN"; // Default to Chinese
const locales: Record<string, any> = {
"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;
}

215
src/wizard/locales/zh-CN.ts Normal file
View File

@ -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: "配置已更新。",
}
};

View File

@ -33,8 +33,9 @@ import {
} from "../commands/daemon-install-helpers.js"; } from "../commands/daemon-install-helpers.js";
import type { GatewayWizardSettings, WizardFlow } from "./onboarding.types.js"; import type { GatewayWizardSettings, WizardFlow } from "./onboarding.types.js";
import type { WizardPrompter } from "./prompts.js"; import type { WizardPrompter } from "./prompts.js";
import { t } from "./i18n.js";
type FinalizeOnboardingOptions = { export type FinalizeOnboardingOptions = {
flow: WizardFlow; flow: WizardFlow;
opts: OnboardOptions; opts: OnboardOptions;
baseConfig: OpenClawConfig; baseConfig: OpenClawConfig;
@ -65,7 +66,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
process.platform === "linux" ? await isSystemdUserServiceAvailable() : true; process.platform === "linux" ? await isSystemdUserServiceAvailable() : true;
if (process.platform === "linux" && !systemdAvailable) { if (process.platform === "linux" && !systemdAvailable) {
await prompter.note( await prompter.note(
"Systemd user services are unavailable. Skipping lingering checks and service install.", t("onboarding.finalize.systemdNote"),
"Systemd", "Systemd",
); );
} }
@ -78,8 +79,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
confirm: prompter.confirm, confirm: prompter.confirm,
note: prompter.note, note: prompter.note,
}, },
reason: reason: t("onboarding.finalize.systemdLinger"),
"Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
requireConfirm: false, requireConfirm: false,
}); });
} }
@ -95,15 +95,15 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
installDaemon = true; installDaemon = true;
} else { } else {
installDaemon = await prompter.confirm({ installDaemon = await prompter.confirm({
message: "Install Gateway service (recommended)", message: t("onboarding.finalize.installService"),
initialValue: true, initialValue: true,
}); });
} }
if (process.platform === "linux" && !systemdAvailable && installDaemon) { if (process.platform === "linux" && !systemdAvailable && installDaemon) {
await prompter.note( await prompter.note(
"Systemd user services are unavailable; skipping service install. Use your container supervisor or `docker compose up -d`.", t("onboarding.finalize.serviceNoSystemd"),
"Gateway service", t("onboarding.finalize.serviceInstalled"),
); );
installDaemon = false; installDaemon = false;
} }
@ -113,33 +113,33 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
flow === "quickstart" flow === "quickstart"
? (DEFAULT_GATEWAY_DAEMON_RUNTIME as GatewayDaemonRuntime) ? (DEFAULT_GATEWAY_DAEMON_RUNTIME as GatewayDaemonRuntime)
: ((await prompter.select({ : ((await prompter.select({
message: "Gateway service runtime", message: t("onboarding.finalize.serviceRuntime"),
options: GATEWAY_DAEMON_RUNTIME_OPTIONS, options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME, initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
})) as GatewayDaemonRuntime); })) as GatewayDaemonRuntime);
if (flow === "quickstart") { if (flow === "quickstart") {
await prompter.note( await prompter.note(
"QuickStart uses Node for the Gateway service (stable + supported).", t("onboarding.finalize.serviceRuntimeQuickstart"),
"Gateway service runtime", t("onboarding.finalize.serviceRuntime"),
); );
} }
const service = resolveGatewayService(); const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env }); const loaded = await service.isLoaded({ env: process.env });
if (loaded) { if (loaded) {
const action = (await prompter.select({ const action = (await prompter.select({
message: "Gateway service already installed", message: t("onboarding.finalize.serviceInstalled"),
options: [ options: [
{ value: "restart", label: "Restart" }, { value: "restart", label: "重启 (Restart)" },
{ value: "reinstall", label: "Reinstall" }, { value: "reinstall", label: "重新安装 (Reinstall)" },
{ value: "skip", label: "Skip" }, { value: "skip", label: "跳过 (Skip)" },
], ],
})) as "restart" | "reinstall" | "skip"; })) as "restart" | "reinstall" | "skip";
if (action === "restart") { if (action === "restart") {
await withWizardProgress( await withWizardProgress(
"Gateway service", t("onboarding.finalize.serviceInstalled"),
{ doneMessage: "Gateway service restarted." }, { doneMessage: t("onboarding.finalize.restarted") },
async (progress) => { async (progress) => {
progress.update("Restarting Gateway service…"); progress.update(t("onboarding.finalize.restarting"));
await service.restart({ await service.restart({
env: process.env, env: process.env,
stdout: process.stdout, stdout: process.stdout,
@ -148,10 +148,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
); );
} else if (action === "reinstall") { } else if (action === "reinstall") {
await withWizardProgress( await withWizardProgress(
"Gateway service", t("onboarding.finalize.serviceInstalled"),
{ doneMessage: "Gateway service uninstalled." }, { doneMessage: t("onboarding.finalize.uninstalled") },
async (progress) => { async (progress) => {
progress.update("Uninstalling Gateway service…"); progress.update(t("onboarding.finalize.uninstalling"));
await service.uninstall({ env: process.env, stdout: process.stdout }); 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)) { 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; let installError: string | null = null;
try { try {
progress.update("Preparing Gateway service…"); progress.update(t("onboarding.finalize.preparing"));
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env, env: process.env,
port: settings.port, port: settings.port,
@ -172,7 +172,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
config: nextConfig, config: nextConfig,
}); });
progress.update("Installing Gateway service…"); progress.update(t("onboarding.finalize.installing"));
await service.install({ await service.install({
env: process.env, env: process.env,
stdout: process.stdout, stdout: process.stdout,
@ -184,11 +184,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
installError = err instanceof Error ? err.message : String(err); installError = err instanceof Error ? err.message : String(err);
} finally { } finally {
progress.stop( progress.stop(
installError ? "Gateway service install failed." : "Gateway service installed.", installError ? t("onboarding.finalize.installFail") : t("onboarding.finalize.installSuccess"),
); );
} }
if (installError) { 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"); await prompter.note(gatewayInstallErrorHint(), "Gateway");
} }
} }
@ -213,11 +213,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
runtime.error(formatHealthCheckFailure(err)); runtime.error(formatHealthCheckFailure(err));
await prompter.note( await prompter.note(
[ [
"Docs:", t("onboarding.finalize.healthDocsPrefix"),
"https://docs.openclaw.ai/gateway/health", "https://docs.openclaw.ai/gateway/health",
"https://docs.openclaw.ai/gateway/troubleshooting", "https://docs.openclaw.ai/gateway/troubleshooting",
].join("\n"), ].join("\n"),
"Health check help", t("onboarding.finalize.healthHelp"),
); );
} }
} }
@ -232,13 +232,8 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
} }
await prompter.note( await prompter.note(
[ t("onboarding.finalize.optionalAppsList"),
"Add nodes for extra features:", t("onboarding.finalize.optionalApps"),
"- macOS app (system + notifications)",
"- iOS app (camera/canvas)",
"- Android app (camera/canvas)",
].join("\n"),
"Optional apps",
); );
const controlUiBasePath = const controlUiBasePath =
@ -260,8 +255,8 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
password: settings.authMode === "password" ? nextConfig.gateway?.auth?.password : "", password: settings.authMode === "password" ? nextConfig.gateway?.auth?.password : "",
}); });
const gatewayStatusLine = gatewayProbe.ok const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable" ? t("onboarding.setup.localOk")
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; : `${t("onboarding.setup.localFail")}${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
const bootstrapPath = path.join( const bootstrapPath = path.join(
resolveUserPath(options.workspaceDir), resolveUserPath(options.workspaceDir),
DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_BOOTSTRAP_FILENAME,
@ -274,14 +269,14 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
await prompter.note( await prompter.note(
[ [
`Web UI: ${links.httpUrl}`, `Web UI: ${links.httpUrl}`,
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined, tokenParam ? `Web UI (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}` : undefined,
`Gateway WS: ${links.wsUrl}`, `Gateway WS: ${links.wsUrl}`,
gatewayStatusLine, gatewayStatusLine,
"Docs: https://docs.openclaw.ai/web/control-ui", "Docs: https://docs.openclaw.ai/web/control-ui",
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"), .join("\n"),
"Control UI", t("onboarding.finalize.controlUi"),
); );
let controlUiOpened = false; let controlUiOpened = false;
@ -292,32 +287,22 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
if (!opts.skipUi && gatewayProbe.ok) { if (!opts.skipUi && gatewayProbe.ok) {
if (hasBootstrap) { if (hasBootstrap) {
await prompter.note( await prompter.note(
[ t("onboarding.finalize.hatchTuiNote"),
"This is the defining action that makes your agent you.", t("onboarding.finalize.hatchTui"),
"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!)",
); );
} }
await prompter.note( await prompter.note(
[ t("onboarding.finalize.tokenNote"),
"Gateway token: shared auth for the Gateway + Control UI.", t("onboarding.gatewayConfig.authToken"),
"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",
); );
hatchChoice = (await prompter.select({ hatchChoice = (await prompter.select({
message: "How do you want to hatch your bot?", message: t("onboarding.finalize.hatchQuestion"),
options: [ options: [
{ value: "tui", label: "Hatch in TUI (recommended)" }, { value: "tui", label: t("onboarding.finalize.hatchTui") },
{ value: "web", label: "Open the Web UI" }, { value: "web", label: t("onboarding.finalize.hatchWeb") },
{ value: "later", label: "Do this later" }, { value: "later", label: t("onboarding.finalize.hatchLater") },
], ],
initialValue: "tui", initialValue: "tui",
})) as "tui" | "web" | "later"; })) as "tui" | "web" | "later";
@ -336,7 +321,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
} }
if (seededInBackground) { if (seededInBackground) {
await prompter.note( await prompter.note(
`Web UI seeded in the background. Open later with: ${formatCliCommand( `${t("onboarding.finalize.webUiSeeded")} ${formatCliCommand(
"openclaw dashboard --no-open", "openclaw dashboard --no-open",
)}`, )}`,
"Web UI", "Web UI",
@ -362,37 +347,37 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
} }
await prompter.note( await prompter.note(
[ [
`Dashboard link (with token): ${authedUrl}`, `${t("onboarding.finalize.dashboardReady")} (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}`,
controlUiOpened controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw." ? t("onboarding.finalize.dashboardOpened")
: "Copy/paste this URL in a browser on this machine to control OpenClaw.", : t("onboarding.finalize.dashboardCopy"),
controlUiOpenHint, controlUiOpenHint,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"), .join("\n"),
"Dashboard ready", t("onboarding.finalize.dashboardReady"),
); );
} else { } else {
await prompter.note( await prompter.note(
`When you're ready: ${formatCliCommand("openclaw dashboard --no-open")}`, `${t("onboarding.finalize.hatchLater")}: ${formatCliCommand("openclaw dashboard --no-open")}`,
"Later", t("onboarding.finalize.hatchLater"),
); );
} }
} else if (opts.skipUi) { } 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( await prompter.note(
[ [
"Back up your agent workspace.", t("onboarding.finalize.backupNote"),
"Docs: https://docs.openclaw.ai/concepts/agent-workspace", "Docs: https://docs.openclaw.ai/concepts/agent-workspace",
].join("\n"), ].join("\n"),
"Workspace backup", t("onboarding.finalize.backupNote"),
); );
await prompter.note( await prompter.note(
"Running agents on your computer is risky — harden your setup: https://docs.openclaw.ai/security", "Running agents on your computer is risky — harden your setup: https://docs.openclaw.ai/security",
"Security", t("onboarding.security.title"),
); );
const shouldOpenControlUi = const shouldOpenControlUi =
@ -421,15 +406,15 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
await prompter.note( await prompter.note(
[ [
`Dashboard link (with token): ${authedUrl}`, `${t("onboarding.finalize.dashboardReady")} (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}`,
controlUiOpened controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw." ? t("onboarding.finalize.dashboardOpened")
: "Copy/paste this URL in a browser on this machine to control OpenClaw.", : t("onboarding.finalize.dashboardCopy"),
controlUiOpenHint, controlUiOpenHint,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"), .join("\n"),
"Dashboard ready", t("onboarding.finalize.dashboardReady"),
); );
} }
@ -439,38 +424,32 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
await prompter.note( await prompter.note(
hasWebSearchKey hasWebSearchKey
? [ ? [
"Web search is enabled, so your agent can look things up online when needed.", t("onboarding.finalize.webSearchEnabled"),
"", "",
webSearchKey webSearchKey
? "API key: stored in config (tools.web.search.apiKey)." ? t("onboarding.finalize.webSearchKeyConfig")
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).", : t("onboarding.finalize.webSearchKeyEnv"),
"Docs: https://docs.openclaw.ai/tools/web", "Docs: https://docs.openclaw.ai/tools/web",
].join("\n") ].join("\n")
: [ : [
"If you want your agent to be able to search the web, youll need an API key.", t("onboarding.finalize.webSearchDisabled"),
"", "",
"OpenClaw uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search wont work.", `设置命令: ${formatCliCommand("openclaw configure --section web")}`,
"", "Docs: https://docs.openclaw.ai/tools/web",
"Set it up interactively:", ].join("\n"),
`- Run: ${formatCliCommand("openclaw configure --section web")}`, t("onboarding.finalize.webSearchOptional"),
"- 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)",
); );
await prompter.note( await prompter.note(
'What now: https://openclaw.ai/showcase ("What People Are Building").', 'Showcase: https://openclaw.ai/showcase',
"What now", t("onboarding.finalize.whatNow"),
); );
await prompter.outro( await prompter.outro(
controlUiOpened controlUiOpened
? "Onboarding complete. Dashboard opened with your token; keep that tab to control OpenClaw." ? t("onboarding.finalize.onboardingCompleteOpened")
: seededInBackground : seededInBackground
? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above." ? t("onboarding.finalize.onboardingCompleteSeeded")
: "Onboarding complete. Use the tokenized dashboard link above to control OpenClaw.", : t("onboarding.finalize.onboardingComplete"),
); );
} }

View File

@ -9,6 +9,7 @@ import type {
WizardFlow, WizardFlow,
} from "./onboarding.types.js"; } from "./onboarding.types.js";
import type { WizardPrompter } from "./prompts.js"; import type { WizardPrompter } from "./prompts.js";
import { t } from "./i18n.js";
type ConfigureGatewayOptions = { type ConfigureGatewayOptions = {
flow: WizardFlow; flow: WizardFlow;
@ -35,29 +36,29 @@ export async function configureGatewayForOnboarding(
flow === "quickstart" flow === "quickstart"
? quickstartGateway.port ? quickstartGateway.port
: Number.parseInt( : Number.parseInt(
String( String(
await prompter.text({ await prompter.text({
message: "Gateway port", message: t("onboarding.gatewayConfig.port"),
initialValue: String(localPort), initialValue: String(localPort),
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"), validate: (value) => (Number.isFinite(Number(value)) ? undefined : t("onboarding.gatewayConfig.invalidPort")),
}), }),
), ),
10, 10,
); );
let bind = ( let bind = (
flow === "quickstart" flow === "quickstart"
? quickstartGateway.bind ? quickstartGateway.bind
: ((await prompter.select({ : ((await prompter.select({
message: "Gateway bind", message: t("onboarding.gatewayConfig.bind"),
options: [ options: [
{ value: "loopback", label: "Loopback (127.0.0.1)" }, { value: "loopback", label: t("onboarding.gateway.bindLoopback") },
{ value: "lan", label: "LAN (0.0.0.0)" }, { value: "lan", label: t("onboarding.gateway.bindLan") },
{ value: "tailnet", label: "Tailnet (Tailscale IP)" }, { value: "tailnet", label: t("onboarding.gateway.bindTailnet") },
{ value: "auto", label: "Auto (Loopback → LAN)" }, { value: "auto", label: t("onboarding.gateway.bindAuto") },
{ value: "custom", label: "Custom IP" }, { value: "custom", label: t("onboarding.gateway.bindCustom") },
], ],
})) as "loopback" | "lan" | "auto" | "custom" | "tailnet") })) as "loopback" | "lan" | "auto" | "custom" | "tailnet")
) as "loopback" | "lan" | "auto" | "custom" | "tailnet"; ) as "loopback" | "lan" | "auto" | "custom" | "tailnet";
let customBindHost = quickstartGateway.customBindHost; let customBindHost = quickstartGateway.customBindHost;
@ -65,14 +66,14 @@ export async function configureGatewayForOnboarding(
const needsPrompt = flow !== "quickstart" || !customBindHost; const needsPrompt = flow !== "quickstart" || !customBindHost;
if (needsPrompt) { if (needsPrompt) {
const input = await prompter.text({ const input = await prompter.text({
message: "Custom IP address", message: t("onboarding.gatewayConfig.customIp"),
placeholder: "192.168.1.100", placeholder: "192.168.1.100",
initialValue: customBindHost ?? "", initialValue: customBindHost ?? "",
validate: (value) => { 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 trimmed = value.trim();
const parts = trimmed.split("."); 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 ( if (
parts.every((part) => { parts.every((part) => {
const n = parseInt(part, 10); const n = parseInt(part, 10);
@ -80,7 +81,7 @@ export async function configureGatewayForOnboarding(
}) })
) )
return undefined; return undefined;
return "Invalid IPv4 address (each octet must be 0-255)"; return t("onboarding.gatewayConfig.invalidIpOctet");
}, },
}); });
customBindHost = typeof input === "string" ? input.trim() : undefined; customBindHost = typeof input === "string" ? input.trim() : undefined;
@ -91,38 +92,38 @@ export async function configureGatewayForOnboarding(
flow === "quickstart" flow === "quickstart"
? quickstartGateway.authMode ? quickstartGateway.authMode
: ((await prompter.select({ : ((await prompter.select({
message: "Gateway auth", message: t("onboarding.gatewayConfig.auth"),
options: [ options: [
{ {
value: "token", value: "token",
label: "Token", label: t("onboarding.gatewayConfig.authToken"),
hint: "Recommended default (local + remote)", hint: t("onboarding.gatewayConfig.authTokenHint"),
}, },
{ value: "password", label: "Password" }, { value: "password", label: t("onboarding.gatewayConfig.authPassword") },
], ],
initialValue: "token", initialValue: "token",
})) as GatewayAuthChoice) })) as GatewayAuthChoice)
) as GatewayAuthChoice; ) as GatewayAuthChoice;
const tailscaleMode = ( const tailscaleMode = (
flow === "quickstart" flow === "quickstart"
? quickstartGateway.tailscaleMode ? quickstartGateway.tailscaleMode
: ((await prompter.select({ : ((await prompter.select({
message: "Tailscale exposure", message: t("onboarding.gatewayConfig.tsExposure"),
options: [ options: [
{ value: "off", label: "Off", hint: "No Tailscale exposure" }, { value: "off", label: t("onboarding.gatewayConfig.tsOff"), hint: t("onboarding.gatewayConfig.tsOffHint") },
{ {
value: "serve", value: "serve",
label: "Serve", label: t("onboarding.gatewayConfig.tsServe"),
hint: "Private HTTPS for your tailnet (devices on Tailscale)", hint: t("onboarding.gatewayConfig.tsServeHint"),
}, },
{ {
value: "funnel", value: "funnel",
label: "Funnel", label: t("onboarding.gatewayConfig.tsFunnel"),
hint: "Public HTTPS via Tailscale Funnel (internet)", hint: t("onboarding.gatewayConfig.tsFunnelHint"),
}, },
], ],
})) as "off" | "serve" | "funnel") })) as "off" | "serve" | "funnel")
) as "off" | "serve" | "funnel"; ) as "off" | "serve" | "funnel";
// Detect Tailscale binary before proceeding with serve/funnel setup. // Detect Tailscale binary before proceeding with serve/funnel setup.
@ -130,14 +131,8 @@ export async function configureGatewayForOnboarding(
const tailscaleBin = await findTailscaleBinary(); const tailscaleBin = await findTailscaleBinary();
if (!tailscaleBin) { if (!tailscaleBin) {
await prompter.note( await prompter.note(
[ t("onboarding.gatewayConfig.tsNotFound"),
"Tailscale binary not found in PATH or /Applications.", t("onboarding.gatewayConfig.tsWarningTitle"),
"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",
); );
} }
} }
@ -145,14 +140,14 @@ export async function configureGatewayForOnboarding(
let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false; let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false;
if (tailscaleMode !== "off" && flow !== "quickstart") { if (tailscaleMode !== "off" && flow !== "quickstart") {
await prompter.note( 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", "\n",
), ),
"Tailscale", t("onboarding.gateway.tailscale"),
); );
tailscaleResetOnExit = Boolean( tailscaleResetOnExit = Boolean(
await prompter.confirm({ await prompter.confirm({
message: "Reset Tailscale serve/funnel on exit?", message: t("onboarding.gatewayConfig.tsResetConfirm"),
initialValue: false, 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. // - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
// - Funnel requires password auth. // - Funnel requires password auth.
if (tailscaleMode !== "off" && bind !== "loopback") { 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"; bind = "loopback";
customBindHost = undefined; customBindHost = undefined;
} }
if (tailscaleMode === "funnel" && authMode !== "password") { 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"; authMode = "password";
} }
@ -178,8 +173,8 @@ export async function configureGatewayForOnboarding(
gatewayToken = quickstartGateway.token ?? randomToken(); gatewayToken = quickstartGateway.token ?? randomToken();
} else { } else {
const tokenInput = await prompter.text({ const tokenInput = await prompter.text({
message: "Gateway token (blank to generate)", message: t("onboarding.gatewayConfig.tokenPlaceholder"),
placeholder: "Needed for multi-machine or non-loopback access", placeholder: t("onboarding.gatewayConfig.tokenHint"),
initialValue: quickstartGateway.token ?? "", initialValue: quickstartGateway.token ?? "",
}); });
gatewayToken = String(tokenInput).trim() || randomToken(); gatewayToken = String(tokenInput).trim() || randomToken();
@ -191,9 +186,9 @@ export async function configureGatewayForOnboarding(
flow === "quickstart" && quickstartGateway.password flow === "quickstart" && quickstartGateway.password
? quickstartGateway.password ? quickstartGateway.password
: await prompter.text({ : await prompter.text({
message: "Gateway password", message: t("onboarding.gatewayConfig.passwordLabel"),
validate: (value) => (value?.trim() ? undefined : "Required"), validate: (value) => (value?.trim() ? undefined : t("onboarding.gatewayConfig.passwordRequired")),
}); });
nextConfig = { nextConfig = {
...nextConfig, ...nextConfig,
gateway: { gateway: {

View File

@ -38,6 +38,7 @@ import { logConfigUpdated } from "../config/logging.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { t } from "./i18n.js";
import { finalizeOnboardingWizard } from "./onboarding.finalize.js"; import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js"; import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js";
@ -50,33 +51,12 @@ async function requireRiskAcknowledgement(params: {
if (params.opts.acceptRisk === true) return; if (params.opts.acceptRisk === true) return;
await params.prompter.note( await params.prompter.note(
[ t("onboarding.security.note"),
"Security warning — please read.", t("onboarding.security.title"),
"",
"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 youre not comfortable with basic security and access control, dont 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 agents 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",
); );
const ok = await params.prompter.confirm({ const ok = await params.prompter.confirm({
message: "I understand this is powerful and inherently risky. Continue?", message: t("onboarding.security.confirm"),
initialValue: false, initialValue: false,
}); });
if (!ok) { if (!ok) {
@ -90,14 +70,14 @@ export async function runOnboardingWizard(
prompter: WizardPrompter, prompter: WizardPrompter,
) { ) {
printWizardHeader(runtime); printWizardHeader(runtime);
await prompter.intro("OpenClaw onboarding"); await prompter.intro(t("onboarding.intro"));
await requireRiskAcknowledgement({ opts, prompter }); await requireRiskAcknowledgement({ opts, prompter });
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
let baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {}; let baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {};
if (snapshot.exists && !snapshot.valid) { 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) { if (snapshot.issues.length > 0) {
await prompter.note( await prompter.note(
[ [
@ -105,18 +85,18 @@ export async function runOnboardingWizard(
"", "",
"Docs: https://docs.openclaw.ai/gateway/configuration", "Docs: https://docs.openclaw.ai/gateway/configuration",
].join("\n"), ].join("\n"),
"Config issues", t("onboarding.config.issues"),
); );
} }
await prompter.outro( await prompter.outro(
`Config invalid. Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run onboarding.`, t("onboarding.config.repair"),
); );
runtime.exit(1); runtime.exit(1);
return; return;
} }
const quickstartHint = `Configure details later via ${formatCliCommand("openclaw configure")}.`; const quickstartHint = t("onboarding.flow.quickstartHint");
const manualHint = "Configure port, network, Tailscale, and auth options."; const manualHint = t("onboarding.flow.manualHint");
const explicitFlowRaw = opts.flow?.trim(); const explicitFlowRaw = opts.flow?.trim();
const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw; const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw;
if ( if (
@ -124,7 +104,7 @@ export async function runOnboardingWizard(
normalizedExplicitFlow !== "quickstart" && normalizedExplicitFlow !== "quickstart" &&
normalizedExplicitFlow !== "advanced" normalizedExplicitFlow !== "advanced"
) { ) {
runtime.error("Invalid --flow (use quickstart, manual, or advanced)."); runtime.error(t("onboarding.flow.invalidFlow"));
runtime.exit(1); runtime.exit(1);
return; return;
} }
@ -135,47 +115,47 @@ export async function runOnboardingWizard(
let flow: WizardFlow = let flow: WizardFlow =
explicitFlow ?? explicitFlow ??
((await prompter.select({ ((await prompter.select({
message: "Onboarding mode", message: t("onboarding.flow.modeSelect"),
options: [ options: [
{ value: "quickstart", label: "QuickStart", hint: quickstartHint }, { value: "quickstart", label: t("onboarding.flow.quickstart"), hint: quickstartHint },
{ value: "advanced", label: "Manual", hint: manualHint }, { value: "advanced", label: t("onboarding.flow.manual"), hint: manualHint },
], ],
initialValue: "quickstart", initialValue: "quickstart",
})) as "quickstart" | "advanced"); })) as "quickstart" | "advanced");
if (opts.mode === "remote" && flow === "quickstart") { if (opts.mode === "remote" && flow === "quickstart") {
await prompter.note( await prompter.note(
"QuickStart only supports local gateways. Switching to Manual mode.", t("onboarding.flow.remoteSwitch"),
"QuickStart", t("onboarding.flow.quickstart"),
); );
flow = "advanced"; flow = "advanced";
} }
if (snapshot.exists) { 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({ const action = (await prompter.select({
message: "Config handling", message: t("onboarding.existingConfig.action"),
options: [ options: [
{ value: "keep", label: "Use existing values" }, { value: "keep", label: t("onboarding.existingConfig.keep") },
{ value: "modify", label: "Update values" }, { value: "modify", label: t("onboarding.existingConfig.modify") },
{ value: "reset", label: "Reset" }, { value: "reset", label: t("onboarding.existingConfig.reset") },
], ],
})) as "keep" | "modify" | "reset"; })) as "keep" | "modify" | "reset";
if (action === "reset") { if (action === "reset") {
const workspaceDefault = baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; const workspaceDefault = baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE;
const resetScope = (await prompter.select({ const resetScope = (await prompter.select({
message: "Reset scope", message: t("onboarding.existingConfig.resetScope"),
options: [ options: [
{ value: "config", label: "Config only" }, { value: "config", label: t("onboarding.existingConfig.scopeConfig") },
{ {
value: "config+creds+sessions", value: "config+creds+sessions",
label: "Config + creds + sessions", label: t("onboarding.existingConfig.scopeConfigCreds"),
}, },
{ {
value: "full", value: "full",
label: "Full reset (config + creds + sessions + workspace)", label: t("onboarding.existingConfig.scopeFull"),
}, },
], ],
})) as ResetScope; })) as ResetScope;
@ -197,10 +177,10 @@ export async function runOnboardingWizard(
const bindRaw = baseConfig.gateway?.bind; const bindRaw = baseConfig.gateway?.bind;
const bind = const bind =
bindRaw === "loopback" || bindRaw === "loopback" ||
bindRaw === "lan" || bindRaw === "lan" ||
bindRaw === "auto" || bindRaw === "auto" ||
bindRaw === "custom" || bindRaw === "custom" ||
bindRaw === "tailnet" bindRaw === "tailnet"
? bindRaw ? bindRaw
: "loopback"; : "loopback";
@ -237,41 +217,41 @@ export async function runOnboardingWizard(
if (flow === "quickstart") { if (flow === "quickstart") {
const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => { const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => {
if (value === "loopback") return "Loopback (127.0.0.1)"; if (value === "loopback") return t("onboarding.gateway.bindLoopback");
if (value === "lan") return "LAN"; if (value === "lan") return t("onboarding.gateway.bindLan");
if (value === "custom") return "Custom IP"; if (value === "custom") return t("onboarding.gateway.bindCustom");
if (value === "tailnet") return "Tailnet (Tailscale IP)"; if (value === "tailnet") return t("onboarding.gateway.bindTailnet");
return "Auto"; return t("onboarding.gateway.bindAuto");
}; };
const formatAuth = (value: GatewayAuthChoice) => { const formatAuth = (value: GatewayAuthChoice) => {
if (value === "token") return "Token (default)"; if (value === "token") return t("onboarding.gateway.authToken");
return "Password"; return t("onboarding.gateway.authPassword");
}; };
const formatTailscale = (value: "off" | "serve" | "funnel") => { const formatTailscale = (value: "off" | "serve" | "funnel") => {
if (value === "off") return "Off"; if (value === "off") return t("onboarding.gateway.tsOff");
if (value === "serve") return "Serve"; if (value === "serve") return t("onboarding.gateway.tsServe");
return "Funnel"; return t("onboarding.gateway.tsFunnel");
}; };
const quickstartLines = quickstartGateway.hasExisting const quickstartLines = quickstartGateway.hasExisting
? [ ? [
"Keeping your current gateway settings:", t("onboarding.gateway.keepSettings"),
`Gateway port: ${quickstartGateway.port}`, `${t("onboarding.gateway.port")}: ${quickstartGateway.port}`,
`Gateway bind: ${formatBind(quickstartGateway.bind)}`, `${t("onboarding.gateway.bind")}: ${formatBind(quickstartGateway.bind)}`,
...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost ...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
? [`Gateway custom IP: ${quickstartGateway.customBindHost}`] ? [`${t("onboarding.gateway.bindCustom")}: ${quickstartGateway.customBindHost}`]
: []), : []),
`Gateway auth: ${formatAuth(quickstartGateway.authMode)}`, `${t("onboarding.gateway.auth")}: ${formatAuth(quickstartGateway.authMode)}`,
`Tailscale exposure: ${formatTailscale(quickstartGateway.tailscaleMode)}`, `${t("onboarding.gateway.tailscale")}: ${formatTailscale(quickstartGateway.tailscaleMode)}`,
"Direct to chat channels.", t("onboarding.gateway.chatChannels"),
] ]
: [ : [
`Gateway port: ${DEFAULT_GATEWAY_PORT}`, `${t("onboarding.gateway.port")}: ${DEFAULT_GATEWAY_PORT}`,
"Gateway bind: Loopback (127.0.0.1)", `${t("onboarding.gateway.bind")}: ${t("onboarding.gateway.bindLoopback")}`,
"Gateway auth: Token (default)", `${t("onboarding.gateway.auth")}: ${t("onboarding.gateway.authToken")}`,
"Tailscale exposure: Off", `${t("onboarding.gateway.tailscale")}: ${t("onboarding.gateway.tsOff")}`,
"Direct to chat channels.", t("onboarding.gateway.chatChannels"),
]; ];
await prompter.note(quickstartLines.join("\n"), "QuickStart"); await prompter.note(quickstartLines.join("\n"), t("onboarding.flow.quickstart"));
} }
const localPort = resolveGatewayPort(baseConfig); const localPort = resolveGatewayPort(baseConfig);
@ -284,9 +264,9 @@ export async function runOnboardingWizard(
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? ""; const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
const remoteProbe = remoteUrl const remoteProbe = remoteUrl
? await probeGatewayReachable({ ? await probeGatewayReachable({
url: remoteUrl, url: remoteUrl,
token: baseConfig.gateway?.remote?.token, token: baseConfig.gateway?.remote?.token,
}) })
: null; : null;
const mode = const mode =
@ -294,33 +274,33 @@ export async function runOnboardingWizard(
(flow === "quickstart" (flow === "quickstart"
? "local" ? "local"
: ((await prompter.select({ : ((await prompter.select({
message: "What do you want to set up?", message: t("onboarding.setup.question"),
options: [ options: [
{ {
value: "local", value: "local",
label: "Local gateway (this machine)", label: t("onboarding.setup.local"),
hint: localProbe.ok hint: localProbe.ok
? `Gateway reachable (${localUrl})` ? `${t("onboarding.setup.localOk")} (${localUrl})`
: `No gateway detected (${localUrl})`, : `${t("onboarding.setup.localFail")} (${localUrl})`,
}, },
{ {
value: "remote", value: "remote",
label: "Remote gateway (info-only)", label: t("onboarding.setup.remote"),
hint: !remoteUrl hint: !remoteUrl
? "No remote URL configured yet" ? t("onboarding.setup.remoteNoUrl")
: remoteProbe?.ok : remoteProbe?.ok
? `Gateway reachable (${remoteUrl})` ? `${t("onboarding.setup.remoteOk")} (${remoteUrl})`
: `Configured but unreachable (${remoteUrl})`, : `${t("onboarding.setup.remoteFail")} (${remoteUrl})`,
}, },
], ],
})) as OnboardMode)); })) as OnboardMode));
if (mode === "remote") { if (mode === "remote") {
let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter); let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
logConfigUpdated(runtime); logConfigUpdated(runtime);
await prompter.outro("Remote gateway configured."); await prompter.outro(t("onboarding.setup.remoteDone"));
return; return;
} }
@ -329,9 +309,9 @@ export async function runOnboardingWizard(
(flow === "quickstart" (flow === "quickstart"
? (baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE) ? (baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE)
: await prompter.text({ : await prompter.text({
message: "Workspace directory", message: t("onboarding.setup.workspaceDir"),
initialValue: baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE, initialValue: baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE,
})); }));
const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_WORKSPACE); const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_WORKSPACE);
@ -403,13 +383,13 @@ export async function runOnboardingWizard(
const settings = gateway.settings; const settings = gateway.settings;
if (opts.skipChannels ?? opts.skipProviders) { if (opts.skipChannels ?? opts.skipProviders) {
await prompter.note("Skipping channel setup.", "Channels"); await prompter.note(t("onboarding.setup.skippingChannels"), t("onboarding.gateway.chatChannels"));
} else { } else {
const quickstartAllowFromChannels = const quickstartAllowFromChannels =
flow === "quickstart" flow === "quickstart"
? listChannelPlugins() ? listChannelPlugins()
.filter((plugin) => plugin.meta.quickstartAllowFrom) .filter((plugin) => plugin.meta.quickstartAllowFrom)
.map((plugin) => plugin.id) .map((plugin) => plugin.id)
: []; : [];
nextConfig = await setupChannels(nextConfig, runtime, prompter, { nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowSignalInstall: true, allowSignalInstall: true,
@ -427,7 +407,7 @@ export async function runOnboardingWizard(
}); });
if (opts.skipSkills) { if (opts.skipSkills) {
await prompter.note("Skipping skills setup.", "Skills"); await prompter.note(t("onboarding.setup.skippingSkills"), t("onboarding.setup.skills"));
} else { } else {
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter); nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
} }

View File

@ -1,26 +1,27 @@
import { t } from "./i18n.js";
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js"; import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
export function formatMs(ms?: number | null): string { 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(); return new Date(ms).toLocaleString();
} }
export function formatAgo(ms?: number | null): string { 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; const diff = Date.now() - ms;
if (diff < 0) return "just now"; if (diff < 0) return t("format.justNow");
const sec = Math.round(diff / 1000); 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); 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); 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); const day = Math.round(hr / 24);
return `${day}d ago`; return t("format.agoDay", { count: day });
} }
export function formatDurationMs(ms?: number | null): string { 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`; if (ms < 1000) return `${ms}ms`;
const sec = Math.round(ms / 1000); const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`; if (sec < 60) return `${sec}s`;
@ -33,7 +34,7 @@ export function formatDurationMs(ms?: number | null): string {
} }
export function formatList(values?: Array<string | null | undefined>): string { export function formatList(values?: Array<string | null | undefined>): 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(", "); return values.filter((v): v is string => Boolean(v && v.trim())).join(", ");
} }

View File

@ -6,7 +6,7 @@ const locales: Record<string, any> = {
"zh-CN": zhCN, "zh-CN": zhCN,
}; };
export function t(key: string): string { export function t(key: string, params?: Record<string, any>): string {
const keys = key.split("."); const keys = key.split(".");
let value: any = locales[currentLocale]; 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;
} }

View File

@ -32,58 +32,397 @@ export const zhCN = {
debug: "调试", debug: "调试",
logs: "日志", logs: "日志",
}, },
sidebarGroups: {
chat: "聊天",
control: "控制台",
agent: "代理",
settings: "设置",
},
overview: { overview: {
title: "概览", 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: { channels: {
title: "渠道", title: "渠道",
subtitle: "管理消息渠道连接", 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: { chat: {
title: "聊天", title: "聊天",
subtitle: "与您的 AI 助手互动", 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: { sessions: {
title: "会话", 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: { cron: {
title: "定时任务", 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: { skills: {
title: "技能", title: "技能",
subtitle: "管理代理能力", subtitle: "内置、托管及工作区技能。",
}, filter: "过滤",
nodes: { searchPlaceholder: "搜索技能",
title: "节点", shown: "显示 {count} 个",
subtitle: "管理计算节点", noSkills: "未发现技能。",
installing: "正在安装...",
eligible: "符合条件",
blocked: "已屏蔽",
disabled: "已禁用",
missing: "缺失:",
reason: "原因:",
apiKey: "API 密钥",
saveKey: "保存密钥",
reasonDisabled: "已禁用",
reasonAllowlist: "被白名单拦截",
enable: "启用",
disable: "禁用",
}, },
instances: { instances: {
title: "实例", title: "已连接实例",
subtitle: "查看连接的客户端实例", subtitle: "来自网关和客户端的存在感应信号 (Presence)。",
noInstances: "暂无实例报告。",
unknownHost: "未知主机",
lastInput: "最近输入",
ago: "{time}前",
scopes: "{count} 个作用域",
reason: "原因",
}, },
config: { gateway: {
title: "配置", changeTitle: "更改网关地址",
subtitle: "编辑网关设置", changeSubtitle: "这将连接到一个不同的网关服务器",
trustWarning: "仅在您信任此 URL 的情况下确认。恶意 URL 可能会危及您的系统安全。",
confirm: "确认",
cancel: "取消",
}, },
debug: { execApproval: {
title: "调试", title: "需要执行审批",
subtitle: "高级工具和状态", expiresIn: "{time} 后过期",
expired: "已过期",
pending: "{count} 个待处理",
allowOnce: "允许一次",
allowAlways: "总是允许",
deny: "拒绝",
host: "主机",
agent: "代理",
session: "会话",
cwd: "工作目录",
resolved: "解析路径",
security: "安全",
ask: "请求人",
}, },
logs: { markdownSidebar: {
title: "日志", title: "工具输出",
subtitle: "查看实时系统日志", 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: { common: {
loading: "加载中...", loading: "加载中...",
error: "错误", refresh: "刷新",
delete: "删除",
save: "保存", save: "保存",
cancel: "取消", cancel: "取消",
delete: "删除", na: "无",
inherit: "继承",
error: "错误",
edit: "编辑", edit: "编辑",
refresh: "刷新",
disconnected: "与网关断开连接。", disconnected: "与网关断开连接。",
valid: "有效",
invalid: "无效",
unknown: "未知",
} }
}; };

View File

@ -2,13 +2,13 @@ import type { IconName } from "./icons.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
export const TAB_GROUPS = [ 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"], tabs: ["overview", "channels", "instances", "sessions", "cron"],
}, },
{ label: "Agent", tabs: ["skills", "nodes"] }, { label: t("sidebarGroups.agent"), tabs: ["skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] }, { label: t("sidebarGroups.settings"), tabs: ["config", "debug", "logs"] },
] as const; ] as const;
export type Tab = export type Tab =
@ -155,7 +155,7 @@ export function titleForTab(tab: Tab) {
case "logs": case "logs":
return t("tabs.logs"); return t("tabs.logs");
default: default:
return "Control"; return t("sidebarGroups.control");
} }
} }

View File

@ -313,6 +313,8 @@ export type PresenceEntry = {
lastInputSeconds?: number | null; lastInputSeconds?: number | null;
reason?: string | null; reason?: string | null;
text?: string | null; text?: string | null;
roles?: string[] | null;
scopes?: string[] | null;
ts?: number | null; ts?: number | null;
}; };
@ -398,23 +400,23 @@ export type CronWakeMode = "next-heartbeat" | "now";
export type CronPayload = export type CronPayload =
| { kind: "systemEvent"; text: string } | { kind: "systemEvent"; text: string }
| { | {
kind: "agentTurn"; kind: "agentTurn";
message: string; message: string;
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
deliver?: boolean; deliver?: boolean;
provider?: provider?:
| "last" | "last"
| "whatsapp" | "whatsapp"
| "telegram" | "telegram"
| "discord" | "discord"
| "slack" | "slack"
| "signal" | "signal"
| "imessage" | "imessage"
| "msteams"; | "msteams";
to?: string; to?: string;
bestEffortDeliver?: boolean; bestEffortDeliver?: boolean;
}; };
export type CronIsolation = { export type CronIsolation = {
postToMainPrefix?: string; postToMainPrefix?: string;

View File

@ -1,4 +1,5 @@
import { html } from "lit"; import { html } from "lit";
import { t } from "../i18n";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types"; import type { ChannelsProps } from "./channels.types";
@ -71,26 +72,26 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema); const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema; const normalized = analysis.schema;
if (!normalized) { if (!normalized) {
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`; return html`<div class="callout danger">${t("channels.config.schemaUnavailable")}</div>`;
} }
const node = resolveSchemaNode(normalized, ["channels", props.channelId]); const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) { if (!node) {
return html`<div class="callout danger">Channel config schema unavailable.</div>`; return html`<div class="callout danger">${t("channels.config.channelSchemaUnavailable")}</div>`;
} }
const configValue = props.configValue ?? {}; const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId); const value = resolveChannelValue(configValue, props.channelId);
return html` return html`
<div class="config-form"> <div class="config-form">
${renderNode({ ${renderNode({
schema: node, schema: node,
value, value,
path: ["channels", props.channelId], path: ["channels", props.channelId],
hints: props.uiHints, hints: props.uiHints,
unsupported: new Set(analysis.unsupportedPaths), unsupported: new Set(analysis.unsupportedPaths),
disabled: props.disabled, disabled: props.disabled,
showLabel: false, showLabel: false,
onPatch: props.onPatch, onPatch: props.onPatch,
})} })}
</div> </div>
`; `;
} }
@ -104,29 +105,29 @@ export function renderChannelConfigSection(params: {
return html` return html`
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
${props.configSchemaLoading ${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>` ? html`<div class="muted">${t("channels.config.loadingSchema")}</div>`
: renderChannelConfigForm({ : renderChannelConfigForm({
channelId, channelId,
configValue: props.configForm, configValue: props.configForm,
schema: props.configSchema, schema: props.configSchema,
uiHints: props.configUiHints, uiHints: props.configUiHints,
disabled, disabled,
onPatch: props.onConfigPatch, onPatch: props.onConfigPatch,
})} })}
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button <button
class="btn primary" class="btn primary"
?disabled=${disabled || !props.configFormDirty} ?disabled=${disabled || !props.configFormDirty}
@click=${() => props.onConfigSave()} @click=${() => props.onConfigSave()}
> >
${props.configSaving ? "Saving…" : "Save"} ${props.configSaving ? t("channels.config.saving") : t("common.save")}
</button> </button>
<button <button
class="btn" class="btn"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => props.onConfigReload()} @click=${() => props.onConfigReload()}
> >
Reload ${t("channels.config.reload")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { DiscordStatus } from "../types"; import type { DiscordStatus } from "../types";
@ -15,46 +16,46 @@ export function renderDiscordCard(params: {
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">Discord</div> <div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div> <div class="card-sub">${t("channels.discord.subtitle")}</div>
${accountCountLabel} ${accountCountLabel}
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${discord?.configured ? "Yes" : "No"}</span> <span>${discord?.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${discord?.running ? "Yes" : "No"}</span> <span>${discord?.running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Last start</span> <span class="label">${t("channels.lastStart")}</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span> <span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Last probe</span> <span class="label">${t("channels.lastProbe")}</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span> <span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : t("common.na")}</span>
</div> </div>
</div> </div>
${discord?.lastError ${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError} ${discord.lastError}
</div>` </div>`
: nothing} : nothing}
${discord?.probe ${discord?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} · ${t("channels.probe")} ${discord.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""} ${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "discord", props })} ${renderChannelConfigSection({ channelId: "discord", props })}
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}> <button class="btn" @click=${() => props.onRefresh(true)}>
Probe ${t("channels.probe")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { GoogleChatStatus } from "../types"; import type { GoogleChatStatus } from "../types";
@ -15,58 +16,58 @@ export function renderGoogleChatCard(params: {
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">Google Chat</div> <div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div> <div class="card-sub">${t("channels.googleChat.subtitle")}</div>
${accountCountLabel} ${accountCountLabel}
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span> <span>${googleChat ? (googleChat.configured ? t("channels.yes") : t("channels.no")) : t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span> <span>${googleChat ? (googleChat.running ? t("channels.yes") : t("channels.no")) : t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Credential</span> <span class="label">${t("channels.googleChat.credential")}</span>
<span>${googleChat?.credentialSource ?? "n/a"}</span> <span>${googleChat?.credentialSource ?? t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Audience</span> <span class="label">${t("channels.googleChat.audience")}</span>
<span> <span>
${googleChat?.audienceType ${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"} : t("common.na")}
</span> </span>
</div> </div>
<div> <div>
<span class="label">Last start</span> <span class="label">${t("channels.lastStart")}</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span> <span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Last probe</span> <span class="label">${t("channels.lastProbe")}</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span> <span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : t("common.na")}</span>
</div> </div>
</div> </div>
${googleChat?.lastError ${googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${googleChat.lastError} ${googleChat.lastError}
</div>` </div>`
: nothing} : nothing}
${googleChat?.probe ${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} · ${t("channels.probe")} ${googleChat.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""} ${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "googlechat", props })} ${renderChannelConfigSection({ channelId: "googlechat", props })}
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}> <button class="btn" @click=${() => props.onRefresh(true)}>
Probe ${t("channels.probe")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { IMessageStatus } from "../types"; import type { IMessageStatus } from "../types";
@ -15,46 +16,46 @@ export function renderIMessageCard(params: {
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">iMessage</div> <div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div> <div class="card-sub">${t("channels.imessage.subtitle")}</div>
${accountCountLabel} ${accountCountLabel}
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${imessage?.configured ? "Yes" : "No"}</span> <span>${imessage?.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${imessage?.running ? "Yes" : "No"}</span> <span>${imessage?.running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Last start</span> <span class="label">${t("channels.lastStart")}</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span> <span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Last probe</span> <span class="label">${t("channels.lastProbe")}</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span> <span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : t("common.na")}</span>
</div> </div>
</div> </div>
${imessage?.lastError ${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError} ${imessage.lastError}
</div>` </div>`
: nothing} : nothing}
${imessage?.probe ${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} · ${t("channels.probe")} ${imessage.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${imessage.probe.error ?? ""} ${imessage.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "imessage", props })} ${renderChannelConfigSection({ channelId: "imessage", props })}
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}> <button class="btn" @click=${() => props.onRefresh(true)}>
Probe ${t("channels.probe")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@
*/ */
import { html, nothing, type TemplateResult } from "lit"; import { html, nothing, type TemplateResult } from "lit";
import { t } from "../i18n";
import type { NostrProfile as NostrProfileType } from "../types"; import type { NostrProfile as NostrProfileType } from "../types";
@ -104,9 +105,9 @@ export function renderNostrProfileForm(params: {
rows="3" rows="3"
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; resize: vertical; font-family: inherit;" style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; resize: vertical; font-family: inherit;"
@input=${(e: InputEvent) => { @input=${(e: InputEvent) => {
const target = e.target as HTMLTextAreaElement; const target = e.target as HTMLTextAreaElement;
callbacks.onFieldChange(field, target.value); callbacks.onFieldChange(field, target.value);
}} }}
?disabled=${state.saving} ?disabled=${state.saving}
></textarea> ></textarea>
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing} ${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
@ -128,9 +129,9 @@ export function renderNostrProfileForm(params: {
maxlength=${maxLength ?? 256} maxlength=${maxLength ?? 256}
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px;" style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px;"
@input=${(e: InputEvent) => { @input=${(e: InputEvent) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
callbacks.onFieldChange(field, target.value); callbacks.onFieldChange(field, target.value);
}} }}
?disabled=${state.saving} ?disabled=${state.saving}
/> />
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing} ${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
@ -147,16 +148,16 @@ export function renderNostrProfileForm(params: {
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<img <img
src=${picture} src=${picture}
alt="Profile picture preview" alt=${t("channels.nostr.form.picturePreview")}
style="max-width: 80px; max-height: 80px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);" style="max-width: 80px; max-height: 80px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => { @error=${(e: Event) => {
const img = e.target as HTMLImageElement; const img = e.target as HTMLImageElement;
img.style.display = "none"; img.style.display = "none";
}} }}
@load=${(e: Event) => { @load=${(e: Event) => {
const img = e.target as HTMLImageElement; const img = e.target as HTMLImageElement;
img.style.display = "block"; img.style.display = "block";
}} }}
/> />
</div> </div>
`; `;
@ -165,74 +166,74 @@ export function renderNostrProfileForm(params: {
return html` return html`
<div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: 8px; margin-top: 12px;"> <div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: 8px; margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<div style="font-weight: 600; font-size: 16px;">Edit Profile</div> <div style="font-weight: 600; font-size: 16px;">${t("channels.nostr.form.title")}</div>
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div> <div style="font-size: 12px; color: var(--text-muted);">${t("channels.nostr.form.account", { accountId })}</div>
</div> </div>
${state.error ${state.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>` ? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>`
: nothing} : nothing}
${state.success ${state.success
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>` ? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing} : nothing}
${renderPicturePreview()} ${renderPicturePreview()}
${renderField("name", "Username", { ${renderField("name", t("channels.nostr.profileFields.username"), {
placeholder: "satoshi", placeholder: "satoshi",
maxLength: 256, 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", placeholder: "Satoshi Nakamoto",
maxLength: 256, 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", type: "textarea",
placeholder: "Tell people about yourself...", placeholder: "Tell people about yourself...",
maxLength: 2000, 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", type: "url",
placeholder: "https://example.com/avatar.jpg", placeholder: "https://example.com/avatar.jpg",
help: "HTTPS URL to your profile picture", help: t("channels.nostr.profileFields.avatarUrlHelp"),
})} })}
${state.showAdvanced ${state.showAdvanced
? html` ? html`
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;"> <div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div> <div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">${t("channels.nostr.form.advanced")}</div>
${renderField("banner", "Banner URL", { ${renderField("banner", t("channels.nostr.profileFields.bannerUrl"), {
type: "url", type: "url",
placeholder: "https://example.com/banner.jpg", placeholder: "https://example.com/banner.jpg",
help: "HTTPS URL to a banner image", help: t("channels.nostr.profileFields.bannerUrlHelp"),
})} })}
${renderField("website", "Website", { ${renderField("website", t("channels.nostr.profileFields.website"), {
type: "url", type: "url",
placeholder: "https://example.com", placeholder: "https://example.com",
help: "Your personal website", help: t("channels.nostr.profileFields.websiteHelp"),
})} })}
${renderField("nip05", "NIP-05 Identifier", { ${renderField("nip05", t("channels.nostr.profileFields.nip05"), {
placeholder: "you@example.com", placeholder: "you@example.com",
help: "Verifiable identifier (e.g., you@domain.com)", help: t("channels.nostr.profileFields.nip05Help"),
})} })}
${renderField("lud16", "Lightning Address", { ${renderField("lud16", t("channels.nostr.profileFields.lud16"), {
placeholder: "you@getalby.com", placeholder: "you@getalby.com",
help: "Lightning address for tips (LUD-16)", help: t("channels.nostr.profileFields.lud16Help"),
})} })}
</div> </div>
` `
: nothing} : nothing}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;"> <div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button <button
@ -240,7 +241,7 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onSave} @click=${callbacks.onSave}
?disabled=${state.saving || !isDirty} ?disabled=${state.saving || !isDirty}
> >
${state.saving ? "Saving..." : "Save & Publish"} ${state.saving ? t("channels.nostr.form.saving") : t("channels.nostr.form.saveAndPublish")}
</button> </button>
<button <button
@ -248,14 +249,14 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onImport} @click=${callbacks.onImport}
?disabled=${state.importing || state.saving} ?disabled=${state.importing || state.saving}
> >
${state.importing ? "Importing..." : "Import from Relays"} ${state.importing ? t("channels.nostr.form.importing") : t("channels.nostr.form.importFromRelays")}
</button> </button>
<button <button
class="btn" class="btn"
@click=${callbacks.onToggleAdvanced} @click=${callbacks.onToggleAdvanced}
> >
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"} ${state.showAdvanced ? t("channels.nostr.form.hideAdvanced") : t("channels.nostr.form.showAdvanced")}
</button> </button>
<button <button
@ -263,15 +264,15 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onCancel} @click=${callbacks.onCancel}
?disabled=${state.saving} ?disabled=${state.saving}
> >
Cancel ${t("common.cancel")}
</button> </button>
</div> </div>
${isDirty ${isDirty
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;"> ? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;">
You have unsaved changes ${t("channels.nostr.form.unsavedChanges")}
</div>` </div>`
: nothing} : nothing}
</div> </div>
`; `;
} }

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, NostrStatus } from "../types"; import type { ChannelAccountSnapshot, NostrStatus } from "../types";
@ -14,7 +15,7 @@ import {
* Truncate a pubkey for display (shows first and last 8 chars) * Truncate a pubkey for display (shows first and last 8 chars)
*/ */
function truncatePubkey(pubkey: string | null | undefined): string { function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) return "n/a"; if (!pubkey) return t("common.na");
if (pubkey.length <= 20) return pubkey; if (pubkey.length <= 20) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`; return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
} }
@ -64,26 +65,26 @@ export function renderNostrCard(params: {
</div> </div>
<div class="status-list account-card-status"> <div class="status-list account-card-status">
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${account.running ? "Yes" : "No"}</span> <span>${account.running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${account.configured ? "Yes" : "No"}</span> <span>${account.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Public Key</span> <span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span> <span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span>
</div> </div>
<div> <div>
<span class="label">Last inbound</span> <span class="label">${t("channels.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span> <span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div> </div>
${account.lastError ${account.lastError
? html` ? html`
<div class="account-card-error">${account.lastError}</div> <div class="account-card-error">${account.lastError}</div>
` `
: nothing} : nothing}
</div> </div>
</div> </div>
`; `;
@ -102,14 +103,14 @@ export function renderNostrCard(params: {
const profile = const profile =
(primaryAccount as (primaryAccount as
| { | {
profile?: { profile?: {
name?: string; name?: string;
displayName?: string; displayName?: string;
about?: string; about?: string;
picture?: string; picture?: string;
nip05?: string; nip05?: string;
}; };
} }
| undefined)?.profile ?? nostr?.profile; | undefined)?.profile ?? nostr?.profile;
const { name, displayName, about, picture, nip05 } = profile ?? {}; const { name, displayName, about, picture, nip05 } = profile ?? {};
const hasAnyProfileData = name || displayName || about || picture || nip05; const hasAnyProfileData = name || displayName || about || picture || nip05;
@ -117,49 +118,49 @@ export function renderNostrCard(params: {
return html` return html`
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;"> <div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div> <div style="font-weight: 500;">${t("channels.nostr.profile")}</div>
${summaryConfigured ${summaryConfigured
? html` ? html`
<button <button
class="btn btn-sm" class="btn btn-sm"
@click=${onEditProfile} @click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;" style="font-size: 12px; padding: 4px 8px;"
> >
Edit Profile ${t("channels.nostr.editProfile")}
</button> </button>
` `
: nothing} : nothing}
</div> </div>
${hasAnyProfileData ${hasAnyProfileData
? html` ? html`
<div class="status-list"> <div class="status-list">
${picture ${picture
? html` ? html`
<div style="margin-bottom: 8px;"> <div style="margin-bottom: 8px;">
<img <img
src=${picture} src=${picture}
alt="Profile picture" alt=${t("channels.nostr.form.picturePreview")}
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);" style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => { @error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none"; (e.target as HTMLImageElement).style.display = "none";
}} }}
/> />
</div> </div>
` `
: nothing} : nothing}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing} ${name ? html`<div><span class="label">${t("channels.nostr.profileFields.username")}</span><span>${name}</span></div>` : nothing}
${displayName ${displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>` ? html`<div><span class="label">${t("channels.nostr.profileFields.displayName")}</span><span>${displayName}</span></div>`
: nothing} : nothing}
${about ${about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>` ? html`<div><span class="label">${t("channels.nostr.profileFields.bio")}</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing} : nothing}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing} ${nip05 ? html`<div><span class="label">${t("channels.nostr.profileFields.nip05")}</span><span>${nip05}</span></div>` : nothing}
</div> </div>
` `
: html` : html`
<div style="color: var(--text-muted); font-size: 13px;"> <div style="color: var(--text-muted); font-size: 13px;">
No profile set. Click "Edit Profile" to add your name, bio, and avatar. ${t("channels.nostr.noProfileHint")}
</div> </div>
`} `}
</div> </div>
@ -169,48 +170,48 @@ export function renderNostrCard(params: {
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">Nostr</div> <div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div> <div class="card-sub">${t("channels.nostr.subtitle")}</div>
${accountCountLabel} ${accountCountLabel}
${hasMultipleAccounts ${hasMultipleAccounts
? html` ? html`
<div class="account-card-list"> <div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))} ${nostrAccounts.map((account) => renderAccountCard(account))}
</div> </div>
` `
: html` : html`
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${summaryConfigured ? "Yes" : "No"}</span> <span>${summaryConfigured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${summaryRunning ? "Yes" : "No"}</span> <span>${summaryRunning ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Public Key</span> <span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${summaryPublicKey ?? ""}" <span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span >${truncatePubkey(summaryPublicKey)}</span
> >
</div> </div>
<div> <div>
<span class="label">Last start</span> <span class="label">${t("channels.lastStart")}</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span> <span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : t("common.na")}</span>
</div> </div>
</div> </div>
`} `}
${summaryLastError ${summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>` ? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing} : nothing}
${renderProfileSection()} ${renderProfileSection()}
${renderChannelConfigSection({ channelId: "nostr", props })} ${renderChannelConfigSection({ channelId: "nostr", props })}
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button> <button class="btn" @click=${() => props.onRefresh(false)}>${t("common.refresh")}</button>
</div> </div>
</div> </div>
`; `;

View File

@ -1,10 +1,11 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import type { ChannelAccountSnapshot } from "../types"; import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types"; import type { ChannelKey, ChannelsProps } from "./channels.types";
export function formatDuration(ms?: number | null) { 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); const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`; if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60); const min = Math.round(sec / 60);
@ -41,5 +42,5 @@ export function renderChannelAccountCount(
) { ) {
const count = getChannelAccountCount(key, channelAccounts); const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing; if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`; return html`<div class="account-count">${t("channels.shared.accounts", { count })}</div>`;
} }

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { SignalStatus } from "../types"; import type { SignalStatus } from "../types";
@ -15,50 +16,50 @@ export function renderSignalCard(params: {
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">Signal</div> <div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div> <div class="card-sub">${t("channels.signal.subtitle")}</div>
${accountCountLabel} ${accountCountLabel}
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${signal?.configured ? "Yes" : "No"}</span> <span>${signal?.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${signal?.running ? "Yes" : "No"}</span> <span>${signal?.running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Base URL</span> <span class="label">${t("channels.signal.baseUrl")}</span>
<span>${signal?.baseUrl ?? "n/a"}</span> <span>${signal?.baseUrl ?? t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Last start</span> <span class="label">${t("channels.lastStart")}</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span> <span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Last probe</span> <span class="label">${t("channels.lastProbe")}</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span> <span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : t("common.na")}</span>
</div> </div>
</div> </div>
${signal?.lastError ${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError} ${signal.lastError}
</div>` </div>`
: nothing} : nothing}
${signal?.probe ${signal?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} · ${t("channels.probe")} ${signal.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""} ${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "signal", props })} ${renderChannelConfigSection({ channelId: "signal", props })}
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}> <button class="btn" @click=${() => props.onRefresh(true)}>
Probe ${t("channels.probe")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { SlackStatus } from "../types"; import type { SlackStatus } from "../types";
@ -15,46 +16,46 @@ export function renderSlackCard(params: {
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">Slack</div> <div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div> <div class="card-sub">${t("channels.slack.subtitle")}</div>
${accountCountLabel} ${accountCountLabel}
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${slack?.configured ? "Yes" : "No"}</span> <span>${slack?.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${slack?.running ? "Yes" : "No"}</span> <span>${slack?.running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Last start</span> <span class="label">${t("channels.lastStart")}</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span> <span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Last probe</span> <span class="label">${t("channels.lastProbe")}</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span> <span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : t("common.na")}</span>
</div> </div>
</div> </div>
${slack?.lastError ${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError} ${slack.lastError}
</div>` </div>`
: nothing} : nothing}
${slack?.probe ${slack?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} · ${t("channels.probe")} ${slack.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""} ${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "slack", props })} ${renderChannelConfigSection({ channelId: "slack", props })}
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}> <button class="btn" @click=${() => props.onRefresh(true)}>
Probe ${t("channels.probe")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types"; import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
@ -28,24 +29,24 @@ export function renderTelegramCard(params: {
</div> </div>
<div class="status-list account-card-status"> <div class="status-list account-card-status">
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${account.running ? "Yes" : "No"}</span> <span>${account.running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${account.configured ? "Yes" : "No"}</span> <span>${account.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Last inbound</span> <span class="label">${t("channels.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span> <span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div> </div>
${account.lastError ${account.lastError
? html` ? html`
<div class="account-card-error"> <div class="account-card-error">
${account.lastError} ${account.lastError}
</div> </div>
` `
: nothing} : nothing}
</div> </div>
</div> </div>
`; `;
@ -54,58 +55,58 @@ export function renderTelegramCard(params: {
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">Telegram</div> <div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div> <div class="card-sub">${t("channels.telegram.subtitle")}</div>
${accountCountLabel} ${accountCountLabel}
${hasMultipleAccounts ${hasMultipleAccounts
? html` ? html`
<div class="account-card-list"> <div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))} ${telegramAccounts.map((account) => renderAccountCard(account))}
</div> </div>
` `
: html` : html`
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${telegram?.configured ? "Yes" : "No"}</span> <span>${telegram?.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${telegram?.running ? "Yes" : "No"}</span> <span>${telegram?.running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Mode</span> <span class="label">${t("channels.telegram.mode")}</span>
<span>${telegram?.mode ?? "n/a"}</span> <span>${telegram?.mode ?? t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Last start</span> <span class="label">${t("channels.lastStart")}</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span> <span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : t("common.na")}</span>
</div> </div>
<div> <div>
<span class="label">Last probe</span> <span class="label">${t("channels.lastProbe")}</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span> <span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : t("common.na")}</span>
</div> </div>
</div> </div>
`} `}
${telegram?.lastError ${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError} ${telegram.lastError}
</div>` </div>`
: nothing} : nothing}
${telegram?.probe ${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} · ${t("channels.probe")} ${telegram.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing}
${renderChannelConfigSection({ channelId: "telegram", props })} ${renderChannelConfigSection({ channelId: "telegram", props })}
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}> <button class="btn" @click=${() => props.onRefresh(true)}>
Probe ${t("channels.probe")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { import type {
@ -60,35 +61,35 @@ export function renderChannels(props: ChannelsProps) {
return html` return html`
<section class="grid grid-cols-2"> <section class="grid grid-cols-2">
${orderedChannels.map((channel) => ${orderedChannels.map((channel) =>
renderChannel(channel.key, props, { renderChannel(channel.key, props, {
whatsapp, whatsapp,
telegram, telegram,
discord, discord,
googlechat, googlechat,
slack, slack,
signal, signal,
imessage, imessage,
nostr, nostr,
channelAccounts: props.snapshot?.channelAccounts ?? null, channelAccounts: props.snapshot?.channelAccounts ?? null,
}), }),
)} )}
</section> </section>
<section class="card" style="margin-top: 18px;"> <section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Channel health</div> <div class="card-title">${t("channels.healthTitle")}</div>
<div class="card-sub">Channel status snapshots from the gateway.</div> <div class="card-sub">${t("channels.healthSubtitle")}</div>
</div> </div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div> <div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : t("common.na")}</div>
</div> </div>
${props.lastError ${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError} ${props.lastError}
</div>` </div>`
: nothing} : nothing}
<pre class="code-block" style="margin-top: 12px;"> <pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."} ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : t("channels.noSnapshot")}
</pre> </pre>
</section> </section>
`; `;
@ -145,7 +146,7 @@ function renderChannel(
case "googlechat": case "googlechat":
return renderGoogleChatCard({ return renderGoogleChatCard({
props, props,
googlechat: data.googlechat, googleChat: data.googlechat,
accountCountLabel, accountCountLabel,
}); });
case "slack": case "slack":
@ -176,12 +177,12 @@ function renderChannel(
props.nostrProfileAccountId === accountId ? props.nostrProfileFormState : null; props.nostrProfileAccountId === accountId ? props.nostrProfileFormState : null;
const profileFormCallbacks = showForm const profileFormCallbacks = showForm
? { ? {
onFieldChange: props.onNostrProfileFieldChange, onFieldChange: props.onNostrProfileFieldChange,
onSave: props.onNostrProfileSave, onSave: props.onNostrProfileSave,
onImport: props.onNostrProfileImport, onImport: props.onNostrProfileImport,
onCancel: props.onNostrProfileCancel, onCancel: props.onNostrProfileCancel,
onToggleAdvanced: props.onNostrProfileToggleAdvanced, onToggleAdvanced: props.onNostrProfileToggleAdvanced,
} }
: null; : null;
return renderNostrCard({ return renderNostrCard({
props, props,
@ -215,37 +216,37 @@ function renderGenericChannelCard(
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">${label}</div> <div class="card-title">${label}</div>
<div class="card-sub">Channel status and configuration.</div> <div class="card-sub">${t("channels.genericSubtitle")}</div>
${accountCountLabel} ${accountCountLabel}
${accounts.length > 0 ${accounts.length > 0
? html` ? html`
<div class="account-card-list"> <div class="account-card-list">
${accounts.map((account) => renderGenericAccount(account))} ${accounts.map((account) => renderGenericAccount(account))}
</div> </div>
` `
: html` : html`
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span> <span>${configured == null ? t("common.na") : configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${running == null ? "n/a" : running ? "Yes" : "No"}</span> <span>${running == null ? t("common.na") : running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Connected</span> <span class="label">${t("channels.connected")}</span>
<span>${connected == null ? "n/a" : connected ? "Yes" : "No"}</span> <span>${connected == null ? t("common.na") : connected ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
</div> </div>
`} `}
${lastError ${lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${lastError} ${lastError}
</div>` </div>`
: nothing} : nothing}
${renderChannelConfigSection({ channelId: key, props })} ${renderChannelConfigSection({ channelId: key, props })}
</div> </div>
@ -274,19 +275,19 @@ function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS; return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS;
} }
function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" { function deriveRunningStatus(account: ChannelAccountSnapshot): string {
if (account.running) return "Yes"; if (account.running) return t("channels.yes");
// If we have recent inbound activity, the channel is effectively running // If we have recent inbound activity, the channel is effectively running
if (hasRecentActivity(account)) return "Active"; if (hasRecentActivity(account)) return t("channels.active");
return "No"; return t("channels.no");
} }
function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" { function deriveConnectedStatus(account: ChannelAccountSnapshot): string {
if (account.connected === true) return "Yes"; if (account.connected === true) return t("channels.yes");
if (account.connected === false) return "No"; if (account.connected === false) return t("channels.no");
// If connected is null/undefined but we have recent activity, show as active // If connected is null/undefined but we have recent activity, show as active
if (hasRecentActivity(account)) return "Active"; if (hasRecentActivity(account)) return t("channels.active");
return "n/a"; return t("common.na");
} }
function renderGenericAccount(account: ChannelAccountSnapshot) { function renderGenericAccount(account: ChannelAccountSnapshot) {
@ -301,28 +302,28 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
</div> </div>
<div class="status-list account-card-status"> <div class="status-list account-card-status">
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${runningStatus}</span> <span>${runningStatus}</span>
</div> </div>
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${account.configured ? "Yes" : "No"}</span> <span>${account.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Connected</span> <span class="label">${t("channels.connected")}</span>
<span>${connectedStatus}</span> <span>${connectedStatus}</span>
</div> </div>
<div> <div>
<span class="label">Last inbound</span> <span class="label">${t("channels.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span> <span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div> </div>
${account.lastError ${account.lastError
? html` ? html`
<div class="account-card-error"> <div class="account-card-error">
${account.lastError} ${account.lastError}
</div> </div>
` `
: nothing} : nothing}
</div> </div>
</div> </div>
`; `;

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { WhatsAppStatus } from "../types"; import type { WhatsAppStatus } from "../types";
@ -16,67 +17,67 @@ export function renderWhatsAppCard(params: {
return html` return html`
<div class="card"> <div class="card">
<div class="card-title">WhatsApp</div> <div class="card-title">WhatsApp</div>
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div> <div class="card-sub">${t("channels.whatsapp.subtitle")}</div>
${accountCountLabel} ${accountCountLabel}
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">${t("channels.configured")}</span>
<span>${whatsapp?.configured ? "Yes" : "No"}</span> <span>${whatsapp?.configured ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Linked</span> <span class="label">${t("channels.whatsapp.linked")}</span>
<span>${whatsapp?.linked ? "Yes" : "No"}</span> <span>${whatsapp?.linked ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Running</span> <span class="label">${t("channels.running")}</span>
<span>${whatsapp?.running ? "Yes" : "No"}</span> <span>${whatsapp?.running ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Connected</span> <span class="label">${t("channels.connected")}</span>
<span>${whatsapp?.connected ? "Yes" : "No"}</span> <span>${whatsapp?.connected ? t("channels.yes") : t("channels.no")}</span>
</div> </div>
<div> <div>
<span class="label">Last connect</span> <span class="label">${t("channels.whatsapp.lastConnect")}</span>
<span> <span>
${whatsapp?.lastConnectedAt ${whatsapp?.lastConnectedAt
? formatAgo(whatsapp.lastConnectedAt) ? formatAgo(whatsapp.lastConnectedAt)
: "n/a"} : t("common.na")}
</span> </span>
</div> </div>
<div> <div>
<span class="label">Last message</span> <span class="label">${t("channels.whatsapp.lastMessage")}</span>
<span> <span>
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : t("common.na")}
</span> </span>
</div> </div>
<div> <div>
<span class="label">Auth age</span> <span class="label">${t("channels.whatsapp.authAge")}</span>
<span> <span>
${whatsapp?.authAgeMs != null ${whatsapp?.authAgeMs != null
? formatDuration(whatsapp.authAgeMs) ? formatDuration(whatsapp.authAgeMs)
: "n/a"} : t("common.na")}
</span> </span>
</div> </div>
</div> </div>
${whatsapp?.lastError ${whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${whatsapp.lastError} ${whatsapp.lastError}
</div>` </div>`
: nothing} : nothing}
${props.whatsappMessage ${props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage} ${props.whatsappMessage}
</div>` </div>`
: nothing} : nothing}
${props.whatsappQrDataUrl ${props.whatsappQrDataUrl
? html`<div class="qr-wrap"> ? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" /> <img src=${props.whatsappQrDataUrl} alt=${t("channels.whatsapp.qrAlt")} />
</div>` </div>`
: nothing} : nothing}
<div class="row" style="margin-top: 14px; flex-wrap: wrap;"> <div class="row" style="margin-top: 14px; flex-wrap: wrap;">
<button <button
@ -84,31 +85,31 @@ export function renderWhatsAppCard(params: {
?disabled=${props.whatsappBusy} ?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)} @click=${() => props.onWhatsAppStart(false)}
> >
${props.whatsappBusy ? "Working…" : "Show QR"} ${props.whatsappBusy ? t("channels.whatsapp.working") : t("channels.whatsapp.showQr")}
</button> </button>
<button <button
class="btn" class="btn"
?disabled=${props.whatsappBusy} ?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(true)} @click=${() => props.onWhatsAppStart(true)}
> >
Relink ${t("channels.whatsapp.relink")}
</button> </button>
<button <button
class="btn" class="btn"
?disabled=${props.whatsappBusy} ?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppWait()} @click=${() => props.onWhatsAppWait()}
> >
Wait for scan ${t("channels.whatsapp.waitForScan")}
</button> </button>
<button <button
class="btn danger" class="btn danger"
?disabled=${props.whatsappBusy} ?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppLogout()} @click=${() => props.onWhatsAppLogout()}
> >
Logout ${t("channels.whatsapp.logout")}
</button> </button>
<button class="btn" @click=${() => props.onRefresh(true)}> <button class="btn" @click=${() => props.onRefresh(true)}>
Refresh ${t("common.refresh")}
</button> </button>
</div> </div>

View File

@ -16,6 +16,7 @@ import {
} from "../chat/grouped-render"; } from "../chat/grouped-render";
import { renderMarkdownSidebar } from "./markdown-sidebar"; import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider"; import "../components/resizable-divider";
import { t } from "../i18n";
export type CompactionIndicatorStatus = { export type CompactionIndicatorStatus = {
active: boolean; active: boolean;
@ -84,7 +85,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (status.active) { if (status.active) {
return html` return html`
<div class="callout info compaction-indicator compaction-indicator--active"> <div class="callout info compaction-indicator compaction-indicator--active">
${icons.loader} Compacting context... ${icons.loader} ${t("chat.compacting")}
</div> </div>
`; `;
} }
@ -95,7 +96,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (elapsed < COMPACTION_TOAST_DURATION_MS) { if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html` return html`
<div class="callout success compaction-indicator compaction-indicator--complete"> <div class="callout success compaction-indicator compaction-indicator--complete">
${icons.check} Context compacted ${icons.check} ${t("chat.compacted")}
</div> </div>
`; `;
} }
@ -153,29 +154,29 @@ function renderAttachmentPreview(props: ChatProps) {
return html` return html`
<div class="chat-attachments"> <div class="chat-attachments">
${attachments.map( ${attachments.map(
(att) => html` (att) => html`
<div class="chat-attachment"> <div class="chat-attachment">
<img <img
src=${att.dataUrl} src=${att.dataUrl}
alt="Attachment preview" alt=${t("chat.attachmentPreview")}
class="chat-attachment__img" class="chat-attachment__img"
/> />
<button <button
class="chat-attachment__remove" class="chat-attachment__remove"
type="button" type="button"
aria-label="Remove attachment" aria-label=${t("chat.removeAttachment")}
@click=${() => { @click=${() => {
const next = (props.attachments ?? []).filter( const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id, (a) => a.id !== att.id,
); );
props.onAttachmentsChange?.(next); props.onAttachmentsChange?.(next);
}} }}
> >
${icons.x} ${icons.x}
</button> </button>
</div> </div>
`, `,
)} )}
</div> </div>
`; `;
} }
@ -197,9 +198,9 @@ export function renderChat(props: ChatProps) {
const hasAttachments = (props.attachments?.length ?? 0) > 0; const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected const composePlaceholder = props.connected
? hasAttachments ? hasAttachments
? "Add a message or paste more images..." ? t("chat.placeholderCompose")
: "Message (↩ to send, Shift+↩ for line breaks, paste images)" : t("chat.placeholderHint")
: "Connect to the gateway to start chatting…"; : t("chat.placeholderConnect");
const splitRatio = props.splitRatio ?? 0.6; const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
@ -210,60 +211,60 @@ export function renderChat(props: ChatProps) {
aria-live="polite" aria-live="polite"
@scroll=${props.onChatScroll} @scroll=${props.onChatScroll}
> >
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing} ${props.loading ? html`<div class="muted">${t("chat.loading")}</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => { ${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") { if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity); return renderReadingIndicatorGroup(assistantIdentity);
} }
if (item.kind === "stream") { if (item.kind === "stream") {
return renderStreamingGroup( return renderStreamingGroup(
item.text, item.text,
item.startedAt, item.startedAt,
props.onOpenSidebar, props.onOpenSidebar,
assistantIdentity, assistantIdentity,
); );
} }
if (item.kind === "group") { if (item.kind === "group") {
return renderMessageGroup(item, { return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar, onOpenSidebar: props.onOpenSidebar,
showReasoning, showReasoning,
assistantName: props.assistantName, assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar, assistantAvatar: assistantIdentity.avatar,
}); });
} }
return nothing; return nothing;
})} })}
</div> </div>
`; `;
return html` return html`
<section class="card chat"> <section class="card chat">
${props.disabledReason ${props.disabledReason
? html`<div class="callout">${props.disabledReason}</div>` ? html`<div class="callout">${props.disabledReason}</div>`
: nothing} : nothing}
${props.error ${props.error
? html`<div class="callout danger">${props.error}</div>` ? html`<div class="callout danger">${props.error}</div>`
: nothing} : nothing}
${renderCompactionIndicator(props.compactionStatus)} ${renderCompactionIndicator(props.compactionStatus)}
${props.focusMode ${props.focusMode
? html` ? html`
<button <button
class="chat-focus-exit" class="chat-focus-exit"
type="button" type="button"
@click=${props.onToggleFocusMode} @click=${props.onToggleFocusMode}
aria-label="Exit focus mode" aria-label=${t("chat.exitFocus")}
title="Exit focus mode" title=${t("chat.exitFocus")}
> >
${icons.x} ${icons.x}
</button> </button>
` `
: nothing} : nothing}
<div <div
class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}" class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}"
@ -276,79 +277,79 @@ export function renderChat(props: ChatProps) {
</div> </div>
${sidebarOpen ${sidebarOpen
? html` ? html`
<resizable-divider <resizable-divider
.splitRatio=${splitRatio} .splitRatio=${splitRatio}
@resize=${(e: CustomEvent) => @resize=${(e: CustomEvent) =>
props.onSplitRatioChange?.(e.detail.splitRatio)} props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider> ></resizable-divider>
<div class="chat-sidebar"> <div class="chat-sidebar">
${renderMarkdownSidebar({ ${renderMarkdownSidebar({
content: props.sidebarContent ?? null, content: props.sidebarContent ?? null,
error: props.sidebarError ?? null, error: props.sidebarError ?? null,
onClose: props.onCloseSidebar!, onClose: props.onCloseSidebar!,
onViewRawText: () => { onViewRawText: () => {
if (!props.sidebarContent || !props.onOpenSidebar) return; if (!props.sidebarContent || !props.onOpenSidebar) return;
props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``); props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``);
}, },
})} })}
</div> </div>
` `
: nothing} : nothing}
</div> </div>
${props.queue.length ${props.queue.length
? html` ? html`
<div class="chat-queue" role="status" aria-live="polite"> <div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div> <div class="chat-queue__title">${t("chat.queued", { count: props.queue.length })}</div>
<div class="chat-queue__list"> <div class="chat-queue__list">
${props.queue.map( ${props.queue.map(
(item) => html` (item) => html`
<div class="chat-queue__item"> <div class="chat-queue__item">
<div class="chat-queue__text"> <div class="chat-queue__text">
${item.text || ${item.text ||
(item.attachments?.length (item.attachments?.length
? `Image (${item.attachments.length})` ? t("chat.imageAttachment", { count: item.attachments.length })
: "")} : "")}
</div> </div>
<button <button
class="btn chat-queue__remove" class="btn chat-queue__remove"
type="button" type="button"
aria-label="Remove queued message" aria-label=${t("chat.removeQueued")}
@click=${() => props.onQueueRemove(item.id)} @click=${() => props.onQueueRemove(item.id)}
> >
${icons.x} ${icons.x}
</button> </button>
</div> </div>
`, `,
)} )}
</div> </div>
</div> </div>
` `
: nothing} : nothing}
<div class="chat-compose"> <div class="chat-compose">
${renderAttachmentPreview(props)} ${renderAttachmentPreview(props)}
<div class="chat-compose__row"> <div class="chat-compose__row">
<label class="field chat-compose__field"> <label class="field chat-compose__field">
<span>Message</span> <span>${t("chat.labelMessage")}</span>
<textarea <textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))} ${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft} .value=${props.draft}
?disabled=${!props.connected} ?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;
if (e.isComposing || e.keyCode === 229) return; if (e.isComposing || e.keyCode === 229) return;
if (e.shiftKey) return; // Allow Shift+Enter for line breaks if (e.shiftKey) return; // Allow Shift+Enter for line breaks
if (!props.connected) return; if (!props.connected) return;
e.preventDefault(); e.preventDefault();
if (canCompose) props.onSend(); if (canCompose) props.onSend();
}} }}
@input=${(e: Event) => { @input=${(e: Event) => {
const target = e.target as HTMLTextAreaElement; const target = e.target as HTMLTextAreaElement;
adjustTextareaHeight(target); adjustTextareaHeight(target);
props.onDraftChange(target.value); props.onDraftChange(target.value);
}} }}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)} @paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${composePlaceholder} placeholder=${composePlaceholder}
></textarea> ></textarea>
@ -359,14 +360,14 @@ export function renderChat(props: ChatProps) {
?disabled=${!props.connected || (!canAbort && props.sending)} ?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession} @click=${canAbort ? props.onAbort : props.onNewSession}
> >
${canAbort ? "Stop" : "New session"} ${canAbort ? t("chat.stop") : t("chat.newSession")}
</button> </button>
<button <button
class="btn primary" class="btn primary"
?disabled=${!props.connected} ?disabled=${!props.connected}
@click=${props.onSend} @click=${props.onSend}
> >
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd"></kbd> ${isBusy ? t("chat.queue") : t("chat.send")}<kbd class="btn-kbd"></kbd>
</button> </button>
</div> </div>
</div> </div>
@ -425,7 +426,7 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
key: "chat:history:notice", key: "chat:history:notice",
message: { message: {
role: "system", 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(), timestamp: Date.now(),
}, },
}); });

View File

@ -1,4 +1,5 @@
import { html, nothing, type TemplateResult } from "lit"; import { html, nothing, type TemplateResult } from "lit";
import { t } from "../i18n";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { import {
defaultValue, defaultValue,
@ -56,7 +57,7 @@ export function renderNode(params: {
if (unsupported.has(key)) { if (unsupported.has(key)) {
return html`<div class="cfg-field cfg-field--error"> return html`<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div> <div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported schema node. Use Raw mode.</div> <div class="cfg-field__error">${t("configNodes.unsupportedNode")}</div>
</div>`; </div>`;
} }
@ -210,7 +211,7 @@ export function renderNode(params: {
return html` return html`
<div class="cfg-field cfg-field--error"> <div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div> <div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported type: ${type}. Use Raw mode.</div> <div class="cfg-field__error">${t("configNodes.unsupportedType", { type })}</div>
</div> </div>
`; `;
} }
@ -233,7 +234,11 @@ function renderTextInput(params: {
const isSensitive = hint?.sensitive ?? isSensitivePath(path); const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = const placeholder =
hint?.placeholder ?? hint?.placeholder ??
(isSensitive ? "••••" : schema.default !== undefined ? `Default: ${schema.default}` : ""); (isSensitive
? "••••"
: schema.default !== undefined
? t("configNodes.defaultLabel", { value: String(schema.default) })
: "");
const displayValue = value ?? ""; const displayValue = value ?? "";
return html` return html`
@ -248,29 +253,29 @@ function renderTextInput(params: {
.value=${displayValue == null ? "" : String(displayValue)} .value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled} ?disabled=${disabled}
@input=${(e: Event) => { @input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value; const raw = (e.target as HTMLInputElement).value;
if (inputType === "number") { if (inputType === "number") {
if (raw.trim() === "") { if (raw.trim() === "") {
onPatch(path, undefined); onPatch(path, undefined);
return; return;
} }
const parsed = Number(raw); const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed); onPatch(path, Number.isNaN(parsed) ? raw : parsed);
return; return;
} }
onPatch(path, raw); onPatch(path, raw);
}} }}
@change=${(e: Event) => { @change=${(e: Event) => {
if (inputType === "number") return; if (inputType === "number") return;
const raw = (e.target as HTMLInputElement).value; const raw = (e.target as HTMLInputElement).value;
onPatch(path, raw.trim()); onPatch(path, raw.trim());
}} }}
/> />
${schema.default !== undefined ? html` ${schema.default !== undefined ? html`
<button <button
type="button" type="button"
class="cfg-input__reset" class="cfg-input__reset"
title="Reset to default" title=${t("configNodes.resetTitle")}
?disabled=${disabled} ?disabled=${disabled}
@click=${() => onPatch(path, schema.default)} @click=${() => onPatch(path, schema.default)}
></button> ></button>
@ -314,10 +319,10 @@ function renderNumberInput(params: {
.value=${displayValue == null ? "" : String(displayValue)} .value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled} ?disabled=${disabled}
@input=${(e: Event) => { @input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value; const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw); const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed); onPatch(path, parsed);
}} }}
/> />
<button <button
type="button" type="button"
@ -360,11 +365,11 @@ function renderSelect(params: {
?disabled=${disabled} ?disabled=${disabled}
.value=${currentIndex >= 0 ? String(currentIndex) : unset} .value=${currentIndex >= 0 ? String(currentIndex) : unset}
@change=${(e: Event) => { @change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value; const val = (e.target as HTMLSelectElement).value;
onPatch(path, val === unset ? undefined : options[Number(val)]); onPatch(path, val === unset ? undefined : options[Number(val)]);
}} }}
> >
<option value=${unset}>Select...</option> <option value=${unset}>${t("configNodes.selectPlaceholder")}</option>
${options.map((opt, idx) => html` ${options.map((opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option> <option value=${String(idx)}>${String(opt)}</option>
`)} `)}
@ -413,26 +418,26 @@ function renderObject(params: {
return html` return html`
<div class="cfg-fields"> <div class="cfg-fields">
${sorted.map(([propKey, node]) => ${sorted.map(([propKey, node]) =>
renderNode({ renderNode({
schema: node, schema: node,
value: obj[propKey], value: obj[propKey],
path: [...path, propKey], path: [...path, propKey],
hints, hints,
unsupported, unsupported,
disabled, disabled,
onPatch, onPatch,
}) })
)} )}
${allowExtra ? renderMapField({ ${allowExtra ? renderMapField({
schema: additional as JsonSchema, schema: additional as JsonSchema,
value: obj, value: obj,
path, path,
hints, hints,
unsupported, unsupported,
disabled, disabled,
reservedKeys: reserved, reservedKeys: reserved,
onPatch, onPatch,
}) : nothing} }) : nothing}
</div> </div>
`; `;
} }
@ -447,26 +452,26 @@ function renderObject(params: {
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
<div class="cfg-object__content"> <div class="cfg-object__content">
${sorted.map(([propKey, node]) => ${sorted.map(([propKey, node]) =>
renderNode({ renderNode({
schema: node, schema: node,
value: obj[propKey], value: obj[propKey],
path: [...path, propKey], path: [...path, propKey],
hints, hints,
unsupported, unsupported,
disabled, disabled,
onPatch, onPatch,
}) })
)} )}
${allowExtra ? renderMapField({ ${allowExtra ? renderMapField({
schema: additional as JsonSchema, schema: additional as JsonSchema,
value: obj, value: obj,
path, path,
hints, hints,
unsupported, unsupported,
disabled, disabled,
reservedKeys: reserved, reservedKeys: reserved,
onPatch, onPatch,
}) : nothing} }) : nothing}
</div> </div>
</details> </details>
`; `;
@ -504,25 +509,25 @@ function renderArray(params: {
<div class="cfg-array"> <div class="cfg-array">
<div class="cfg-array__header"> <div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing} ${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span> <span class="cfg-array__count">${t("configNodes.itemsCount", { count: arr.length })}</span>
<button <button
type="button" type="button"
class="cfg-array__add" class="cfg-array__add"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
const next = [...arr, defaultValue(itemsSchema)]; const next = [...arr, defaultValue(itemsSchema)];
onPatch(path, next); onPatch(path, next);
}} }}
> >
<span class="cfg-array__add-icon">${icons.plus}</span> <span class="cfg-array__add-icon">${icons.plus}</span>
Add ${t("configNodes.addItem")}
</button> </button>
</div> </div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
${arr.length === 0 ? html` ${arr.length === 0 ? html`
<div class="cfg-array__empty"> <div class="cfg-array__empty">
No items yet. Click "Add" to create one. ${t("configNodes.noItems")}
</div> </div>
` : html` ` : html`
<div class="cfg-array__items"> <div class="cfg-array__items">
@ -533,28 +538,28 @@ function renderArray(params: {
<button <button
type="button" type="button"
class="cfg-array__item-remove" class="cfg-array__item-remove"
title="Remove item" title=${t("configNodes.removeItem")}
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
const next = [...arr]; const next = [...arr];
next.splice(idx, 1); next.splice(idx, 1);
onPatch(path, next); onPatch(path, next);
}} }}
> >
${icons.trash} ${icons.trash}
</button> </button>
</div> </div>
<div class="cfg-array__item-content"> <div class="cfg-array__item-content">
${renderNode({ ${renderNode({
schema: itemsSchema, schema: itemsSchema,
value: item, value: item,
path: [...path, idx], path: [...path, idx],
hints, hints,
unsupported, unsupported,
disabled, disabled,
showLabel: false, showLabel: false,
onPatch, onPatch,
})} })}
</div> </div>
</div> </div>
`)} `)}
@ -581,106 +586,106 @@ function renderMapField(params: {
return html` return html`
<div class="cfg-map"> <div class="cfg-map">
<div class="cfg-map__header"> <div class="cfg-map__header">
<span class="cfg-map__label">Custom entries</span> <span class="cfg-map__label">${t("configNodes.customEntries")}</span>
<button <button
type="button" type="button"
class="cfg-map__add" class="cfg-map__add"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
const next = { ...(value ?? {}) }; const next = { ...(value ?? {}) };
let index = 1; let index = 1;
let key = `custom-${index}`; let key = `custom-${index}`;
while (key in next) { while (key in next) {
index += 1; index += 1;
key = `custom-${index}`; key = `custom-${index}`;
} }
next[key] = anySchema ? {} : defaultValue(schema); next[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, next); onPatch(path, next);
}} }}
> >
<span class="cfg-map__add-icon">${icons.plus}</span> <span class="cfg-map__add-icon">${icons.plus}</span>
Add Entry ${t("configNodes.addEntry")}
</button> </button>
</div> </div>
${entries.length === 0 ? html` ${entries.length === 0 ? html`
<div class="cfg-map__empty">No custom entries.</div> <div class="cfg-map__empty">${t("configNodes.noCustomEntries")}</div>
` : html` ` : html`
<div class="cfg-map__items"> <div class="cfg-map__items">
${entries.map(([key, entryValue]) => { ${entries.map(([key, entryValue]) => {
const valuePath = [...path, key]; const valuePath = [...path, key];
const fallback = jsonValue(entryValue); const fallback = jsonValue(entryValue);
return html` return html`
<div class="cfg-map__item"> <div class="cfg-map__item">
<div class="cfg-map__item-key"> <div class="cfg-map__item-key">
<input <input
type="text" type="text"
class="cfg-input cfg-input--sm" class="cfg-input cfg-input--sm"
placeholder="Key" placeholder=${t("configNodes.keyPlaceholder")}
.value=${key} .value=${key}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => { @change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim(); const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) return; if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) }; const next = { ...(value ?? {}) };
if (nextKey in next) return; if (nextKey in next) return;
next[nextKey] = next[key]; next[nextKey] = next[key];
delete next[key]; delete next[key];
onPatch(path, next); onPatch(path, next);
}} }}
/> />
</div> </div>
<div class="cfg-map__item-value"> <div class="cfg-map__item-value">
${anySchema ${anySchema
? html` ? html`
<textarea <textarea
class="cfg-textarea cfg-textarea--sm" class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value" placeholder=${t("configNodes.jsonValuePlaceholder")}
rows="2" rows="2"
.value=${fallback} .value=${fallback}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => { @change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement; const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim(); const raw = target.value.trim();
if (!raw) { if (!raw) {
onPatch(valuePath, undefined); onPatch(valuePath, undefined);
return; return;
} }
try { try {
onPatch(valuePath, JSON.parse(raw)); onPatch(valuePath, JSON.parse(raw));
} catch { } catch {
target.value = fallback; target.value = fallback;
} }
}} }}
></textarea> ></textarea>
` `
: renderNode({ : renderNode({
schema, schema,
value: entryValue, value: entryValue,
path: valuePath, path: valuePath,
hints, hints,
unsupported, unsupported,
disabled, disabled,
showLabel: false, showLabel: false,
onPatch, onPatch,
})} })}
</div> </div>
<button <button
type="button" type="button"
class="cfg-map__item-remove" class="cfg-map__item-remove"
title="Remove entry" title=${t("configNodes.removeEntry")}
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
const next = { ...(value ?? {}) }; const next = { ...(value ?? {}) };
delete next[key]; delete next[key];
onPatch(path, next); onPatch(path, next);
}} }}
> >
${icons.trash} ${icons.trash}
</button> </button>
</div> </div>
`; `;
})} })}
</div> </div>
`} `}
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { icons } from "../icons"; import { icons } from "../icons";
import { import {
@ -54,37 +55,15 @@ const sectionIcons = {
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`, default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
}; };
// Section metadata // Section metadata is now retrieved via getSectionMeta(key) to support localization
export const SECTION_META: Record<string, { label: string; description: string }> = { function getSectionMeta(key: string): { label: string; description: string } {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" }, return (
update: { label: "Updates", description: "Auto-update settings and release channel" }, (t(`configSections.${key}` as any) as any) ?? {
agents: { label: "Agents", description: "Agent configurations, models, and identities" }, label: key.charAt(0).toUpperCase() + key.slice(1),
auth: { label: "Authentication", description: "API keys and authentication profiles" }, description: "",
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" },
};
function getSectionIcon(key: string) { function getSectionIcon(key: string) {
return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default; 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 { function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
if (!query) return true; if (!query) return true;
const q = query.toLowerCase(); const q = query.toLowerCase();
const meta = SECTION_META[key]; const meta = getSectionMeta(key);
// Check key name // Check key name
if (key.toLowerCase().includes(q)) return true; if (key.toLowerCase().includes(q)) return true;
@ -142,12 +121,12 @@ function schemaMatches(schema: JsonSchema, query: string): boolean {
export function renderConfigForm(props: ConfigFormProps) { export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) { if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`; return html`<div class="muted">${t("configErrors.schemaUnavailable")}</div>`;
} }
const schema = props.schema; const schema = props.schema;
const value = props.value ?? {}; const value = props.value ?? {};
if (schemaType(schema) !== "object" || !schema.properties) { if (schemaType(schema) !== "object" || !schema.properties) {
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`; return html`<div class="callout danger">${t("configErrors.unsupportedSchema")}</div>`;
} }
const unsupported = new Set(props.unsupportedPaths ?? []); const unsupported = new Set(props.unsupportedPaths ?? []);
const properties = schema.properties; const properties = schema.properties;
@ -193,8 +172,8 @@ export function renderConfigForm(props: ConfigFormProps) {
<div class="config-empty__icon">${icons.search}</div> <div class="config-empty__icon">${icons.search}</div>
<div class="config-empty__text"> <div class="config-empty__text">
${searchQuery ${searchQuery
? `No settings match "${searchQuery}"` ? t("configErrors.noMatch", { query: searchQuery })
: "No settings in this section"} : t("configErrors.emptySection")}
</div> </div>
</div> </div>
`; `;
@ -203,75 +182,72 @@ export function renderConfigForm(props: ConfigFormProps) {
return html` return html`
<div class="config-form config-form--modern"> <div class="config-form config-form--modern">
${subsectionContext ${subsectionContext
? (() => { ? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext; const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints); const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey); const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? ""; const description = hint?.help ?? node.description ?? "";
const sectionValue = (value as Record<string, unknown>)[sectionKey]; const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue = const scopedValue =
sectionValue && typeof sectionValue === "object" sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey] ? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined; : undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`; const id = `config-section-${sectionKey}-${subsectionKey}`;
return html` return html`
<section class="config-section-card" id=${id}> <section class="config-section-card" id=${id}>
<div class="config-section-card__header"> <div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span> <span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
<div class="config-section-card__titles"> <div class="config-section-card__titles">
<h3 class="config-section-card__title">${label}</h3> <h3 class="config-section-card__title">${label}</h3>
${description ${description
? html`<p class="config-section-card__desc">${description}</p>` ? html`<p class="config-section-card__desc">${description}</p>`
: nothing} : nothing}
</div> </div>
</div> </div>
<div class="config-section-card__content"> <div class="config-section-card__content">
${renderNode({ ${renderNode({
schema: node, schema: node,
value: scopedValue, value: scopedValue,
path: [sectionKey, subsectionKey], path: [sectionKey, subsectionKey],
hints: props.uiHints, hints: props.uiHints,
unsupported, unsupported,
disabled: props.disabled ?? false, disabled: props.disabled ?? false,
showLabel: false, showLabel: false,
onPatch: props.onPatch, onPatch: props.onPatch,
})} })}
</div> </div>
</section> </section>
`; `;
})() })()
: filteredEntries.map(([key, node]) => { : filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? { const meta = getSectionMeta(key);
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
return html` return html`
<section class="config-section-card" id="config-section-${key}"> <section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header"> <div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span> <span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles"> <div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3> <h3 class="config-section-card__title">${meta.label}</h3>
${meta.description ${meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>` ? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing} : nothing}
</div> </div>
</div> </div>
<div class="config-section-card__content"> <div class="config-section-card__content">
${renderNode({ ${renderNode({
schema: node, schema: node,
value: (value as Record<string, unknown>)[key], value: (value as Record<string, unknown>)[key],
path: [key], path: [key],
hints: props.uiHints, hints: props.uiHints,
unsupported, unsupported,
disabled: props.disabled ?? false, disabled: props.disabled ?? false,
showLabel: false, showLabel: false,
onPatch: props.onPatch, onPatch: props.onPatch,
})} })}
</div> </div>
</section> </section>
`; `;
})} })}
</div> </div>
`; `;
} }

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { t } from "../i18n";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import { import {
hintForPath, hintForPath,
@ -75,18 +76,18 @@ const sidebarIcons = {
// Section definitions // Section definitions
const SECTIONS: Array<{ key: string; label: string }> = [ const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "env", label: "Environment" }, { key: "env", label: t("config.sections.env") },
{ key: "update", label: "Updates" }, { key: "update", label: t("config.sections.update") },
{ key: "agents", label: "Agents" }, { key: "agents", label: t("config.sections.agents") },
{ key: "auth", label: "Authentication" }, { key: "auth", label: t("config.sections.auth") },
{ key: "channels", label: "Channels" }, { key: "channels", label: t("config.sections.channels") },
{ key: "messages", label: "Messages" }, { key: "messages", label: t("config.sections.messages") },
{ key: "commands", label: "Commands" }, { key: "commands", label: t("config.sections.commands") },
{ key: "hooks", label: "Hooks" }, { key: "hooks", label: t("config.sections.hooks") },
{ key: "skills", label: "Skills" }, { key: "skills", label: t("config.sections.skills") },
{ key: "tools", label: "Tools" }, { key: "tools", label: t("config.sections.tools") },
{ key: "gateway", label: "Gateway" }, { key: "gateway", label: t("config.sections.gateway") },
{ key: "wizard", label: "Setup Wizard" }, { key: "wizard", label: t("config.sections.wizard") },
]; ];
type SubsectionEntry = { type SubsectionEntry = {
@ -210,10 +211,10 @@ export function renderConfig(props: ConfigProps) {
: null; : null;
const subsections = props.activeSection const subsections = props.activeSection
? resolveSubsections({ ? resolveSubsections({
key: props.activeSection, key: props.activeSection,
schema: activeSectionSchema, schema: activeSectionSchema,
uiHints: props.uiHints, uiHints: props.uiHints,
}) })
: []; : [];
const allowSubnav = const allowSubnav =
props.formMode === "form" && props.formMode === "form" &&
@ -255,8 +256,8 @@ export function renderConfig(props: ConfigProps) {
<!-- Sidebar --> <!-- Sidebar -->
<aside class="config-sidebar"> <aside class="config-sidebar">
<div class="config-sidebar__header"> <div class="config-sidebar__header">
<div class="config-sidebar__title">Settings</div> <div class="config-sidebar__title">${t("nav.settings")}</div>
<span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${validity}</span> <span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${t(`common.${validity}`)}</span>
</div> </div>
<!-- Search --> <!-- Search -->
@ -268,7 +269,7 @@ export function renderConfig(props: ConfigProps) {
<input <input
type="text" type="text"
class="config-search__input" class="config-search__input"
placeholder="Search settings..." placeholder=${t("config.searchPlaceholder")}
.value=${props.searchQuery} .value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)} @input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/> />
@ -287,7 +288,7 @@ export function renderConfig(props: ConfigProps) {
@click=${() => props.onSectionChange(null)} @click=${() => props.onSectionChange(null)}
> >
<span class="config-nav__icon">${sidebarIcons.all}</span> <span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span> <span class="config-nav__label">${t("config.allSettings")}</span>
</button> </button>
${allSections.map(section => html` ${allSections.map(section => html`
<button <button
@ -308,13 +309,13 @@ export function renderConfig(props: ConfigProps) {
?disabled=${props.schemaLoading || !props.schema} ?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")} @click=${() => props.onFormModeChange("form")}
> >
Form ${t("config.formMode")}
</button> </button>
<button <button
class="config-mode-toggle__btn ${props.formMode === "raw" ? "active" : ""}" class="config-mode-toggle__btn ${props.formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")} @click=${() => props.onFormModeChange("raw")}
> >
Raw ${t("config.rawMode")}
</button> </button>
</div> </div>
</div> </div>
@ -326,35 +327,35 @@ export function renderConfig(props: ConfigProps) {
<div class="config-actions"> <div class="config-actions">
<div class="config-actions__left"> <div class="config-actions__left">
${hasChanges ? html` ${hasChanges ? html`
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span> <span class="config-changes-badge">${props.formMode === "raw" ? t("config.unsavedChanges") : t("config.unsavedChangeCount", { count: diff.length })}</span>
` : html` ` : html`
<span class="config-status muted">No changes</span> <span class="config-status muted">${t("config.noChanges")}</span>
`} `}
</div> </div>
<div class="config-actions__right"> <div class="config-actions__right">
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}> <button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"} ${props.loading ? t("config.reloading") : t("config.reload")}
</button> </button>
<button <button
class="btn btn--sm primary" class="btn btn--sm primary"
?disabled=${!canSave} ?disabled=${!canSave}
@click=${props.onSave} @click=${props.onSave}
> >
${props.saving ? "Saving…" : "Save"} ${props.saving ? t("config.saving") : t("config.save")}
</button> </button>
<button <button
class="btn btn--sm" class="btn btn--sm"
?disabled=${!canApply} ?disabled=${!canApply}
@click=${props.onApply} @click=${props.onApply}
> >
${props.applying ? "Applying…" : "Apply"} ${props.applying ? t("config.applying") : t("config.apply")}
</button> </button>
<button <button
class="btn btn--sm" class="btn btn--sm"
?disabled=${!canUpdate} ?disabled=${!canUpdate}
@click=${props.onUpdate} @click=${props.onUpdate}
> >
${props.updating ? "Updating…" : "Update"} ${props.updating ? t("config.updating") : t("config.update")}
</button> </button>
</div> </div>
</div> </div>
@ -363,7 +364,7 @@ export function renderConfig(props: ConfigProps) {
${hasChanges && props.formMode === "form" ? html` ${hasChanges && props.formMode === "form" ? html`
<details class="config-diff"> <details class="config-diff">
<summary class="config-diff__summary"> <summary class="config-diff__summary">
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span> <span>${t("config.viewChanges", { count: diff.length })}</span>
<svg class="config-diff__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="config-diff__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline> <polyline points="6 9 12 15 18 9"></polyline>
</svg> </svg>
@ -384,89 +385,87 @@ export function renderConfig(props: ConfigProps) {
` : nothing} ` : nothing}
${activeSectionMeta && props.formMode === "form" ${activeSectionMeta && props.formMode === "form"
? html` ? html`
<div class="config-section-hero"> <div class="config-section-hero">
<div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div> <div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div>
<div class="config-section-hero__text"> <div class="config-section-hero__text">
<div class="config-section-hero__title">${activeSectionMeta.label}</div> <div class="config-section-hero__title">${activeSectionMeta.label}</div>
${activeSectionMeta.description ${activeSectionMeta.description
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>` ? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
: nothing} : nothing}
</div> </div>
</div> </div>
` `
: nothing} : nothing}
${allowSubnav ${allowSubnav
? html` ? html`
<div class="config-subnav"> <div class="config-subnav">
<button <button
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}" class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)} @click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
> >
All ${t("config.allSubsections")}
</button> </button>
${subsections.map( ${subsections.map(
(entry) => html` (entry) => html`
<button <button
class="config-subnav__item ${ class="config-subnav__item ${effectiveSubsection === entry.key ? "active" : ""
effectiveSubsection === entry.key ? "active" : "" }"
}"
title=${entry.description || entry.label} title=${entry.description || entry.label}
@click=${() => props.onSubsectionChange(entry.key)} @click=${() => props.onSubsectionChange(entry.key)}
> >
${entry.label} ${entry.label}
</button> </button>
`, `,
)} )}
</div> </div>
` `
: nothing} : nothing}
<!-- Form content --> <!-- Form content -->
<div class="config-content"> <div class="config-content">
${props.formMode === "form" ${props.formMode === "form"
? html` ? html`
${props.schemaLoading ${props.schemaLoading
? html`<div class="config-loading"> ? html`<div class="config-loading">
<div class="config-loading__spinner"></div> <div class="config-loading__spinner"></div>
<span>Loading schema</span> <span>${t("config.loadingSchema")}</span>
</div>` </div>`
: renderConfigForm({ : renderConfigForm({
schema: analysis.schema, schema: analysis.schema,
uiHints: props.uiHints, uiHints: props.uiHints,
value: props.formValue, value: props.formValue,
disabled: props.loading || !props.formValue, disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths, unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch, onPatch: props.onFormPatch,
searchQuery: props.searchQuery, searchQuery: props.searchQuery,
activeSection: props.activeSection, activeSection: props.activeSection,
activeSubsection: effectiveSubsection, activeSubsection: effectiveSubsection,
})} })}
${formUnsafe ${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
Form view can't safely edit some fields. ${t("config.unsafeWarning")}
Use Raw to avoid losing config entries.
</div>` </div>`
: nothing} : nothing}
` `
: html` : html`
<label class="field config-raw-field"> <label class="field config-raw-field">
<span>Raw JSON5</span> <span>${t("config.rawLabel")}</span>
<textarea <textarea
.value=${props.raw} .value=${props.raw}
@input=${(e: Event) => @input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)} props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea> ></textarea>
</label> </label>
`} `}
</div> </div>
${props.issues.length > 0 ${props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
<pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre> <pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre>
</div>` </div>`
: nothing} : nothing}
</main> </main>
</div> </div>
`; `;

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatMs } from "../format"; import { formatMs } from "../format";
import { import {
@ -46,7 +47,7 @@ function buildChannelOptions(props: CronProps): string[] {
} }
function resolveChannelLabel(props: CronProps, channel: string): 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); const meta = props.channelMeta?.find((entry) => entry.id === channel);
if (meta?.label) return meta.label; if (meta?.label) return meta.label;
return props.channelLabels?.[channel] ?? channel; return props.channelLabels?.[channel] ?? channel;
@ -57,223 +58,223 @@ export function renderCron(props: CronProps) {
return html` return html`
<section class="grid grid-cols-2"> <section class="grid grid-cols-2">
<div class="card"> <div class="card">
<div class="card-title">Scheduler</div> <div class="card-title">${t("cron.scheduler")}</div>
<div class="card-sub">Gateway-owned cron scheduler status.</div> <div class="card-sub">${t("cron.schedulerSubtitle")}</div>
<div class="stat-grid" style="margin-top: 16px;"> <div class="stat-grid" style="margin-top: 16px;">
<div class="stat"> <div class="stat">
<div class="stat-label">Enabled</div> <div class="stat-label">${t("cron.enabled")}</div>
<div class="stat-value"> <div class="stat-value">
${props.status ${props.status
? props.status.enabled ? props.status.enabled
? "Yes" ? t("channels.yes")
: "No" : t("channels.no")
: "n/a"} : t("common.na")}
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Jobs</div> <div class="stat-label">${t("cron.jobs")}</div>
<div class="stat-value">${props.status?.jobs ?? "n/a"}</div> <div class="stat-value">${props.status?.jobs ?? t("common.na")}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Next wake</div> <div class="stat-label">${t("cron.nextWake")}</div>
<div class="stat-value">${formatNextRun(props.status?.nextWakeAtMs ?? null)}</div> <div class="stat-value">${formatNextRun(props.status?.nextWakeAtMs ?? null)}</div>
</div> </div>
</div> </div>
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"} ${props.loading ? t("common.loading") : t("common.refresh")}
</button> </button>
${props.error ? html`<span class="muted">${props.error}</span>` : nothing} ${props.error ? html`<span class="muted">${props.error}</span>` : nothing}
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">New Job</div> <div class="card-title">${t("cron.newJob")}</div>
<div class="card-sub">Create a scheduled wakeup or agent run.</div> <div class="card-sub">${t("cron.newJobSubtitle")}</div>
<div class="form-grid" style="margin-top: 16px;"> <div class="form-grid" style="margin-top: 16px;">
<label class="field"> <label class="field">
<span>Name</span> <span>${t("cron.name")}</span>
<input <input
.value=${props.form.name} .value=${props.form.name}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ name: (e.target as HTMLInputElement).value })} props.onFormChange({ name: (e.target as HTMLInputElement).value })}
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Description</span> <span>${t("cron.description")}</span>
<input <input
.value=${props.form.description} .value=${props.form.description}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ description: (e.target as HTMLInputElement).value })} props.onFormChange({ description: (e.target as HTMLInputElement).value })}
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Agent ID</span> <span>${t("cron.agentId")}</span>
<input <input
.value=${props.form.agentId} .value=${props.form.agentId}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ agentId: (e.target as HTMLInputElement).value })} props.onFormChange({ agentId: (e.target as HTMLInputElement).value })}
placeholder="default" placeholder="default"
/> />
</label> </label>
<label class="field checkbox"> <label class="field checkbox">
<span>Enabled</span> <span>${t("cron.enabled")}</span>
<input <input
type="checkbox" type="checkbox"
.checked=${props.form.enabled} .checked=${props.form.enabled}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })} props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })}
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Schedule</span> <span>${t("cron.schedule")}</span>
<select <select
.value=${props.form.scheduleKind} .value=${props.form.scheduleKind}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"], scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
})} })}
> >
<option value="every">Every</option> <option value="every">${t("cron.every")}</option>
<option value="at">At</option> <option value="at">${t("cron.at")}</option>
<option value="cron">Cron</option> <option value="cron">${t("cron.cron")}</option>
</select> </select>
</label> </label>
</div> </div>
${renderScheduleFields(props)} ${renderScheduleFields(props)}
<div class="form-grid" style="margin-top: 12px;"> <div class="form-grid" style="margin-top: 12px;">
<label class="field"> <label class="field">
<span>Session</span> <span>${t("cron.session")}</span>
<select <select
.value=${props.form.sessionTarget} .value=${props.form.sessionTarget}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"], sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
})} })}
> >
<option value="main">Main</option> <option value="main">${t("cron.main")}</option>
<option value="isolated">Isolated</option> <option value="isolated">${t("cron.isolated")}</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">
<span>Wake mode</span> <span>${t("cron.wakeMode")}</span>
<select <select
.value=${props.form.wakeMode} .value=${props.form.wakeMode}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"], wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
})} })}
> >
<option value="next-heartbeat">Next heartbeat</option> <option value="next-heartbeat">${t("cron.nextHeartbeat")}</option>
<option value="now">Now</option> <option value="now">${t("cron.now")}</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">
<span>Payload</span> <span>${t("cron.payload")}</span>
<select <select
.value=${props.form.payloadKind} .value=${props.form.payloadKind}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"], payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
})} })}
> >
<option value="systemEvent">System event</option> <option value="systemEvent">${t("cron.systemEvent")}</option>
<option value="agentTurn">Agent turn</option> <option value="agentTurn">${t("cron.agentTurn")}</option>
</select> </select>
</label> </label>
</div> </div>
<label class="field" style="margin-top: 12px;"> <label class="field" style="margin-top: 12px;">
<span>${props.form.payloadKind === "systemEvent" ? "System text" : "Agent message"}</span> <span>${props.form.payloadKind === "systemEvent" ? t("cron.systemText") : t("cron.agentMessage")}</span>
<textarea <textarea
.value=${props.form.payloadText} .value=${props.form.payloadText}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ props.onFormChange({
payloadText: (e.target as HTMLTextAreaElement).value, payloadText: (e.target as HTMLTextAreaElement).value,
})} })}
rows="4" rows="4"
></textarea> ></textarea>
</label> </label>
${props.form.payloadKind === "agentTurn" ${props.form.payloadKind === "agentTurn"
? html` ? html`
<div class="form-grid" style="margin-top: 12px;"> <div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox"> <label class="field checkbox">
<span>Deliver</span> <span>${t("cron.deliver")}</span>
<input <input
type="checkbox" type="checkbox"
.checked=${props.form.deliver} .checked=${props.form.deliver}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
deliver: (e.target as HTMLInputElement).checked, deliver: (e.target as HTMLInputElement).checked,
})} })}
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Channel</span> <span>${t("cron.channel")}</span>
<select <select
.value=${props.form.channel || "last"} .value=${props.form.channel || "last"}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"], channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})} })}
> >
${channelOptions.map( ${channelOptions.map(
(channel) => (channel) =>
html`<option value=${channel}> html`<option value=${channel}>
${resolveChannelLabel(props, channel)} ${resolveChannelLabel(props, channel)}
</option>`, </option>`,
)} )}
</select> </select>
</label> </label>
<label class="field"> <label class="field">
<span>To</span> <span>${t("cron.to")}</span>
<input <input
.value=${props.form.to} .value=${props.form.to}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ to: (e.target as HTMLInputElement).value })} props.onFormChange({ to: (e.target as HTMLInputElement).value })}
placeholder="+1555… or chat id" placeholder="+1555… or chat id"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Timeout (seconds)</span> <span>${t("cron.timeout")}</span>
<input <input
.value=${props.form.timeoutSeconds} .value=${props.form.timeoutSeconds}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ props.onFormChange({
timeoutSeconds: (e.target as HTMLInputElement).value, timeoutSeconds: (e.target as HTMLInputElement).value,
})} })}
/> />
</label> </label>
${props.form.sessionTarget === "isolated" ${props.form.sessionTarget === "isolated"
? html` ? html`
<label class="field"> <label class="field">
<span>Post to main prefix</span> <span>${t("cron.postToMainPrefix")}</span>
<input <input
.value=${props.form.postToMainPrefix} .value=${props.form.postToMainPrefix}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ props.onFormChange({
postToMainPrefix: (e.target as HTMLInputElement).value, postToMainPrefix: (e.target as HTMLInputElement).value,
})} })}
/> />
</label> </label>
` `
: nothing} : nothing}
</div> </div>
` `
: nothing} : nothing}
<div class="row" style="margin-top: 14px;"> <div class="row" style="margin-top: 14px;">
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}> <button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
${props.busy ? "Saving…" : "Add job"} ${props.busy ? t("cron.saving") : t("cron.addJob")}
</button> </button>
</div> </div>
</div> </div>
</section> </section>
<section class="card" style="margin-top: 18px;"> <section class="card" style="margin-top: 18px;">
<div class="card-title">Jobs</div> <div class="card-title">${t("cron.jobsTitle")}</div>
<div class="card-sub">All scheduled jobs stored in the gateway.</div> <div class="card-sub">${t("cron.jobsSubtitle")}</div>
${props.jobs.length === 0 ${props.jobs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>` ? html`<div class="muted" style="margin-top: 12px;">${t("cron.noJobs")}</div>`
: html` : html`
<div class="list" style="margin-top: 12px;"> <div class="list" style="margin-top: 12px;">
${props.jobs.map((job) => renderJob(job, props))} ${props.jobs.map((job) => renderJob(job, props))}
</div> </div>
@ -281,17 +282,17 @@ export function renderCron(props: CronProps) {
</section> </section>
<section class="card" style="margin-top: 18px;"> <section class="card" style="margin-top: 18px;">
<div class="card-title">Run history</div> <div class="card-title">${t("cron.runHistory")}</div>
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div> <div class="card-sub">${t("cron.latestRuns", { id: props.runsJobId ?? t("cron.selectJob") })}.</div>
${props.runsJobId == null ${props.runsJobId == null
? html` ? html`
<div class="muted" style="margin-top: 12px;"> <div class="muted" style="margin-top: 12px;">
Select a job to inspect run history. ${t("cron.selectJobHint")}
</div> </div>
` `
: props.runs.length === 0 : props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>` ? html`<div class="muted" style="margin-top: 12px;">${t("cron.noRuns")}</div>`
: html` : html`
<div class="list" style="margin-top: 12px;"> <div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))} ${props.runs.map((entry) => renderRun(entry))}
</div> </div>
@ -305,14 +306,14 @@ function renderScheduleFields(props: CronProps) {
if (form.scheduleKind === "at") { if (form.scheduleKind === "at") {
return html` return html`
<label class="field" style="margin-top: 12px;"> <label class="field" style="margin-top: 12px;">
<span>Run at</span> <span>${t("cron.runAt")}</span>
<input <input
type="datetime-local" type="datetime-local"
.value=${form.scheduleAt} .value=${form.scheduleAt}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ props.onFormChange({
scheduleAt: (e.target as HTMLInputElement).value, scheduleAt: (e.target as HTMLInputElement).value,
})} })}
/> />
</label> </label>
`; `;
@ -321,27 +322,27 @@ function renderScheduleFields(props: CronProps) {
return html` return html`
<div class="form-grid" style="margin-top: 12px;"> <div class="form-grid" style="margin-top: 12px;">
<label class="field"> <label class="field">
<span>Every</span> <span>${t("cron.every")}</span>
<input <input
.value=${form.everyAmount} .value=${form.everyAmount}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ props.onFormChange({
everyAmount: (e.target as HTMLInputElement).value, everyAmount: (e.target as HTMLInputElement).value,
})} })}
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Unit</span> <span>${t("cron.unit")}</span>
<select <select
.value=${form.everyUnit} .value=${form.everyUnit}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"], everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
})} })}
> >
<option value="minutes">Minutes</option> <option value="minutes">${t("cron.minutes")}</option>
<option value="hours">Hours</option> <option value="hours">${t("cron.hours")}</option>
<option value="days">Days</option> <option value="days">${t("cron.days")}</option>
</select> </select>
</label> </label>
</div> </div>
@ -350,19 +351,19 @@ function renderScheduleFields(props: CronProps) {
return html` return html`
<div class="form-grid" style="margin-top: 12px;"> <div class="form-grid" style="margin-top: 12px;">
<label class="field"> <label class="field">
<span>Expression</span> <span>${t("cron.expression")}</span>
<input <input
.value=${form.cronExpr} .value=${form.cronExpr}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })} props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Timezone (optional)</span> <span>${t("cron.timezone")}</span>
<input <input
.value=${form.cronTz} .value=${form.cronTz}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })} props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
/> />
</label> </label>
</div> </div>
@ -378,9 +379,9 @@ function renderJob(job: CronJob, props: CronProps) {
<div class="list-title">${job.name}</div> <div class="list-title">${job.name}</div>
<div class="list-sub">${formatCronSchedule(job)}</div> <div class="list-sub">${formatCronSchedule(job)}</div>
<div class="muted">${formatCronPayload(job)}</div> <div class="muted">${formatCronPayload(job)}</div>
${job.agentId ? html`<div class="muted">Agent: ${job.agentId}</div>` : nothing} ${job.agentId ? html`<div class="muted">${t("cron.agentId")}: ${job.agentId}</div>` : nothing}
<div class="chip-row" style="margin-top: 6px;"> <div class="chip-row" style="margin-top: 6px;">
<span class="chip">${job.enabled ? "enabled" : "disabled"}</span> <span class="chip">${job.enabled ? t("cron.enable") : t("cron.disable")}</span>
<span class="chip">${job.sessionTarget}</span> <span class="chip">${job.sessionTarget}</span>
<span class="chip">${job.wakeMode}</span> <span class="chip">${job.wakeMode}</span>
</div> </div>
@ -392,41 +393,41 @@ function renderJob(job: CronJob, props: CronProps) {
class="btn" class="btn"
?disabled=${props.busy} ?disabled=${props.busy}
@click=${(event: Event) => { @click=${(event: Event) => {
event.stopPropagation(); event.stopPropagation();
props.onToggle(job, !job.enabled); props.onToggle(job, !job.enabled);
}} }}
> >
${job.enabled ? "Disable" : "Enable"} ${job.enabled ? t("cron.disable") : t("cron.enable")}
</button> </button>
<button <button
class="btn" class="btn"
?disabled=${props.busy} ?disabled=${props.busy}
@click=${(event: Event) => { @click=${(event: Event) => {
event.stopPropagation(); event.stopPropagation();
props.onRun(job); props.onRun(job);
}} }}
> >
Run ${t("cron.run")}
</button> </button>
<button <button
class="btn" class="btn"
?disabled=${props.busy} ?disabled=${props.busy}
@click=${(event: Event) => { @click=${(event: Event) => {
event.stopPropagation(); event.stopPropagation();
props.onLoadRuns(job.id); props.onLoadRuns(job.id);
}} }}
> >
Runs ${t("cron.runs")}
</button> </button>
<button <button
class="btn danger" class="btn danger"
?disabled=${props.busy} ?disabled=${props.busy}
@click=${(event: Event) => { @click=${(event: Event) => {
event.stopPropagation(); event.stopPropagation();
props.onRemove(job); props.onRemove(job);
}} }}
> >
Remove ${t("cron.remove")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatEventPayload } from "../presenter"; import { formatEventPayload } from "../presenter";
import type { EventLogEntry } from "../app-events"; 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 securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success";
const securityLabel = const securityLabel =
critical > 0 critical > 0
? `${critical} critical` ? t("debug.criticalIssues", { count: critical })
: warn > 0 : warn > 0
? `${warn} warnings` ? t("debug.warningIssues", { count: warn })
: "No critical issues"; : t("debug.noCriticalIssues");
return html` return html`
<section class="grid grid-cols-2"> <section class="grid grid-cols-2">
<div class="card"> <div class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Snapshots</div> <div class="card-title">${t("debug.snapshotsTitle")}</div>
<div class="card-sub">Status, health, and heartbeat data.</div> <div class="card-sub">${t("debug.snapshotsSubtitle")}</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Refreshing…" : "Refresh"} ${props.loading ? t("debug.refreshing") : t("common.refresh")}
</button> </button>
</div> </div>
<div class="stack" style="margin-top: 12px;"> <div class="stack" style="margin-top: 12px;">
<div> <div>
<div class="muted">Status</div> <div class="muted">${t("debug.status")}</div>
${securitySummary ${securitySummary
? html`<div class="callout ${securityTone}" style="margin-top: 8px;"> ? html`<div class="callout ${securityTone}" style="margin-top: 8px;">
Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run ${t("debug.securityAudit", {
<span class="mono">openclaw security audit --deep</span> for details. label: securityLabel + (info > 0 ? ` · ${t("debug.infoIssues", { count: info })}` : ""),
})}
</div>` </div>`
: nothing} : nothing}
<pre class="code-block">${JSON.stringify(props.status ?? {}, null, 2)}</pre> <pre class="code-block">${JSON.stringify(props.status ?? {}, null, 2)}</pre>
</div> </div>
<div> <div>
<div class="muted">Health</div> <div class="muted">${t("debug.health")}</div>
<pre class="code-block">${JSON.stringify(props.health ?? {}, null, 2)}</pre> <pre class="code-block">${JSON.stringify(props.health ?? {}, null, 2)}</pre>
</div> </div>
<div> <div>
<div class="muted">Last heartbeat</div> <div class="muted">${t("debug.lastHeartbeat")}</div>
<pre class="code-block">${JSON.stringify(props.heartbeat ?? {}, null, 2)}</pre> <pre class="code-block">${JSON.stringify(props.heartbeat ?? {}, null, 2)}</pre>
</div> </div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">Manual RPC</div> <div class="card-title">${t("debug.manualRpcTitle")}</div>
<div class="card-sub">Send a raw gateway method with JSON params.</div> <div class="card-sub">${t("debug.manualRpcSubtitle")}</div>
<div class="form-grid" style="margin-top: 16px;"> <div class="form-grid" style="margin-top: 16px;">
<label class="field"> <label class="field">
<span>Method</span> <span>${t("debug.methodLabel")}</span>
<input <input
.value=${props.callMethod} .value=${props.callMethod}
@input=${(e: Event) => @input=${(e: Event) =>
props.onCallMethodChange((e.target as HTMLInputElement).value)} props.onCallMethodChange((e.target as HTMLInputElement).value)}
placeholder="system-presence" placeholder="system-presence"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Params (JSON)</span> <span>${t("debug.paramsLabel")}</span>
<textarea <textarea
.value=${props.callParams} .value=${props.callParams}
@input=${(e: Event) => @input=${(e: Event) =>
props.onCallParamsChange((e.target as HTMLTextAreaElement).value)} props.onCallParamsChange((e.target as HTMLTextAreaElement).value)}
rows="6" rows="6"
></textarea> ></textarea>
</label> </label>
</div> </div>
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn primary" @click=${props.onCall}>Call</button> <button class="btn primary" @click=${props.onCall}>${t("debug.call")}</button>
</div> </div>
${props.callError ${props.callError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${props.callError} ${props.callError}
</div>` </div>`
: nothing} : nothing}
${props.callResult ${props.callResult
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>` ? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>`
: nothing} : nothing}
</div> </div>
</section> </section>
<section class="card" style="margin-top: 18px;"> <section class="card" style="margin-top: 18px;">
<div class="card-title">Models</div> <div class="card-title">${t("debug.modelsTitle")}</div>
<div class="card-sub">Catalog from models.list.</div> <div class="card-sub">${t("debug.modelsSubtitle")}</div>
<pre class="code-block" style="margin-top: 12px;">${JSON.stringify( <pre class="code-block" style="margin-top: 12px;">${JSON.stringify(
props.models ?? [], props.models ?? [],
null, null,
@ -119,14 +121,14 @@ export function renderDebug(props: DebugProps) {
</section> </section>
<section class="card" style="margin-top: 18px;"> <section class="card" style="margin-top: 18px;">
<div class="card-title">Event Log</div> <div class="card-title">${t("debug.eventLogTitle")}</div>
<div class="card-sub">Latest gateway events.</div> <div class="card-sub">${t("debug.eventLogSubtitle")}</div>
${props.eventLog.length === 0 ${props.eventLog.length === 0
? html`<div class="muted" style="margin-top: 12px;">No events yet.</div>` ? html`<div class="muted" style="margin-top: 12px;">${t("debug.noEvents")}</div>`
: html` : html`
<div class="list" style="margin-top: 12px;"> <div class="list" style="margin-top: 12px;">
${props.eventLog.map( ${props.eventLog.map(
(evt) => html` (evt) => html`
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">${evt.event}</div> <div class="list-title">${evt.event}</div>
@ -137,7 +139,7 @@ export function renderDebug(props: DebugProps) {
</div> </div>
</div> </div>
`, `,
)} )}
</div> </div>
`} `}
</section> </section>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import type { AppViewState } from "../app-view-state"; import type { AppViewState } from "../app-view-state";
@ -22,54 +23,54 @@ export function renderExecApprovalPrompt(state: AppViewState) {
if (!active) return nothing; if (!active) return nothing;
const request = active.request; const request = active.request;
const remainingMs = active.expiresAtMs - Date.now(); 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; const queueCount = state.execApprovalQueue.length;
return html` return html`
<div class="exec-approval-overlay" role="dialog" aria-live="polite"> <div class="exec-approval-overlay" role="dialog" aria-live="polite">
<div class="exec-approval-card"> <div class="exec-approval-card">
<div class="exec-approval-header"> <div class="exec-approval-header">
<div> <div>
<div class="exec-approval-title">Exec approval needed</div> <div class="exec-approval-title">${t("execApproval.title")}</div>
<div class="exec-approval-sub">${remaining}</div> <div class="exec-approval-sub">${remaining}</div>
</div> </div>
${queueCount > 1 ${queueCount > 1
? html`<div class="exec-approval-queue">${queueCount} pending</div>` ? html`<div class="exec-approval-queue">${t("execApproval.pending", { count: queueCount })}</div>`
: nothing} : nothing}
</div> </div>
<div class="exec-approval-command mono">${request.command}</div> <div class="exec-approval-command mono">${request.command}</div>
<div class="exec-approval-meta"> <div class="exec-approval-meta">
${renderMetaRow("Host", request.host)} ${renderMetaRow(t("execApproval.host"), request.host)}
${renderMetaRow("Agent", request.agentId)} ${renderMetaRow(t("execApproval.agent"), request.agentId)}
${renderMetaRow("Session", request.sessionKey)} ${renderMetaRow(t("execApproval.session"), request.sessionKey)}
${renderMetaRow("CWD", request.cwd)} ${renderMetaRow(t("execApproval.cwd"), request.cwd)}
${renderMetaRow("Resolved", request.resolvedPath)} ${renderMetaRow(t("execApproval.resolved"), request.resolvedPath)}
${renderMetaRow("Security", request.security)} ${renderMetaRow(t("execApproval.security"), request.security)}
${renderMetaRow("Ask", request.ask)} ${renderMetaRow(t("execApproval.ask"), request.ask)}
</div> </div>
${state.execApprovalError ${state.execApprovalError
? html`<div class="exec-approval-error">${state.execApprovalError}</div>` ? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing} : nothing}
<div class="exec-approval-actions"> <div class="exec-approval-actions">
<button <button
class="btn primary" class="btn primary"
?disabled=${state.execApprovalBusy} ?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-once")} @click=${() => state.handleExecApprovalDecision("allow-once")}
> >
Allow once ${t("execApproval.allowOnce")}
</button> </button>
<button <button
class="btn" class="btn"
?disabled=${state.execApprovalBusy} ?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("allow-always")} @click=${() => state.handleExecApprovalDecision("allow-always")}
> >
Always allow ${t("execApproval.allowAlways")}
</button> </button>
<button <button
class="btn danger" class="btn danger"
?disabled=${state.execApprovalBusy} ?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision("deny")} @click=${() => state.handleExecApprovalDecision("deny")}
> >
Deny ${t("execApproval.deny")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import type { AppViewState } from "../app-view-state"; import type { AppViewState } from "../app-view-state";
@ -11,26 +12,26 @@ export function renderGatewayUrlConfirmation(state: AppViewState) {
<div class="exec-approval-card"> <div class="exec-approval-card">
<div class="exec-approval-header"> <div class="exec-approval-header">
<div> <div>
<div class="exec-approval-title">Change Gateway URL</div> <div class="exec-approval-title">${t("gateway.changeTitle")}</div>
<div class="exec-approval-sub">This will reconnect to a different gateway server</div> <div class="exec-approval-sub">${t("gateway.changeSubtitle")}</div>
</div> </div>
</div> </div>
<div class="exec-approval-command mono">${pendingGatewayUrl}</div> <div class="exec-approval-command mono">${pendingGatewayUrl}</div>
<div class="callout danger" style="margin-top: 12px;"> <div class="callout danger" style="margin-top: 12px;">
Only confirm if you trust this URL. Malicious URLs can compromise your system. ${t("gateway.trustWarning")}
</div> </div>
<div class="exec-approval-actions"> <div class="exec-approval-actions">
<button <button
class="btn primary" class="btn primary"
@click=${() => state.handleGatewayUrlConfirm()} @click=${() => state.handleGatewayUrlConfirm()}
> >
Confirm ${t("gateway.confirm")}
</button> </button>
<button <button
class="btn" class="btn"
@click=${() => state.handleGatewayUrlCancel()} @click=${() => state.handleGatewayUrlCancel()}
> >
Cancel ${t("gateway.cancel")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatPresenceAge, formatPresenceSummary } from "../presenter"; import { formatPresenceAge, formatPresenceSummary } from "../presenter";
import type { PresenceEntry } from "../types"; import type { PresenceEntry } from "../types";
@ -16,27 +17,27 @@ export function renderInstances(props: InstancesProps) {
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Connected Instances</div> <div class="card-title">${t("instances.title")}</div>
<div class="card-sub">Presence beacons from the gateway and clients.</div> <div class="card-sub">${t("instances.subtitle")}</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? t("common.loading") : t("common.refresh")}
</button> </button>
</div> </div>
${props.lastError ${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError} ${props.lastError}
</div>` </div>`
: nothing} : nothing}
${props.statusMessage ${props.statusMessage
? html`<div class="callout" style="margin-top: 12px;"> ? html`<div class="callout" style="margin-top: 12px;">
${props.statusMessage} ${props.statusMessage}
</div>` </div>`
: nothing} : nothing}
<div class="list" style="margin-top: 16px;"> <div class="list" style="margin-top: 16px;">
${props.entries.length === 0 ${props.entries.length === 0
? html`<div class="muted">No instances reported yet.</div>` ? html`<div class="muted">${t("instances.noInstances")}</div>`
: props.entries.map((entry) => renderEntry(entry))} : props.entries.map((entry) => renderEntry(entry))}
</div> </div>
</section> </section>
`; `;
@ -45,21 +46,21 @@ export function renderInstances(props: InstancesProps) {
function renderEntry(entry: PresenceEntry) { function renderEntry(entry: PresenceEntry) {
const lastInput = const lastInput =
entry.lastInputSeconds != null entry.lastInputSeconds != null
? `${entry.lastInputSeconds}s ago` ? t("instances.ago", { time: `${entry.lastInputSeconds}s` })
: "n/a"; : t("common.na");
const mode = entry.mode ?? "unknown"; const mode = entry.mode ?? "unknown";
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
const scopesLabel = const scopesLabel =
scopes.length > 0 scopes.length > 0
? scopes.length > 3 ? scopes.length > 3
? `${scopes.length} scopes` ? t("instances.scopes", { count: scopes.length })
: `scopes: ${scopes.join(", ")}` : `scopes: ${scopes.join(", ")}`
: null; : null;
return html` return html`
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">${entry.host ?? "unknown host"}</div> <div class="list-title">${entry.host ?? t("instances.unknownHost")}</div>
<div class="list-sub">${formatPresenceSummary(entry)}</div> <div class="list-sub">${formatPresenceSummary(entry)}</div>
<div class="chip-row"> <div class="chip-row">
<span class="chip">${mode}</span> <span class="chip">${mode}</span>
@ -67,18 +68,18 @@ function renderEntry(entry: PresenceEntry) {
${scopesLabel ? html`<span class="chip">${scopesLabel}</span>` : nothing} ${scopesLabel ? html`<span class="chip">${scopesLabel}</span>` : nothing}
${entry.platform ? html`<span class="chip">${entry.platform}</span>` : nothing} ${entry.platform ? html`<span class="chip">${entry.platform}</span>` : nothing}
${entry.deviceFamily ${entry.deviceFamily
? html`<span class="chip">${entry.deviceFamily}</span>` ? html`<span class="chip">${entry.deviceFamily}</span>`
: nothing} : nothing}
${entry.modelIdentifier ${entry.modelIdentifier
? html`<span class="chip">${entry.modelIdentifier}</span>` ? html`<span class="chip">${entry.modelIdentifier}</span>`
: nothing} : nothing}
${entry.version ? html`<span class="chip">${entry.version}</span>` : nothing} ${entry.version ? html`<span class="chip">${entry.version}</span>` : nothing}
</div> </div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<div>${formatPresenceAge(entry)}</div> <div>${formatPresenceAge(entry)}</div>
<div class="muted">Last input ${lastInput}</div> <div class="muted">${t("instances.lastInput")} ${lastInput}</div>
<div class="muted">Reason ${entry.reason ?? ""}</div> <div class="muted">${t("instances.reason")} ${entry.reason ?? ""}</div>
</div> </div>
</div> </div>
`; `;

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import type { LogEntry, LogLevel } from "../types"; import type { LogEntry, LogLevel } from "../types";
@ -44,83 +45,84 @@ export function renderLogs(props: LogsProps) {
if (entry.level && !props.levelFilters[entry.level]) return false; if (entry.level && !props.levelFilters[entry.level]) return false;
return matchesFilter(entry, needle); return matchesFilter(entry, needle);
}); });
const exportLabel = needle || levelFiltered ? "filtered" : "visible"; const exportLabel = needle || levelFiltered ? t("logs.exportFiltered") : t("logs.exportVisible");
return html` return html`
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Logs</div> <div class="card-title">${t("logs.logsTitle")}</div>
<div class="card-sub">Gateway file logs (JSONL).</div> <div class="card-sub">${t("logs.logsSubtitle")}</div>
</div> </div>
<div class="row" style="gap: 8px;"> <div class="row" style="gap: 8px;">
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? t("common.loading") : t("common.refresh")}
</button> </button>
<button <button
class="btn" class="btn"
?disabled=${filtered.length === 0} ?disabled=${filtered.length === 0}
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)} @click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
> >
Export ${exportLabel} ${t("logs.export", { label: exportLabel })}
</button> </button>
</div> </div>
</div> </div>
<div class="filters" style="margin-top: 14px;"> <div class="filters" style="margin-top: 14px;">
<label class="field" style="min-width: 220px;"> <label class="field" style="min-width: 220px;">
<span>Filter</span> <span>${t("logs.filterLabel")}</span>
<input <input
class="form-control"
.value=${props.filterText} .value=${props.filterText}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFilterTextChange((e.target as HTMLInputElement).value)} props.onFilterTextChange((e.target as HTMLInputElement).value)}
placeholder="Search logs" placeholder=${t("logs.searchPlaceholder")}
/> />
</label> </label>
<label class="field checkbox"> <label class="field checkbox">
<span>Auto-follow</span> <span>${t("logs.autoFollow")}</span>
<input <input
type="checkbox" type="checkbox"
.checked=${props.autoFollow} .checked=${props.autoFollow}
@change=${(e: Event) => @change=${(e: Event) =>
props.onToggleAutoFollow((e.target as HTMLInputElement).checked)} props.onToggleAutoFollow((e.target as HTMLInputElement).checked)}
/> />
</label> </label>
</div> </div>
<div class="chip-row" style="margin-top: 12px;"> <div class="chip-row" style="margin-top: 12px;">
${LEVELS.map( ${LEVELS.map(
(level) => html` (level) => html`
<label class="chip log-chip ${level}"> <label class="chip log-chip ${level}">
<input <input
type="checkbox" type="checkbox"
.checked=${props.levelFilters[level]} .checked=${props.levelFilters[level]}
@change=${(e: Event) => @change=${(e: Event) =>
props.onLevelToggle(level, (e.target as HTMLInputElement).checked)} props.onLevelToggle(level, (e.target as HTMLInputElement).checked)}
/> />
<span>${level}</span> <span>${level}</span>
</label> </label>
`, `,
)} )}
</div> </div>
${props.file ${props.file
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>` ? html`<div class="muted" style="margin-top: 10px;">${t("logs.fileLabel")}: ${props.file}</div>`
: nothing} : nothing}
${props.truncated ${props.truncated
? html`<div class="callout" style="margin-top: 10px;"> ? html`<div class="callout" style="margin-top: 10px;">
Log output truncated; showing latest chunk. ${t("logs.truncatedWarn")}
</div>` </div>`
: nothing} : nothing}
${props.error ${props.error
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>` ? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>`
: nothing} : nothing}
<div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}> <div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}>
${filtered.length === 0 ${filtered.length === 0
? html`<div class="muted" style="padding: 12px;">No log entries.</div>` ? html`<div class="muted" style="padding: 12px;">${t("logs.noEntries")}</div>`
: filtered.map( : filtered.map(
(entry) => html` (entry) => html`
<div class="log-row"> <div class="log-row">
<div class="log-time mono">${formatTime(entry.time)}</div> <div class="log-time mono">${formatTime(entry.time)}</div>
<div class="log-level ${entry.level ?? ""}">${entry.level ?? ""}</div> <div class="log-level ${entry.level ?? ""}">${entry.level ?? ""}</div>
@ -128,7 +130,7 @@ export function renderLogs(props: LogsProps) {
<div class="log-message mono">${entry.message ?? entry.raw}</div> <div class="log-message mono">${entry.message ?? entry.raw}</div>
</div> </div>
`, `,
)} )}
</div> </div>
</section> </section>
`; `;

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../icons"; import { icons } from "../icons";
@ -15,22 +16,22 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
return html` return html`
<div class="sidebar-panel"> <div class="sidebar-panel">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-title">Tool Output</div> <div class="sidebar-title">${t("markdownSidebar.title")}</div>
<button @click=${props.onClose} class="btn" title="Close sidebar"> <button @click=${props.onClose} class="btn" title=${t("markdownSidebar.close")}>
${icons.x} ${icons.x}
</button> </button>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
${props.error ${props.error
? html` ? html`
<div class="callout danger">${props.error}</div> <div class="callout danger">${props.error}</div>
<button @click=${props.onViewRawText} class="btn" style="margin-top: 12px;"> <button @click=${props.onViewRawText} class="btn" style="margin-top: 12px;">
View Raw Text ${t("markdownSidebar.viewRaw")}
</button> </button>
` `
: props.content : props.content
? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>` ? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>`
: html`<div class="muted">No content available</div>`} : html`<div class="muted">${t("markdownSidebar.noContent")}</div>`}
</div> </div>
</div> </div>
`; `;

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { clampText, formatAgo, formatList } from "../format"; import { clampText, formatAgo, formatList } from "../format";
import type { import type {
@ -60,17 +61,17 @@ export function renderNodes(props: NodesProps) {
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Nodes</div> <div class="card-title">${t("nodes.nodesTitle")}</div>
<div class="card-sub">Paired devices and live links.</div> <div class="card-sub">${t("nodes.nodesSubtitle")}</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? t("common.loading") : t("common.refresh")}
</button> </button>
</div> </div>
<div class="list" style="margin-top: 16px;"> <div class="list" style="margin-top: 16px;">
${props.nodes.length === 0 ${props.nodes.length === 0
? html`<div class="muted">No nodes found.</div>` ? html`<div class="muted">${t("nodes.noNodesFound")}</div>`
: props.nodes.map((n) => renderNode(n))} : props.nodes.map((n) => renderNode(n))}
</div> </div>
</section> </section>
`; `;
@ -84,32 +85,32 @@ function renderDevices(props: NodesProps) {
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Devices</div> <div class="card-title">${t("nodes.devicesTitle")}</div>
<div class="card-sub">Pairing requests + role tokens.</div> <div class="card-sub">${t("nodes.devicesSubtitle")}</div>
</div> </div>
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}> <button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? "Loading…" : "Refresh"} ${props.devicesLoading ? t("common.loading") : t("common.refresh")}
</button> </button>
</div> </div>
${props.devicesError ${props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>` ? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing} : nothing}
<div class="list" style="margin-top: 16px;"> <div class="list" style="margin-top: 16px;">
${pending.length > 0 ${pending.length > 0
? html` ? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div> <div class="muted" style="margin-bottom: 8px;">${t("nodes.pending")}</div>
${pending.map((req) => renderPendingDevice(req, props))} ${pending.map((req) => renderPendingDevice(req, props))}
` `
: nothing} : nothing}
${paired.length > 0 ${paired.length > 0
? html` ? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div> <div class="muted" style="margin-top: 12px; margin-bottom: 8px;">${t("nodes.paired")}</div>
${paired.map((device) => renderPairedDevice(device, props))} ${paired.map((device) => renderPairedDevice(device, props))}
` `
: nothing} : nothing}
${pending.length === 0 && paired.length === 0 ${pending.length === 0 && paired.length === 0
? html`<div class="muted">No paired devices.</div>` ? html`<div class="muted">${t("nodes.noPairedDevices")}</div>`
: nothing} : nothing}
</div> </div>
</section> </section>
`; `;
@ -133,10 +134,10 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
<div class="list-meta"> <div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;"> <div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}> <button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
Approve ${t("nodes.approve")}
</button> </button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}> <button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject ${t("nodes.reject")}
</button> </button>
</div> </div>
</div> </div>
@ -157,9 +158,9 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
<div class="list-sub">${device.deviceId}${ip}</div> <div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div> <div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${tokens.length === 0 ${tokens.length === 0
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>` ? html`<div class="muted" style="margin-top: 6px;">${t("nodes.tokensNone")}</div>`
: html` : html`
<div class="muted" style="margin-top: 10px;">Tokens</div> <div class="muted" style="margin-top: 10px;">${t("nodes.tokens")}</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;"> <div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))} ${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
</div> </div>
@ -170,7 +171,7 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
} }
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) { function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active"; const status = token.revokedAtMs ? t("nodes.revoked") : t("nodes.active");
const scopes = `scopes: ${formatList(token.scopes)}`; const scopes = `scopes: ${formatList(token.scopes)}`;
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null); const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
return html` return html`
@ -181,16 +182,16 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
class="btn btn--sm" class="btn btn--sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)} @click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
> >
Rotate ${t("nodes.rotate")}
</button> </button>
${token.revokedAtMs ${token.revokedAtMs
? nothing ? nothing
: html` : html`
<button <button
class="btn btn--sm danger" class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)} @click=${() => props.onDeviceRevoke(deviceId, token.role)}
> >
Revoke ${t("nodes.revoke")}
</button> </button>
`} `}
</div> </div>
@ -274,15 +275,15 @@ type ExecApprovalsState = {
const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__"; const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__";
const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [ const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [
{ value: "deny", label: "Deny" }, { value: "deny", label: t("nodes.securityOptions.deny") },
{ value: "allowlist", label: "Allowlist" }, { value: "allowlist", label: t("nodes.securityOptions.allowlist") },
{ value: "full", label: "Full" }, { value: "full", label: t("nodes.securityOptions.full") },
]; ];
const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [ const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [
{ value: "off", label: "Off" }, { value: "off", label: t("nodes.askOptions.off") },
{ value: "on-miss", label: "On miss" }, { value: "on-miss", label: t("nodes.askOptions.onMiss") },
{ value: "always", label: "Always" }, { value: "always", label: t("nodes.askOptions.always") },
]; ];
function resolveBindingsState(props: NodesProps): BindingState { function resolveBindingsState(props: NodesProps): BindingState {
@ -399,11 +400,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const selectedAgent = const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ?? ? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ??
null null
: null; : null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist) const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? ? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
[]) [])
: []; : [];
return { return {
ready, ready,
@ -436,9 +437,9 @@ function renderBindings(state: BindingState) {
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between; align-items: center;"> <div class="row" style="justify-content: space-between; align-items: center;">
<div> <div>
<div class="card-title">Exec node binding</div> <div class="card-title">${t("nodes.bindingTitle")}</div>
<div class="card-sub"> <div class="card-sub">
Pin agents to a specific node when using <span class="mono">exec host=node</span>. ${t("nodes.bindingSubtitle")}
</div> </div>
</div> </div>
<button <button
@ -446,64 +447,64 @@ function renderBindings(state: BindingState) {
?disabled=${state.disabled || !state.configDirty} ?disabled=${state.disabled || !state.configDirty}
@click=${state.onSave} @click=${state.onSave}
> >
${state.configSaving ? "Saving…" : "Save"} ${state.configSaving ? t("common.saving") : t("common.save")}
</button> </button>
</div> </div>
${state.formMode === "raw" ${state.formMode === "raw"
? html`<div class="callout warn" style="margin-top: 12px;"> ? html`<div class="callout warn" style="margin-top: 12px;">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here. ${t("nodes.bindingRawWarn")}
</div>` </div>`
: nothing} : nothing}
${!state.ready ${!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;"> ? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load config to edit bindings.</div> <div class="muted">${t("nodes.loadConfigToEdit")}</div>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}> <button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"} ${state.configLoading ? t("common.loading") : t("nodes.loadConfig")}
</button> </button>
</div>` </div>`
: html` : html`
<div class="list" style="margin-top: 16px;"> <div class="list" style="margin-top: 16px;">
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">Default binding</div> <div class="list-title">${t("nodes.defaultBinding")}</div>
<div class="list-sub">Used when agents do not override a node binding.</div> <div class="list-sub">${t("nodes.defaultBindingHint")}</div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <label class="field">
<span>Node</span> <span>${t("nodes.nodeLabel")}</span>
<select <select
?disabled=${state.disabled || !supportsBinding} ?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => { @change=${(event: Event) => {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
const value = target.value.trim(); const value = target.value.trim();
state.onBindDefault(value ? value : null); state.onBindDefault(value ? value : null);
}} }}
> >
<option value="" ?selected=${defaultValue === ""}>Any node</option> <option value="" ?selected=${defaultValue === ""}>${t("nodes.anyNode")}</option>
${state.nodes.map( ${state.nodes.map(
(node) => (node) =>
html`<option html`<option
value=${node.id} value=${node.id}
?selected=${defaultValue === node.id} ?selected=${defaultValue === node.id}
> >
${node.label} ${node.label}
</option>`, </option>`,
)} )}
</select> </select>
</label> </label>
${!supportsBinding ${!supportsBinding
? html`<div class="muted">No nodes with system.run available.</div>` ? html`<div class="muted">${t("nodes.noRunNodes")}</div>`
: nothing} : nothing}
</div> </div>
</div> </div>
${state.agents.length === 0 ${state.agents.length === 0
? html`<div class="muted">No agents found.</div>` ? html`<div class="muted">No agents found.</div>`
: state.agents.map((agent) => : state.agents.map((agent) =>
renderAgentBinding(agent, state), renderAgentBinding(agent, state),
)} )}
</div> </div>
`} `}
</section> </section>
@ -517,9 +518,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between; align-items: center;"> <div class="row" style="justify-content: space-between; align-items: center;">
<div> <div>
<div class="card-title">Exec approvals</div> <div class="card-title">${t("nodes.approvalsTitle")}</div>
<div class="card-sub"> <div class="card-sub">
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>. ${t("nodes.approvalsSubtitle")}
</div> </div>
</div> </div>
<button <button
@ -527,25 +528,25 @@ function renderExecApprovals(state: ExecApprovalsState) {
?disabled=${state.disabled || !state.dirty || !targetReady} ?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave} @click=${state.onSave}
> >
${state.saving ? "Saving…" : "Save"} ${state.saving ? t("common.saving") : t("common.save")}
</button> </button>
</div> </div>
${renderExecApprovalsTarget(state)} ${renderExecApprovalsTarget(state)}
${!ready ${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;"> ? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div> <div class="muted">${t("nodes.loadApprovalsToEdit")}</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}> <button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"} ${state.loading ? t("common.loading") : t("nodes.loadApprovals")}
</button> </button>
</div>` </div>`
: html` : html`
${renderExecApprovalsTabs(state)} ${renderExecApprovalsTabs(state)}
${renderExecApprovalsPolicy(state)} ${renderExecApprovalsPolicy(state)}
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing ? nothing
: renderExecApprovalsAllowlist(state)} : renderExecApprovalsAllowlist(state)}
`} `}
</section> </section>
`; `;
@ -558,62 +559,62 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<div class="list" style="margin-top: 12px;"> <div class="list" style="margin-top: 12px;">
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">Target</div> <div class="list-title">${t("nodes.target")}</div>
<div class="list-sub"> <div class="list-sub">
Gateway edits local approvals; node edits the selected node. ${t("nodes.targetHint")}
</div> </div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <label class="field">
<span>Host</span> <span>${t("nodes.hostLabel")}</span>
<select <select
?disabled=${state.disabled} ?disabled=${state.disabled}
@change=${(event: Event) => { @change=${(event: Event) => {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
const value = target.value; const value = target.value;
if (value === "node") { if (value === "node") {
const first = state.targetNodes[0]?.id ?? null; const first = state.targetNodes[0]?.id ?? null;
state.onSelectTarget("node", nodeValue || first); state.onSelectTarget("node", nodeValue || first);
} else { } else {
state.onSelectTarget("gateway", null); state.onSelectTarget("gateway", null);
} }
}} }}
> >
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option> <option value="gateway" ?selected=${state.target === "gateway"}>${t("nodes.gateway")}</option>
<option value="node" ?selected=${state.target === "node"}>Node</option> <option value="node" ?selected=${state.target === "node"}>${t("nodes.node")}</option>
</select> </select>
</label> </label>
${state.target === "node" ${state.target === "node"
? html` ? html`
<label class="field"> <label class="field">
<span>Node</span> <span>${t("nodes.nodeLabel")}</span>
<select <select
?disabled=${state.disabled || !hasNodes} ?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => { @change=${(event: Event) => {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
const value = target.value.trim(); const value = target.value.trim();
state.onSelectTarget("node", value ? value : null); state.onSelectTarget("node", value ? value : null);
}} }}
> >
<option value="" ?selected=${nodeValue === ""}>Select node</option> <option value="" ?selected=${nodeValue === ""}>${t("nodes.selectNode")}</option>
${state.targetNodes.map( ${state.targetNodes.map(
(node) => (node) =>
html`<option html`<option
value=${node.id} value=${node.id}
?selected=${nodeValue === node.id} ?selected=${nodeValue === node.id}
> >
${node.label} ${node.label}
</option>`, </option>`,
)} )}
</select> </select>
</label> </label>
` `
: nothing} : nothing}
</div> </div>
</div> </div>
${state.target === "node" && !hasNodes ${state.target === "node" && !hasNodes
? html`<div class="muted">No nodes advertise exec approvals yet.</div>` ? html`<div class="muted">${t("nodes.noApprovalsNodes")}</div>`
: nothing} : nothing}
</div> </div>
`; `;
} }
@ -621,17 +622,17 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
function renderExecApprovalsTabs(state: ExecApprovalsState) { function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html` return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;"> <div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span> <span class="label">${t("nodes.scope")}</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;"> <div class="row" style="gap: 8px; flex-wrap: wrap;">
<button <button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}" class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)} @click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
> >
Defaults ${t("nodes.defaults")}
</button> </button>
${state.agents.map((agent) => { ${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
return html` return html`
<button <button
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}" class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}"
@click=${() => state.onSelectScope(agent.id)} @click=${() => state.onSelectScope(agent.id)}
@ -639,7 +640,7 @@ function renderExecApprovalsTabs(state: ExecApprovalsState) {
${label} ${label}
</button> </button>
`; `;
})} })}
</div> </div>
</div> </div>
`; `;
@ -668,42 +669,42 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list" style="margin-top: 16px;"> <div class="list" style="margin-top: 16px;">
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">Security</div> <div class="list-title">${t("nodes.security")}</div>
<div class="list-sub"> <div class="list-sub">
${isDefaults ${isDefaults
? "Default security mode." ? t("nodes.securityDefaultHint")
: `Default: ${defaults.security}.`} : t("nodes.securityAgentHint", { security: defaults.security })}
</div> </div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <label class="field">
<span>Mode</span> <span>${t("nodes.modeLabel")}</span>
<select <select
?disabled=${state.disabled} ?disabled=${state.disabled}
@change=${(event: Event) => { @change=${(event: Event) => {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
const value = target.value; const value = target.value;
if (!isDefaults && value === "__default__") { if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "security"]); state.onRemove([...basePath, "security"]);
} else { } else {
state.onPatch([...basePath, "security"], value); state.onPatch([...basePath, "security"], value);
} }
}} }}
> >
${!isDefaults ${!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}> ? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
Use default (${defaults.security}) ${t("nodes.useDefault", { security: defaults.security })}
</option>` </option>`
: nothing} : nothing}
${SECURITY_OPTIONS.map( ${SECURITY_OPTIONS.map(
(option) => (option) =>
html`<option html`<option
value=${option.value} value=${option.value}
?selected=${securityValue === option.value} ?selected=${securityValue === option.value}
> >
${option.label} ${option.label}
</option>`, </option>`,
)} )}
</select> </select>
</label> </label>
</div> </div>
@ -711,40 +712,40 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">Ask</div> <div class="list-title">${t("nodes.ask")}</div>
<div class="list-sub"> <div class="list-sub">
${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`} ${isDefaults ? t("nodes.askDefaultHint") : t("nodes.securityAgentHint", { security: defaults.ask })}
</div> </div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <label class="field">
<span>Mode</span> <span>${t("nodes.modeLabel")}</span>
<select <select
?disabled=${state.disabled} ?disabled=${state.disabled}
@change=${(event: Event) => { @change=${(event: Event) => {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
const value = target.value; const value = target.value;
if (!isDefaults && value === "__default__") { if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "ask"]); state.onRemove([...basePath, "ask"]);
} else { } else {
state.onPatch([...basePath, "ask"], value); state.onPatch([...basePath, "ask"], value);
} }
}} }}
> >
${!isDefaults ${!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}> ? html`<option value="__default__" ?selected=${askValue === "__default__"}>
Use default (${defaults.ask}) ${t("nodes.useDefault", { security: defaults.ask })}
</option>` </option>`
: nothing} : nothing}
${ASK_OPTIONS.map( ${ASK_OPTIONS.map(
(option) => (option) =>
html`<option html`<option
value=${option.value} value=${option.value}
?selected=${askValue === option.value} ?selected=${askValue === option.value}
> >
${option.label} ${option.label}
</option>`, </option>`,
)} )}
</select> </select>
</label> </label>
</div> </div>
@ -752,42 +753,42 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">Ask fallback</div> <div class="list-title">${t("nodes.askFallback")}</div>
<div class="list-sub"> <div class="list-sub">
${isDefaults ${isDefaults
? "Applied when the UI prompt is unavailable." ? t("nodes.askFallbackHint")
: `Default: ${defaults.askFallback}.`} : t("nodes.securityAgentHint", { security: defaults.askFallback })}
</div> </div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <label class="field">
<span>Fallback</span> <span>${t("nodes.fallbackLabel")}</span>
<select <select
?disabled=${state.disabled} ?disabled=${state.disabled}
@change=${(event: Event) => { @change=${(event: Event) => {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
const value = target.value; const value = target.value;
if (!isDefaults && value === "__default__") { if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "askFallback"]); state.onRemove([...basePath, "askFallback"]);
} else { } else {
state.onPatch([...basePath, "askFallback"], value); state.onPatch([...basePath, "askFallback"], value);
} }
}} }}
> >
${!isDefaults ${!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}> ? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
Use default (${defaults.askFallback}) ${t("nodes.useDefault", { security: defaults.askFallback })}
</option>` </option>`
: nothing} : nothing}
${SECURITY_OPTIONS.map( ${SECURITY_OPTIONS.map(
(option) => (option) =>
html`<option html`<option
value=${option.value} value=${option.value}
?selected=${askFallbackValue === option.value} ?selected=${askFallbackValue === option.value}
> >
${option.label} ${option.label}
</option>`, </option>`,
)} )}
</select> </select>
</label> </label>
</div> </div>
@ -795,37 +796,37 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">Auto-allow skill CLIs</div> <div class="list-title">${t("nodes.autoAllowSkills")}</div>
<div class="list-sub"> <div class="list-sub">
${isDefaults ${isDefaults
? "Allow skill executables listed by the Gateway." ? t("nodes.autoAllowSkillsHint")
: autoIsDefault : autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).` ? 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.
: `Override (${autoEffective ? "on" : "off"}).`} : t("nodes.securityAgentHint", { security: autoEffective ? "开启" : "关闭" })}
</div> </div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <label class="field">
<span>Enabled</span> <span></span>
<input <input
type="checkbox" type="checkbox"
?disabled=${state.disabled} ?disabled=${state.disabled}
.checked=${autoEffective} .checked=${autoEffective}
@change=${(event: Event) => { @change=${(event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
state.onPatch([...basePath, "autoAllowSkills"], target.checked); state.onPatch([...basePath, "autoAllowSkills"], target.checked);
}} }}
/> />
</label> </label>
${!isDefaults && !autoIsDefault ${!isDefaults && !autoIsDefault
? html`<button ? html`<button
class="btn btn--sm" class="btn btn--sm"
?disabled=${state.disabled} ?disabled=${state.disabled}
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])} @click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
> >
Use default ${t("nodes.defaults")}
</button>` </button>`
: nothing} : nothing}
</div> </div>
</div> </div>
</div> </div>
@ -838,26 +839,26 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
return html` return html`
<div class="row" style="margin-top: 18px; justify-content: space-between;"> <div class="row" style="margin-top: 18px; justify-content: space-between;">
<div> <div>
<div class="card-title">Allowlist</div> <div class="card-title">${t("nodes.allowlist")}</div>
<div class="card-sub">Case-insensitive glob patterns.</div> <div class="card-sub">${t("nodes.allowlistHint")}</div>
</div> </div>
<button <button
class="btn btn--sm" class="btn btn--sm"
?disabled=${state.disabled} ?disabled=${state.disabled}
@click=${() => { @click=${() => {
const next = [...entries, { pattern: "" }]; const next = [...entries, { pattern: "" }];
state.onPatch(allowlistPath, next); state.onPatch(allowlistPath, next);
}} }}
> >
Add pattern ${t("nodes.addEntry")}
</button> </button>
</div> </div>
<div class="list" style="margin-top: 12px;"> <div class="list" style="margin-top: 12px;">
${entries.length === 0 ${entries.length === 0
? html`<div class="muted">No allowlist entries yet.</div>` ? html`<div class="muted">${t("nodes.noEntries")}</div>`
: entries.map((entry, index) => : entries.map((entry, index) =>
renderAllowlistEntry(state, entry, index), renderAllowlistEntry(state, entry, index),
)} )}
</div> </div>
`; `;
} }
@ -877,39 +878,39 @@ function renderAllowlistEntry(
return html` return html`
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">${entry.pattern?.trim() ? entry.pattern : "New pattern"}</div> <div class="list-title">${entry.pattern?.trim() ? entry.pattern : t("nodes.addEntry")}</div>
<div class="list-sub">Last used: ${lastUsed}</div> <div class="list-sub">上次使用: ${lastUsed}</div>
${lastCommand ? html`<div class="list-sub mono">${lastCommand}</div>` : nothing} ${lastCommand ? html`<div class="list-sub mono">${lastCommand}</div>` : nothing}
${lastPath ? html`<div class="list-sub mono">${lastPath}</div>` : nothing} ${lastPath ? html`<div class="list-sub mono">${lastPath}</div>` : nothing}
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <label class="field">
<span>Pattern</span> <span>${t("nodes.entryCommand")}</span>
<input <input
type="text" type="text"
.value=${entry.pattern ?? ""} .value=${entry.pattern ?? ""}
?disabled=${state.disabled} ?disabled=${state.disabled}
@input=${(event: Event) => { @input=${(event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
state.onPatch( state.onPatch(
["agents", state.selectedScope, "allowlist", index, "pattern"], ["agents", state.selectedScope, "allowlist", index, "pattern"],
target.value, target.value,
); );
}} }}
/> />
</label> </label>
<button <button
class="btn btn--sm danger" class="btn btn--sm danger"
?disabled=${state.disabled} ?disabled=${state.disabled}
@click=${() => { @click=${() => {
if (state.allowlist.length <= 1) { if (state.allowlist.length <= 1) {
state.onRemove(["agents", state.selectedScope, "allowlist"]); state.onRemove(["agents", state.selectedScope, "allowlist"]);
return; return;
} }
state.onRemove(["agents", state.selectedScope, "allowlist", index]); state.onRemove(["agents", state.selectedScope, "allowlist", index]);
}} }}
> >
Remove ${t("common.delete")}
</button> </button>
</div> </div>
</div> </div>
@ -925,35 +926,35 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
<div class="list-main"> <div class="list-main">
<div class="list-title">${label}</div> <div class="list-title">${label}</div>
<div class="list-sub"> <div class="list-sub">
${agent.isDefault ? "default agent" : "agent"} · ${agent.isDefault ? "默认代理" : "代理"} ·
${bindingValue === "__default__" ${bindingValue === "__default__"
? `uses default (${state.defaultBinding ?? "any"})` ? t("nodes.useDefault", { security: state.defaultBinding ?? t("nodes.anyNode") })
: `override: ${agent.binding}`} : `${t("nodes.rotate")}: ${agent.binding}`}
</div> </div>
</div> </div>
<div class="list-meta"> <div class="list-meta">
<label class="field"> <label class="field">
<span>Binding</span> <span>${t("nodes.nodeLabel")}</span>
<select <select
?disabled=${state.disabled || !supportsBinding} ?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => { @change=${(event: Event) => {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
const value = target.value.trim(); const value = target.value.trim();
state.onBindAgent(agent.index, value === "__default__" ? null : value); state.onBindAgent(agent.index, value === "__default__" ? null : value);
}} }}
> >
<option value="__default__" ?selected=${bindingValue === "__default__"}> <option value="__default__" ?selected=${bindingValue === "__default__"}>
Use default ${t("nodes.defaults")}
</option> </option>
${state.nodes.map( ${state.nodes.map(
(node) => (node) =>
html`<option html`<option
value=${node.id} value=${node.id}
?selected=${bindingValue === node.id} ?selected=${bindingValue === node.id}
> >
${node.label} ${node.label}
</option>`, </option>`,
)} )}
</select> </select>
</label> </label>
</div> </div>
@ -1071,15 +1072,16 @@ function renderNode(node: Record<string, unknown>) {
${typeof node.remoteIp === "string" ? ` · ${node.remoteIp}` : ""} ${typeof node.remoteIp === "string" ? ` · ${node.remoteIp}` : ""}
${typeof node.version === "string" ? ` · ${node.version}` : ""} ${typeof node.version === "string" ? ` · ${node.version}` : ""}
</div> </div>
</div>
<div class="chip-row" style="margin-top: 6px;"> <div class="chip-row" style="margin-top: 6px;">
<span class="chip">${paired ? "paired" : "unpaired"}</span> <span class="chip">${paired ? t("nodes.paired") : "未配对"}</span>
<span class="chip ${connected ? "chip-ok" : "chip-warn"}"> <span class="chip ${connected ? "chip-ok" : "chip-warn"}">
${connected ? "connected" : "offline"} ${connected ? "已连接" : "离线"}
</span> </span>
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)} ${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands ${commands
.slice(0, 8) .slice(0, 8)
.map((c) => html`<span class="chip">${String(c)}</span>`)} .map((c) => html`<span class="chip">${String(c)}</span>`)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html } from "lit"; import { html } from "lit";
import { t } from "../i18n";
import type { GatewayHelloOk } from "../gateway"; import type { GatewayHelloOk } from "../gateway";
import { formatAgo, formatDurationMs } from "../format"; import { formatAgo, formatDurationMs } from "../format";
@ -41,7 +42,7 @@ export function renderOverview(props: OverviewProps) {
if (!hasToken && !hasPassword) { if (!hasToken && !hasPassword) {
return html` return html`
<div class="muted" style="margin-top: 8px;"> <div class="muted" style="margin-top: 8px;">
This gateway requires auth. Add a token or password, then click Connect. ${t("overview.authRequired")}
<div style="margin-top: 6px;"> <div style="margin-top: 6px;">
<span class="mono">openclaw dashboard --no-open</span> tokenized URL<br /> <span class="mono">openclaw dashboard --no-open</span> tokenized URL<br />
<span class="mono">openclaw doctor --generate-gateway-token</span> set token <span class="mono">openclaw doctor --generate-gateway-token</span> set token
@ -61,9 +62,7 @@ export function renderOverview(props: OverviewProps) {
} }
return html` return html`
<div class="muted" style="margin-top: 8px;"> <div class="muted" style="margin-top: 8px;">
Auth failed. Re-copy a tokenized URL with ${t("overview.authFailed")}
<span class="mono">openclaw dashboard --no-open</span>, or update the token,
then click Connect.
<div style="margin-top: 6px;"> <div style="margin-top: 6px;">
<a <a
class="session-link" class="session-link"
@ -87,11 +86,10 @@ export function renderOverview(props: OverviewProps) {
} }
return html` return html`
<div class="muted" style="margin-top: 8px;"> <div class="muted" style="margin-top: 8px;">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or ${t("overview.insecureContext")}
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
<div style="margin-top: 6px;"> <div style="margin-top: 6px;">
If you must stay on HTTP, set HTTP
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only). <span class="mono">gateway.controlUi.allowInsecureAuth: true</span> ()
</div> </div>
<div style="margin-top: 6px;"> <div style="margin-top: 6px;">
<a <a
@ -119,141 +117,141 @@ export function renderOverview(props: OverviewProps) {
return html` return html`
<section class="grid grid-cols-2"> <section class="grid grid-cols-2">
<div class="card"> <div class="card">
<div class="card-title">Gateway Access</div> <div class="card-title">${t("overview.gatewayAccess")}</div>
<div class="card-sub">Where the dashboard connects and how it authenticates.</div> <div class="card-sub">${t("overview.gatewayAccessSubtitle")}</div>
<div class="form-grid" style="margin-top: 16px;"> <div class="form-grid" style="margin-top: 16px;">
<label class="field"> <label class="field">
<span>WebSocket URL</span> <span>${t("overview.websocketUrl")}</span>
<input <input
.value=${props.settings.gatewayUrl} .value=${props.settings.gatewayUrl}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, gatewayUrl: v }); props.onSettingsChange({ ...props.settings, gatewayUrl: v });
}} }}
placeholder="ws://100.x.y.z:18789" placeholder="ws://100.x.y.z:18789"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Gateway Token</span> <span>${t("overview.gatewayToken")}</span>
<input <input
.value=${props.settings.token} .value=${props.settings.token}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v }); props.onSettingsChange({ ...props.settings, token: v });
}} }}
placeholder="OPENCLAW_GATEWAY_TOKEN" placeholder="OPENCLAW_GATEWAY_TOKEN"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Password (not stored)</span> <span>${t("overview.passwordLabel")}</span>
<input <input
type="password" type="password"
.value=${props.password} .value=${props.password}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v); props.onPasswordChange(v);
}} }}
placeholder="system or shared password" placeholder="system or shared password"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Default Session Key</span> <span>${t("overview.sessionKeyLabel")}</span>
<input <input
.value=${props.settings.sessionKey} .value=${props.settings.sessionKey}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
props.onSessionKeyChange(v); props.onSessionKeyChange(v);
}} }}
/> />
</label> </label>
</div> </div>
<div class="row" style="margin-top: 14px;"> <div class="row" style="margin-top: 14px;">
<button class="btn" @click=${() => props.onConnect()}>Connect</button> <button class="btn" @click=${() => props.onConnect()}>${t("overview.connect")}</button>
<button class="btn" @click=${() => props.onRefresh()}>Refresh</button> <button class="btn" @click=${() => props.onRefresh()}>${t("common.refresh")}</button>
<span class="muted">Click Connect to apply connection changes.</span> <span class="muted">${t("overview.connectHint")}</span>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">Snapshot</div> <div class="card-title">${t("overview.snapshotTitle")}</div>
<div class="card-sub">Latest gateway handshake information.</div> <div class="card-sub">${t("overview.snapshotSubtitle")}</div>
<div class="stat-grid" style="margin-top: 16px;"> <div class="stat-grid" style="margin-top: 16px;">
<div class="stat"> <div class="stat">
<div class="stat-label">Status</div> <div class="stat-label">${t("nodes.statusLabel")}</div>
<div class="stat-value ${props.connected ? "ok" : "warn"}"> <div class="stat-value ${props.connected ? "ok" : "warn"}">
${props.connected ? "Connected" : "Disconnected"} ${props.connected ? t("overview.statusOk") : t("overview.statusErr")}
</div> </div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Uptime</div> <div class="stat-label">${t("overview.uptime")}</div>
<div class="stat-value">${uptime}</div> <div class="stat-value">${uptime}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Tick Interval</div> <div class="stat-label">${t("overview.tickInterval")}</div>
<div class="stat-value">${tick}</div> <div class="stat-value">${tick}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Last Channels Refresh</div> <div class="stat-label">${t("overview.lastChannelsRefresh")}</div>
<div class="stat-value"> <div class="stat-value">
${props.lastChannelsRefresh ${props.lastChannelsRefresh
? formatAgo(props.lastChannelsRefresh) ? formatAgo(props.lastChannelsRefresh)
: "n/a"} : "n/a"}
</div> </div>
</div> </div>
</div> </div>
${props.lastError ${props.lastError
? html`<div class="callout danger" style="margin-top: 14px;"> ? html`<div class="callout danger" style="margin-top: 14px;">
<div>${props.lastError}</div> <div>${props.lastError}</div>
${authHint ?? ""} ${authHint ?? ""}
${insecureContextHint ?? ""} ${insecureContextHint ?? ""}
</div>` </div>`
: html`<div class="callout" style="margin-top: 14px;"> : html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage. ${t("overview.channelsHint")}
</div>`} </div>`}
</div> </div>
</section> </section>
<section class="grid grid-cols-3" style="margin-top: 18px;"> <section class="grid grid-cols-3" style="margin-top: 18px;">
<div class="card stat-card"> <div class="card stat-card">
<div class="stat-label">Instances</div> <div class="stat-label">${t("overview.instances")}</div>
<div class="stat-value">${props.presenceCount}</div> <div class="stat-value">${props.presenceCount}</div>
<div class="muted">Presence beacons in the last 5 minutes.</div> <div class="muted">${t("overview.instancesHint")}</div>
</div> </div>
<div class="card stat-card"> <div class="card stat-card">
<div class="stat-label">Sessions</div> <div class="stat-label">${t("overview.sessions")}</div>
<div class="stat-value">${props.sessionsCount ?? "n/a"}</div> <div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
<div class="muted">Recent session keys tracked by the gateway.</div> <div class="muted">${t("overview.sessionsHint")}</div>
</div> </div>
<div class="card stat-card"> <div class="card stat-card">
<div class="stat-label">Cron</div> <div class="stat-label">${t("overview.cron")}</div>
<div class="stat-value"> <div class="stat-value">
${props.cronEnabled == null ${props.cronEnabled == null
? "n/a" ? "n/a"
: props.cronEnabled : props.cronEnabled
? "Enabled" ? "已启用"
: "Disabled"} : "已禁用"}
</div> </div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div> <div class="muted">${t("overview.nextWake", { run: formatNextRun(props.cronNext) })}</div>
</div> </div>
</section> </section>
<section class="card" style="margin-top: 18px;"> <section class="card" style="margin-top: 18px;">
<div class="card-title">Notes</div> <div class="card-title">${t("overview.notesTitle")}</div>
<div class="card-sub">Quick reminders for remote control setups.</div> <div class="card-sub">${t("overview.notesSubtitle")}</div>
<div class="note-grid" style="margin-top: 14px;"> <div class="note-grid" style="margin-top: 14px;">
<div> <div>
<div class="note-title">Tailscale serve</div> <div class="note-title">${t("overview.tailscaleTitle")}</div>
<div class="muted"> <div class="muted">
Prefer serve mode to keep the gateway on loopback with tailnet auth. ${t("overview.tailscaleHint")}
</div> </div>
</div> </div>
<div> <div>
<div class="note-title">Session hygiene</div> <div class="note-title">${t("overview.hygieneTitle")}</div>
<div class="muted">Use /new or sessions.patch to reset context.</div> <div class="muted">${t("overview.hygieneHint")}</div>
</div> </div>
<div> <div>
<div class="note-title">Cron reminders</div> <div class="note-title">${t("overview.cronRemindersTitle")}</div>
<div class="muted">Use isolated sessions for recurring runs.</div> <div class="muted">${t("overview.cronRemindersHint")}</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import { formatSessionTokens } from "../presenter"; import { formatSessionTokens } from "../presenter";
@ -36,11 +37,16 @@ export type SessionsProps = {
const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const;
const BINARY_THINK_LEVELS = ["", "off", "on"] as const; const BINARY_THINK_LEVELS = ["", "off", "on"] as const;
const VERBOSE_LEVELS = [ const VERBOSE_LEVELS = [
{ value: "", label: "inherit" }, { value: "", label: "common.inherit" },
{ value: "off", label: "off (explicit)" }, { value: "off", label: "sessions.offExplicit" },
{ value: "on", label: "on" }, { 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; ] as const;
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
function normalizeProviderId(provider?: string | null): string { function normalizeProviderId(provider?: string | null): string {
if (!provider) return ""; if (!provider) return "";
@ -76,96 +82,96 @@ export function renderSessions(props: SessionsProps) {
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Sessions</div> <div class="card-title">${t("sessions.title")}</div>
<div class="card-sub">Active session keys and per-session overrides.</div> <div class="card-sub">${t("sessions.subtitle")}</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? t("common.loading") : t("common.refresh")}
</button> </button>
</div> </div>
<div class="filters" style="margin-top: 14px;"> <div class="filters" style="margin-top: 14px;">
<label class="field"> <label class="field">
<span>Active within (minutes)</span> <span>${t("sessions.activeWithin")}</span>
<input <input
.value=${props.activeMinutes} .value=${props.activeMinutes}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFiltersChange({ props.onFiltersChange({
activeMinutes: (e.target as HTMLInputElement).value, activeMinutes: (e.target as HTMLInputElement).value,
limit: props.limit, limit: props.limit,
includeGlobal: props.includeGlobal, includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown, includeUnknown: props.includeUnknown,
})} })}
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Limit</span> <span>${t("sessions.limit")}</span>
<input <input
.value=${props.limit} .value=${props.limit}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFiltersChange({ props.onFiltersChange({
activeMinutes: props.activeMinutes, activeMinutes: props.activeMinutes,
limit: (e.target as HTMLInputElement).value, limit: (e.target as HTMLInputElement).value,
includeGlobal: props.includeGlobal, includeGlobal: props.includeGlobal,
includeUnknown: props.includeUnknown, includeUnknown: props.includeUnknown,
})} })}
/> />
</label> </label>
<label class="field checkbox"> <label class="field checkbox">
<span>Include global</span> <span>${t("sessions.includeGlobal")}</span>
<input <input
type="checkbox" type="checkbox"
.checked=${props.includeGlobal} .checked=${props.includeGlobal}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFiltersChange({ props.onFiltersChange({
activeMinutes: props.activeMinutes, activeMinutes: props.activeMinutes,
limit: props.limit, limit: props.limit,
includeGlobal: (e.target as HTMLInputElement).checked, includeGlobal: (e.target as HTMLInputElement).checked,
includeUnknown: props.includeUnknown, includeUnknown: props.includeUnknown,
})} })}
/> />
</label> </label>
<label class="field checkbox"> <label class="field checkbox">
<span>Include unknown</span> <span>${t("sessions.includeUnknown")}</span>
<input <input
type="checkbox" type="checkbox"
.checked=${props.includeUnknown} .checked=${props.includeUnknown}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFiltersChange({ props.onFiltersChange({
activeMinutes: props.activeMinutes, activeMinutes: props.activeMinutes,
limit: props.limit, limit: props.limit,
includeGlobal: props.includeGlobal, includeGlobal: props.includeGlobal,
includeUnknown: (e.target as HTMLInputElement).checked, includeUnknown: (e.target as HTMLInputElement).checked,
})} })}
/> />
</label> </label>
</div> </div>
${props.error ${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>` ? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing} : nothing}
<div class="muted" style="margin-top: 12px;"> <div class="muted" style="margin-top: 12px;">
${props.result ? `Store: ${props.result.path}` : ""} ${props.result ? t("sessions.storePath", { path: props.result.path }) : ""}
</div> </div>
<div class="table" style="margin-top: 16px;"> <div class="table" style="margin-top: 16px;">
<div class="table-head"> <div class="table-head">
<div>Key</div> <div>${t("sessions.table.key")}</div>
<div>Label</div> <div>${t("sessions.table.label")}</div>
<div>Kind</div> <div>${t("sessions.table.kind")}</div>
<div>Updated</div> <div>${t("sessions.table.updated")}</div>
<div>Tokens</div> <div>${t("sessions.table.tokens")}</div>
<div>Thinking</div> <div>${t("sessions.table.thinking")}</div>
<div>Verbose</div> <div>${t("sessions.table.verbose")}</div>
<div>Reasoning</div> <div>${t("sessions.table.reasoning")}</div>
<div>Actions</div> <div>${t("sessions.table.actions")}</div>
</div> </div>
${rows.length === 0 ${rows.length === 0
? html`<div class="muted">No sessions found.</div>` ? html`<div class="muted">${t("sessions.noSessions")}</div>`
: rows.map((row) => : rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading), renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)} )}
</div> </div>
</section> </section>
`; `;
@ -194,17 +200,17 @@ function renderRow(
return html` return html`
<div class="table-row"> <div class="table-row">
<div class="mono">${canLink <div class="mono">${canLink
? html`<a href=${chatUrl} class="session-link">${displayName}</a>` ? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div> : displayName}</div>
<div> <div>
<input <input
.value=${row.label ?? ""} .value=${row.label ?? ""}
?disabled=${disabled} ?disabled=${disabled}
placeholder="(optional)" placeholder=${t("sessions.optional")}
@change=${(e: Event) => { @change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim(); const value = (e.target as HTMLInputElement).value.trim();
onPatch(row.key, { label: value || null }); onPatch(row.key, { label: value || null });
}} }}
/> />
</div> </div>
<div>${row.kind}</div> <div>${row.kind}</div>
@ -215,15 +221,15 @@ function renderRow(
.value=${thinking} .value=${thinking}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => { @change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value; const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { onPatch(row.key, {
thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking), thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking),
}); });
}} }}
> >
${thinkLevels.map((level) => ${thinkLevels.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`, html`<option value=${level}>${level ? t(`sessions.thinkingLevels.${level}`) : t("common.inherit")}</option>`,
)} )}
</select> </select>
</div> </div>
<div> <div>
@ -231,13 +237,13 @@ function renderRow(
.value=${verbose} .value=${verbose}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => { @change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value; const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { verboseLevel: value || null }); onPatch(row.key, { verboseLevel: value || null });
}} }}
> >
${VERBOSE_LEVELS.map( ${VERBOSE_LEVELS.map(
(level) => html`<option value=${level.value}>${level.label}</option>`, (level) => html`<option value=${level.value}>${t(level.label)}</option>`,
)} )}
</select> </select>
</div> </div>
<div> <div>
@ -245,18 +251,18 @@ function renderRow(
.value=${reasoning} .value=${reasoning}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => { @change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value; const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { reasoningLevel: value || null }); onPatch(row.key, { reasoningLevel: value || null });
}} }}
> >
${REASONING_LEVELS.map((level) => ${REASONING_LEVELS.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`, html`<option value=${level.value}>${t(level.label)}</option>`,
)} )}
</select> </select>
</div> </div>
<div> <div>
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}> <button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
Delete ${t("common.delete")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n";
import { clampText } from "../format"; import { clampText } from "../format";
import type { SkillStatusEntry, SkillStatusReport } from "../types"; import type { SkillStatusEntry, SkillStatusReport } from "../types";
@ -25,45 +26,45 @@ export function renderSkills(props: SkillsProps) {
const filter = props.filter.trim().toLowerCase(); const filter = props.filter.trim().toLowerCase();
const filtered = filter const filtered = filter
? skills.filter((skill) => ? skills.filter((skill) =>
[skill.name, skill.description, skill.source] [skill.name, skill.description, skill.source]
.join(" ") .join(" ")
.toLowerCase() .toLowerCase()
.includes(filter), .includes(filter),
) )
: skills; : skills;
return html` return html`
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Skills</div> <div class="card-title">${t("skills.title")}</div>
<div class="card-sub">Bundled, managed, and workspace skills.</div> <div class="card-sub">${t("skills.subtitle")}</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? t("common.loading") : t("common.refresh")}
</button> </button>
</div> </div>
<div class="filters" style="margin-top: 14px;"> <div class="filters" style="margin-top: 14px;">
<label class="field" style="flex: 1;"> <label class="field" style="flex: 1;">
<span>Filter</span> <span>${t("skills.filter")}</span>
<input <input
.value=${props.filter} .value=${props.filter}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFilterChange((e.target as HTMLInputElement).value)} props.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills" placeholder=${t("skills.searchPlaceholder")}
/> />
</label> </label>
<div class="muted">${filtered.length} shown</div> <div class="muted">${t("skills.shown", { count: filtered.length })}</div>
</div> </div>
${props.error ${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>` ? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing} : nothing}
${filtered.length === 0 ${filtered.length === 0
? html`<div class="muted" style="margin-top: 16px;">No skills found.</div>` ? html`<div class="muted" style="margin-top: 16px;">${t("skills.noSkills")}</div>`
: html` : html`
<div class="list" style="margin-top: 16px;"> <div class="list" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))} ${filtered.map((skill) => renderSkill(skill, props))}
</div> </div>
@ -85,8 +86,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
...skill.missing.os.map((o) => `os:${o}`), ...skill.missing.os.map((o) => `os:${o}`),
]; ];
const reasons: string[] = []; const reasons: string[] = [];
if (skill.disabled) reasons.push("disabled"); if (skill.disabled) reasons.push(t("skills.reasonDisabled"));
if (skill.blockedByAllowlist) reasons.push("blocked by allowlist"); if (skill.blockedByAllowlist) reasons.push(t("skills.reasonAllowlist"));
return html` return html`
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
@ -97,24 +98,24 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
<div class="chip-row" style="margin-top: 6px;"> <div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span> <span class="chip">${skill.source}</span>
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}"> <span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
${skill.eligible ? "eligible" : "blocked"} ${skill.eligible ? t("skills.eligible") : t("skills.blocked")}
</span> </span>
${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing} ${skill.disabled ? html`<span class="chip chip-warn">${t("skills.disabled")}</span>` : nothing}
</div> </div>
${missing.length > 0 ${missing.length > 0
? html` ? html`
<div class="muted" style="margin-top: 6px;"> <div class="muted" style="margin-top: 6px;">
Missing: ${missing.join(", ")} ${t("skills.missing")} ${missing.join(", ")}
</div> </div>
` `
: nothing} : nothing}
${reasons.length > 0 ${reasons.length > 0
? html` ? html`
<div class="muted" style="margin-top: 6px;"> <div class="muted" style="margin-top: 6px;">
Reason: ${reasons.join(", ")} ${t("skills.reason")} ${reasons.join(", ")}
</div> </div>
` `
: nothing} : nothing}
</div> </div>
<div class="list-meta"> <div class="list-meta">
<div class="row" style="justify-content: flex-end; flex-wrap: wrap;"> <div class="row" style="justify-content: flex-end; flex-wrap: wrap;">
@ -123,40 +124,39 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
?disabled=${busy} ?disabled=${busy}
@click=${() => props.onToggle(skill.skillKey, skill.disabled)} @click=${() => props.onToggle(skill.skillKey, skill.disabled)}
> >
${skill.disabled ? "Enable" : "Disable"} ${skill.disabled ? t("skills.enable") : t("skills.disable")}
</button> </button>
${canInstall ${canInstall
? html`<button ? html`<button
class="btn" class="btn"
?disabled=${busy} ?disabled=${busy}
@click=${() => @click=${() =>
props.onInstall(skill.skillKey, skill.name, skill.install[0].id)} props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
> >
${busy ? "Installing…" : skill.install[0].label} ${busy ? t("skills.installing") : skill.install[0].label}
</button>` </button>`
: nothing} : nothing}
</div> </div>
${message ${message
? html`<div ? html`<div
class="muted" class="muted"
style="margin-top: 8px; color: ${ style="margin-top: 8px; color: ${message.kind === "error"
message.kind === "error" ? "var(--danger-color, #d14343)"
? "var(--danger-color, #d14343)" : "var(--success-color, #0a7f5a)"
: "var(--success-color, #0a7f5a)" };"
};"
> >
${message.message} ${message.message}
</div>` </div>`
: nothing} : nothing}
${skill.primaryEnv ${skill.primaryEnv
? html` ? html`
<div class="field" style="margin-top: 10px;"> <div class="field" style="margin-top: 10px;">
<span>API key</span> <span>${t("skills.apiKey")}</span>
<input <input
type="password" type="password"
.value=${apiKey} .value=${apiKey}
@input=${(e: Event) => @input=${(e: Event) =>
props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)} props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)}
/> />
</div> </div>
<button <button
@ -165,10 +165,10 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
?disabled=${busy} ?disabled=${busy}
@click=${() => props.onSaveKey(skill.skillKey)} @click=${() => props.onSaveKey(skill.skillKey)}
> >
Save key ${t("skills.saveKey")}
</button> </button>
` `
: nothing} : nothing}
</div> </div>
</div> </div>
`; `;