5 Commits
Author SHA1 Message Date
taodengandClaude Opus 4.6 1f5f92ba36 feat: add Dashboard tab + cache-control + version cache busting (v0.6.5)
- New Dashboard landing tab with system info, gateway health, agent stats, storage
- Cache-Control: no-store on all HTTP responses
- Version-based cache busting with X-OCM-Version header + client-side detection
- Confirmed old /api/agents/main endpoint fully removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:04:56 +00:00
taodengandClaude Opus 4.6 c8629f8670 docs: update DEVLOG with v0.6.0 changes and next steps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:51:51 +00:00
taodengandClaude Opus 4.6 e2f4e88a7a fix: prevent Add Agent from overwriting existing bot token + multi-bot support
CRITICAL BUG FIX: Add Agent was overwriting channels.telegram.botToken,
breaking all existing agents. Now uses OpenClaw's multi-account structure
(channels.telegram.accounts) with automatic migration from old format.

Changes:
- Replace POST /api/agents/main with POST /api/agents/bot (new agent
  with its own bot token, never overwrites existing tokens)
- Auto-migrate old botToken format to accounts.default on first new bot
- Sub-agent form now selects parent agent (not hardcoded to "main")
- GET /api/agents returns hasOwnBot flag for each agent
- Stats rewritten to parse session JSONL files (not gateway.log)
- Fix togglePopover conflict with browser native Popover API
- Agent tree uses event delegation (data-action) instead of inline onclick
- Backup timestamps use Australia/Brisbane timezone
- Center toolbar buttons, constrain agent tree width
- README rewritten: professional, removed empty screenshot placeholders
- All new UI strings in English

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:22:43 +00:00
taodeng 089ff8986a docs: remove all screenshots (possible sensitive IDs) 2026-02-26 07:58:00 +10:00
taodeng d57d26471f docs: remove screenshots with sensitive binding IDs 2026-02-26 07:26:51 +10:00
3 changed files with 642 additions and 312 deletions
+141 -2
View File
@@ -1,7 +1,146 @@
# OpenClaw Manager — 开发日志
> 最后更新:2026-02-25
> 当前版本:v0.5.2
> 最后更新:2026-02-26
> 当前版本:v0.6.5
---
## v0.6.5 更新日志(2026-02-26
### New Features
**Dashboard Tab (🏠 Dashboard)**
- New default landing tab showing system overview at a glance
- **System card**: hostname, OS, Node.js version, CPU model/cores, uptime, memory usage with progress bar
- **Gateway card**: process status indicator (running/stopped/unknown) with coloured dot, port, PID, HTTP ping reachability
- **Agents card**: total agent count, last session activity timestamp (Brisbane time), OCM version, server time
- **Storage card**: OpenClaw dir size, disk usage with progress bar, free space
- New `GET /api/dashboard` endpoint aggregates all info (ps grep + curl ping + fs stat)
- Lazy-loaded on tab switch; auto-loaded on first page load
**Cache-Control + Version-Based Cache Busting**
- All HTTP responses now include `Cache-Control: no-store, no-cache, must-revalidate`
- HTML responses include `ETag: "ocm-<version>"` for version-based cache validation
- All responses include `X-OCM-Version` header
- Browser `api()` function checks `X-OCM-Version` against client version; shows toast notification when server has been updated, prompting user to refresh
- Prevents stale frontend from calling deleted/changed API endpoints after update
### Cleanup
**Old `/api/agents/main` endpoint fully removed** (confirmed via code search — no residual references)
### Technical Notes
- Dashboard gateway detection: `ps aux | grep openclaw.*gateway` for process status, `curl --max-time 2 http://127.0.0.1:<port>` for HTTP ping
- Disk usage: `df -k` for filesystem stats, `du -sk` for OpenClaw dir size
- Agent last activity: scans `~/.openclaw/agents/*/sessions/*.jsonl` mtime
- Version cache busting: `OCM_CLIENT_VERSION` is injected into browser script via template literal `${APP_VERSION}`, compared against `X-OCM-Version` response header on every API call
---
## v0.6.0 更新日志(2026-02-26
### 重大 Bug 修复
**Add Agent 覆盖已有 Bot Token(数据破坏性 Bug**
- **症状**:通过 Add Agent 表单添加新 agent 时,直接覆盖 `channels.telegram.botToken`,导致所有已有 agent(包括 sub-agent)全部失效
- **根本原因**`POST /api/agents/main` 端点无条件覆盖 `channels.telegram.botToken` 字段,没有保护已有配置
- **修复**
- 彻底删除 `POST /api/agents/main` 端点
- 新建 `POST /api/agents/bot` 端点,使用 OpenClaw 的 `channels.telegram.accounts` 多 bot 结构
- 每个新 agent 获得独立的 `accountId``botToken`,绝不覆盖已有 token
- 自动迁移:首次添加新 bot 时,自动将旧格式(顶层 `botToken`)迁移到 `accounts.default`
- **数据恢复**:程序在修改前自动创建 `openclaw.json.create.*` 备份,可通过 `cp` 恢复
**浏览器 Popover API 命名冲突**
- **症状**:点击 " Add Agent" / " Add Sub-Agent" 按钮报错 `NotSupportedError: Failed to execute 'togglePopover' on 'HTMLElement'`
- **根本原因**:自定义函数 `togglePopover()` 与浏览器原生 Popover API 的 `HTMLElement.togglePopover()` 方法冲突
- **修复**:函数重命名为 `showConfigPop()`
### 架构变更
**多 Bot 支持(Multi-Account**
- 支持 OpenClaw 的 `channels.telegram.accounts` 结构,每个主 agent 可绑定独立的 Telegram bot
- 配置格式:
```json
{
"channels": { "telegram": { "accounts": {
"default": { "botToken": "TOKEN_A" },
"research": { "botToken": "TOKEN_B" }
}}}
}
```
- `GET /api/agents` 返回 `hasOwnBot` 字段,标识 agent 是否拥有独立 bot
- Sub-Agent 表单新增 "Parent Agent" 下拉,选择共享哪个 bot(不再硬编码 "main"
**去除 Landing Page,直接进入主程序**
- 移除模式选择首页(Sub-agent / Multi-agent 二选一)
- 启动后直接进入 Agent 管理页面
- 移除 landing page 相关 HTML、CSS、JS、i18n keys
**Agent 页面重设计(popover 配置窗口)**
- 不再使用左右分屏布局
- " Add Agent" / " Add Sub-Agent" 按钮居中显示在 agent 树上方
- 点击按钮弹出浮动配置窗口(popover),包含引导步骤和表单
- Agent 树宽度限制 720px 居中,多个 agent 树纵向排列
### 功能改进
**Stats 重写 — 从 session JSONL 文件解析真实用量**
- 不再从 `gateway.log` 解析(之前一直是 0 数据)
- 改为扫描 `~/.openclaw/agents/*/sessions/*.jsonl`
- 解析 `type: "message"` + `role: "assistant"` 的 `message.usage` 字段
- 新增维度:By Agent(每个 agent 的用量)、Cache Read tokens
- 测试验证:成功解析出 990 条请求、6 个 agent、10 个模型的真实数据
**Model 下拉 — 只显示已注册模型**
- 不再使用硬编码的 KNOWN_MODELS 列表
- 改为从 `openclaw.json` 的 `agents.defaults.models` 读取实际注册的模型
**Agent 树事件委托**
- `renderAgents()` 中的按钮不再使用 `onclick="func('escaped-string')"` 内联写法
- 改用 `data-action` / `data-id` 属性 + 事件委托(`agentTreeAction`),避免 template literal 转义问题
**响应式布局**
- `main` 容器改为 `max-width:100%`,适配不同屏幕宽度
- 新增 `@media (max-width: 600px)` 断点:侧边导航折叠、按钮纵向排列
**备份时间戳改为 Brisbane 时区**
- `brisbaneTimestamp()` 函数,所有备份文件名使用 `Australia/Brisbane` 时区
- 重要:系统所有时间显示统一按 Brisbane 处理
**启动脚本全面重写**
- `start.sh` / `start.bat` 全部英文
- 端口冲突时自动 kill 旧进程,而非报错退出
- 支持 `--host` 参数
**README 重写**
- 移除所有空的 screenshot 占位符(含敏感 ID 的截图已删除)
- 更新功能描述匹配 v0.6
- 精简结构,保留中英文双语
### 新功能 i18n 策略
- v0.6 新增的所有 UI 文案仅提供英文
- 中文翻译推迟到 v1.0 正式版
### 技术备忘
- **Template literal 转义规则**MAIN_HTML_SCRIPT 是反引号模板字符串
- `\n` → 真实换行(浏览器 JS 字符串跨行 → SyntaxError),必须写 `\\n`
- `\'` → `'`(无法用于 onclick 里的引号转义),改用 data 属性 + 事件委托
- `\`` → `` ` ``(嵌套模板字符串在 evaluated output 中正常工作)
- **`assertBrowserScriptSyntax()`** 在启动时检查 MAIN_HTML_SCRIPT 的 evaluated 值
- **OpenClaw config 多 bot 格式**`channels.telegram.accounts.<id>.botToken`binding 用 `accountId` 路由
### 下一步(v0.6.2+
- [x] Dashboard 首页 tab(系统信息 + OpenClaw health 状态)— done in v0.6.5
- [x] HTTP 响应加 `Cache-Control: no-store` + version-based cache busting — done in v0.6.5
- [x] 彻底删除旧 `/api/agents/main` 端点残留(确认已清除)— confirmed in v0.6.5
- [ ] Dashboard: auto-refresh every 30s when tab is active
- [ ] Dashboard: OpenClaw version display (from `openclaw --version`)
- [ ] DEVLOG.md 中文 → 逐步迁移为英文
---
+62 -178
View File
@@ -1,116 +1,56 @@
# OpenClaw Manager (OCM)
> A zero-dependency, single-file local dashboard for [OpenClaw](https://github.com/anthropics/openclaw) — manage sub-agents, run commands in a built-in CLI, and stop hand-editing JSON.
A zero-dependency, single-file web dashboard for [OpenClaw](https://github.com/anthropics/openclaw). Manage agents, monitor token usage, and run commands — all from your browser, no `npm install` required.
[中文说明](#中文说明)
---
## Quick Start (1 min)
## Quick Start
```bash
# Install
# Clone
git clone https://github.com/dtzp555-max/ocm.git
cd ocm
# Update (existing clone)
git pull --ff-only
# Run (no npm install needed)
bash start.sh # macOS / Linux
start.bat # Windows
bash start.sh # macOS / Linux
start.bat # Windows
# Update
git pull --ff-only
```
Open [http://localhost:3333](http://localhost:3333).
## Why OCM
- **Single-file + zero dependency**: one `openclaw-manager.js`, no build step
- **Fast daily operations**: agent/model/auth/cron/backup in one local UI
- **Built-in terminal**: run `openclaw` commands with streaming output and presets
## Screenshots
### Landing Page
Choose between Sub-agent mode and Multi-agent mode. Switch language (EN / 中文) anytime.
![Landing Page](screenshots/landing.png)
### Agent Management
View all agents (main + sub-agents) at a glance. Each card shows model, group binding, and workspace path. Switch models inline, browse files, or delete with auto-backup.
![Agents](screenshots/agents.png)
### Built-in CLI Terminal
Run any openclaw command with real-time streaming output. The terminal panel sits at the bottom of the page and expands on demand.
![CLI Overview](screenshots/cli1.png)
<details>
<summary>More screenshots</summary>
### New Subagent Wizard
4-step guided wizard to create a sub-agent: basic info → model → personality & memory → confirm. Fully bilingual.
![New Subagent](screenshots/subagents.png)
### Model Selector
Rich model dropdown grouped by provider — GitHub Copilot, Anthropic, OpenAI, Google, DeepSeek, Kimi, Groq, Mistral, Together, plus your custom models.
![Models](screenshots/models.png)
### Actions Menu
Quick access to gateway operations, backups, health checks, and directory management. Use **"Switch OpenClaw Dir"** to point OCM to your OpenClaw config on first setup.
![Actions](screenshots/actions.png)
### Cron Job Management
View, add, enable/disable, and manually trigger openclaw-related cron tasks. Integrates with NAS backup schedules.
![Cron](screenshots/cron.png)
Preset command menu with built-in commands and your personal favorites — one click to run.
![CLI Presets](screenshots/cli3.png)
Command output streams in real-time. Star frequently used commands for quick access, or use the Manage button to organize favorites.
![CLI Detail](screenshots/cli2.png)
</details>
Open [http://localhost:3333](http://localhost:3333) in your browser. For remote access, use `bash start.sh --host 0.0.0.0`.
## Features
- **Agents & Workspace** — Create sub-agents, switch models inline, and browse/edit workspace files
- **Models & Auth** — Manage primary/fallback models and follow provider auth guides
- **Ops Panel** — Restart gateway, view logs, run health checks, and switch OpenClaw directory
- **Stats & Cron** — Token/cost stats from `gateway.log` plus cron task management
- **Backups** — Local backup/rollback and NAS backup (SFTP/rsync)
- **Built-in CLI** — Real-time command terminal with presets, favorites, and tab completion
**Agent Management**Add main agents and sub-agents through a guided setup flow. View all agents in a tree structure with model selection, workspace browsing, and inline configuration.
**Usage Statistics** — Real token usage data parsed directly from OpenClaw session files. Breakdown by model, agent, and day with a visual chart.
**Model & Auth**Configure models from your registered provider list. Follow built-in guides for provider authentication setup.
**Built-in CLI** — Run any `openclaw` command with real-time streaming output. Preset commands, favorites, and tab completion included.
**Ops Panel** — Restart gateway, view logs, run health checks, manage backups (local + NAS via SFTP/rsync), and handle cron tasks.
**Bilingual UI** — English and Chinese interface with one-click language switching.
## Requirements
- Node.js >= 18 ([download](https://nodejs.org/))
- Node.js 18+ ([download](https://nodejs.org/))
- A working [OpenClaw](https://github.com/anthropics/openclaw) installation
Install/update/run commands are in **Quick Start (1 min)** above.
### First Run
On first launch, the start script auto-detects `~/.openclaw` and creates a `manager-config.json` for you. If your OpenClaw config is elsewhere, use **Actions → Switch OpenClaw Dir** in the UI to point to the correct path.
### macOS Double-click
Double-click `OpenClaw Manager.app` in Finder. On first launch, you may need to allow it in System Settings → Privacy & Security.
### Custom Port
```bash
bash start.sh --port 8080
```
## Configuration
The app locates your OpenClaw config directory in this order:
On first launch, OCM auto-detects `~/.openclaw` and creates a local `manager-config.json`. If your OpenClaw config is elsewhere, use **Actions → Switch OpenClaw Dir** or specify it directly:
```bash
bash start.sh --dir /path/to/.openclaw --port 8080
```
Detection priority:
| Priority | Method |
|----------|--------|
@@ -125,9 +65,17 @@ You can also create `manager-config.json` manually:
{ "dir": "~/.openclaw" }
```
`~` expands to your home directory on all platforms. `manager-config.json` is in `.gitignore` and won't be committed.
`manager-config.json` is gitignored and won't be committed.
## Optional: Shell Alias
## Remote Access
By default OCM binds to `0.0.0.0` (all interfaces). Access it from another device on your LAN using the IP shown at startup. To restrict to localhost only:
```bash
bash start.sh --host 127.0.0.1
```
## Shell Alias (Optional)
```bash
# Add to ~/.zshrc or ~/.bashrc
@@ -138,14 +86,12 @@ alias ocm="bash ~/path/to/ocm/start.sh"
```
ocm/
├── openclaw-manager.js ← The entire app (server + frontend, single file)
├── start.sh ← macOS/Linux start script (auto-detect Node, port, config)
├── start.bat ← Windows start script
├── OpenClaw Manager.app/ ← macOS Finder double-click launcher
├── screenshots/ ← README screenshots
├── manager-config.json ← Local config (gitignored)
├── package.json
├── DEVLOG.md ← Development log
├── openclaw-manager.js ← Entire app (server + frontend, single file)
├── start.sh ← macOS/Linux launcher (auto-detect, port conflict handling)
├── start.bat ← Windows launcher
├── OpenClaw Manager.app/ ← macOS Finder double-click launcher
├── manager-config.json ← Local config (gitignored)
├── DEVLOG.md ← Development log
└── README.md
```
@@ -155,12 +101,6 @@ ocm/
- **Frontend**: Vanilla HTML/CSS/JS embedded in a template literal — no build step, no framework
- **Architecture**: Single-file, zero-dependency, runs anywhere Node.js runs
## Support / Donate
If OCM saves you time, consider starring the repo or sponsoring development.
- GitHub Sponsors: *(add link when ready)*
## License
MIT
@@ -169,94 +109,38 @@ MIT
## 中文说明
### 简介
OpenClaw Manager 是一个零依赖的本地 Web 管理界面,用于可视化管理 [OpenClaw](https://github.com/anthropics/openclaw) AI 智能体。所有代码在一个 `.js` 文件中,只需 Node.js 18+,不需要 `npm install`
OpenClaw Manager 是一个零依赖的本地 Web 管理界面,用于可视化管理 [OpenClaw](https://github.com/anthropics/openclaw) AI 智能体。所有代码合并在一个 `.js` 文件中,只需要 Node.js 18+,不需要 `npm install`
### 1 分钟上手
### 快速开始
```bash
# 首次安装
git clone https://github.com/dtzp555-max/ocm.git
cd ocm
# 已安装后更新
git pull --ff-only
# 运行(无需 npm install
bash start.sh # macOS / Linux
start.bat # Windows
bash start.sh # macOS / Linux
start.bat # Windows
```
访问 [http://localhost:3333](http://localhost:3333)。
访问 [http://localhost:3333](http://localhost:3333)。远程访问请使用 `bash start.sh --host 0.0.0.0`
### 界面预览
### 功能
#### 模式选择
![Landing](screenshots/landing.png)
- **Agent 管理** — 添加主 Agent 和子 Agent,树状结构查看,内联切换模型,浏览工作区文件
- **使用统计** — 从 OpenClaw 会话文件解析真实 Token 用量,按模型/Agent/日期维度展示
- **模型与认证** — 管理注册模型列表,内置 Provider 认证引导
- **内置终端** — 实时流式输出,预设命令,收藏夹,Tab 补全
- **运维面板** — 重启网关、查看日志、健康检查、本地/NAS 备份、Cron 任务管理
- **双语界面** — 中英文一键切换
#### Agent 管理
![Agents](screenshots/agents.png)
### 配置
#### 内置 CLI 终端
底部终端面板,支持实时流式输出、预设命令、收藏夹、Tab 补全。
启动脚本自动检测 `~/.openclaw` 目录。如需指定其他路径:
![CLI](screenshots/cli1.png)
```bash
bash start.sh --dir /path/to/.openclaw --port 8080
```
<details>
<summary>更多截图</summary>
#### 新建向导
![New Subagent](screenshots/subagents.png)
#### 模型选择 & 操作菜单
首次安装后,通过 **Actions → Switch OpenClaw Dir** 指向你的 OpenClaw 配置目录即可开始使用。
![Models](screenshots/models.png)
![Actions](screenshots/actions.png)
#### Cron 定时任务
![Cron](screenshots/cron.png)
![CLI Presets](screenshots/cli3.png)
![CLI Detail](screenshots/cli2.png)
</details>
### 功能一览
- **Agent 与 Workspace** — 新建子智能体、内联切换模型、浏览/编辑工作区文件
- **模型与认证** — 管理主模型/Fallback 链,按引导完成各 Provider 认证
- **运维操作面板** — 重启网关、查看日志、健康检查、切换 OpenClaw 目录
- **统计与计划任务** — 从 `gateway.log` 汇总 Token/费用,并管理 Cron 任务
- **备份能力** — 本地备份回滚 + NAS 远程备份(SFTP/rsync
- **内置 CLI 终端** — 实时输出、预设命令、收藏、Tab 补全
安装/更新/运行命令见上方 **1 分钟上手**
### 首次运行
启动脚本会自动检测 `~/.openclaw` 目录并创建 `manager-config.json`。如果你的 OpenClaw 配置在其他位置,进入界面后点击 **Actions → Switch OpenClaw Dir** 切换即可。
### 指定 OpenClaw 目录
程序按以下优先级查找配置:
1. `--dir` 命令行参数
2. `OPENCLAW_DIR` 环境变量
3. 同目录下的 `manager-config.json`
4. `~/.openclaw`(默认)
也可以手动创建 `manager-config.json`
也可手动创建 `manager-config.json`
```json
{ "dir": "~/.openclaw" }
```
`~` 在所有平台自动展开为用户主目录。
### Shell 别名(可选)
```bash
# 加到 ~/.zshrc 或 ~/.bashrc
alias ocm="bash ~/path/to/ocm/start.sh"
```
+439 -132
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// ================================================================
// OpenClaw Manager v0.6.0
// OpenClaw Manager v0.6.5
// 跨平台本地管理工具 (Windows / macOS / Linux)
//
// 用法:
@@ -24,7 +24,7 @@ const SCRIPT_DIR = __dirname;
const MANAGER_CONFIG = path.join(SCRIPT_DIR, 'manager-config.json');
let PORT = 3333;
let HOST = '0.0.0.0';
const APP_VERSION = '0.6.0';
const APP_VERSION = '0.6.5';
// --port 参数
const portIdx = process.argv.indexOf('--port');
if (portIdx !== -1 && process.argv[portIdx + 1]) PORT = parseInt(process.argv[portIdx + 1]) || 3333;
@@ -114,8 +114,12 @@ async function readConfig() {
return JSON.parse(raw);
}
function brisbaneTimestamp() {
return new Date().toLocaleString('sv-SE', { timeZone: 'Australia/Brisbane', hour12: false }).replace(/[: ]/g, '-').slice(0, 19);
}
async function backupConfig(label) {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const ts = brisbaneTimestamp();
const suffix = label ? `.${label}.${ts}` : `.bak.${ts}`;
const bakPath = CONFIG_PATH + suffix;
await fsp.copyFile(CONFIG_PATH, bakPath);
@@ -345,63 +349,114 @@ async function handleApi(req, res, urlObj, body) {
const binding = bindings.find(b => b.agentId === a.id && b.match?.peer?.kind === 'group');
const groupId = binding?.match?.peer?.id || null;
const modelVal = a.model?.primary || (typeof a.model === 'string' ? a.model : null);
// Check if agent has its own bot account (binding with accountId but no peer, or accountId matching agent ID)
const botBinding = bindings.find(b => b.agentId === a.id && b.match?.accountId && !b.match?.peer);
const hasOwnBot = botBinding ? true : false;
return { ...a, groupId, requireMention: groupId ? (groups[groupId]?.requireMention ?? true) : null,
effectiveModel: modelVal || defaults.model?.primary || '默认' };
effectiveModel: modelVal || defaults.model?.primary || '默认', hasOwnBot };
});
res.writeHead(200);
res.end(JSON.stringify({ agents: enriched, defaults }));
return;
}
// POST /api/agents/main — create or update main agent (set Bot Token)
if (method === 'POST' && pathname === '/api/agents/main') {
const { botToken, name, model } = body;
// POST /api/agents/bot — create agent with its own bot token
if (method === 'POST' && pathname === '/api/agents/bot') {
const { botToken, agentId, name, model, workspace } = body;
if (!botToken || !botToken.trim()) {
res.writeHead(400); res.end(JSON.stringify({ error: 'Bot Token is required' })); return;
}
if (!agentId || !/^[a-zA-Z0-9_-]+$/.test(agentId)) {
res.writeHead(400); res.end(JSON.stringify({ error: 'Agent ID must contain only alphanumeric characters, underscores, or dashes' })); return;
}
if (!workspace || !workspace.trim()) {
res.writeHead(400); res.end(JSON.stringify({ error: 'Workspace name is required' })); return;
}
if (workspace === 'main') {
res.writeHead(400); res.end(JSON.stringify({ error: 'Workspace name cannot be "main"' })); return;
}
const cfg = await readConfig();
// Set bot token
// Check if agentId already exists
if (cfg.agents?.list?.some(a => a.id === agentId)) {
res.writeHead(400); res.end(JSON.stringify({ error: `Agent ID "${agentId}" already exists` })); return;
}
// Migrate from old format if necessary
if (cfg.channels?.telegram?.botToken && !cfg.channels.telegram.accounts) {
cfg.channels.telegram.accounts = {};
cfg.channels.telegram.accounts.default = { botToken: cfg.channels.telegram.botToken };
delete cfg.channels.telegram.botToken;
}
// Initialize structure
if (!cfg.channels) cfg.channels = {};
if (!cfg.channels.telegram) cfg.channels.telegram = {};
cfg.channels.telegram.botToken = botToken.trim();
if (!cfg.channels.telegram.accounts) cfg.channels.telegram.accounts = {};
// Check for duplicate bot token in existing accounts
const tokenTrimmed = botToken.trim();
for (const acctId in cfg.channels.telegram.accounts) {
if (cfg.channels.telegram.accounts[acctId].botToken === tokenTrimmed) {
res.writeHead(400); res.end(JSON.stringify({ error: `Bot Token already used by account "${acctId}"` })); return;
}
}
// Add new account
cfg.channels.telegram.accounts[agentId] = { botToken: tokenTrimmed };
cfg.channels.telegram.enabled = true;
// Ensure main agent exists
// Ensure agents structure
if (!cfg.agents) cfg.agents = { defaults: {}, list: [] };
if (!cfg.agents.list) cfg.agents.list = [];
let mainAgent = cfg.agents.list.find(a => a.id === 'main');
if (!mainAgent) {
mainAgent = { id: 'main' };
cfg.agents.list.push(mainAgent);
}
if (name) mainAgent.name = name.trim();
if (model) mainAgent.model = { primary: model };
// Ensure main catch-all binding exists
// Create agent entry
const wsPath = path.join(OPENCLAW_DIR, 'workspaces', workspace);
const wsAlias = `~/.openclaw/workspaces/${workspace}`;
const agentEntry = { id: agentId, name: name || agentId, workspace: wsAlias };
if (model && model !== '__default__') agentEntry.model = { primary: model };
cfg.agents.list.push(agentEntry);
// Add binding
if (!cfg.bindings) cfg.bindings = [];
const hasMainBinding = cfg.bindings.some(b => b.agentId === 'main' && !b.match?.peer);
if (!hasMainBinding) {
cfg.bindings.push({ agentId: 'main', match: { channel: 'telegram', accountId: 'default' } });
}
cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId: agentId } });
// Save config
const bakPath = await writeConfig(cfg, 'create');
// Create workspace directories and files
await fsp.mkdir(wsPath, { recursive: true });
await fsp.mkdir(path.join(wsPath, 'memory'), { recursive: true });
const agentName = name || agentId;
await fsp.writeFile(path.join(wsPath, 'SOUL.md'), generateSoulMd(agentName, '', ''), 'utf8');
await fsp.writeFile(path.join(wsPath, 'MEMORY.md'), generateMemoryMd(agentName, ''), 'utf8');
res.writeHead(200);
res.end(JSON.stringify({ ok: true, configBackup: bakPath }));
res.end(JSON.stringify({
ok: true, agentId, workspacePath: wsPath, configBackup: bakPath,
notes: [
'Agent created with its own bot token',
'Workspace directory created with SOUL.md and MEMORY.md',
'Configuration updated and backed up',
'Changes take effect in ~300ms without restart'
]
}));
return;
}
// POST /api/agents
// POST /api/agents — create sub-agent (shares parent bot)
if (method === 'POST' && pathname === '/api/agents') {
const { agentId, displayName, groupId, workspaceFolder, model, purpose, personality, initialMemory } = body;
const { agentId, displayName, groupId, workspaceFolder, model, purpose, personality, initialMemory, parentAgentId } = body;
if (!agentId || !/^[a-zA-Z0-9_-]+$/.test(agentId)) {
res.writeHead(400); res.end(JSON.stringify({ error: 'Agent ID 只能包含英文字母、数字、_ 或 -' })); return;
}
if (agentId.toLowerCase() === 'main') {
res.writeHead(400); res.end(JSON.stringify({ error: '"main" 是系统保留 ID' })); return;
res.writeHead(400); res.end(JSON.stringify({ error: 'Agent ID must contain only alphanumeric characters, underscores, or dashes' })); return;
}
if (!groupId?.trim()) {
res.writeHead(400); res.end(JSON.stringify({ error: '群组 ID 不能为空' })); return;
res.writeHead(400); res.end(JSON.stringify({ error: 'Group ID cannot be empty' })); return;
}
if (!parentAgentId?.trim()) {
res.writeHead(400); res.end(JSON.stringify({ error: 'Parent Agent ID is required' })); return;
}
const cfg = await readConfig();
// Verify parent agent exists and has a bot account
const parentAgent = cfg.agents?.list?.find(a => a.id === parentAgentId);
if (!parentAgent) {
res.writeHead(404); res.end(JSON.stringify({ error: `Parent agent "${parentAgentId}" does not exist` })); return;
}
const parentBinding = cfg.bindings?.find(b => b.agentId === parentAgentId && b.match?.accountId);
if (!parentBinding) {
res.writeHead(400); res.end(JSON.stringify({ error: `Parent agent "${parentAgentId}" does not have its own bot account` })); return;
}
if (cfg.agents.list.some(a => a.id === agentId)) {
res.writeHead(400); res.end(JSON.stringify({ error: `Agent ID "${agentId}" 已存在` })); return;
res.writeHead(400); res.end(JSON.stringify({ error: `Agent ID "${agentId}" already exists` })); return;
}
const gid = String(groupId).trim();
const folder = workspaceFolder || agentId;
@@ -410,10 +465,11 @@ async function handleApi(req, res, urlObj, body) {
const agentEntry = { id: agentId, name: displayName || agentId, workspace: wsAlias };
if (model && model !== '__default__') agentEntry.model = { primary: model };
cfg.agents.list.push(agentEntry);
const newBinding = { agentId, match: { channel: 'telegram', peer: { kind: 'group', id: gid } } };
const mainIdx = cfg.bindings.findIndex(b => b.agentId === 'main' && !b.match?.peer);
if (mainIdx >= 0) cfg.bindings.splice(mainIdx, 0, newBinding);
else cfg.bindings.unshift(newBinding);
// Binding uses parent's accountId
const parentAccountId = parentBinding.match.accountId;
const newBinding = { agentId, match: { channel: 'telegram', accountId: parentAccountId, peer: { kind: 'group', id: gid } } };
if (!cfg.bindings) cfg.bindings = [];
cfg.bindings.unshift(newBinding);
if (!cfg.channels) cfg.channels = {};
if (!cfg.channels.telegram) cfg.channels.telegram = {};
if (!cfg.channels.telegram.groups) cfg.channels.telegram.groups = {};
@@ -427,11 +483,10 @@ async function handleApi(req, res, urlObj, body) {
res.writeHead(200);
res.end(JSON.stringify({ ok: true, agentId, workspacePath: wsPath, configBackup: bakPath,
notes: [
'✅ openclaw.json 已更新(自动备份)',
'Workspace 已创建(SOUL.md + MEMORY.md',
'🔄 配置约 300ms 后自动生效,无需重启网关',
'📱 请在 Telegram 群内发送 /new 切换到新 Agent',
'⚠️ 若群内无回复,请确认 BotFather 已关闭隐私模式(/setprivacy → Disable',
'Sub-agent created and shares parent bot',
'Workspace directory created with SOUL.md and MEMORY.md',
'Configuration updated and backed up',
'Changes take effect in ~300ms without restart'
],
}));
return;
@@ -910,71 +965,85 @@ async function handleApi(req, res, urlObj, body) {
return;
}
// ── Stats API — Token 用量统计 ──────────────────────────────
// ── Stats API — Token usage from session JSONL files ──────────
if (method === 'GET' && pathname === '/api/stats') {
const days = parseInt(urlObj.searchParams.get('days') || '30');
const cutoff = Date.now() - days * 86400000;
const logPath = path.join(OPENCLAW_DIR, 'logs', 'gateway.log');
const byModel = {};
const byDay = {};
let totalIn = 0, totalOut = 0;
const byAgent = {};
let totalIn = 0, totalOut = 0, totalCacheRead = 0, totalCacheWrite = 0;
let totalCost = 0;
try {
const content = await fsp.readFile(logPath, 'utf8');
const lines = content.split('\n');
for (const line of lines) {
// 尝试解析 JSON 日志行
let obj = null;
try {
const jm = line.match(/\{[^{}]*"(tokens|usage|input_tokens|output_tokens)"[^{}]*\}/);
if (jm) obj = JSON.parse(jm[0]);
} catch { /* not JSON */ }
// 尝试解析文本格式: "input_tokens: 123, output_tokens: 456"
if (!obj) {
const inM = line.match(/input_tokens[:\s=]+(\d+)/i);
const outM = line.match(/output_tokens[:\s=]+(\d+)/i);
if (inM || outM) {
obj = {};
if (inM) obj.input_tokens = parseInt(inM[1]);
if (outM) obj.output_tokens = parseInt(outM[1]);
const agentsDir = path.join(OPENCLAW_DIR, 'agents');
const agentDirs = await fsp.readdir(agentsDir).catch(() => []);
for (const agentId of agentDirs) {
const sessDir = path.join(agentsDir, agentId, 'sessions');
let files;
try { files = await fsp.readdir(sessDir); } catch { continue; }
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
for (const fname of jsonlFiles) {
let content;
try { content = await fsp.readFile(path.join(sessDir, fname), 'utf8'); } catch { continue; }
const lines = content.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
let obj;
try { obj = JSON.parse(line); } catch { continue; }
if (obj.type !== 'message') continue;
const msg = obj.message;
if (!msg || msg.role !== 'assistant' || !msg.usage) continue;
// Check timestamp filter
const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : (msg.timestamp || 0);
if (ts && ts < cutoff) continue;
const u = msg.usage;
const inTk = u.input || 0;
const outTk = u.output || 0;
const cacheR = u.cacheRead || 0;
const cacheW = u.cacheWrite || 0;
const msgCost = u.cost && typeof u.cost === 'object' ? (u.cost.total || 0) : 0;
const model = msg.model || 'unknown';
totalIn += inTk; totalOut += outTk;
totalCacheRead += cacheR; totalCacheWrite += cacheW;
totalCost += msgCost;
// By model
if (!byModel[model]) byModel[model] = { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0, requestCount: 0, cost: 0 };
byModel[model].inputTokens += inTk;
byModel[model].outputTokens += outTk;
byModel[model].cacheRead += cacheR;
byModel[model].cacheWrite += cacheW;
byModel[model].requestCount++;
byModel[model].cost += msgCost;
// By day
const dayKey = ts ? new Date(ts).toISOString().slice(0, 10) : 'unknown';
if (!byDay[dayKey]) byDay[dayKey] = { inputTokens: 0, outputTokens: 0, requestCount: 0, cost: 0 };
byDay[dayKey].inputTokens += inTk;
byDay[dayKey].outputTokens += outTk;
byDay[dayKey].requestCount++;
byDay[dayKey].cost += msgCost;
// By agent
if (!byAgent[agentId]) byAgent[agentId] = { inputTokens: 0, outputTokens: 0, requestCount: 0, cost: 0 };
byAgent[agentId].inputTokens += inTk;
byAgent[agentId].outputTokens += outTk;
byAgent[agentId].requestCount++;
byAgent[agentId].cost += msgCost;
}
}
if (!obj) continue;
// 提取时间戳
let ts = null;
const tsM = line.match(/(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})/);
if (tsM) ts = new Date(tsM[1]).getTime();
if (ts && ts < cutoff) continue;
// 提取模型
const model = obj.model || (line.match(/model[:\s="]+([a-zA-Z0-9_./-]+)/i) || [])[1] || 'unknown';
const inTk = obj.input_tokens || obj.prompt_tokens || 0;
const outTk = obj.output_tokens || obj.completion_tokens || 0;
totalIn += inTk; totalOut += outTk;
if (!byModel[model]) byModel[model] = { inputTokens: 0, outputTokens: 0, requestCount: 0 };
byModel[model].inputTokens += inTk;
byModel[model].outputTokens += outTk;
byModel[model].requestCount++;
const dayKey = ts ? new Date(ts).toISOString().slice(0, 10) : 'unknown';
if (!byDay[dayKey]) byDay[dayKey] = { inputTokens: 0, outputTokens: 0, requestCount: 0 };
byDay[dayKey].inputTokens += inTk;
byDay[dayKey].outputTokens += outTk;
byDay[dayKey].requestCount++;
}
} catch { /* log file may not exist */ }
// 默认模型定价(每百万 token,USD)
const mc = loadManagerConfig();
const pricing = mc.modelPricing || {};
const defaultPricing = { input: 3, output: 15 }; // $3/M in, $15/M out
function calcCost(model, inTk, outTk) {
const p = pricing[model] || defaultPricing;
return ((inTk * (p.input || 3)) + (outTk * (p.output || 15))) / 1000000;
}
Object.entries(byModel).forEach(([m, d]) => { d.cost = calcCost(m, d.inputTokens, d.outputTokens).toFixed(4); });
Object.entries(byDay).forEach(([d, v]) => { v.cost = calcCost('', v.inputTokens, v.outputTokens).toFixed(4); });
const totalCost = calcCost('', totalIn, totalOut);
} catch { /* agents dir may not exist */ }
// Format costs
Object.values(byModel).forEach(d => { d.cost = d.cost.toFixed(4); });
Object.values(byDay).forEach(d => { d.cost = d.cost.toFixed(4); });
Object.values(byAgent).forEach(d => { d.cost = d.cost.toFixed(4); });
res.writeHead(200);
res.end(JSON.stringify({
summary: { totalInputTokens: totalIn, totalOutputTokens: totalOut, estimatedCost: '$' + totalCost.toFixed(4) },
byModel, byDay
summary: {
totalInputTokens: totalIn, totalOutputTokens: totalOut,
totalCacheRead: totalCacheRead, totalCacheWrite: totalCacheWrite,
estimatedCost: '$' + totalCost.toFixed(4),
totalTokens: totalIn + totalOut + totalCacheRead + totalCacheWrite
},
byModel, byDay, byAgent
}));
return;
}
@@ -1139,6 +1208,108 @@ async function handleApi(req, res, urlObj, body) {
return;
}
// GET /api/dashboard — system info + gateway health for Dashboard tab
if (method === 'GET' && pathname === '/api/dashboard') {
try {
const cfg = await configExists() ? await readConfig() : null;
// System info
const sysUptime = os.uptime();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const nodeVer = process.version;
const platform = `${os.type()} ${os.release()} (${os.arch()})`;
const hostname = os.hostname();
const cpus = os.cpus();
const cpuModel = cpus.length ? cpus[0].model.trim() : 'Unknown';
const cpuCores = cpus.length;
// Disk usage (best-effort, works on macOS/Linux)
let diskTotal = 0, diskUsed = 0, diskFree = 0;
try {
const dfOut = execSync('df -k ' + JSON.stringify(OPENCLAW_DIR), { encoding: 'utf8', timeout: 3000 });
const dfLines = dfOut.trim().split('\\n');
if (dfLines.length >= 2) {
const parts = dfLines[1].split(/\\s+/);
diskTotal = parseInt(parts[1] || 0) * 1024;
diskUsed = parseInt(parts[2] || 0) * 1024;
diskFree = parseInt(parts[3] || 0) * 1024;
}
} catch (_) {}
// OpenClaw dir size (best-effort)
let dirSize = 0;
try {
const duOut = execSync('du -sk ' + JSON.stringify(OPENCLAW_DIR), { encoding: 'utf8', timeout: 5000 });
dirSize = parseInt(duOut.split(/\\s/)[0] || 0) * 1024;
} catch (_) {}
// Gateway process detection
let gatewayRunning = 'unknown';
let gatewayPid = null;
try {
const psOut = execSync("ps aux 2>/dev/null | grep -i 'openclaw.*gateway' | grep -v grep", { encoding: 'utf8', timeout: 3000 }).trim();
if (psOut) {
gatewayRunning = 'running';
const psParts = psOut.split(/\\s+/);
gatewayPid = psParts[1] || null;
} else {
gatewayRunning = 'stopped';
}
} catch (_) { gatewayRunning = 'stopped'; }
// HTTP ping gateway (port from config or default 3000)
let gatewayPort = null;
let gatewayPing = false;
try {
if (cfg && cfg.channels && cfg.channels.telegram) {
gatewayPort = cfg.channels.telegram.port || null;
}
if (!gatewayPort) gatewayPort = 3000;
const pingResult = spawnSync('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', 'http://127.0.0.1:' + gatewayPort], { encoding: 'utf8', timeout: 4000 });
const code = parseInt((pingResult.stdout || '').trim());
gatewayPing = code > 0 && code < 500;
} catch (_) {}
// Agent count & last activity
let agentCount = 0;
let lastActivity = null;
try {
const sessionsBase = path.join(OPENCLAW_DIR, 'agents');
const agentDirs = await fsp.readdir(sessionsBase);
agentCount = agentDirs.length;
for (const ad of agentDirs) {
const sessDir = path.join(sessionsBase, ad, 'sessions');
try {
const sessFiles = await fsp.readdir(sessDir);
for (const sf of sessFiles) {
if (sf.endsWith('.jsonl')) {
const st = await fsp.stat(path.join(sessDir, sf));
if (!lastActivity || st.mtime > lastActivity) lastActivity = st.mtime;
}
}
} catch (_) {}
}
} catch (_) {}
// Brisbane time for display
const now = new Date().toLocaleString('en-AU', { timeZone: 'Australia/Brisbane', hour12: false });
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
system: { hostname, platform, nodeVer, cpuModel, cpuCores, uptime: sysUptime, totalMem, freeMem, diskTotal, diskUsed, diskFree, dirSize },
gateway: { status: gatewayRunning, pid: gatewayPid, port: gatewayPort, ping: gatewayPing },
agents: { count: agentCount, lastActivity: lastActivity ? lastActivity.toISOString() : null },
ocmVersion: APP_VERSION,
serverTime: now,
}));
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ ok: false, error: e.message }));
}
return;
}
// POST /api/gateway/doctor
if (method === 'POST' && pathname === '/api/gateway/doctor') {
try {
@@ -1357,6 +1528,23 @@ select option { background:var(--surface); }
.input-pw-wrap input { padding-right:36px; }
.pw-toggle { position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--muted); cursor:pointer; font-size:14px; padding:0; }
/* ── Dashboard ── */
.dash-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; }
.dash-card { padding:20px; }
.dash-card h3 { font-size:14px; font-weight:600; margin-bottom:14px; color:var(--text); }
.dash-row { display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid var(--border); font-size:12px; }
.dash-row:last-child { border-bottom:none; }
.dash-label { color:var(--muted); }
.dash-val { color:var(--text); font-weight:500; font-family:monospace; font-size:11px; }
.dash-indicator { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.dash-indicator.running { background:#22c55e; box-shadow:0 0 6px rgba(34,197,94,.5); }
.dash-indicator.stopped { background:#ef4444; box-shadow:0 0 6px rgba(239,68,68,.5); }
.dash-indicator.unknown { background:#f59e0b; }
.dash-bar-wrap { width:100%; height:6px; background:var(--border); border-radius:3px; margin-top:4px; }
.dash-bar { height:100%; border-radius:3px; background:var(--accent); transition:width .3s; }
.dash-bar.warn { background:#f59e0b; }
.dash-bar.danger { background:#ef4444; }
/* ── Modal ── */
.backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100; display:none; align-items:center; justify-content:center; }
.backdrop.open { display:flex; }
@@ -1498,7 +1686,8 @@ const MAIN_HTML_BODY = String.raw`
<!-- Tabs -->
<nav>
<div class="tab active" data-tab="agents"><span data-i18n="tab.agents">🤖 Agents</span></div>
<div class="tab active" data-tab="dashboard"><span data-i18n="tab.dashboard">🏠 Dashboard</span></div>
<div class="tab" data-tab="agents"><span data-i18n="tab.agents">🤖 Agents</span></div>
<div class="tab" data-tab="channels"><span data-i18n="tab.channels">📡 Channels</span></div>
<div class="tab" data-tab="models"><span data-i18n="tab.models">🧠 模型</span></div>
<div class="tab" data-tab="auth"><span data-i18n="tab.auth">🔑 认证</span></div>
@@ -1516,8 +1705,30 @@ const MAIN_HTML_BODY = String.raw`
<button class="btn-ghost" onclick="dismissBanner()" style="font-size:11px" data-i18n="banner.dismiss">忽略</button>
</div>
<!-- ══ Dashboard ════════════════════════════════════════════ -->
<div class="panel active" id="panel-dashboard">
<div class="dash-grid" id="dashGrid">
<div class="card dash-card" id="dashSystem">
<h3>🖥️ System</h3>
<div class="dash-items" id="dashSysItems"><div class="empty">Loading...</div></div>
</div>
<div class="card dash-card" id="dashGateway">
<h3>🦀 Gateway</h3>
<div class="dash-items" id="dashGwItems"><div class="empty">Loading...</div></div>
</div>
<div class="card dash-card" id="dashAgents">
<h3>🤖 Agents</h3>
<div class="dash-items" id="dashAgentItems"><div class="empty">Loading...</div></div>
</div>
<div class="card dash-card" id="dashStorage">
<h3>💾 Storage</h3>
<div class="dash-items" id="dashStorageItems"><div class="empty">Loading...</div></div>
</div>
</div>
</div>
<!-- ══ Agents ════════════════════════════════════════════════ -->
<div class="panel active" id="panel-agents">
<div class="panel" id="panel-agents">
<div class="agents-split">
<!-- Left: Add forms -->
<div class="agents-left">
@@ -1596,16 +1807,19 @@ const MAIN_HTML_BODY = String.raw`
<option value="90">90 days</option>
</select>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px">
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)" data-i18n="stats.input">输入 Token</div><div style="font-size:22px;font-weight:700;margin-top:8px" id="statsTotalInput">--</div></div>
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)" data-i18n="stats.output">输出 Token</div><div style="font-size:22px;font-weight:700;margin-top:8px" id="statsTotalOutput">--</div></div>
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)" data-i18n="stats.cost">估计成本</div><div style="font-size:22px;font-weight:700;margin-top:8px;color:var(--accent)" id="statsTotalCost">--</div></div>
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)" data-i18n="stats.requests">请求数</div><div style="font-size:22px;font-weight:700;margin-top:8px" id="statsTotalReqs">--</div></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px">
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)" data-i18n="stats.input">Input Tokens</div><div style="font-size:22px;font-weight:700;margin-top:8px" id="statsTotalInput">--</div></div>
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)" data-i18n="stats.output">Output Tokens</div><div style="font-size:22px;font-weight:700;margin-top:8px" id="statsTotalOutput">--</div></div>
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)">Cache Read</div><div style="font-size:22px;font-weight:700;margin-top:8px" id="statsCacheRead">--</div></div>
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)" data-i18n="stats.cost">Estimated Cost</div><div style="font-size:22px;font-weight:700;margin-top:8px;color:var(--accent)" id="statsTotalCost">--</div></div>
<div class="card" style="padding:16px"><div style="font-size:12px;color:var(--muted)" data-i18n="stats.requests">Requests</div><div style="font-size:22px;font-weight:700;margin-top:8px" id="statsTotalReqs">--</div></div>
</div>
<h3 style="margin:16px 0 10px;font-size:14px;font-weight:600" data-i18n="stats.byDay">按日期</h3>
<h3 style="margin:16px 0 10px;font-size:14px;font-weight:600" data-i18n="stats.byDay">By Day</h3>
<div class="card" style="padding:16px;margin-bottom:16px"><div id="statsChart" style="display:flex;align-items:flex-end;gap:2px;height:100px;overflow-x:auto"></div></div>
<h3 style="margin:16px 0 10px;font-size:14px;font-weight:600" data-i18n="stats.byModel">按模型</h3>
<div class="card-grid" id="statsByModel"><div class="empty" data-i18n="stats.noData">暂无数据</div></div>
<h3 style="margin:16px 0 10px;font-size:14px;font-weight:600" data-i18n="stats.byModel">By Model</h3>
<div class="card-grid" id="statsByModel"><div class="empty" data-i18n="stats.noData">No data</div></div>
<h3 style="margin:16px 0 10px;font-size:14px;font-weight:600">By Agent</h3>
<div class="card-grid" id="statsByAgent"><div class="empty">No data</div></div>
</div>
<!-- ══ Cron ═════════════════════════════════════════════════ -->
@@ -1885,6 +2099,7 @@ const MAIN_HTML_BODY = String.raw`
const MAIN_HTML_SCRIPT = `// ── i18n ──────────────────────────────────────────────────────
const I18N = {
zh: {
'tab.dashboard':'🏠 Dashboard',
'tab.agents':'🤖 Agents','tab.channels':'📡 Channels','tab.models':'🧠 模型','tab.auth':'🔑 认证',
'tab.stats':'📊 Stats','tab.cron':'⏰ Cron',
'agents.title':'Agents','agents.new':' 新建 Subagent',
@@ -1896,7 +2111,7 @@ const I18N = {
'auth.step1':'1. 获取 API Key','auth.step2':'2. 在终端运行以下命令','auth.step3':'3. 按提示粘贴 API Key 并回车',
'auth.onboard':'完成认证后,运行 openclaw onboard 注册可用模型',
'stats.title':'使用统计','stats.input':'输入 Token','stats.output':'输出 Token',
'stats.cost':'估计成本','stats.requests':'请求数','stats.byModel':'按模型','stats.byDay':'按日期','stats.noData':'暂无数据(日志为空或无 Token 记录)',
'stats.cost':'估计成本','stats.requests':'请求数','stats.byModel':'按模型','stats.byDay':'按日期','stats.noData':'暂无数据(未找到会话记录)',
'cron.title':'计划任务','cron.add':' 添加任务','cron.hint':'管理与 OpenClaw 相关的 crontab 计划任务。',
'cron.addTitle':'添加计划任务','cron.schedule':'Cron 表达式','cron.command':'命令','cron.label':'标签',
'cron.enabled':'启用','cron.disabled':'已禁用','cron.run':'▶ 运行','cron.edit':'编辑','cron.empty':'暂无 OpenClaw 相关的计划任务',
@@ -1991,6 +2206,7 @@ const I18N = {
'nas.backupToast':'NAS 备份完成','nas.errNoHost':'请填写主机和用户名',
},
en: {
'tab.dashboard':'🏠 Dashboard',
'tab.agents':'🤖 Agents','tab.channels':'📡 Channels','tab.models':'🧠 Models','tab.auth':'🔑 Auth',
'tab.stats':'📊 Stats','tab.cron':'⏰ Cron',
'agents.title':'Agents','agents.new':' New Subagent',
@@ -2002,7 +2218,7 @@ const I18N = {
'auth.step1':'1. Get API Key','auth.step2':'2. Run the command below in terminal','auth.step3':'3. Paste your API Key when prompted',
'auth.onboard':'After auth, run openclaw onboard to register available models',
'stats.title':'Usage Stats','stats.input':'Input Tokens','stats.output':'Output Tokens',
'stats.cost':'Estimated Cost','stats.requests':'Requests','stats.byModel':'By Model','stats.byDay':'By Day','stats.noData':'No data (log empty or no token records)',
'stats.cost':'Estimated Cost','stats.requests':'Requests','stats.byModel':'By Model','stats.byDay':'By Day','stats.noData':'No data (no session records found)',
'cron.title':'Scheduled Tasks','cron.add':' Add Task','cron.hint':'Manage OpenClaw-related crontab scheduled tasks.',
'cron.addTitle':'Add Scheduled Task','cron.schedule':'Cron Expression','cron.command':'Command','cron.label':'Label',
'cron.enabled':'Enabled','cron.disabled':'Disabled','cron.run':'▶ Run','cron.edit':'Edit','cron.empty':'No OpenClaw-related cron tasks',
@@ -2172,6 +2388,60 @@ async function checkStatus(){
}
async function loadAll(){ await Promise.all([loadAgents(), loadModels(), loadChannels()]); }
// ── Dashboard ─────────────────────────────────────────────────
let dashLoaded=false;
function fmtBytes(b){if(!b||b<=0)return '—';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<u.length-1){b/=1024;i++;}return b.toFixed(i>0?1:0)+' '+u[i];}
function fmtUptime(s){const d=Math.floor(s/86400);const h=Math.floor((s%86400)/3600);const m=Math.floor((s%3600)/60);if(d>0)return d+'d '+h+'h '+m+'m';if(h>0)return h+'h '+m+'m';return m+'m';}
function dashRow(label,val){return '<div class="dash-row"><span class="dash-label">'+esc(label)+'</span><span class="dash-val">'+val+'</span></div>';}
function dashBar(pct){const cls=pct>90?'danger':pct>70?'warn':'';return '<div class="dash-bar-wrap"><div class="dash-bar '+cls+'" style="width:'+Math.min(pct,100)+'%"></div></div>';}
async function loadDashboard(){
try{
const r=await api('GET','/api/dashboard');
if(!r.ok)return;
const s=r.system, g=r.gateway, a=r.agents;
// System card
const memPct=s.totalMem?((s.totalMem-s.freeMem)/s.totalMem*100):0;
let sysHtml=dashRow('Hostname',esc(s.hostname));
sysHtml+=dashRow('OS',esc(s.platform));
sysHtml+=dashRow('Node.js',esc(s.nodeVer));
sysHtml+=dashRow('CPU',esc(s.cpuModel)+' ('+s.cpuCores+' cores)');
sysHtml+=dashRow('Uptime',fmtUptime(s.uptime));
sysHtml+=dashRow('Memory',fmtBytes(s.totalMem-s.freeMem)+' / '+fmtBytes(s.totalMem)+' ('+memPct.toFixed(0)+'%)');
sysHtml+=dashBar(memPct);
document.getElementById('dashSysItems').innerHTML=sysHtml;
// Gateway card
const statusIcon='<span class="dash-indicator '+esc(g.status)+'"></span>';
const statusLabel=g.status==='running'?'Running':g.status==='stopped'?'Stopped':'Unknown';
let gwHtml=dashRow('Status',statusIcon+statusLabel);
gwHtml+=dashRow('Port',String(g.port||'—'));
if(g.pid)gwHtml+=dashRow('PID',g.pid);
gwHtml+=dashRow('HTTP Ping',g.ping?'<span style="color:#22c55e">✓ Reachable</span>':'<span style="color:#ef4444">✗ Unreachable</span>');
document.getElementById('dashGwItems').innerHTML=gwHtml;
// Agents card
let agHtml=dashRow('Total Agents',String(a.count));
if(a.lastActivity){
const d=new Date(a.lastActivity);
agHtml+=dashRow('Last Activity',d.toLocaleString('en-AU',{timeZone:'Australia/Brisbane',hour12:false}));
}else{
agHtml+=dashRow('Last Activity','—');
}
agHtml+=dashRow('OCM Version','v'+esc(r.ocmVersion));
agHtml+=dashRow('Server Time',esc(r.serverTime));
document.getElementById('dashAgentItems').innerHTML=agHtml;
// Storage card
let stHtml=dashRow('OpenClaw Dir Size',fmtBytes(s.dirSize));
if(s.diskTotal>0){
const diskPct=s.diskUsed/s.diskTotal*100;
stHtml+=dashRow('Disk',fmtBytes(s.diskUsed)+' / '+fmtBytes(s.diskTotal)+' ('+diskPct.toFixed(0)+'%)');
stHtml+=dashBar(diskPct);
stHtml+=dashRow('Disk Free',fmtBytes(s.diskFree));
}
document.getElementById('dashStorageItems').innerHTML=stHtml;
dashLoaded=true;
}catch(e){console.error('Dashboard load error:',e);}
}
let healthTimer=null;
async function refreshHealth(){
try{
@@ -2246,9 +2516,15 @@ function buildAddAgentForm() {
'<div class="form-group"><label>' + t('agents.botToken') + '</label>' +
'<input id="fa-token" type="text" placeholder="123456:ABC-DEF...">' +
'</div>' +
'<div class="form-group"><label>Agent ID</label>' +
'<input id="fa-agentid" type="text" placeholder="research, alice_bot, etc." pattern="[a-zA-Z0-9_-]+">' +
'<span class="hint-text">Alphanumeric, underscore, or dash only</span></div>' +
'<div class="form-group"><label>' + t('agents.botName') + '</label>' +
'<input id="fa-name" type="text" placeholder="' + t('agents.botNamePh') + '">' +
'</div>' +
'<div class="form-group"><label>Workspace Name</label>' +
'<input id="fa-workspace" type="text" placeholder="research, alice_workspace, etc.">' +
'</div>' +
'<div class="form-group"><label>' + t('wiz.model') + '</label>' +
'<select id="fa-model">' + modelOpts + '</select>' +
'</div>' +
@@ -2259,10 +2535,17 @@ function buildAddAgentForm() {
}
function buildAddSubForm() {
// Build parent agent dropdown (only main agents)
// Build parent agent dropdown (agents with their own bot)
const cfg = S.agents || [];
const mainAgent = cfg.find(a => a.id === 'main');
const parentOpts = mainAgent ? '<option value="main">' + esc(mainAgent.name || 'main') + '</option>' : '<option value="main">main</option>';
const botAgents = cfg.filter(a => a.hasOwnBot);
let parentOpts = '';
if (botAgents.length === 0) {
parentOpts = '<option value="">No agents with bot available</option>';
} else {
botAgents.forEach(a => {
parentOpts += '<option value="' + esc(a.id) + '">' + esc(a.name || a.id) + '</option>';
});
}
const modelOpts = buildModelOpts('__default__');
return '<div class="add-form">' +
'<h3>' + t('agents.addSubTitle') + '</h3>' +
@@ -2271,6 +2554,8 @@ function buildAddSubForm() {
'<li>' + t('guide.sub.s2') + '</li>' +
'<li>' + t('guide.sub.s3') + '</li>' +
'</ol></details>' +
'<div class="form-group"><label>Parent Agent</label>' +
'<select id="fs-parent">' + parentOpts + '</select></div>' +
'<div class="form-group"><label>' + t('wiz.groupId') + '</label>' +
'<input id="fs-gid" placeholder="-100XXXXXXXXXX">' +
'<span class="hint-text">' + t('wiz.groupHint') + '</span></div>' +
@@ -2293,25 +2578,33 @@ function buildAddSubForm() {
'</div></div>';
}
// ── Submit Add Agent (Main Bot) ──────────────────────────────
// ── Submit Add Agent (with own bot) ──────────────────────────
async function submitAddAgent() {
const token = document.getElementById('fa-token').value.trim();
const agentId = document.getElementById('fa-agentid').value.trim();
const name = document.getElementById('fa-name').value.trim();
const workspace = document.getElementById('fa-workspace').value.trim();
const model = document.getElementById('fa-model').value;
if (!token) { toast(t('agents.errToken'), 'err'); return; }
if (!agentId) { toast('Agent ID is required', 'err'); return; }
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) { toast('Agent ID must contain only alphanumeric characters, underscores, or dashes', 'err'); return; }
if (!name) { toast(t('agents.errName'), 'err'); return; }
if (!workspace) { toast('Workspace name is required', 'err'); return; }
try {
const r = await api('POST', '/api/agents/main', { botToken: token, name, model: model === '__default__' ? '' : model });
if (r.error) { toast(r.error, 'err'); return; }
toast(t('agents.agentCreated'), 'ok');
document.getElementById('addFormArea').innerHTML = '';
const payload = { botToken: token, agentId, name, workspace, model: model === '__default__' ? '' : model };
const r = await api('POST', '/api/agents/bot', payload);
toast('Agent created successfully', 'ok');
closePopover();
showRestartBanner();
await loadAll();
} catch (e) { toast(e.message, 'err'); }
} catch (e) {
toast(e.message, 'err');
}
}
// ── Submit Add Sub-Agent ────────────────────────────────────
async function submitAddSub() {
const parentAgentId = document.getElementById('fs-parent').value.trim();
const groupId = document.getElementById('fs-gid').value.trim();
const agentId = document.getElementById('fs-aid').value.trim();
const displayName = document.getElementById('fs-name').value.trim();
@@ -2319,12 +2612,12 @@ async function submitAddSub() {
const purpose = document.getElementById('fs-purpose').value.trim();
const personality = document.getElementById('fs-soul').value.trim();
const initialMemory = document.getElementById('fs-mem').value.trim();
if (!parentAgentId) { toast('Parent Agent is required', 'err'); return; }
if (!groupId) { toast(t('wiz.errGroupId'), 'err'); return; }
if (!agentId) { toast(t('wiz.errAgentId'), 'err'); return; }
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) { toast(t('wiz.errIdFormat'), 'err'); return; }
if (agentId.toLowerCase() === 'main') { toast(t('wiz.errIdReserved'), 'err'); return; }
try {
const r = await api('POST', '/api/agents', { agentId, displayName: displayName || agentId, groupId, workspaceFolder: agentId, model: model === '__default__' ? '' : model, purpose, personality, initialMemory });
const r = await api('POST', '/api/agents', { parentAgentId, agentId, displayName: displayName || agentId, groupId, workspaceFolder: agentId, model: model === '__default__' ? '' : model, purpose, personality, initialMemory });
if (r.error) { toast(r.error, 'err'); return; }
toast(t('wiz.created'), 'ok');
document.getElementById('addFormArea').innerHTML = '';
@@ -3059,7 +3352,7 @@ function cliCloseAC(){ const b=document.getElementById('cliACBox'); if(b) b.remo
}
})();
// ── Stats(使用统计)────────────────────────────────────────
// ── Stats (Usage Statistics) ────────────────────────────────
async function loadStats(){
try{
const days=document.getElementById('statsDaysFilter')?.value||30;
@@ -3067,16 +3360,24 @@ async function loadStats(){
const s=r.summary||{};
document.getElementById('statsTotalInput').textContent=fmtNum(s.totalInputTokens||0);
document.getElementById('statsTotalOutput').textContent=fmtNum(s.totalOutputTokens||0);
document.getElementById('statsCacheRead').textContent=fmtNum(s.totalCacheRead||0);
document.getElementById('statsTotalCost').textContent=s.estimatedCost||'$0';
const totalReqs=Object.values(r.byModel||{}).reduce((a,b)=>a+(b.requestCount||0),0);
document.getElementById('statsTotalReqs').textContent=fmtNum(totalReqs);
// by model
const bmEl=document.getElementById('statsByModel');
const bmEntries=Object.entries(r.byModel||{});
bmEl.innerHTML=bmEntries.length?bmEntries.map(([m,d])=>\`<div class="card" style="padding:12px">
<div style="font-size:13px;font-weight:600;margin-bottom:6px">\${esc(m)}</div>
<div style="font-size:12px;color:var(--muted);line-height:1.8">In: \${fmtNum(d.inputTokens)} · Out: \${fmtNum(d.outputTokens)} · \${d.requestCount} reqs · $\${d.cost}</div>
</div>\`).join(''):'<div class="empty" style="padding:20px">'+t('stats.noData')+'</div>';
const bmEntries=Object.entries(r.byModel||{}).sort((a,b)=>(b[1].requestCount||0)-(a[1].requestCount||0));
bmEl.innerHTML=bmEntries.length?bmEntries.map(([m,d])=>'<div class="card" style="padding:12px">' +
'<div style="font-size:13px;font-weight:600;margin-bottom:6px">' + esc(m) + '</div>' +
'<div style="font-size:12px;color:var(--muted);line-height:1.8">In: ' + fmtNum(d.inputTokens) + ' · Out: ' + fmtNum(d.outputTokens) + ' · Cache: ' + fmtNum(d.cacheRead||0) + ' · ' + d.requestCount + ' reqs · $' + d.cost + '</div>' +
'</div>').join(''):'<div class="empty" style="padding:20px">'+t('stats.noData')+'</div>';
// by agent
const baEl=document.getElementById('statsByAgent');
const baEntries=Object.entries(r.byAgent||{}).sort((a,b)=>(b[1].requestCount||0)-(a[1].requestCount||0));
baEl.innerHTML=baEntries.length?baEntries.map(([a,d])=>'<div class="card" style="padding:12px">' +
'<div style="font-size:13px;font-weight:600;margin-bottom:6px">' + esc(a) + '</div>' +
'<div style="font-size:12px;color:var(--muted);line-height:1.8">In: ' + fmtNum(d.inputTokens) + ' · Out: ' + fmtNum(d.outputTokens) + ' · ' + d.requestCount + ' reqs · $' + d.cost + '</div>' +
'</div>').join(''):'<div class="empty" style="padding:20px">No data</div>';
// by day chart
const chartEl=document.getElementById('statsChart');
const dayEntries=Object.entries(r.byDay||{}).sort((a,b)=>a[0].localeCompare(b[0]));
@@ -3085,12 +3386,12 @@ async function loadStats(){
chartEl.innerHTML=dayEntries.map(([day,d])=>{
const total=(d.inputTokens||0)+(d.outputTokens||0);
const pct=Math.max(2,total/maxTk*100);
return \`<div style="flex:1;min-width:12px;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%" title="\${day}: \${fmtNum(total)} tokens · $\${d.cost}">
<div style="width:100%;max-width:28px;background:var(--accent);height:\${pct}%;border-radius:3px 3px 0 0;opacity:.8;min-height:2px"></div>
<div style="font-size:8px;color:var(--muted);margin-top:3px;writing-mode:vertical-rl;transform:rotate(180deg)">\${day.slice(5)}</div>
</div>\`;
return '<div style="flex:1;min-width:12px;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%" title="' + day + ': ' + fmtNum(total) + ' tokens · $' + d.cost + '">' +
'<div style="width:100%;max-width:28px;background:var(--accent);height:' + pct + '%;border-radius:3px 3px 0 0;opacity:.8;min-height:2px"></div>' +
'<div style="font-size:8px;color:var(--muted);margin-top:3px;writing-mode:vertical-rl;transform:rotate(180deg)">' + day.slice(5) + '</div>' +
'</div>';
}).join('');
}catch(e){ toast((lang==='en'?'Stats load failed: ':'统计加载失败: ')+e.message,'error'); }
}catch(e){ toast('Stats load failed: '+e.message,'error'); }
}
function fmtNum(n){ if(n>=1e6) return (n/1e6).toFixed(1)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'K'; return String(n); }
@@ -3357,12 +3658,15 @@ function togglePwd(inputId,btn){
}
// ── 工具 ─────────────────────────────────────────────────────
const OCM_CLIENT_VERSION='${APP_VERSION}';
async function api(method,path,body){
const opts={method,headers:{'Content-Type':'application/json'}};
if(body) opts.body=JSON.stringify(body);
const r=await fetch(path,opts);
const sv=r.headers.get('X-OCM-Version');
if(sv&&sv!==OCM_CLIENT_VERSION&&!window._ocmVersionWarn){window._ocmVersionWarn=true;toast('Server updated to v'+sv+'. Refresh page for latest version.','info');}
const d=await r.json();
if(!r.ok) throw new Error(d.error||r.status);
if(!r.ok){ const e=new Error(d.error||r.status); e.data=d; e.status=r.status; throw e; }
return d;
}
function openModal(id){document.getElementById(id).classList.add('open');}
@@ -3387,7 +3691,8 @@ document.querySelectorAll('.tab').forEach(t=>{
document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
document.getElementById('panel-'+t.dataset.tab).classList.add('active');
// 懒加载
// lazy-load
if(t.dataset.tab==='dashboard'&&!dashLoaded) loadDashboard();
if(t.dataset.tab==='stats') loadStats();
if(t.dataset.tab==='cron') loadCrons();
});
@@ -3397,7 +3702,7 @@ document.querySelectorAll('.tab').forEach(t=>{
(function() {
LS.del('ocm_mode');
applyLang();
checkStatus().then(() => loadAll()).catch(e => console.error('Init error:', e));
checkStatus().then(() => { loadAll(); loadDashboard(); }).catch(e => console.error('Init error:', e));
startHealthPolling();
})();`;
@@ -3438,6 +3743,8 @@ const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('X-OCM-Version', APP_VERSION);
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const urlObj = new URL(req.url, `http://127.0.0.1:${PORT}`);
@@ -3448,7 +3755,7 @@ const server = http.createServer(async (req, res) => {
await handleApi(req, res, urlObj, body);
} else {
const needsSetup = !(await configExists());
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'ETag': '"ocm-' + APP_VERSION + '"' });
res.end(needsSetup ? SETUP_HTML : MAIN_HTML);
}
} catch (err) {