Merge fc8eae7426 into 09be5d45d5
This commit is contained in:
commit
b39808a2db
@ -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
171
README.zh-CN.md
Normal 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)。
|
||||
43
docs/examples/deepseek.json
Normal file
43
docs/examples/deepseek.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
docs/zh-CN/deepseek-guide.md
Normal file
91
docs/zh-CN/deepseek-guide.md
Normal 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)。
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
"模型验证",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
26
src/wizard/i18n.ts
Normal 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
301
src/wizard/locales/zh-CN.ts
Normal 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 用户服务。如果不启用 lingering,systemd 会在注销/空闲时停止用户会话并终止网关。",
|
||||
}
|
||||
},
|
||||
common: {
|
||||
configUpdated: "配置已更新。",
|
||||
}
|
||||
};
|
||||
@ -33,8 +33,9 @@ import {
|
||||
} from "../commands/daemon-install-helpers.js";
|
||||
import type { GatewayWizardSettings, WizardFlow } from "./onboarding.types.js";
|
||||
import type { WizardPrompter } from "./prompts.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
type FinalizeOnboardingOptions = {
|
||||
export type FinalizeOnboardingOptions = {
|
||||
flow: WizardFlow;
|
||||
opts: OnboardOptions;
|
||||
baseConfig: OpenClawConfig;
|
||||
@ -65,7 +66,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
process.platform === "linux" ? await isSystemdUserServiceAvailable() : true;
|
||||
if (process.platform === "linux" && !systemdAvailable) {
|
||||
await prompter.note(
|
||||
"Systemd user services are unavailable. Skipping lingering checks and service install.",
|
||||
t("onboarding.finalize.systemdNote"),
|
||||
"Systemd",
|
||||
);
|
||||
}
|
||||
@ -78,8 +79,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
confirm: prompter.confirm,
|
||||
note: prompter.note,
|
||||
},
|
||||
reason:
|
||||
"Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
|
||||
reason: t("onboarding.finalize.systemdLinger"),
|
||||
requireConfirm: false,
|
||||
});
|
||||
}
|
||||
@ -95,15 +95,15 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
installDaemon = true;
|
||||
} else {
|
||||
installDaemon = await prompter.confirm({
|
||||
message: "Install Gateway service (recommended)",
|
||||
message: t("onboarding.finalize.installService"),
|
||||
initialValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (process.platform === "linux" && !systemdAvailable && installDaemon) {
|
||||
await prompter.note(
|
||||
"Systemd user services are unavailable; skipping service install. Use your container supervisor or `docker compose up -d`.",
|
||||
"Gateway service",
|
||||
t("onboarding.finalize.serviceNoSystemd"),
|
||||
t("onboarding.finalize.serviceInstalled"),
|
||||
);
|
||||
installDaemon = false;
|
||||
}
|
||||
@ -113,33 +113,33 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
flow === "quickstart"
|
||||
? (DEFAULT_GATEWAY_DAEMON_RUNTIME as GatewayDaemonRuntime)
|
||||
: ((await prompter.select({
|
||||
message: "Gateway service runtime",
|
||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
})) as GatewayDaemonRuntime);
|
||||
message: t("onboarding.finalize.serviceRuntime"),
|
||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
initialValue: opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
})) as GatewayDaemonRuntime);
|
||||
if (flow === "quickstart") {
|
||||
await prompter.note(
|
||||
"QuickStart uses Node for the Gateway service (stable + supported).",
|
||||
"Gateway service runtime",
|
||||
t("onboarding.finalize.serviceRuntimeQuickstart"),
|
||||
t("onboarding.finalize.serviceRuntime"),
|
||||
);
|
||||
}
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({ env: process.env });
|
||||
if (loaded) {
|
||||
const action = (await prompter.select({
|
||||
message: "Gateway service already installed",
|
||||
message: t("onboarding.finalize.serviceInstalled"),
|
||||
options: [
|
||||
{ value: "restart", label: "Restart" },
|
||||
{ value: "reinstall", label: "Reinstall" },
|
||||
{ value: "skip", label: "Skip" },
|
||||
{ value: "restart", label: "重启 (Restart)" },
|
||||
{ value: "reinstall", label: "重新安装 (Reinstall)" },
|
||||
{ value: "skip", label: "跳过 (Skip)" },
|
||||
],
|
||||
})) as "restart" | "reinstall" | "skip";
|
||||
if (action === "restart") {
|
||||
await withWizardProgress(
|
||||
"Gateway service",
|
||||
{ doneMessage: "Gateway service restarted." },
|
||||
t("onboarding.finalize.serviceInstalled"),
|
||||
{ doneMessage: t("onboarding.finalize.restarted") },
|
||||
async (progress) => {
|
||||
progress.update("Restarting Gateway service…");
|
||||
progress.update(t("onboarding.finalize.restarting"));
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
@ -148,10 +148,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
);
|
||||
} else if (action === "reinstall") {
|
||||
await withWizardProgress(
|
||||
"Gateway service",
|
||||
{ doneMessage: "Gateway service uninstalled." },
|
||||
t("onboarding.finalize.serviceInstalled"),
|
||||
{ doneMessage: t("onboarding.finalize.uninstalled") },
|
||||
async (progress) => {
|
||||
progress.update("Uninstalling Gateway service…");
|
||||
progress.update(t("onboarding.finalize.uninstalling"));
|
||||
await service.uninstall({ env: process.env, stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
@ -159,10 +159,10 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
}
|
||||
|
||||
if (!loaded || (loaded && (await service.isLoaded({ env: process.env })) === false)) {
|
||||
const progress = prompter.progress("Gateway service");
|
||||
const progress = prompter.progress(t("onboarding.finalize.serviceInstalled"));
|
||||
let installError: string | null = null;
|
||||
try {
|
||||
progress.update("Preparing Gateway service…");
|
||||
progress.update(t("onboarding.finalize.preparing"));
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port: settings.port,
|
||||
@ -172,7 +172,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
config: nextConfig,
|
||||
});
|
||||
|
||||
progress.update("Installing Gateway service…");
|
||||
progress.update(t("onboarding.finalize.installing"));
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
@ -184,11 +184,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
installError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
progress.stop(
|
||||
installError ? "Gateway service install failed." : "Gateway service installed.",
|
||||
installError ? t("onboarding.finalize.installFail") : t("onboarding.finalize.installSuccess"),
|
||||
);
|
||||
}
|
||||
if (installError) {
|
||||
await prompter.note(`Gateway service install failed: ${installError}`, "Gateway");
|
||||
await prompter.note(`${t("onboarding.finalize.installFail")}: ${installError}`, "Gateway");
|
||||
await prompter.note(gatewayInstallErrorHint(), "Gateway");
|
||||
}
|
||||
}
|
||||
@ -213,11 +213,11 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
runtime.error(formatHealthCheckFailure(err));
|
||||
await prompter.note(
|
||||
[
|
||||
"Docs:",
|
||||
t("onboarding.finalize.healthDocsPrefix"),
|
||||
"https://docs.openclaw.ai/gateway/health",
|
||||
"https://docs.openclaw.ai/gateway/troubleshooting",
|
||||
].join("\n"),
|
||||
"Health check help",
|
||||
t("onboarding.finalize.healthHelp"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -232,13 +232,8 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Add nodes for extra features:",
|
||||
"- macOS app (system + notifications)",
|
||||
"- iOS app (camera/canvas)",
|
||||
"- Android app (camera/canvas)",
|
||||
].join("\n"),
|
||||
"Optional apps",
|
||||
t("onboarding.finalize.optionalAppsList"),
|
||||
t("onboarding.finalize.optionalApps"),
|
||||
);
|
||||
|
||||
const controlUiBasePath =
|
||||
@ -260,8 +255,8 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
password: settings.authMode === "password" ? nextConfig.gateway?.auth?.password : "",
|
||||
});
|
||||
const gatewayStatusLine = gatewayProbe.ok
|
||||
? "Gateway: reachable"
|
||||
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||
? t("onboarding.setup.localOk")
|
||||
: `${t("onboarding.setup.localFail")}${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||
const bootstrapPath = path.join(
|
||||
resolveUserPath(options.workspaceDir),
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
@ -274,14 +269,14 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
await prompter.note(
|
||||
[
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||||
tokenParam ? `Web UI (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}` : undefined,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
gatewayStatusLine,
|
||||
"Docs: https://docs.openclaw.ai/web/control-ui",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Control UI",
|
||||
t("onboarding.finalize.controlUi"),
|
||||
);
|
||||
|
||||
let controlUiOpened = false;
|
||||
@ -292,32 +287,22 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
if (!opts.skipUi && gatewayProbe.ok) {
|
||||
if (hasBootstrap) {
|
||||
await prompter.note(
|
||||
[
|
||||
"This is the defining action that makes your agent you.",
|
||||
"Please take your time.",
|
||||
"The more you tell it, the better the experience will be.",
|
||||
'We will send: "Wake up, my friend!"',
|
||||
].join("\n"),
|
||||
"Start TUI (best option!)",
|
||||
t("onboarding.finalize.hatchTuiNote"),
|
||||
t("onboarding.finalize.hatchTui"),
|
||||
);
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Gateway token: shared auth for the Gateway + Control UI.",
|
||||
"Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.",
|
||||
"Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).",
|
||||
`Get the tokenized link anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
|
||||
].join("\n"),
|
||||
"Token",
|
||||
t("onboarding.finalize.tokenNote"),
|
||||
t("onboarding.gatewayConfig.authToken"),
|
||||
);
|
||||
|
||||
hatchChoice = (await prompter.select({
|
||||
message: "How do you want to hatch your bot?",
|
||||
message: t("onboarding.finalize.hatchQuestion"),
|
||||
options: [
|
||||
{ value: "tui", label: "Hatch in TUI (recommended)" },
|
||||
{ value: "web", label: "Open the Web UI" },
|
||||
{ value: "later", label: "Do this later" },
|
||||
{ value: "tui", label: t("onboarding.finalize.hatchTui") },
|
||||
{ value: "web", label: t("onboarding.finalize.hatchWeb") },
|
||||
{ value: "later", label: t("onboarding.finalize.hatchLater") },
|
||||
],
|
||||
initialValue: "tui",
|
||||
})) as "tui" | "web" | "later";
|
||||
@ -336,7 +321,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
}
|
||||
if (seededInBackground) {
|
||||
await prompter.note(
|
||||
`Web UI seeded in the background. Open later with: ${formatCliCommand(
|
||||
`${t("onboarding.finalize.webUiSeeded")} ${formatCliCommand(
|
||||
"openclaw dashboard --no-open",
|
||||
)}`,
|
||||
"Web UI",
|
||||
@ -362,37 +347,37 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
}
|
||||
await prompter.note(
|
||||
[
|
||||
`Dashboard link (with token): ${authedUrl}`,
|
||||
`${t("onboarding.finalize.dashboardReady")} (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}`,
|
||||
controlUiOpened
|
||||
? "Opened in your browser. Keep that tab to control OpenClaw."
|
||||
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
|
||||
? t("onboarding.finalize.dashboardOpened")
|
||||
: t("onboarding.finalize.dashboardCopy"),
|
||||
controlUiOpenHint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Dashboard ready",
|
||||
t("onboarding.finalize.dashboardReady"),
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
`When you're ready: ${formatCliCommand("openclaw dashboard --no-open")}`,
|
||||
"Later",
|
||||
`${t("onboarding.finalize.hatchLater")}: ${formatCliCommand("openclaw dashboard --no-open")}`,
|
||||
t("onboarding.finalize.hatchLater"),
|
||||
);
|
||||
}
|
||||
} else if (opts.skipUi) {
|
||||
await prompter.note("Skipping Control UI/TUI prompts.", "Control UI");
|
||||
await prompter.note("Skipping Control UI/TUI prompts.", t("onboarding.finalize.controlUi"));
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Back up your agent workspace.",
|
||||
t("onboarding.finalize.backupNote"),
|
||||
"Docs: https://docs.openclaw.ai/concepts/agent-workspace",
|
||||
].join("\n"),
|
||||
"Workspace backup",
|
||||
t("onboarding.finalize.backupNote"),
|
||||
);
|
||||
|
||||
await prompter.note(
|
||||
"Running agents on your computer is risky — harden your setup: https://docs.openclaw.ai/security",
|
||||
"Security",
|
||||
t("onboarding.security.title"),
|
||||
);
|
||||
|
||||
const shouldOpenControlUi =
|
||||
@ -421,15 +406,15 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
`Dashboard link (with token): ${authedUrl}`,
|
||||
`${t("onboarding.finalize.dashboardReady")} (${t("onboarding.gatewayConfig.authToken")}): ${authedUrl}`,
|
||||
controlUiOpened
|
||||
? "Opened in your browser. Keep that tab to control OpenClaw."
|
||||
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
|
||||
? t("onboarding.finalize.dashboardOpened")
|
||||
: t("onboarding.finalize.dashboardCopy"),
|
||||
controlUiOpenHint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Dashboard ready",
|
||||
t("onboarding.finalize.dashboardReady"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -439,38 +424,32 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
|
||||
await prompter.note(
|
||||
hasWebSearchKey
|
||||
? [
|
||||
"Web search is enabled, so your agent can look things up online when needed.",
|
||||
"",
|
||||
webSearchKey
|
||||
? "API key: stored in config (tools.web.search.apiKey)."
|
||||
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n")
|
||||
t("onboarding.finalize.webSearchEnabled"),
|
||||
"",
|
||||
webSearchKey
|
||||
? t("onboarding.finalize.webSearchKeyConfig")
|
||||
: t("onboarding.finalize.webSearchKeyEnv"),
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n")
|
||||
: [
|
||||
"If you want your agent to be able to search the web, you’ll need an API key.",
|
||||
"",
|
||||
"OpenClaw uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search won’t work.",
|
||||
"",
|
||||
"Set it up interactively:",
|
||||
`- Run: ${formatCliCommand("openclaw configure --section web")}`,
|
||||
"- Enable web_search and paste your Brave Search API key",
|
||||
"",
|
||||
"Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search (optional)",
|
||||
t("onboarding.finalize.webSearchDisabled"),
|
||||
"",
|
||||
`设置命令: ${formatCliCommand("openclaw configure --section web")}`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
t("onboarding.finalize.webSearchOptional"),
|
||||
);
|
||||
|
||||
await prompter.note(
|
||||
'What now: https://openclaw.ai/showcase ("What People Are Building").',
|
||||
"What now",
|
||||
'Showcase: https://openclaw.ai/showcase',
|
||||
t("onboarding.finalize.whatNow"),
|
||||
);
|
||||
|
||||
await prompter.outro(
|
||||
controlUiOpened
|
||||
? "Onboarding complete. Dashboard opened with your token; keep that tab to control OpenClaw."
|
||||
? t("onboarding.finalize.onboardingCompleteOpened")
|
||||
: seededInBackground
|
||||
? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above."
|
||||
: "Onboarding complete. Use the tokenized dashboard link above to control OpenClaw.",
|
||||
? t("onboarding.finalize.onboardingCompleteSeeded")
|
||||
: t("onboarding.finalize.onboardingComplete"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -38,6 +38,7 @@ import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
|
||||
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
|
||||
import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js";
|
||||
@ -50,33 +51,12 @@ async function requireRiskAcknowledgement(params: {
|
||||
if (params.opts.acceptRisk === true) return;
|
||||
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Security warning — please read.",
|
||||
"",
|
||||
"OpenClaw is a hobby project and still in beta. Expect sharp edges.",
|
||||
"This bot can read files and run actions if tools are enabled.",
|
||||
"A bad prompt can trick it into doing unsafe things.",
|
||||
"",
|
||||
"If you’re not comfortable with basic security and access control, don’t run OpenClaw.",
|
||||
"Ask someone experienced to help before enabling tools or exposing it to the internet.",
|
||||
"",
|
||||
"Recommended baseline:",
|
||||
"- Pairing/allowlists + mention gating.",
|
||||
"- Sandbox + least-privilege tools.",
|
||||
"- Keep secrets out of the agent’s reachable filesystem.",
|
||||
"- Use the strongest available model for any bot with tools or untrusted inboxes.",
|
||||
"",
|
||||
"Run regularly:",
|
||||
"openclaw security audit --deep",
|
||||
"openclaw security audit --fix",
|
||||
"",
|
||||
"Must read: https://docs.openclaw.ai/gateway/security",
|
||||
].join("\n"),
|
||||
"Security",
|
||||
t("onboarding.security.note"),
|
||||
t("onboarding.security.title"),
|
||||
);
|
||||
|
||||
const ok = await params.prompter.confirm({
|
||||
message: "I understand this is powerful and inherently risky. Continue?",
|
||||
message: t("onboarding.security.confirm"),
|
||||
initialValue: false,
|
||||
});
|
||||
if (!ok) {
|
||||
@ -90,14 +70,14 @@ export async function runOnboardingWizard(
|
||||
prompter: WizardPrompter,
|
||||
) {
|
||||
printWizardHeader(runtime);
|
||||
await prompter.intro("OpenClaw onboarding");
|
||||
await prompter.intro(t("onboarding.intro"));
|
||||
await requireRiskAcknowledgement({ opts, prompter });
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
let baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {};
|
||||
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
await prompter.note(summarizeExistingConfig(baseConfig), "Invalid config");
|
||||
await prompter.note(summarizeExistingConfig(baseConfig), t("onboarding.config.invalid"));
|
||||
if (snapshot.issues.length > 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
@ -105,18 +85,18 @@ export async function runOnboardingWizard(
|
||||
"",
|
||||
"Docs: https://docs.openclaw.ai/gateway/configuration",
|
||||
].join("\n"),
|
||||
"Config issues",
|
||||
t("onboarding.config.issues"),
|
||||
);
|
||||
}
|
||||
await prompter.outro(
|
||||
`Config invalid. Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run onboarding.`,
|
||||
t("onboarding.config.repair"),
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const quickstartHint = `Configure details later via ${formatCliCommand("openclaw configure")}.`;
|
||||
const manualHint = "Configure port, network, Tailscale, and auth options.";
|
||||
const quickstartHint = t("onboarding.flow.quickstartHint");
|
||||
const manualHint = t("onboarding.flow.manualHint");
|
||||
const explicitFlowRaw = opts.flow?.trim();
|
||||
const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw;
|
||||
if (
|
||||
@ -124,7 +104,7 @@ export async function runOnboardingWizard(
|
||||
normalizedExplicitFlow !== "quickstart" &&
|
||||
normalizedExplicitFlow !== "advanced"
|
||||
) {
|
||||
runtime.error("Invalid --flow (use quickstart, manual, or advanced).");
|
||||
runtime.error(t("onboarding.flow.invalidFlow"));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
@ -135,47 +115,47 @@ export async function runOnboardingWizard(
|
||||
let flow: WizardFlow =
|
||||
explicitFlow ??
|
||||
((await prompter.select({
|
||||
message: "Onboarding mode",
|
||||
message: t("onboarding.flow.modeSelect"),
|
||||
options: [
|
||||
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
|
||||
{ value: "advanced", label: "Manual", hint: manualHint },
|
||||
{ value: "quickstart", label: t("onboarding.flow.quickstart"), hint: quickstartHint },
|
||||
{ value: "advanced", label: t("onboarding.flow.manual"), hint: manualHint },
|
||||
],
|
||||
initialValue: "quickstart",
|
||||
})) as "quickstart" | "advanced");
|
||||
|
||||
if (opts.mode === "remote" && flow === "quickstart") {
|
||||
await prompter.note(
|
||||
"QuickStart only supports local gateways. Switching to Manual mode.",
|
||||
"QuickStart",
|
||||
t("onboarding.flow.remoteSwitch"),
|
||||
t("onboarding.flow.quickstart"),
|
||||
);
|
||||
flow = "advanced";
|
||||
}
|
||||
|
||||
if (snapshot.exists) {
|
||||
await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected");
|
||||
await prompter.note(summarizeExistingConfig(baseConfig), t("onboarding.existingConfig.title"));
|
||||
|
||||
const action = (await prompter.select({
|
||||
message: "Config handling",
|
||||
message: t("onboarding.existingConfig.action"),
|
||||
options: [
|
||||
{ value: "keep", label: "Use existing values" },
|
||||
{ value: "modify", label: "Update values" },
|
||||
{ value: "reset", label: "Reset" },
|
||||
{ value: "keep", label: t("onboarding.existingConfig.keep") },
|
||||
{ value: "modify", label: t("onboarding.existingConfig.modify") },
|
||||
{ value: "reset", label: t("onboarding.existingConfig.reset") },
|
||||
],
|
||||
})) as "keep" | "modify" | "reset";
|
||||
|
||||
if (action === "reset") {
|
||||
const workspaceDefault = baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE;
|
||||
const resetScope = (await prompter.select({
|
||||
message: "Reset scope",
|
||||
message: t("onboarding.existingConfig.resetScope"),
|
||||
options: [
|
||||
{ value: "config", label: "Config only" },
|
||||
{ value: "config", label: t("onboarding.existingConfig.scopeConfig") },
|
||||
{
|
||||
value: "config+creds+sessions",
|
||||
label: "Config + creds + sessions",
|
||||
label: t("onboarding.existingConfig.scopeConfigCreds"),
|
||||
},
|
||||
{
|
||||
value: "full",
|
||||
label: "Full reset (config + creds + sessions + workspace)",
|
||||
label: t("onboarding.existingConfig.scopeFull"),
|
||||
},
|
||||
],
|
||||
})) as ResetScope;
|
||||
@ -197,10 +177,10 @@ export async function runOnboardingWizard(
|
||||
const bindRaw = baseConfig.gateway?.bind;
|
||||
const bind =
|
||||
bindRaw === "loopback" ||
|
||||
bindRaw === "lan" ||
|
||||
bindRaw === "auto" ||
|
||||
bindRaw === "custom" ||
|
||||
bindRaw === "tailnet"
|
||||
bindRaw === "lan" ||
|
||||
bindRaw === "auto" ||
|
||||
bindRaw === "custom" ||
|
||||
bindRaw === "tailnet"
|
||||
? bindRaw
|
||||
: "loopback";
|
||||
|
||||
@ -237,41 +217,41 @@ export async function runOnboardingWizard(
|
||||
|
||||
if (flow === "quickstart") {
|
||||
const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => {
|
||||
if (value === "loopback") return "Loopback (127.0.0.1)";
|
||||
if (value === "lan") return "LAN";
|
||||
if (value === "custom") return "Custom IP";
|
||||
if (value === "tailnet") return "Tailnet (Tailscale IP)";
|
||||
return "Auto";
|
||||
if (value === "loopback") return t("onboarding.gateway.bindLoopback");
|
||||
if (value === "lan") return t("onboarding.gateway.bindLan");
|
||||
if (value === "custom") return t("onboarding.gateway.bindCustom");
|
||||
if (value === "tailnet") return t("onboarding.gateway.bindTailnet");
|
||||
return t("onboarding.gateway.bindAuto");
|
||||
};
|
||||
const formatAuth = (value: GatewayAuthChoice) => {
|
||||
if (value === "token") return "Token (default)";
|
||||
return "Password";
|
||||
if (value === "token") return t("onboarding.gateway.authToken");
|
||||
return t("onboarding.gateway.authPassword");
|
||||
};
|
||||
const formatTailscale = (value: "off" | "serve" | "funnel") => {
|
||||
if (value === "off") return "Off";
|
||||
if (value === "serve") return "Serve";
|
||||
return "Funnel";
|
||||
if (value === "off") return t("onboarding.gateway.tsOff");
|
||||
if (value === "serve") return t("onboarding.gateway.tsServe");
|
||||
return t("onboarding.gateway.tsFunnel");
|
||||
};
|
||||
const quickstartLines = quickstartGateway.hasExisting
|
||||
? [
|
||||
"Keeping your current gateway settings:",
|
||||
`Gateway port: ${quickstartGateway.port}`,
|
||||
`Gateway bind: ${formatBind(quickstartGateway.bind)}`,
|
||||
...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
|
||||
? [`Gateway custom IP: ${quickstartGateway.customBindHost}`]
|
||||
: []),
|
||||
`Gateway auth: ${formatAuth(quickstartGateway.authMode)}`,
|
||||
`Tailscale exposure: ${formatTailscale(quickstartGateway.tailscaleMode)}`,
|
||||
"Direct to chat channels.",
|
||||
]
|
||||
t("onboarding.gateway.keepSettings"),
|
||||
`${t("onboarding.gateway.port")}: ${quickstartGateway.port}`,
|
||||
`${t("onboarding.gateway.bind")}: ${formatBind(quickstartGateway.bind)}`,
|
||||
...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
|
||||
? [`${t("onboarding.gateway.bindCustom")}: ${quickstartGateway.customBindHost}`]
|
||||
: []),
|
||||
`${t("onboarding.gateway.auth")}: ${formatAuth(quickstartGateway.authMode)}`,
|
||||
`${t("onboarding.gateway.tailscale")}: ${formatTailscale(quickstartGateway.tailscaleMode)}`,
|
||||
t("onboarding.gateway.chatChannels"),
|
||||
]
|
||||
: [
|
||||
`Gateway port: ${DEFAULT_GATEWAY_PORT}`,
|
||||
"Gateway bind: Loopback (127.0.0.1)",
|
||||
"Gateway auth: Token (default)",
|
||||
"Tailscale exposure: Off",
|
||||
"Direct to chat channels.",
|
||||
];
|
||||
await prompter.note(quickstartLines.join("\n"), "QuickStart");
|
||||
`${t("onboarding.gateway.port")}: ${DEFAULT_GATEWAY_PORT}`,
|
||||
`${t("onboarding.gateway.bind")}: ${t("onboarding.gateway.bindLoopback")}`,
|
||||
`${t("onboarding.gateway.auth")}: ${t("onboarding.gateway.authToken")}`,
|
||||
`${t("onboarding.gateway.tailscale")}: ${t("onboarding.gateway.tsOff")}`,
|
||||
t("onboarding.gateway.chatChannels"),
|
||||
];
|
||||
await prompter.note(quickstartLines.join("\n"), t("onboarding.flow.quickstart"));
|
||||
}
|
||||
|
||||
const localPort = resolveGatewayPort(baseConfig);
|
||||
@ -284,9 +264,9 @@ export async function runOnboardingWizard(
|
||||
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
|
||||
const remoteProbe = remoteUrl
|
||||
? await probeGatewayReachable({
|
||||
url: remoteUrl,
|
||||
token: baseConfig.gateway?.remote?.token,
|
||||
})
|
||||
url: remoteUrl,
|
||||
token: baseConfig.gateway?.remote?.token,
|
||||
})
|
||||
: null;
|
||||
|
||||
const mode =
|
||||
@ -294,33 +274,33 @@ export async function runOnboardingWizard(
|
||||
(flow === "quickstart"
|
||||
? "local"
|
||||
: ((await prompter.select({
|
||||
message: "What do you want to set up?",
|
||||
options: [
|
||||
{
|
||||
value: "local",
|
||||
label: "Local gateway (this machine)",
|
||||
hint: localProbe.ok
|
||||
? `Gateway reachable (${localUrl})`
|
||||
: `No gateway detected (${localUrl})`,
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: "Remote gateway (info-only)",
|
||||
hint: !remoteUrl
|
||||
? "No remote URL configured yet"
|
||||
: remoteProbe?.ok
|
||||
? `Gateway reachable (${remoteUrl})`
|
||||
: `Configured but unreachable (${remoteUrl})`,
|
||||
},
|
||||
],
|
||||
})) as OnboardMode));
|
||||
message: t("onboarding.setup.question"),
|
||||
options: [
|
||||
{
|
||||
value: "local",
|
||||
label: t("onboarding.setup.local"),
|
||||
hint: localProbe.ok
|
||||
? `${t("onboarding.setup.localOk")} (${localUrl})`
|
||||
: `${t("onboarding.setup.localFail")} (${localUrl})`,
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: t("onboarding.setup.remote"),
|
||||
hint: !remoteUrl
|
||||
? t("onboarding.setup.remoteNoUrl")
|
||||
: remoteProbe?.ok
|
||||
? `${t("onboarding.setup.remoteOk")} (${remoteUrl})`
|
||||
: `${t("onboarding.setup.remoteFail")} (${remoteUrl})`,
|
||||
},
|
||||
],
|
||||
})) as OnboardMode));
|
||||
|
||||
if (mode === "remote") {
|
||||
let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
|
||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||
await writeConfigFile(nextConfig);
|
||||
logConfigUpdated(runtime);
|
||||
await prompter.outro("Remote gateway configured.");
|
||||
await prompter.outro(t("onboarding.setup.remoteDone"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -329,9 +309,9 @@ export async function runOnboardingWizard(
|
||||
(flow === "quickstart"
|
||||
? (baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE)
|
||||
: await prompter.text({
|
||||
message: "Workspace directory",
|
||||
initialValue: baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE,
|
||||
}));
|
||||
message: t("onboarding.setup.workspaceDir"),
|
||||
initialValue: baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE,
|
||||
}));
|
||||
|
||||
const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_WORKSPACE);
|
||||
|
||||
@ -403,13 +383,13 @@ export async function runOnboardingWizard(
|
||||
const settings = gateway.settings;
|
||||
|
||||
if (opts.skipChannels ?? opts.skipProviders) {
|
||||
await prompter.note("Skipping channel setup.", "Channels");
|
||||
await prompter.note(t("onboarding.setup.skippingChannels"), t("onboarding.gateway.chatChannels"));
|
||||
} else {
|
||||
const quickstartAllowFromChannels =
|
||||
flow === "quickstart"
|
||||
? listChannelPlugins()
|
||||
.filter((plugin) => plugin.meta.quickstartAllowFrom)
|
||||
.map((plugin) => plugin.id)
|
||||
.filter((plugin) => plugin.meta.quickstartAllowFrom)
|
||||
.map((plugin) => plugin.id)
|
||||
: [];
|
||||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowSignalInstall: true,
|
||||
@ -427,7 +407,7 @@ export async function runOnboardingWizard(
|
||||
});
|
||||
|
||||
if (opts.skipSkills) {
|
||||
await prompter.note("Skipping skills setup.", "Skills");
|
||||
await prompter.note(t("onboarding.setup.skippingSkills"), t("onboarding.setup.skills"));
|
||||
} else {
|
||||
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
|
||||
}
|
||||
|
||||
1686
ui/package-lock.json
generated
Normal file
1686
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)}
|
||||
|
||||
@ -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
28
ui/src/ui/i18n.ts
Normal 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
669
ui/src/ui/locales/zh-CN.ts
Normal 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: "未知",
|
||||
}
|
||||
};
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user