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
English | [简体中文](README.zh-CN.md)
<p align="center">
<picture>
<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
- **操作系统**: macOS, Linux, Windows (via WSL2)
- **操作系统**: macOS, Linux, Windows (推荐 WSL2)
### 安装

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,58 +32,397 @@ export const zhCN = {
debug: "调试",
logs: "日志",
},
sidebarGroups: {
chat: "聊天",
control: "控制台",
agent: "代理",
settings: "设置",
},
overview: {
title: "概览",
subtitle: "查看网关的运行状态和摘要",
subtitle: "网关状态与摘要",
gatewayAccess: "网关访问",
gatewayAccessSubtitle: "控制台连接地址及其身份验证方式。",
websocketUrl: "WebSocket 地址",
gatewayToken: "网关令牌 (Token)",
passwordLabel: "密码 (不存储)",
sessionKeyLabel: "默认会话密钥",
connect: "连接",
connectHint: "点击连接以应用连接更改。",
snapshotTitle: "快照",
snapshotSubtitle: "最新的网关握手信息。",
statusOk: "已连接",
statusErr: "已断开",
uptime: "运行时间",
tickInterval: "打点间隔",
lastChannelsRefresh: "上次渠道刷新",
channelsHint: "使用“渠道”连接 WhatsApp、Telegram、Discord、Signal 或 iMessage。",
instances: "实例",
instancesHint: "过去 5 分钟内的存在信号 (Presence)。",
sessions: "会话",
sessionsHint: "网关跟踪的最近会话密钥。",
cron: "定时任务 (Cron)",
nextWake: "下次唤醒 {run}",
notesTitle: "备注",
notesSubtitle: "远程控制设置的快速提示。",
tailscaleTitle: "Tailscale Serve",
tailscaleHint: "推荐使用 Serve 模式通过 Tailscale 身份验证锁定网关。",
hygieneTitle: "会话规范",
hygieneHint: "使用 /new 或 sessions.patch 重置上下文。",
cronRemindersTitle: "定时提醒",
cronRemindersHint: "为循环运行使用隔离的会话。",
authRequired: "网关需要身份验证。添加令牌或密码,然后点击连接。",
authFailed: "身份验证失败。请重新复制包含令牌的 URL 或更新令牌,然后点击连接。",
insecureContext: "当前页面为 HTTP浏览器已禁用设备身份。请使用 HTTPS 或在网关主机上访问 localhost。",
},
channels: {
title: "渠道",
subtitle: "管理消息渠道连接",
healthTitle: "渠道健康状况",
healthSubtitle: "来自网关的渠道状态快照。",
noSnapshot: "暂无快照。",
genericSubtitle: "渠道状态与配置。",
configured: "已配置",
running: "运行中",
connected: "已连接",
lastInbound: "最近上行",
yes: "是",
no: "否",
active: "活跃",
lastStart: "最近启动",
lastProbe: "最近探测",
probe: "探测",
probeOk: "探测成功",
probeFailed: "探测失败",
discord: {
subtitle: "机器人状态与频道配置。",
},
googleChat: {
subtitle: "机器人状态与空间配置。",
credential: "凭据",
audience: "目标受众",
},
imessage: {
subtitle: "iMessage 状态与网关配置。",
},
nostr: {
subtitle: "通过 Nostr 中继 (NIP-04) 进行的去中心化私信。",
publicKey: "公钥",
profile: "简介",
editProfile: "编辑简介",
noProfileHint: "尚未设置简介。点击“编辑简介”来添加您的姓名、个人简介和头像。",
profileFields: {
username: "用户名",
usernameHelp: "简短的用户名 (例如 satoshi)",
displayName: "显示名称",
displayNameHelp: "您的完整显示名称",
bio: "个人简介",
bioHelp: "简短的个人介绍或描述",
avatarUrl: "头像 URL",
avatarUrlHelp: "头像图片的 HTTPS URL",
bannerUrl: "横幅 URL",
bannerUrlHelp: "横幅图片的 HTTPS URL",
website: "网站",
websiteHelp: "您的个人网站",
nip05: "NIP-05 标识符",
nip05Help: "可验证的标识符 (例如 you@domain.com)",
lud16: "闪电网络地址",
lud16Help: "用于打赏的闪电网络地址 (LUD-16)",
},
form: {
title: "编辑简介",
account: "账户: {accountId}",
saveAndPublish: "保存并发布",
saving: "正在保存...",
importFromRelays: "从中继导入",
importing: "正在导入...",
hideAdvanced: "隐藏高级选项",
showAdvanced: "显示高级选项",
unsavedChanges: "您有未保存的更改",
picturePreview: "头像预览",
advanced: "高级选项",
}
},
signal: {
subtitle: "signal-cli 状态与渠道配置。",
baseUrl: "基础 URL",
},
slack: {
subtitle: "Socket 模式状态与渠道配置。",
},
telegram: {
subtitle: "机器人状态与渠道配置。",
mode: "模式",
},
whatsapp: {
subtitle: "连接 WhatsApp Web 并监控连接健康状况。",
linked: "已链接",
lastConnect: "最近连接",
lastMessage: "最近消息",
authAge: "身份验证时长",
working: "正在处理...",
showQr: "显示二维码",
relink: "重新链接",
waitForScan: "等待扫描",
logout: "退出登录",
qrAlt: "WhatsApp 二维码",
},
shared: {
accounts: "账户 ({count})",
},
config: {
schemaUnavailable: "配置架构预览不可用。请使用原始数据 (Raw)。",
channelSchemaUnavailable: "渠道配置架构不可用。",
loadingSchema: "正在加载配置架构...",
saving: "正在保存...",
reload: "重新加载",
}
},
chat: {
title: "聊天",
subtitle: "与您的 AI 助手互动",
compacting: "正在压缩上下文...",
compacted: "上下文已压缩",
attachmentPreview: "附件预览",
removeAttachment: "移除附件",
placeholderCompose: "添加消息或粘贴图像...",
placeholderHint: "消息 (↩ 发送, Shift+↩ 换行, 可粘贴图像)",
placeholderConnect: "连接到网关以开始聊天...",
loading: "正在加载聊天...",
exitFocus: "退出专注模式",
queued: "已进入队列 ({count})",
imageAttachment: "图像 ({count})",
removeQueued: "移除队列消息",
labelMessage: "消息",
stop: "停止",
newSession: "新会话",
queue: "入队",
send: "发送",
historyNotice: "显示最近 {limit} 条消息 (隐藏了 {hidden} 条)。",
},
sessions: {
title: "会话",
subtitle: "查看活跃的代理会话",
subtitle: "活跃会话密钥及按会话进行的行为覆盖。",
activeWithin: "活跃于(分钟)",
limit: "限制",
includeGlobal: "包含全局",
includeUnknown: "包含未知",
storePath: "存储路径: {path}",
table: {
key: "密钥",
label: "标签",
kind: "类型",
updated: "更新于",
tokens: "令牌 (Tokens)",
thinking: "思考",
verbose: "详细模式",
reasoning: "推理",
actions: "操作",
},
noSessions: "未发现会话。",
inherit: "继承",
offExplicit: "禁用 (显式)",
on: "启用",
stream: "流式",
delete: "删除",
optional: "(可选)",
thinkingLevels: {
off: "关闭",
minimal: "极简",
low: "低",
medium: "中",
high: "高",
}
},
cron: {
title: "定时任务",
subtitle: "管理定时作业",
scheduler: "调度器",
schedulerSubtitle: "网关原生定时调度器状态。",
jobs: "任务数",
nextWake: "下次唤醒",
newJob: "新建任务",
newJobSubtitle: "创建定时唤醒或代理执行任务。",
name: "名称",
description: "描述",
agentId: "代理 ID",
enabled: "已启用",
schedule: "调度模式",
every: "每隔",
at: "在",
cron: "Cron 表达式",
session: "会话",
main: "主会话",
isolated: "隔离会话",
wakeMode: "唤醒模式",
nextHeartbeat: "下次心跳",
now: "立即",
payload: "载荷类型",
systemEvent: "系统事件",
agentTurn: "代理回合",
systemText: "系统文本",
agentMessage: "代理消息",
deliver: "投递",
channel: "渠道",
to: "发送至",
timeout: "超时 (秒)",
postToMainPrefix: "发布到主会话前缀",
addJob: "添加任务",
saving: "正在保存...",
jobsTitle: "任务列表",
jobsSubtitle: "网关中存储的所有定时任务。",
noJobs: "暂无任务。",
runHistory: "运行历史",
latestRuns: "最近运行记录: {id}",
selectJob: "(请选择一个任务)",
selectJobHint: "选择一个任务以查看运行历史。",
noRuns: "暂无运行记录。",
runAt: "运行时间",
unit: "单位",
minutes: "分钟",
hours: "小时",
days: "天",
expression: "表达式",
timezone: "时区 (可选)",
lastChannel: "最近渠道",
disable: "禁用",
enable: "启用",
run: "运行",
runs: "历史",
remove: "移除",
},
skills: {
title: "技能",
subtitle: "管理代理能力",
},
nodes: {
title: "节点",
subtitle: "管理计算节点",
subtitle: "内置、托管及工作区技能。",
filter: "过滤",
searchPlaceholder: "搜索技能",
shown: "显示 {count} 个",
noSkills: "未发现技能。",
installing: "正在安装...",
eligible: "符合条件",
blocked: "已屏蔽",
disabled: "已禁用",
missing: "缺失:",
reason: "原因:",
apiKey: "API 密钥",
saveKey: "保存密钥",
reasonDisabled: "已禁用",
reasonAllowlist: "被白名单拦截",
enable: "启用",
disable: "禁用",
},
instances: {
title: "实例",
subtitle: "查看连接的客户端实例",
title: "已连接实例",
subtitle: "来自网关和客户端的存在感应信号 (Presence)。",
noInstances: "暂无实例报告。",
unknownHost: "未知主机",
lastInput: "最近输入",
ago: "{time}前",
scopes: "{count} 个作用域",
reason: "原因",
},
config: {
title: "配置",
subtitle: "编辑网关设置",
gateway: {
changeTitle: "更改网关地址",
changeSubtitle: "这将连接到一个不同的网关服务器",
trustWarning: "仅在您信任此 URL 的情况下确认。恶意 URL 可能会危及您的系统安全。",
confirm: "确认",
cancel: "取消",
},
debug: {
title: "调试",
subtitle: "高级工具和状态",
execApproval: {
title: "需要执行审批",
expiresIn: "{time} 后过期",
expired: "已过期",
pending: "{count} 个待处理",
allowOnce: "允许一次",
allowAlways: "总是允许",
deny: "拒绝",
host: "主机",
agent: "代理",
session: "会话",
cwd: "工作目录",
resolved: "解析路径",
security: "安全",
ask: "请求人",
},
logs: {
title: "日志",
subtitle: "查看实时系统日志",
markdownSidebar: {
title: "工具输出",
close: "关闭侧边栏",
viewRaw: "查看原始文本",
noContent: "暂无内容",
},
configSections: {
env: { label: "环境变量", description: "传递给网关进程的环境变量" },
update: { label: "更新", description: "自动更新设置和发布渠道" },
agents: { label: "代理", description: "代理配置、模型和身份" },
auth: { label: "身份验证", description: "API 密钥和身份验证配置文件" },
channels: { label: "渠道", description: "消息渠道 (Telegram, Discord, Slack 等)" },
messages: { label: "消息", description: "消息处理和路由设置" },
commands: { label: "命令", description: "自定义斜杠命令" },
hooks: { label: "钩子", description: "Webhook 和事件钩子" },
skills: { label: "技能", description: "技能包和能力" },
tools: { label: "工具", description: "工具配置 (浏览器、搜索等)" },
gateway: { label: "网关", description: "网关服务器设置 (端口、身份验证、绑定)" },
wizard: { label: "设置向导", description: "设置向导状态和历史" },
meta: { label: "元数据", description: "网关元数据和版本信息" },
logging: { label: "日志", description: "日志级别和输出配置" },
browser: { label: "浏览器", description: "浏览器自动化设置" },
ui: { label: "界面", description: "用户界面偏好设置" },
models: { label: "模型", description: "AI 模型配置和提供商" },
bindings: { label: "绑定", description: "按键绑定和快捷键" },
broadcast: { label: "广播", description: "广播和通知设置" },
audio: { label: "音频", description: "音频输入/输出设置" },
session: { label: "会话", description: "会话管理和持久化" },
cron: { label: "定时任务", description: "计划任务和自动化" },
web: { label: "Web", description: "Web 服务器和 API 设置" },
discovery: { label: "发现", description: "服务发现和网络" },
canvasHost: { label: "画布主机", description: "画布渲染和显示" },
talk: { label: "语音", description: "语音和通话设置" },
plugins: { label: "插件", description: "插件管理和扩展" },
},
configErrors: {
noMatch: "没有匹配 \"{query}\" 的设置",
emptySection: "此章节没有设置",
schemaUnavailable: "配置结构不可用。",
unsupportedSchema: "不支持的配置结构。请使用原始数据 (Raw)。",
},
configNodes: {
unsupportedNode: "不支持的配置节点。请使用原始数据 (Raw)。",
defaultLabel: "默认值: {value}",
resetTitle: "重置为默认值",
selectPlaceholder: "请选择...",
itemsCount: "{count} 个项目",
addItem: "添加",
removeItem: "移除项目",
noItems: "暂无项目。点击“添加”来创建一个。",
customEntries: "自定义条目",
addEntry: "添加条目",
keyPlaceholder: "键",
jsonValuePlaceholder: "JSON 值",
removeEntry: "移除条目",
noCustomEntries: "暂无自定义条目。",
unsupportedType: "不支持的类型: {type}。请使用原始数据 (Raw)。",
},
format: {
na: "暂无",
justNow: "刚刚",
agoSec: "{count} 秒前",
agoMin: "{count} 分钟前",
agoHr: "{count} 小时前",
agoDay: "{count} 天前",
none: "无",
},
common: {
loading: "加载中...",
error: "错误",
refresh: "刷新",
delete: "删除",
save: "保存",
cancel: "取消",
delete: "删除",
na: "无",
inherit: "继承",
error: "错误",
edit: "编辑",
refresh: "刷新",
disconnected: "与网关断开连接。",
valid: "有效",
invalid: "无效",
unknown: "未知",
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { t } from "../i18n";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
@ -14,7 +15,7 @@ import {
* Truncate a pubkey for display (shows first and last 8 chars)
*/
function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) return "n/a";
if (!pubkey) return t("common.na");
if (pubkey.length <= 20) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
@ -64,26 +65,26 @@ export function renderNostrCard(params: {
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.running")}</span>
<span>${account.running ? t("channels.yes") : t("channels.no")}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.configured")}</span>
<span>${account.configured ? t("channels.yes") : t("channels.no")}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
<span class="label">${t("channels.lastInbound")}</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : t("common.na")}</span>
</div>
${account.lastError
? html`
? html`
<div class="account-card-error">${account.lastError}</div>
`
: nothing}
: nothing}
</div>
</div>
`;
@ -102,14 +103,14 @@ export function renderNostrCard(params: {
const profile =
(primaryAccount as
| {
profile?: {
name?: string;
displayName?: string;
about?: string;
picture?: string;
nip05?: string;
};
}
profile?: {
name?: string;
displayName?: string;
about?: string;
picture?: string;
nip05?: string;
};
}
| undefined)?.profile ?? nostr?.profile;
const { name, displayName, about, picture, nip05 } = profile ?? {};
const hasAnyProfileData = name || displayName || about || picture || nip05;
@ -117,49 +118,49 @@ export function renderNostrCard(params: {
return html`
<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="font-weight: 500;">Profile</div>
<div style="font-weight: 500;">${t("channels.nostr.profile")}</div>
${summaryConfigured
? html`
? html`
<button
class="btn btn-sm"
@click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;"
>
Edit Profile
${t("channels.nostr.editProfile")}
</button>
`
: nothing}
: nothing}
</div>
${hasAnyProfileData
? html`
? html`
<div class="status-list">
${picture
? html`
? html`
<div style="margin-bottom: 8px;">
<img
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);"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
}}
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
`
: nothing}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
: nothing}
${name ? html`<div><span class="label">${t("channels.nostr.profileFields.username")}</span><span>${name}</span></div>` : nothing}
${displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
: nothing}
? html`<div><span class="label">${t("channels.nostr.profileFields.displayName")}</span><span>${displayName}</span></div>`
: nothing}
${about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
? 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}
${nip05 ? html`<div><span class="label">${t("channels.nostr.profileFields.nip05")}</span><span>${nip05}</span></div>` : nothing}
</div>
`
: html`
: html`
<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>
@ -169,48 +170,48 @@ export function renderNostrCard(params: {
return html`
<div class="card">
<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}
${hasMultipleAccounts
? html`
? html`
<div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${summaryConfigured ? "Yes" : "No"}</span>
<span class="label">${t("channels.configured")}</span>
<span>${summaryConfigured ? t("channels.yes") : t("channels.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${summaryRunning ? "Yes" : "No"}</span>
<span class="label">${t("channels.running")}</span>
<span>${summaryRunning ? t("channels.yes") : t("channels.no")}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="label">${t("channels.nostr.publicKey")}</span>
<span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span
>
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
<span class="label">${t("channels.lastStart")}</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : t("common.na")}</span>
</div>
</div>
`}
${summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing}
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing}
${renderProfileSection()}
${renderChannelConfigSection({ channelId: "nostr", props })}
<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>
`;

View File

@ -1,10 +1,11 @@
import { html, nothing } from "lit";
import { t } from "../i18n";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) return "n/a";
if (!ms && ms !== 0) return t("common.na");
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
@ -41,5 +42,5 @@ export function renderChannelAccountCount(
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
return html`<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 { t } from "../i18n";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
@ -15,50 +16,50 @@ export function renderSignalCard(params: {
return html`
<div class="card">
<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}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
<span class="label">${t("channels.configured")}</span>
<span>${signal?.configured ? t("channels.yes") : t("channels.no")}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
<span class="label">${t("channels.running")}</span>
<span>${signal?.running ? t("channels.yes") : t("channels.no")}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
<span class="label">${t("channels.signal.baseUrl")}</span>
<span>${signal?.baseUrl ?? t("common.na")}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
<span class="label">${t("channels.lastStart")}</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : t("common.na")}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
<span class="label">${t("channels.lastProbe")}</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : t("common.na")}</span>
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
: nothing}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
? html`<div class="callout" style="margin-top: 12px;">
${t("channels.probe")} ${signal.probe.ok ? t("channels.probeOk") : t("channels.probeFailed")} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
: nothing}
${renderChannelConfigSection({ channelId: "signal", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
${t("channels.probe")}
</button>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { t } from "../i18n";
import type { ConfigUiHints } from "../types";
import { icons } from "../icons";
import {
@ -54,37 +55,15 @@ const sectionIcons = {
default: html`<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
export const SECTION_META: Record<string, { label: string; description: string }> = {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
auth: { label: "Authentication", description: "API keys and authentication profiles" },
channels: { label: "Channels", description: "Messaging channels (Telegram, Discord, Slack, etc.)" },
messages: { label: "Messages", description: "Message handling and routing settings" },
commands: { label: "Commands", description: "Custom slash commands" },
hooks: { label: "Hooks", description: "Webhooks and event hooks" },
skills: { label: "Skills", description: "Skill packs and capabilities" },
tools: { label: "Tools", description: "Tool configurations (browser, search, etc.)" },
gateway: { label: "Gateway", description: "Gateway server settings (port, auth, binding)" },
wizard: { label: "Setup Wizard", description: "Setup wizard state and history" },
// Additional sections
meta: { label: "Metadata", description: "Gateway metadata and version information" },
logging: { label: "Logging", description: "Log levels and output configuration" },
browser: { label: "Browser", description: "Browser automation settings" },
ui: { label: "UI", description: "User interface preferences" },
models: { label: "Models", description: "AI model configurations and providers" },
bindings: { label: "Bindings", description: "Key bindings and shortcuts" },
broadcast: { label: "Broadcast", description: "Broadcast and notification settings" },
audio: { label: "Audio", description: "Audio input/output settings" },
session: { label: "Session", description: "Session management and persistence" },
cron: { label: "Cron", description: "Scheduled tasks and automation" },
web: { label: "Web", description: "Web server and API settings" },
discovery: { label: "Discovery", description: "Service discovery and networking" },
canvasHost: { label: "Canvas Host", description: "Canvas rendering and display" },
talk: { label: "Talk", description: "Voice and speech settings" },
plugins: { label: "Plugins", description: "Plugin management and extensions" },
};
// Section metadata is now retrieved via getSectionMeta(key) to support localization
function getSectionMeta(key: string): { label: string; description: string } {
return (
(t(`configSections.${key}` as any) as any) ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: "",
}
);
}
function getSectionIcon(key: string) {
return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default;
@ -93,7 +72,7 @@ function getSectionIcon(key: string) {
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
const meta = SECTION_META[key];
const meta = getSectionMeta(key);
// Check key name
if (key.toLowerCase().includes(q)) return true;
@ -142,12 +121,12 @@ function schemaMatches(schema: JsonSchema, query: string): boolean {
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`;
return html`<div class="muted">${t("configErrors.schemaUnavailable")}</div>`;
}
const schema = props.schema;
const value = props.value ?? {};
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 properties = schema.properties;
@ -193,8 +172,8 @@ export function renderConfigForm(props: ConfigFormProps) {
<div class="config-empty__icon">${icons.search}</div>
<div class="config-empty__text">
${searchQuery
? `No settings match "${searchQuery}"`
: "No settings in this section"}
? t("configErrors.noMatch", { query: searchQuery })
: t("configErrors.emptySection")}
</div>
</div>
`;
@ -203,75 +182,72 @@ export function renderConfigForm(props: ConfigFormProps) {
return html`
<div class="config-form config-form--modern">
${subsectionContext
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
<section class="config-section-card" id=${id}>
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${label}</h3>
${description
? html`<p class="config-section-card__desc">${description}</p>`
: nothing}
? html`<p class="config-section-card__desc">${description}</p>`
: nothing}
</div>
</div>
<div class="config-section-card__content">
${renderNode({
schema: node,
value: scopedValue,
path: [sectionKey, subsectionKey],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
schema: node,
value: scopedValue,
path: [sectionKey, subsectionKey],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</section>
`;
})()
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
})()
: filteredEntries.map(([key, node]) => {
const meta = getSectionMeta(key);
return html`
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing}
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing}
</div>
</div>
<div class="config-section-card__content">
${renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
</section>
`;
})}
})}
</div>
`;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { t } from "../i18n";
import type { AppViewState } from "../app-view-state";
@ -11,26 +12,26 @@ export function renderGatewayUrlConfirmation(state: AppViewState) {
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">Change Gateway URL</div>
<div class="exec-approval-sub">This will reconnect to a different gateway server</div>
<div class="exec-approval-title">${t("gateway.changeTitle")}</div>
<div class="exec-approval-sub">${t("gateway.changeSubtitle")}</div>
</div>
</div>
<div class="exec-approval-command mono">${pendingGatewayUrl}</div>
<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 class="exec-approval-actions">
<button
class="btn primary"
@click=${() => state.handleGatewayUrlConfirm()}
>
Confirm
${t("gateway.confirm")}
</button>
<button
class="btn"
@click=${() => state.handleGatewayUrlCancel()}
>
Cancel
${t("gateway.cancel")}
</button>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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