This commit is contained in:
jingrongx 2026-01-30 16:42:04 +00:00 committed by GitHub
commit b39808a2db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 5221 additions and 2178 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">

171
README.zh-CN.md Normal file
View File

@ -0,0 +1,171 @@
# 🦞 OpenClaw CN — 个人 AI 助手 (中文增强版)
<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">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
</picture>
</p>
**OpenClaw CN** 是 OpenClaw 的中文增强版本,为中国用户提供更好的使用体验,并集成了国内主流的通信渠道和大模型提供商。
## 🎯 特性增强
### 🇨🇳 全面中文化
- ✅ **Web UI 汉化**:完整的中文用户界面
- ✅ **中文文档**:安装指南、配置说明和使用教程
- ✅ **本地化体验**:针对中文用户优化的交互设计
### 🤖 新增大模型支持
- ✅ **DeepSeek**:完整支持 DeepSeek API
- `deepseek-chat` (V3.2 非思考模式)
- `deepseek-reasoner` (V3.2 思考模式)
- 完全兼容 OpenAI API 格式
- 极具竞争力的价格
### 💬 新增通信渠道
#### 已实现
- 🔵 **企业渠道**(基于官方 API
- 钉钉 (DingTalk)
- 飞书 (Feishu/Lark)
- 企业微信 (WeChat Work)
#### 计划中
- 🟡 **个人渠道**(基于 OneBot 协议)
- 微信(通过 OneBot 适配器如 Gewechat
- QQ通过 OneBot 适配器如 NapCatQQ
- 华为畅连(开发中)
> **注意**:个人微信和 QQ 需要运行独立的 OneBot 客户端。推荐方案:
> - **QQ**: [NapCatQQ](https://github.com/NapNeko/NapCatQQ), [Lagrange](https://github.com/LagrangeDev/Lagrange.Core)
> - **微信**: [Gewechat](https://github.com/Devo919/Gewechat)
## 核心功能 (继承自 OpenClaw)
- **多渠道消息支持**:连接 WhatsApp、Telegram、Slack、Discord、Google Chat、Signal、iMessage、WebChat 等
- **本地优先网关**:统一的控制平面,管理会话、渠道、工具和事件
- **语音唤醒与对话**:支持 macOS/iOS/Android 上的语音交互
- **实时画布**:代理驱动的可视化工作空间
- **浏览器控制**:通过 Chrome/Chromium 实现网页自动化
- **技能平台**:可扩展的技能系统,支持自定义工具
[完整功能列表](https://docs.openclaw.ai)
## 快速开始
### 环境要求
- **Node.js** ≥ 22
- **操作系统**: macOS, Linux, Windows (推荐 WSL2)
### 安装
```bash
npm install -g openclaw@latest
# 运行安装向导(推荐)
openclaw onboard --install-daemon
```
### DeepSeek 配置示例
`~/.openclaw/openclaw.json` 中添加:
```json
{
"models": {
"providers": {
"deepseek": {
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "${DEEPSEEK_API_KEY}",
"api": "openai-responses",
"models": [
{
"id": "deepseek-chat",
"name": "DeepSeek Chat",
"contextWindow": 64000,
"maxTokens": 8000
},
{
"id": "deepseek-reasoner",
"name": "DeepSeek Reasoner (思考模式)",
"reasoning": true,
"contextWindow": 64000,
"maxTokens": 8000
}
]
}
}
},
"agent": {
"model": "deepseek/deepseek-chat"
}
}
```
或使用环境变量:
```bash
export DEEPSEEK_API_KEY=sk-xxxxxxxxx
```
### 启动网关
```bash
# 启动网关
openclaw gateway --port 18789 --verbose
# 发送消息
openclaw agent --message "你好,请介绍一下自己" --model deepseek/deepseek-chat
```
## 文档
- 📘 [DeepSeek 配置指南](docs/zh-CN/deepseek-guide.md)
- 📘 [钉钉配置指南](docs/zh-CN/dingtalk-guide.md) *(即将推出)*
- 📘 [飞书配置指南](docs/zh-CN/feishu-guide.md) *(即将推出)*
- 📘 [官方文档](https://docs.openclaw.ai) (英文)
## 价格优势
DeepSeekV3.2 定价(/百万 tokens
| 模型 | 输入 | 输出 | 缓存读 | 缓存写 |
|------|------|------|--------|--------|
| deepseek-chat | $0.14 | $0.28 | $0.014 | $0.14 |
| deepseek-reasoner | $0.55 | $2.19 | - | - |
**相比 Claude 和 GPT 系列DeepSeek 价格仅为其几分之一,同时性能优异!**
## 安全性
- **默认安全**DM 配对机制,未知发送者需要批准
- **沙箱模式**:支持 Docker 沙箱运行非主会话
- **权限控制**:基于白名单的渠道访问控制
详见 [安全指南](https://docs.openclaw.ai/gateway/security)
## 贡献
欢迎提交 Pull Request查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南。
## 开源协议
MIT License - 详见 [LICENSE](LICENSE)
## 致谢
- 感谢 [OpenClaw 官方团队](https://github.com/openclaw/openclaw)
- 感谢 Peter Steinberger 和社区贡献者
- 特别感谢 DeepSeek 提供优秀的开源大模型
## 链接
- [OpenClaw 官网](https://openclaw.ai)
- [官方文档](https://docs.openclaw.ai)
- [Discord 社区](https://discord.gg/clawd)
- [GitHub 仓库](https://github.com/openclaw/openclaw)
---
**⚠️ 注意**: 本项目是 OpenClaw 的社区增强版本,主要面向中文用户。如需使用原版 OpenClaw请访问 [官方仓库](https://github.com/openclaw/openclaw)。

View File

@ -0,0 +1,43 @@
{
"models": {
"providers": {
"deepseek": {
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "${DEEPSEEK_API_KEY}",
"api": "openai-responses",
"models": [
{
"id": "deepseek-chat",
"name": "Deep Seek Chat (V3.2 非思考模式)",
"contextWindow": 64000,
"maxTokens": 8000,
"cost": {
"input": 0.14,
"output": 0.28,
"cacheRead": 0.014,
"cacheWrite": 0.14
},
"compat": {
"supportsStore": false
}
},
{
"id": "deepseek-reasoner",
"name": "DeepSeek Reasoner (V3.2 思考模式)",
"reasoning": true,
"contextWindow": 64000,
"maxTokens": 8000,
"cost": {
"input": 0.55,
"output": 2.19
},
"compat": {
"supportsStore": false,
"supportsReasoningEffort": true
}
}
]
}
}
}
}

View File

@ -0,0 +1,91 @@
# DeepSeek 大模型配置指南
本文档说明如何在 OpenClaw 中配置 DeepSeek API。
## 获取 API 密钥
1. 访问 [DeepSeek 开放平台](https://platform.deepseek.com/)
2. 注册并登录您的账户
3. 在 API Keys 页面创建新的 API 密钥
## 配置步骤
### 方式一:环境变量
在您的环境中设置 `DEEPSEEK_API_KEY`
```bash
export DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxx
```
### 方式二:配置文件
`~/.openclaw/openclaw.json` 中添加以下配置:
```json
{
"models": {
"providers": {
"deepseek": {
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "sk-xxxxxxxxxxxxxxxxxx",
"api": "openai-responses",
"models": [
{
"id": "deepseek-chat",
"name": "DeepSeek Chat (V3.2)",
"contextWindow": 64000,
"maxTokens": 8000
},
{
"id": "deepseek-reasoner",
"name": "DeepSeek Reasoner (V3.2 思考模式)",
"reasoning": true,
"contextWindow": 64000,
"maxTokens": 8000
}
]
}
}
}
}
```
## 使用模型
配置完成后,您可以在 OpenClaw 中使用以下模型标识符:
- `deepseek/deepseek-chat` - 标准对话模型(无思考模式)
- `deepseek/deepseek-reasoner` - 推理模型(启用思考模式)
示例:
```bash
openclaw chat --model deepseek/deepseek-chat "你好,请介绍一下自己"
```
## 定价说明
DeepSeek V3.2 定价(以每百万 tokens 计):
**deepseek-chat** (非思考模式):
- 输入:$0.14/M tokens
- 输出:$0.28/M tokens
- 缓存读取:$0.014/M tokens
- 缓存写入:$0.14/M tokens
**deepseek-reasoner** (思考模式):
- 输入:$0.55/M tokens
- 输出:$2.19/M tokens
## 注意事项
1. DeepSeek API 完全兼容 OpenAI API 格式
2. 推荐使用 `deepseek-chat` 进行日常对话
3. 对于复杂推理任务,使用 `deepseek-reasoner`
## 故障排查
如果遇到问题,请检查:
1. API 密钥是否正确设置
2. 网络是否能访问 `api.deepseek.com`
3. 配置文件 JSON 格式是否正确
更多信息请访问 [DeepSeek API 文档](https://platform.deepseek.com/docs)。

View File

@ -44,14 +44,14 @@ export function resolveAuthProfileSource(_profileId: string): AuthProfileSource
}
export function formatRemainingShort(remainingMs?: number): string {
if (remainingMs === undefined || Number.isNaN(remainingMs)) return "unknown";
if (remainingMs <= 0) return "0m";
if (remainingMs === undefined || Number.isNaN(remainingMs)) return "未知";
if (remainingMs <= 0) return "0";
const minutes = Math.max(1, Math.round(remainingMs / 60_000));
if (minutes < 60) return `${minutes}m`;
if (minutes < 60) return `${minutes}`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h`;
if (hours < 48) return `${hours}小时`;
const days = Math.round(hours / 24);
return `${days}d`;
return `${days}`;
}
function resolveOAuthStatus(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,9 +30,9 @@ export async function maybeRepairAnthropicOAuthProfileId(
});
if (!repair.migrated || repair.changes.length === 0) return cfg;
note(repair.changes.map((c) => `- ${c}`).join("\n"), "Auth profiles");
note(repair.changes.map((c) => `- ${c}`).join("\n"), "身份验证配置文件");
const apply = await prompter.confirm({
message: "Update Anthropic OAuth profile id in config now?",
message: "立即更新配置中的 Anthropic OAuth 配置文件 ID",
initialValue: true,
});
if (!apply) return cfg;
@ -80,10 +80,10 @@ function pruneAuthProfiles(
const nextAuth =
nextProfiles || prunedOrder.next
? {
...cfg.auth,
profiles: nextProfiles && Object.keys(nextProfiles).length > 0 ? nextProfiles : undefined,
order: prunedOrder.next,
}
...cfg.auth,
profiles: nextProfiles && Object.keys(nextProfiles).length > 0 ? nextProfiles : undefined,
order: prunedOrder.next,
}
: undefined;
return {
@ -110,23 +110,23 @@ export async function maybeRemoveDeprecatedCliAuthProfiles(
if (deprecated.size === 0) return cfg;
const lines = ["Deprecated external CLI auth profiles detected (no longer supported):"];
const lines = ["检测到已弃用的外部 CLI 身份验证配置文件(不再支持):"];
if (deprecated.has(CLAUDE_CLI_PROFILE_ID)) {
lines.push(
`- ${CLAUDE_CLI_PROFILE_ID} (Anthropic): use setup-token → ${formatCliCommand("openclaw models auth setup-token")}`,
`- ${CLAUDE_CLI_PROFILE_ID} (Anthropic): 请使用 setup-token → ${formatCliCommand("openclaw models auth setup-token")}`,
);
}
if (deprecated.has(CODEX_CLI_PROFILE_ID)) {
lines.push(
`- ${CODEX_CLI_PROFILE_ID} (OpenAI Codex): use OAuth → ${formatCliCommand(
`- ${CODEX_CLI_PROFILE_ID} (OpenAI Codex): 请使用 OAuth → ${formatCliCommand(
"openclaw models auth login --provider openai-codex",
)}`,
);
}
note(lines.join("\n"), "Auth profiles");
note(lines.join("\n"), "身份验证配置文件");
const shouldRemove = await prompter.confirmRepair({
message: "Remove deprecated CLI auth profiles now?",
message: "立即移除已弃用的 CLI 身份验证配置文件?",
initialValue: true,
});
if (!shouldRemove) return cfg;
@ -175,7 +175,7 @@ export async function maybeRemoveDeprecatedCliAuthProfiles(
Array.from(deprecated.values())
.map((id) => `- removed ${id} from config`)
.join("\n"),
"Doctor changes",
"医生修改",
);
}
return pruned.next;
@ -190,16 +190,16 @@ type AuthIssue = {
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) {
return `Deprecated profile. Use ${formatCliCommand("openclaw models auth setup-token")} or ${formatCliCommand(
return `已弃用的配置文件。请使用 ${formatCliCommand("openclaw models auth setup-token")} ${formatCliCommand(
"openclaw configure",
)}.`;
)}`;
}
if (issue.provider === "openai-codex" && issue.profileId === CODEX_CLI_PROFILE_ID) {
return `Deprecated profile. Use ${formatCliCommand(
return `已弃用的配置文件。请使用 ${formatCliCommand(
"openclaw models auth login --provider openai-codex",
)} or ${formatCliCommand("openclaw configure")}.`;
)} ${formatCliCommand("openclaw configure")}`;
}
return `Re-auth via \`${formatCliCommand("openclaw configure")}\` or \`${formatCliCommand("openclaw onboard")}\`.`;
return `通过 \`${formatCliCommand("openclaw configure")}\` \`${formatCliCommand("openclaw onboard")}\` 重新验证。`;
}
function formatAuthIssueLine(issue: AuthIssue): string {
@ -227,18 +227,18 @@ export async function noteAuthProfileHealth(params: {
const remaining = formatRemainingShort(until - now);
const kind =
typeof stats?.disabledUntil === "number" && now < stats.disabledUntil
? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}`
: "cooldown";
const hint = kind.startsWith("disabled:billing")
? "Top up credits (provider billing) or switch provider."
: "Wait for cooldown or switch provider.";
? `已禁用${stats.disabledReason ? `:${stats.disabledReason}` : ""}`
: "冷却中";
const hint = kind.startsWith("已禁用:billing")
? "充值(提供商计费)或切换提供商。"
: "等待冷却或切换提供商。";
out.push(`- ${profileId}: ${kind} (${remaining})${hint ? `${hint}` : ""}`);
}
return out;
})();
if (unusable.length > 0) {
note(unusable.join("\n"), "Auth profile cooldowns");
note(unusable.join("\n"), "身份验证配置文件冷却");
}
let summary = buildAuthHealthSummary({
@ -260,7 +260,7 @@ export async function noteAuthProfileHealth(params: {
if (issues.length === 0) return;
const shouldRefresh = await params.prompter.confirmRepair({
message: "Refresh expiring OAuth tokens now? (static tokens need re-auth)",
message: "立即刷新过期的 OAuth 令牌?(静态令牌需要重新验证)",
initialValue: true,
});
@ -282,7 +282,7 @@ export async function noteAuthProfileHealth(params: {
}
}
if (errors.length > 0) {
note(errors.join("\n"), "OAuth refresh errors");
note(errors.join("\n"), "OAuth 刷新错误");
}
summary = buildAuthHealthSummary({
store: ensureAuthProfileStore(undefined, {
@ -306,7 +306,7 @@ export async function noteAuthProfileHealth(params: {
}),
)
.join("\n"),
"Model auth",
"模型验证",
);
}
}

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) {

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

@ -0,0 +1,26 @@
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, args?: Record<string, string | number>): 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;
}
}
let str = typeof value === "string" ? value : key;
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(new RegExp(`{${k}}`, "g"), String(v));
}
}
return str;
}

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

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

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);
}

1686
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -82,6 +82,7 @@ import {
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
import { t } from "./i18n";
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
@ -105,7 +106,7 @@ export function renderApp(state: AppViewState) {
const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
const chatDisabledReason = state.connected ? null : t("common.disconnected");
const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@ -119,12 +120,12 @@ export function renderApp(state: AppViewState) {
<button
class="nav-collapse-toggle"
@click=${() =>
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? t("app.expandSidebar") : t("app.collapseSidebar")}"
aria-label="${state.settings.navCollapsed ? t("app.expandSidebar") : t("app.collapseSidebar")}"
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
@ -133,39 +134,43 @@ export function renderApp(state: AppViewState) {
<img src="https://mintcdn.com/clawdhub/4rYvG-uuZrMK_URE/assets/pixel-lobster.svg?fit=max&auto=format&n=4rYvG-uuZrMK_URE&q=85&s=da2032e9eac3b5d9bfe7eb96ca6a8a26" alt="OpenClaw" />
</div>
<div class="brand-text">
<div class="brand-title">OPENCLAW</div>
<div class="brand-sub">Gateway Dashboard</div>
<div class="brand-title">${t("app.title")}</div>
<div class="brand-sub">${t("app.subtitle")}</div>
</div>
</div>
</div>
<div class="topbar-status">
<div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
<span>${t("app.health")}</span>
<span class="mono">${state.connected ? t("app.healthOk") : t("app.healthOffline")}</span>
</div>
${renderThemeToggle(state)}
</div>
</header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html`
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
const groupLabel = group.label === "Chat" ? t("nav.chat") :
group.label === "Control" ? t("nav.control") :
group.label === "Agent" ? t("nav.agent") :
group.label === "Settings" ? t("nav.settings") : group.label;
return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<button
class="nav-label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
<span class="nav-label__text">${groupLabel}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">
@ -173,10 +178,10 @@ export function renderApp(state: AppViewState) {
</div>
</div>
`;
})}
})}
<div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span>
<span class="nav-label__text">${t("nav.resources")}</span>
</div>
<div class="nav-group__items">
<a
@ -184,10 +189,10 @@ export function renderApp(state: AppViewState) {
href="https://docs.openclaw.ai"
target="_blank"
rel="noreferrer"
title="Docs (opens in new tab)"
title="${t("app.openDocs")}"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span>
<span class="nav-item__text">${t("app.docs")}</span>
</a>
</div>
</div>
@ -200,383 +205,383 @@ export function renderApp(state: AppViewState) {
</div>
<div class="page-meta">
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
${isChat ? renderChatControls(state) : nothing}
</div>
</section>
${state.tab === "overview"
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing}
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing}
${state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing}
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing}
${state.tab === "instances"
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing}
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing}
${state.tab === "sessions"
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing}
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing}
${state.tab === "cron"
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing}
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing}
${state.tab === "skills"
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing}
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing}
${state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}
${state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) return;
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing}
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) return;
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing}
${state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing}
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing}
${state.tab === "debug"
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing}
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing}
${state.tab === "logs"
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing}
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing}
</main>
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}

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(", ");
}

28
ui/src/ui/i18n.ts Normal file
View File

@ -0,0 +1,28 @@
import { zhCN } from "./locales/zh-CN";
const currentLocale = "zh-CN"; // Default to Chinese
const locales: Record<string, any> = {
"zh-CN": zhCN,
};
export function t(key: string, params?: Record<string, any>): string {
const keys = key.split(".");
let value: any = locales[currentLocale];
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return key; // Return key if not found
}
}
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;
}

669
ui/src/ui/locales/zh-CN.ts Normal file
View File

@ -0,0 +1,669 @@
export const zhCN = {
app: {
title: "OPENCLAW",
subtitle: "网关仪表板",
expandSidebar: "展开侧边栏",
collapseSidebar: "收起侧边栏",
health: "健康状态",
healthOk: "正常",
healthOffline: "离线",
resources: "资源",
docs: "文档",
openDocs: "文档 (打开新标签页)",
},
nav: {
chat: "聊天",
control: "控制",
agent: "代理",
settings: "设置",
resources: "资源",
},
tabs: {
overview: "概览",
channels: "渠道",
chat: "聊天",
sessions: "会话",
cron: "定时任务",
skills: "技能",
nodes: "节点",
instances: "实例",
config: "配置",
debug: "调试",
logs: "日志",
},
sidebarGroups: {
chat: "聊天",
control: "控制台",
agent: "代理",
settings: "设置",
},
overview: {
title: "概览",
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。",
},
nodes: {
approvalsTitle: "节点审批",
approvalsSubtitle: "配置对新节点的默认连接策略",
nodesTitle: "节点",
nodesSubtitle: "管理已连接的计算节点",
devicesTitle: "设备",
devicesSubtitle: "管理已配对的设备",
target: "目标",
targetHint: "允许连接的目标节点类型",
hostLabel: "主机",
gateway: "网关",
node: "节点",
selectNode: "选择节点...",
noApprovalsNodes: "未找到可审批的节点",
scope: "范围",
defaults: "默认",
security: "安全",
securityDefaultHint: "默认安全策略",
securityAgentHint: "当前设置: {security}",
modeLabel: "模式",
useDefault: "使用默认值 ({security})",
modelLabel: "模型",
securityOptions: {
deny: "拒绝",
allowlist: "白名单",
full: "完全访问",
},
ask: "询问",
askDefaultHint: "连接时的询问策略",
askOptions: {
off: "关闭",
onMiss: "仅缺失时",
always: "总是",
},
askFallback: "后备询问",
askFallbackHint: "后备询问策略",
fallbackLabel: "后备",
autoAllowSkills: "自动允许技能",
autoAllowSkillsHint: "自动允许已知技能运行",
bindingTitle: "绑定",
bindingSubtitle: "将代理绑定到特定节点",
save: "保存",
saving: "保存中...",
bindingRawWarn: "高级模式:请小心编辑 JSON",
loadConfigToEdit: "加载配置以编辑绑定",
loadConfig: "加载配置",
loadApprovalsToEdit: "加载审批配置以编辑",
loadApprovals: "加载审批配置",
defaultBinding: "默认绑定",
defaultBindingHint: "未指定绑定的代理将使用此节点",
nodeLabel: "节点",
anyNode: "任意节点 (自动调度)",
noRunNodes: "未发现可作为运行目标的节点",
noNodesFound: "未发现已连接的节点",
pending: "待批准",
paired: "已配对",
noPairedDevices: "暂无已配对设备",
approve: "批准",
reject: "拒绝",
tokens: "访问令牌",
tokensNone: "无令牌",
revoked: "已撤销",
active: "活跃",
rotate: "轮换",
revoke: "撤销",
},
debug: {
snapshotsTitle: "快照",
snapshotsSubtitle: "当前系统状态快照",
status: "状态",
health: "健康状况",
lastHeartbeat: "最近心跳",
securityAudit: "安全审计: {label}",
criticalIssues: "{count} 个严重问题",
warningIssues: "{count} 个警告",
infoIssues: "{count} 个提示",
noCriticalIssues: "无严重问题",
refreshing: "刷新中...",
manualRpcTitle: "手动 RPC",
manualRpcSubtitle: "手动调用远程过程",
methodLabel: "方法",
paramsLabel: "参数 (JSON)",
call: "调用",
modelsTitle: "模型",
modelsSubtitle: "已加载的模型",
eventLogTitle: "事件日志",
eventLogSubtitle: "最近的系统事件",
noEvents: "暂无事件。",
},
logs: {
logsTitle: "日志",
logsSubtitle: "实时系统日志流",
export: "导出",
exportFiltered: "导出筛选结果",
exportVisible: "导出可见日志",
noEntries: "暂无日志条目。",
filterLabel: "过滤",
searchPlaceholder: "搜索日志...",
autoFollow: "自动滚动",
fileLabel: "日志文件",
truncatedWarn: "日志已截断,仅显示最近的部分。",
trace: "Trace",
debug: "Debug",
info: "Info",
warn: "Warn",
error: "Error",
fatal: "Fatal",
},
config: {
subtitle: "全局配置编辑器",
searchPlaceholder: "搜索配置项...",
allSettings: "所有设置",
sections: {
env: "环境",
update: "更新",
agents: "代理",
auth: "认证",
channels: "渠道",
messages: "消息",
commands: "命令",
hooks: "钩子",
skills: "技能",
tools: "工具",
gateway: "网关",
wizard: "向导",
meta: "元数据",
diagnostics: "诊断",
logging: "日志",
browser: "浏览器",
ui: "界面",
models: "模型",
nodeHost: "节点主机",
bindings: "绑定",
broadcast: "广播",
audio: "音频",
media: "媒体",
approvals: "审批",
session: "会话",
cron: "定时任务",
web: "Web 服务",
discovery: "发现",
canvasHost: "画布主机",
talk: "语音",
plugins: "插件",
},
formMode: "表单模式",
rawMode: "原始模式",
reload: "重新加载",
save: "保存",
apply: "应用",
update: "更新",
noChanges: "没有更改",
schema: {
logging: {
label: "日志",
description: "日志级别和输出配置",
consoleLevel: { label: "控制台级别", description: "控制台日志的输出级别" },
consoleStyle: { label: "控制台样式", description: "控制台日志的格式 (plain/json)" },
file: { label: "文件日志", description: "日志文件路径" },
level: { label: "日志级别", description: "全局日志级别 (trace/debug/info/warn/error)" },
redactPatterns: { label: "脱敏模式", description: "用于在日志中隐藏敏感信息的正则表达式" },
redactSensitive: { label: "自动脱敏", description: "自动隐藏已知的敏感键值 (如 password, token)" },
},
diagnostics: {
label: "诊断",
description: "OpenTelemetry 与调试选项",
enabled: { label: "启用诊断", description: "开启系统诊断功能" },
flags: { label: "诊断标志", description: "启用特定模块的详细日志 (如 *telegram*)" },
cacheTrace: {
label: "缓存跟踪",
description: "配置缓存操作的跟踪详细程度",
enabled: { label: "启用缓存跟踪", description: "记录嵌入式代理运行的缓存跟踪快照" },
filePath: { label: "缓存跟踪文件路径", description: "缓存跟踪日志的 JSONL 输出路径" },
includeMessages: { label: "包含消息", description: "在跟踪输出中包含完整的消息负载" },
includePrompt: { label: "包含提示词", description: "在跟踪中包含提示词文本" },
includeSystem: { label: "包含系统提示", description: "在跟踪中包含系统提示词" },
},
otel: {
label: "OpenTelemetry",
description: "OTLP 导出配置",
enabled: { label: "启用 OpenTelemetry", description: "发送遥测数据到 OTLP 端点" },
endpoint: { label: "OTLP 端点", description: "OTLP 收集器 URL" },
flushInterval: { label: "刷新间隔 (ms)", description: "数据上报的频率" },
headers: { label: "请求头", description: "附加的 HTTP 请求头" },
logsEnabled: { label: "启用日志", description: "发送日志数据" },
metricsEnabled: { label: "启用指标", description: "发送指标数据" },
tracesEnabled: { label: "启用链路追踪", description: "发送链路追踪数据" },
protocol: { label: "协议", description: "传输协议 (http/grpc)" },
},
},
update: {
label: "更新",
description: "配置更新行为",
channel: { label: "更新渠道", description: "选择更新发布渠道 (main/beta/dev)" },
},
meta: {
label: "元数据",
description: "系统运行信息",
lastRunAt: { label: "上次运行时间", description: "" },
lastRunCommand: { label: "上次运行命令", description: "" },
lastRunCommit: { label: "上次运行提交", description: "" },
lastRunMode: { label: "上次运行模式", description: "" },
lastRunVersion: { label: "上次运行版本", description: "" },
},
auth: {
label: "认证",
description: "身份验证设置",
allowTailscale: { label: "允许 Tailscale", description: "允许 Tailscale 访问" },
mode: { label: "模式", description: "认证模式 (token/password)" },
password: { label: "网关密码", description: "Tailscale funnel 需要此密码" },
token: { label: "网关令牌", description: "访问网关所需的令牌" },
gatewayPassword: { label: "网关密码", description: "Tailscale funnel 需要此密码" },
gatewayToken: { label: "网关令牌", description: "访问网关所需的令牌" },
},
nodeHost: {
label: "节点主机",
description: "网关与节点通信配置",
allowTobacco: { label: "允许烟草内容", description: "是否允许涉及烟草的内容" },
password: { label: "网关密码", description: "连接网关所需的密码" },
token: { label: "网关令牌", description: "连接网关所需的令牌" },
mode: { label: "认证模式", description: "选择 token 或 password 认证" },
},
env: {
label: "环境变量",
description: "传递给网关进程的环境变量",
allSubsections: { label: "所有变量", description: "所有配置的环境变量列表" },
},
},
},
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: "活跃会话密钥及按会话进行的行为覆盖。",
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: "定时任务",
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: "内置、托管及工作区技能。",
filter: "过滤",
searchPlaceholder: "搜索技能",
shown: "显示 {count} 个",
noSkills: "未发现技能。",
installing: "正在安装...",
eligible: "符合条件",
blocked: "已屏蔽",
disabled: "已禁用",
missing: "缺失:",
reason: "原因:",
apiKey: "API 密钥",
saveKey: "保存密钥",
reasonDisabled: "已禁用",
reasonAllowlist: "被白名单拦截",
enable: "启用",
disable: "禁用",
names: {
"1password": "1Password CLI",
"apple-notes": "备忘录 (Apple Notes)",
"apple-reminders": "提醒事项 (Apple Reminders)",
"github": "GitHub",
"openai-whisper": "OpenAI Whisper",
},
descriptions: {
"1password": "设置并使用 1Password CLI (op)。用于安装 CLI、启用桌面集成、登录账户或通过 op 访问机密。",
"apple-notes": "在 macOS 上通过 `memo` CLI 管理 Apple Notes (创建、查看、编辑、删除、搜索、移动和导出笔记)。",
"apple-reminders": "在 macOS 上通过 `remindctl` CLI 管理 Apple Reminders (列表、添加、编辑、完成、删除)。",
},
},
instances: {
title: "已连接实例",
subtitle: "来自网关和客户端的存在感应信号 (Presence)。",
noInstances: "暂无实例报告。",
unknownHost: "未知主机",
lastInput: "最近输入",
ago: "{time}前",
scopes: "{count} 个作用域",
reason: "原因",
},
gateway: {
changeTitle: "更改网关地址",
changeSubtitle: "这将连接到一个不同的网关服务器",
trustWarning: "仅在您信任此 URL 的情况下确认。恶意 URL 可能会危及您的系统安全。",
confirm: "确认",
cancel: "取消",
},
execApproval: {
title: "需要执行审批",
expiresIn: "{time} 后过期",
expired: "已过期",
pending: "{count} 个待处理",
allowOnce: "允许一次",
allowAlways: "总是允许",
deny: "拒绝",
host: "主机",
agent: "代理",
session: "会话",
cwd: "工作目录",
resolved: "解析路径",
security: "安全",
ask: "请求人",
},
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: "加载中...",
refresh: "刷新",
delete: "删除",
save: "保存",
cancel: "取消",
na: "无",
inherit: "继承",
error: "错误",
edit: "编辑",
disconnected: "与网关断开连接。",
valid: "有效",
invalid: "无效",
unknown: "未知",
}
};

View File

@ -1,13 +1,14 @@
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 =
@ -132,57 +133,58 @@ export function iconForTab(tab: Tab): IconName {
export function titleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Overview";
return t("tabs.overview");
case "channels":
return "Channels";
return t("tabs.channels");
case "instances":
return "Instances";
return t("tabs.instances");
case "sessions":
return "Sessions";
return t("tabs.sessions");
case "cron":
return "Cron Jobs";
return t("tabs.cron");
case "skills":
return "Skills";
return t("tabs.skills");
case "nodes":
return "Nodes";
return t("tabs.nodes");
case "chat":
return "Chat";
return t("tabs.chat");
case "config":
return "Config";
return t("tabs.config");
case "debug":
return "Debug";
return t("tabs.debug");
case "logs":
return "Logs";
return t("tabs.logs");
default:
return "Control";
return t("sidebarGroups.control");
}
}
export function subtitleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Gateway status, entry points, and a fast health read.";
return t("overview.subtitle");
case "channels":
return "Manage channels and settings.";
return t("channels.subtitle");
case "instances":
return "Presence beacons from connected clients and nodes.";
return t("instances.subtitle");
case "sessions":
return "Inspect active sessions and adjust per-session defaults.";
return t("sessions.subtitle");
case "cron":
return "Schedule wakeups and recurring agent runs.";
return t("cron.subtitle");
case "skills":
return "Manage skill availability and API key injection.";
return t("skills.subtitle");
case "nodes":
return "Paired devices, capabilities, and command exposure.";
return t("nodes.subtitle");
case "chat":
return "Direct gateway chat session for quick interventions.";
return t("chat.subtitle");
case "config":
return "Edit ~/.openclaw/openclaw.json safely.";
return t("config.subtitle");
case "debug":
return "Gateway snapshots, events, and manual RPC calls.";
return t("debug.subtitle");
case "logs":
return "Live tail of the gateway file logs.";
return t("logs.subtitle");
default:
return "";
}
}

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,
@ -26,6 +27,34 @@ function jsonValue(value: unknown): string {
}
}
function resolveNodeLabels(
path: Array<string | number>,
schema: JsonSchema,
hints: ConfigUiHints
): { label: string; help?: string } {
const hint = hintForPath(path, hints);
// Try translation
// Filter out numeric indices to handle array items if necessary,
// but for settings keys (which are object properties), we usually want the specific path.
// However, many array items might share the same schema.
// For now, we focus on the settings panel which is mostly object paths.
const strPath = path.join(".");
const schemaPath = `config.schema.${strPath}`;
const labelKey = `${schemaPath}.label`;
const descKey = `${schemaPath}.description`;
const translatedLabel = t(labelKey);
const translatedDesc = t(descKey);
const fallbackLabel = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const fallbackHelp = hint?.help ?? schema.description;
return {
label: translatedLabel && translatedLabel !== labelKey ? translatedLabel : fallbackLabel,
help: translatedDesc && translatedDesc !== descKey ? translatedDesc : fallbackHelp,
};
}
// SVG Icons as template literals
const icons = {
chevronDown: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
@ -48,15 +77,18 @@ export function renderNode(params: {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const hint = hintForPath(path, hints); // Keep for sensitive check below?
// Actually resolveNodeLabels does `hintForPath` internally but we might need `hint` variable for other things like `sensitive`.
// `renderNode` doesn't use `hint` for anything else except label/help.
// `pathKey` is used below.
const { label, help } = resolveNodeLabels(path, schema, hints);
const key = pathKey(path);
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 +242,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>
`;
}
@ -228,12 +260,15 @@ function renderTextInput(params: {
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
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 +283,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>
@ -292,8 +327,7 @@ function renderNumberInput(params: {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const displayValue = value ?? schema.default ?? "";
const numValue = typeof displayValue === "number" ? displayValue : 0;
@ -314,10 +348,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"
@ -343,8 +377,7 @@ function renderSelect(params: {
const { schema, value, path, hints, disabled, options, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex(
(opt) => opt === resolvedValue || String(opt) === String(resolvedValue),
@ -360,11 +393,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>
`)}
@ -386,8 +419,7 @@ function renderObject(params: {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const fallback = value ?? schema.default;
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback)
@ -413,26 +445,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 +479,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>
`;
@ -485,8 +517,7 @@ function renderArray(params: {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
@ -504,25 +535,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 +564,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 +612,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,91 @@ 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);
// Try translation first
const schemaPath = `config.schema.${sectionKey}.${subsectionKey}`;
const labelKey = `${schemaPath}.label`;
const descKey = `${schemaPath}.description`;
const translatedLabel = t(labelKey);
const translatedDesc = t(descKey);
const label = translatedLabel !== labelKey ? translatedLabel : (hint?.label ?? node.title ?? humanize(subsectionKey));
const description = translatedDesc !== descKey ? translatedDesc : (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);
// Try translation for top-level schema items if not in meta
const schemaPath = `config.schema.${key}`;
const labelKey = `${schemaPath}.label`;
const descKey = `${schemaPath}.description`;
const translatedLabel = t(labelKey);
const translatedDesc = t(descKey);
return html`
const label = translatedLabel !== labelKey ? translatedLabel : meta.label;
const description = translatedDesc !== descKey ? translatedDesc : meta.description;
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}
<h3 class="config-section-card__title">${label}</h3>
${description
? 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>
`;
}
export const SECTION_META = sectionIcons;

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,37 @@ 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") },
{ key: "meta", label: t("config.sections.meta") },
{ key: "diagnostics", label: t("config.sections.diagnostics") },
{ key: "logging", label: t("config.sections.logging") },
{ key: "browser", label: t("config.sections.browser") },
{ key: "ui", label: t("config.sections.ui") },
{ key: "models", label: t("config.sections.models") },
{ key: "nodeHost", label: t("config.sections.nodeHost") },
{ key: "bindings", label: t("config.sections.bindings") },
{ key: "broadcast", label: t("config.sections.broadcast") },
{ key: "audio", label: t("config.sections.audio") },
{ key: "media", label: t("config.sections.media") },
{ key: "approvals", label: t("config.sections.approvals") },
{ key: "session", label: t("config.sections.session") },
{ key: "cron", label: t("config.sections.cron") },
{ key: "web", label: t("config.sections.web") },
{ key: "discovery", label: t("config.sections.discovery") },
{ key: "canvasHost", label: t("config.sections.canvasHost") },
{ key: "talk", label: t("config.sections.talk") },
{ key: "plugins", label: t("config.sections.plugins") },
];
type SubsectionEntry = {
@ -210,10 +230,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 +275,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 +288,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 +307,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 +328,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 +346,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 +383,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 +404,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,36 +86,36 @@ 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">
<div class="list-title">
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
${skill.emoji ? `${skill.emoji} ` : ""}${t(`skills.names.${skill.skillKey}`) !== `skills.names.${skill.skillKey}` ? t(`skills.names.${skill.skillKey}`) : skill.name}
</div>
<div class="list-sub">${clampText(skill.description, 140)}</div>
<div class="list-sub">${clampText((t(`skills.descriptions.${skill.skillKey}`) !== `skills.descriptions.${skill.skillKey}` ? t(`skills.descriptions.${skill.skillKey}`) : skill.description), 140)}</div>
<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>
`;