mirror of
https://github.com/dtzp555-max/ocm.git
synced 2026-07-21 21:15:08 +00:00
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>
This commit is contained in:
@@ -1,116 +1,56 @@
|
|||||||
# OpenClaw Manager (OCM)
|
# 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
|
```bash
|
||||||
# Install
|
# Clone
|
||||||
git clone https://github.com/dtzp555-max/ocm.git
|
git clone https://github.com/dtzp555-max/ocm.git
|
||||||
cd ocm
|
cd ocm
|
||||||
|
|
||||||
# Update (existing clone)
|
|
||||||
git pull --ff-only
|
|
||||||
|
|
||||||
# Run (no npm install needed)
|
# Run (no npm install needed)
|
||||||
bash start.sh # macOS / Linux
|
bash start.sh # macOS / Linux
|
||||||
start.bat # Windows
|
start.bat # Windows
|
||||||
|
|
||||||
|
# Update
|
||||||
|
git pull --ff-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3333](http://localhost:3333).
|
Open [http://localhost:3333](http://localhost:3333) in your browser. For remote access, use `bash start.sh --host 0.0.0.0`.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (contained sensitive binding IDs) -->
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
|
|
||||||
<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.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (contained sensitive binding IDs) -->
|
|
||||||
|
|
||||||
### Model Selector
|
|
||||||
Rich model dropdown grouped by provider — GitHub Copilot, Anthropic, OpenAI, Google, DeepSeek, Kimi, Groq, Mistral, Together, plus your custom models.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
|
|
||||||
### Cron Job Management
|
|
||||||
View, add, enable/disable, and manually trigger openclaw-related cron tasks. Integrates with NAS backup schedules.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
|
|
||||||
Preset command menu with built-in commands and your personal favorites — one click to run.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
|
|
||||||
Command output streams in real-time. Star frequently used commands for quick access, or use the Manage button to organize favorites.
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Agents & Workspace** — Create sub-agents, switch models inline, and browse/edit workspace files
|
**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.
|
||||||
- **Models & Auth** — Manage primary/fallback models and follow provider auth guides
|
|
||||||
- **Ops Panel** — Restart gateway, view logs, run health checks, and switch OpenClaw directory
|
**Usage Statistics** — Real token usage data parsed directly from OpenClaw session files. Breakdown by model, agent, and day with a visual chart.
|
||||||
- **Stats & Cron** — Token/cost stats from `gateway.log` plus cron task management
|
|
||||||
- **Backups** — Local backup/rollback and NAS backup (SFTP/rsync)
|
**Model & Auth** — Configure models from your registered provider list. Follow built-in guides for provider authentication setup.
|
||||||
- **Built-in CLI** — Real-time command terminal with presets, favorites, and tab completion
|
|
||||||
|
**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
|
## Requirements
|
||||||
|
|
||||||
- Node.js >= 18 ([download](https://nodejs.org/))
|
- Node.js 18+ ([download](https://nodejs.org/))
|
||||||
- A working [OpenClaw](https://github.com/anthropics/openclaw) installation
|
- 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
|
## 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 |
|
| Priority | Method |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
@@ -125,9 +65,17 @@ You can also create `manager-config.json` manually:
|
|||||||
{ "dir": "~/.openclaw" }
|
{ "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
|
```bash
|
||||||
# Add to ~/.zshrc or ~/.bashrc
|
# Add to ~/.zshrc or ~/.bashrc
|
||||||
@@ -138,12 +86,11 @@ alias ocm="bash ~/path/to/ocm/start.sh"
|
|||||||
|
|
||||||
```
|
```
|
||||||
ocm/
|
ocm/
|
||||||
├── openclaw-manager.js ← The entire app (server + frontend, single file)
|
├── openclaw-manager.js ← Entire app (server + frontend, single file)
|
||||||
├── start.sh ← macOS/Linux start script (auto-detect Node, port, config)
|
├── start.sh ← macOS/Linux launcher (auto-detect, port conflict handling)
|
||||||
├── start.bat ← Windows start script
|
├── start.bat ← Windows launcher
|
||||||
├── OpenClaw Manager.app/ ← macOS Finder double-click launcher
|
├── OpenClaw Manager.app/ ← macOS Finder double-click launcher
|
||||||
├── manager-config.json ← Local config (gitignored)
|
├── manager-config.json ← Local config (gitignored)
|
||||||
├── package.json
|
|
||||||
├── DEVLOG.md ← Development log
|
├── DEVLOG.md ← Development log
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
@@ -154,12 +101,6 @@ ocm/
|
|||||||
- **Frontend**: Vanilla HTML/CSS/JS embedded in a template literal — no build step, no framework
|
- **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
|
- **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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
@@ -168,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
|
```bash
|
||||||
# 首次安装
|
|
||||||
git clone https://github.com/dtzp555-max/ocm.git
|
git clone https://github.com/dtzp555-max/ocm.git
|
||||||
cd ocm
|
cd ocm
|
||||||
|
|
||||||
# 已安装后更新
|
|
||||||
git pull --ff-only
|
|
||||||
|
|
||||||
# 运行(无需 npm install)
|
|
||||||
bash start.sh # macOS / Linux
|
bash start.sh # macOS / Linux
|
||||||
start.bat # Windows
|
start.bat # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 [http://localhost:3333](http://localhost:3333)。
|
访问 [http://localhost:3333](http://localhost:3333)。远程访问请使用 `bash start.sh --host 0.0.0.0`。
|
||||||
|
|
||||||
### 界面预览
|
### 功能
|
||||||
|
|
||||||
#### 模式选择
|
- **Agent 管理** — 添加主 Agent 和子 Agent,树状结构查看,内联切换模型,浏览工作区文件
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
- **使用统计** — 从 OpenClaw 会话文件解析真实 Token 用量,按模型/Agent/日期维度展示
|
||||||
|
- **模型与认证** — 管理注册模型列表,内置 Provider 认证引导
|
||||||
|
- **内置终端** — 实时流式输出,预设命令,收藏夹,Tab 补全
|
||||||
|
- **运维面板** — 重启网关、查看日志、健康检查、本地/NAS 备份、Cron 任务管理
|
||||||
|
- **双语界面** — 中英文一键切换
|
||||||
|
|
||||||
#### Agent 管理
|
### 配置
|
||||||
<!-- Screenshot removed (contained sensitive binding IDs) -->
|
|
||||||
|
|
||||||
#### 内置 CLI 终端
|
启动脚本自动检测 `~/.openclaw` 目录。如需指定其他路径:
|
||||||
底部终端面板,支持实时流式输出、预设命令、收藏夹、Tab 补全。
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
```bash
|
||||||
|
bash start.sh --dir /path/to/.openclaw --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
<details>
|
也可手动创建 `manager-config.json`:
|
||||||
<summary>更多截图</summary>
|
|
||||||
|
|
||||||
#### 新建向导
|
|
||||||
<!-- Screenshot removed (contained sensitive binding IDs) -->
|
|
||||||
|
|
||||||
#### 模型选择 & 操作菜单
|
|
||||||
首次安装后,通过 **Actions → Switch OpenClaw Dir** 指向你的 OpenClaw 配置目录即可开始使用。
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
|
|
||||||
#### Cron 定时任务
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
<!-- Screenshot removed (possible sensitive IDs) -->
|
|
||||||
</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`:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "dir": "~/.openclaw" }
|
{ "dir": "~/.openclaw" }
|
||||||
```
|
```
|
||||||
|
|
||||||
`~` 在所有平台自动展开为用户主目录。
|
|
||||||
|
|
||||||
### Shell 别名(可选)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 加到 ~/.zshrc 或 ~/.bashrc
|
|
||||||
alias ocm="bash ~/path/to/ocm/start.sh"
|
|
||||||
```
|
|
||||||
|
|||||||
+218
-115
@@ -114,8 +114,12 @@ async function readConfig() {
|
|||||||
return JSON.parse(raw);
|
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) {
|
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 suffix = label ? `.${label}.${ts}` : `.bak.${ts}`;
|
||||||
const bakPath = CONFIG_PATH + suffix;
|
const bakPath = CONFIG_PATH + suffix;
|
||||||
await fsp.copyFile(CONFIG_PATH, bakPath);
|
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 binding = bindings.find(b => b.agentId === a.id && b.match?.peer?.kind === 'group');
|
||||||
const groupId = binding?.match?.peer?.id || null;
|
const groupId = binding?.match?.peer?.id || null;
|
||||||
const modelVal = a.model?.primary || (typeof a.model === 'string' ? a.model : 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,
|
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.writeHead(200);
|
||||||
res.end(JSON.stringify({ agents: enriched, defaults }));
|
res.end(JSON.stringify({ agents: enriched, defaults }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/agents/main — create or update main agent (set Bot Token)
|
// POST /api/agents/bot — create agent with its own bot token
|
||||||
if (method === 'POST' && pathname === '/api/agents/main') {
|
if (method === 'POST' && pathname === '/api/agents/bot') {
|
||||||
const { botToken, name, model } = body;
|
const { botToken, agentId, name, model, workspace } = body;
|
||||||
if (!botToken || !botToken.trim()) {
|
if (!botToken || !botToken.trim()) {
|
||||||
res.writeHead(400); res.end(JSON.stringify({ error: 'Bot Token is required' })); return;
|
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();
|
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) cfg.channels = {};
|
||||||
if (!cfg.channels.telegram) cfg.channels.telegram = {};
|
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;
|
cfg.channels.telegram.enabled = true;
|
||||||
// Ensure main agent exists
|
// Ensure agents structure
|
||||||
if (!cfg.agents) cfg.agents = { defaults: {}, list: [] };
|
if (!cfg.agents) cfg.agents = { defaults: {}, list: [] };
|
||||||
if (!cfg.agents.list) cfg.agents.list = [];
|
if (!cfg.agents.list) cfg.agents.list = [];
|
||||||
let mainAgent = cfg.agents.list.find(a => a.id === 'main');
|
// Create agent entry
|
||||||
if (!mainAgent) {
|
const wsPath = path.join(OPENCLAW_DIR, 'workspaces', workspace);
|
||||||
mainAgent = { id: 'main' };
|
const wsAlias = `~/.openclaw/workspaces/${workspace}`;
|
||||||
cfg.agents.list.push(mainAgent);
|
const agentEntry = { id: agentId, name: name || agentId, workspace: wsAlias };
|
||||||
}
|
if (model && model !== '__default__') agentEntry.model = { primary: model };
|
||||||
if (name) mainAgent.name = name.trim();
|
cfg.agents.list.push(agentEntry);
|
||||||
if (model) mainAgent.model = { primary: model };
|
// Add binding
|
||||||
// Ensure main catch-all binding exists
|
|
||||||
if (!cfg.bindings) cfg.bindings = [];
|
if (!cfg.bindings) cfg.bindings = [];
|
||||||
const hasMainBinding = cfg.bindings.some(b => b.agentId === 'main' && !b.match?.peer);
|
cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId: agentId } });
|
||||||
if (!hasMainBinding) {
|
// Save config
|
||||||
cfg.bindings.push({ agentId: 'main', match: { channel: 'telegram', accountId: 'default' } });
|
|
||||||
}
|
|
||||||
const bakPath = await writeConfig(cfg, 'create');
|
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.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/agents
|
// POST /api/agents — create sub-agent (shares parent bot)
|
||||||
if (method === 'POST' && pathname === '/api/agents') {
|
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)) {
|
if (!agentId || !/^[a-zA-Z0-9_-]+$/.test(agentId)) {
|
||||||
res.writeHead(400); res.end(JSON.stringify({ error: 'Agent ID 只能包含英文字母、数字、_ 或 -' })); return;
|
res.writeHead(400); res.end(JSON.stringify({ error: 'Agent ID must contain only alphanumeric characters, underscores, or dashes' })); return;
|
||||||
}
|
|
||||||
if (agentId.toLowerCase() === 'main') {
|
|
||||||
res.writeHead(400); res.end(JSON.stringify({ error: '"main" 是系统保留 ID' })); return;
|
|
||||||
}
|
}
|
||||||
if (!groupId?.trim()) {
|
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();
|
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)) {
|
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 gid = String(groupId).trim();
|
||||||
const folder = workspaceFolder || agentId;
|
const folder = workspaceFolder || agentId;
|
||||||
@@ -410,10 +465,11 @@ async function handleApi(req, res, urlObj, body) {
|
|||||||
const agentEntry = { id: agentId, name: displayName || agentId, workspace: wsAlias };
|
const agentEntry = { id: agentId, name: displayName || agentId, workspace: wsAlias };
|
||||||
if (model && model !== '__default__') agentEntry.model = { primary: model };
|
if (model && model !== '__default__') agentEntry.model = { primary: model };
|
||||||
cfg.agents.list.push(agentEntry);
|
cfg.agents.list.push(agentEntry);
|
||||||
const newBinding = { agentId, match: { channel: 'telegram', peer: { kind: 'group', id: gid } } };
|
// Binding uses parent's accountId
|
||||||
const mainIdx = cfg.bindings.findIndex(b => b.agentId === 'main' && !b.match?.peer);
|
const parentAccountId = parentBinding.match.accountId;
|
||||||
if (mainIdx >= 0) cfg.bindings.splice(mainIdx, 0, newBinding);
|
const newBinding = { agentId, match: { channel: 'telegram', accountId: parentAccountId, peer: { kind: 'group', id: gid } } };
|
||||||
else cfg.bindings.unshift(newBinding);
|
if (!cfg.bindings) cfg.bindings = [];
|
||||||
|
cfg.bindings.unshift(newBinding);
|
||||||
if (!cfg.channels) cfg.channels = {};
|
if (!cfg.channels) cfg.channels = {};
|
||||||
if (!cfg.channels.telegram) cfg.channels.telegram = {};
|
if (!cfg.channels.telegram) cfg.channels.telegram = {};
|
||||||
if (!cfg.channels.telegram.groups) cfg.channels.telegram.groups = {};
|
if (!cfg.channels.telegram.groups) cfg.channels.telegram.groups = {};
|
||||||
@@ -427,11 +483,10 @@ async function handleApi(req, res, urlObj, body) {
|
|||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
res.end(JSON.stringify({ ok: true, agentId, workspacePath: wsPath, configBackup: bakPath,
|
res.end(JSON.stringify({ ok: true, agentId, workspacePath: wsPath, configBackup: bakPath,
|
||||||
notes: [
|
notes: [
|
||||||
'✅ openclaw.json 已更新(自动备份)',
|
'Sub-agent created and shares parent bot',
|
||||||
'✅ Workspace 已创建(SOUL.md + MEMORY.md)',
|
'Workspace directory created with SOUL.md and MEMORY.md',
|
||||||
'🔄 配置约 300ms 后自动生效,无需重启网关',
|
'Configuration updated and backed up',
|
||||||
'📱 请在 Telegram 群内发送 /new 切换到新 Agent',
|
'Changes take effect in ~300ms without restart'
|
||||||
'⚠️ 若群内无回复,请确认 BotFather 已关闭隐私模式(/setprivacy → Disable)',
|
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
@@ -910,71 +965,85 @@ async function handleApi(req, res, urlObj, body) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stats API — Token 用量统计 ──────────────────────────────
|
// ── Stats API — Token usage from session JSONL files ──────────
|
||||||
if (method === 'GET' && pathname === '/api/stats') {
|
if (method === 'GET' && pathname === '/api/stats') {
|
||||||
const days = parseInt(urlObj.searchParams.get('days') || '30');
|
const days = parseInt(urlObj.searchParams.get('days') || '30');
|
||||||
const cutoff = Date.now() - days * 86400000;
|
const cutoff = Date.now() - days * 86400000;
|
||||||
const logPath = path.join(OPENCLAW_DIR, 'logs', 'gateway.log');
|
|
||||||
const byModel = {};
|
const byModel = {};
|
||||||
const byDay = {};
|
const byDay = {};
|
||||||
let totalIn = 0, totalOut = 0;
|
const byAgent = {};
|
||||||
|
let totalIn = 0, totalOut = 0, totalCacheRead = 0, totalCacheWrite = 0;
|
||||||
|
let totalCost = 0;
|
||||||
try {
|
try {
|
||||||
const content = await fsp.readFile(logPath, 'utf8');
|
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');
|
const lines = content.split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// 尝试解析 JSON 日志行
|
if (!line.trim()) continue;
|
||||||
let obj = null;
|
let obj;
|
||||||
try {
|
try { obj = JSON.parse(line); } catch { continue; }
|
||||||
const jm = line.match(/\{[^{}]*"(tokens|usage|input_tokens|output_tokens)"[^{}]*\}/);
|
if (obj.type !== 'message') continue;
|
||||||
if (jm) obj = JSON.parse(jm[0]);
|
const msg = obj.message;
|
||||||
} catch { /* not JSON */ }
|
if (!msg || msg.role !== 'assistant' || !msg.usage) continue;
|
||||||
// 尝试解析文本格式: "input_tokens: 123, output_tokens: 456"
|
// Check timestamp filter
|
||||||
if (!obj) {
|
const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : (msg.timestamp || 0);
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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;
|
if (ts && ts < cutoff) continue;
|
||||||
// 提取模型
|
const u = msg.usage;
|
||||||
const model = obj.model || (line.match(/model[:\s="]+([a-zA-Z0-9_./-]+)/i) || [])[1] || 'unknown';
|
const inTk = u.input || 0;
|
||||||
const inTk = obj.input_tokens || obj.prompt_tokens || 0;
|
const outTk = u.output || 0;
|
||||||
const outTk = obj.output_tokens || obj.completion_tokens || 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;
|
totalIn += inTk; totalOut += outTk;
|
||||||
if (!byModel[model]) byModel[model] = { inputTokens: 0, outputTokens: 0, requestCount: 0 };
|
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].inputTokens += inTk;
|
||||||
byModel[model].outputTokens += outTk;
|
byModel[model].outputTokens += outTk;
|
||||||
|
byModel[model].cacheRead += cacheR;
|
||||||
|
byModel[model].cacheWrite += cacheW;
|
||||||
byModel[model].requestCount++;
|
byModel[model].requestCount++;
|
||||||
|
byModel[model].cost += msgCost;
|
||||||
|
// By day
|
||||||
const dayKey = ts ? new Date(ts).toISOString().slice(0, 10) : 'unknown';
|
const dayKey = ts ? new Date(ts).toISOString().slice(0, 10) : 'unknown';
|
||||||
if (!byDay[dayKey]) byDay[dayKey] = { inputTokens: 0, outputTokens: 0, requestCount: 0 };
|
if (!byDay[dayKey]) byDay[dayKey] = { inputTokens: 0, outputTokens: 0, requestCount: 0, cost: 0 };
|
||||||
byDay[dayKey].inputTokens += inTk;
|
byDay[dayKey].inputTokens += inTk;
|
||||||
byDay[dayKey].outputTokens += outTk;
|
byDay[dayKey].outputTokens += outTk;
|
||||||
byDay[dayKey].requestCount++;
|
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;
|
||||||
}
|
}
|
||||||
} 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); });
|
} catch { /* agents dir may not exist */ }
|
||||||
const totalCost = calcCost('', totalIn, totalOut);
|
// 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.writeHead(200);
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
summary: { totalInputTokens: totalIn, totalOutputTokens: totalOut, estimatedCost: '$' + totalCost.toFixed(4) },
|
summary: {
|
||||||
byModel, byDay
|
totalInputTokens: totalIn, totalOutputTokens: totalOut,
|
||||||
|
totalCacheRead: totalCacheRead, totalCacheWrite: totalCacheWrite,
|
||||||
|
estimatedCost: '$' + totalCost.toFixed(4),
|
||||||
|
totalTokens: totalIn + totalOut + totalCacheRead + totalCacheWrite
|
||||||
|
},
|
||||||
|
byModel, byDay, byAgent
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1596,16 +1665,19 @@ const MAIN_HTML_BODY = String.raw`
|
|||||||
<option value="90">90 days</option>
|
<option value="90">90 days</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px">
|
<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">输入 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.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">输出 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.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)" 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)">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.requests">请求数</div><div style="font-size:22px;font-weight:700;margin-top:8px" id="statsTotalReqs">--</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>
|
</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>
|
<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>
|
<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">暂无数据</div></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- ══ Cron ═════════════════════════════════════════════════ -->
|
<!-- ══ Cron ═════════════════════════════════════════════════ -->
|
||||||
@@ -1896,7 +1968,7 @@ const I18N = {
|
|||||||
'auth.step1':'1. 获取 API Key','auth.step2':'2. 在终端运行以下命令','auth.step3':'3. 按提示粘贴 API Key 并回车',
|
'auth.step1':'1. 获取 API Key','auth.step2':'2. 在终端运行以下命令','auth.step3':'3. 按提示粘贴 API Key 并回车',
|
||||||
'auth.onboard':'完成认证后,运行 openclaw onboard 注册可用模型',
|
'auth.onboard':'完成认证后,运行 openclaw onboard 注册可用模型',
|
||||||
'stats.title':'使用统计','stats.input':'输入 Token','stats.output':'输出 Token',
|
'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.title':'计划任务','cron.add':'+ 添加任务','cron.hint':'管理与 OpenClaw 相关的 crontab 计划任务。',
|
||||||
'cron.addTitle':'添加计划任务','cron.schedule':'Cron 表达式','cron.command':'命令','cron.label':'标签',
|
'cron.addTitle':'添加计划任务','cron.schedule':'Cron 表达式','cron.command':'命令','cron.label':'标签',
|
||||||
'cron.enabled':'启用','cron.disabled':'已禁用','cron.run':'▶ 运行','cron.edit':'编辑','cron.empty':'暂无 OpenClaw 相关的计划任务',
|
'cron.enabled':'启用','cron.disabled':'已禁用','cron.run':'▶ 运行','cron.edit':'编辑','cron.empty':'暂无 OpenClaw 相关的计划任务',
|
||||||
@@ -2002,7 +2074,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.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',
|
'auth.onboard':'After auth, run openclaw onboard to register available models',
|
||||||
'stats.title':'Usage Stats','stats.input':'Input Tokens','stats.output':'Output Tokens',
|
'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.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.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',
|
'cron.enabled':'Enabled','cron.disabled':'Disabled','cron.run':'▶ Run','cron.edit':'Edit','cron.empty':'No OpenClaw-related cron tasks',
|
||||||
@@ -2246,9 +2318,15 @@ function buildAddAgentForm() {
|
|||||||
'<div class="form-group"><label>' + t('agents.botToken') + '</label>' +
|
'<div class="form-group"><label>' + t('agents.botToken') + '</label>' +
|
||||||
'<input id="fa-token" type="text" placeholder="123456:ABC-DEF...">' +
|
'<input id="fa-token" type="text" placeholder="123456:ABC-DEF...">' +
|
||||||
'</div>' +
|
'</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>' +
|
'<div class="form-group"><label>' + t('agents.botName') + '</label>' +
|
||||||
'<input id="fa-name" type="text" placeholder="' + t('agents.botNamePh') + '">' +
|
'<input id="fa-name" type="text" placeholder="' + t('agents.botNamePh') + '">' +
|
||||||
'</div>' +
|
'</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>' +
|
'<div class="form-group"><label>' + t('wiz.model') + '</label>' +
|
||||||
'<select id="fa-model">' + modelOpts + '</select>' +
|
'<select id="fa-model">' + modelOpts + '</select>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -2259,10 +2337,17 @@ function buildAddAgentForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAddSubForm() {
|
function buildAddSubForm() {
|
||||||
// Build parent agent dropdown (only main agents)
|
// Build parent agent dropdown (agents with their own bot)
|
||||||
const cfg = S.agents || [];
|
const cfg = S.agents || [];
|
||||||
const mainAgent = cfg.find(a => a.id === 'main');
|
const botAgents = cfg.filter(a => a.hasOwnBot);
|
||||||
const parentOpts = mainAgent ? '<option value="main">' + esc(mainAgent.name || 'main') + '</option>' : '<option value="main">main</option>';
|
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__');
|
const modelOpts = buildModelOpts('__default__');
|
||||||
return '<div class="add-form">' +
|
return '<div class="add-form">' +
|
||||||
'<h3>' + t('agents.addSubTitle') + '</h3>' +
|
'<h3>' + t('agents.addSubTitle') + '</h3>' +
|
||||||
@@ -2271,6 +2356,8 @@ function buildAddSubForm() {
|
|||||||
'<li>' + t('guide.sub.s2') + '</li>' +
|
'<li>' + t('guide.sub.s2') + '</li>' +
|
||||||
'<li>' + t('guide.sub.s3') + '</li>' +
|
'<li>' + t('guide.sub.s3') + '</li>' +
|
||||||
'</ol></details>' +
|
'</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>' +
|
'<div class="form-group"><label>' + t('wiz.groupId') + '</label>' +
|
||||||
'<input id="fs-gid" placeholder="-100XXXXXXXXXX">' +
|
'<input id="fs-gid" placeholder="-100XXXXXXXXXX">' +
|
||||||
'<span class="hint-text">' + t('wiz.groupHint') + '</span></div>' +
|
'<span class="hint-text">' + t('wiz.groupHint') + '</span></div>' +
|
||||||
@@ -2293,25 +2380,33 @@ function buildAddSubForm() {
|
|||||||
'</div></div>';
|
'</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Submit Add Agent (Main Bot) ──────────────────────────────
|
// ── Submit Add Agent (with own bot) ──────────────────────────
|
||||||
async function submitAddAgent() {
|
async function submitAddAgent() {
|
||||||
const token = document.getElementById('fa-token').value.trim();
|
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 name = document.getElementById('fa-name').value.trim();
|
||||||
|
const workspace = document.getElementById('fa-workspace').value.trim();
|
||||||
const model = document.getElementById('fa-model').value;
|
const model = document.getElementById('fa-model').value;
|
||||||
if (!token) { toast(t('agents.errToken'), 'err'); return; }
|
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 (!name) { toast(t('agents.errName'), 'err'); return; }
|
||||||
|
if (!workspace) { toast('Workspace name is required', 'err'); return; }
|
||||||
try {
|
try {
|
||||||
const r = await api('POST', '/api/agents/main', { botToken: token, name, model: model === '__default__' ? '' : model });
|
const payload = { botToken: token, agentId, name, workspace, model: model === '__default__' ? '' : model };
|
||||||
if (r.error) { toast(r.error, 'err'); return; }
|
const r = await api('POST', '/api/agents/bot', payload);
|
||||||
toast(t('agents.agentCreated'), 'ok');
|
toast('Agent created successfully', 'ok');
|
||||||
document.getElementById('addFormArea').innerHTML = '';
|
closePopover();
|
||||||
showRestartBanner();
|
showRestartBanner();
|
||||||
await loadAll();
|
await loadAll();
|
||||||
} catch (e) { toast(e.message, 'err'); }
|
} catch (e) {
|
||||||
|
toast(e.message, 'err');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Submit Add Sub-Agent ────────────────────────────────────
|
// ── Submit Add Sub-Agent ────────────────────────────────────
|
||||||
async function submitAddSub() {
|
async function submitAddSub() {
|
||||||
|
const parentAgentId = document.getElementById('fs-parent').value.trim();
|
||||||
const groupId = document.getElementById('fs-gid').value.trim();
|
const groupId = document.getElementById('fs-gid').value.trim();
|
||||||
const agentId = document.getElementById('fs-aid').value.trim();
|
const agentId = document.getElementById('fs-aid').value.trim();
|
||||||
const displayName = document.getElementById('fs-name').value.trim();
|
const displayName = document.getElementById('fs-name').value.trim();
|
||||||
@@ -2319,12 +2414,12 @@ async function submitAddSub() {
|
|||||||
const purpose = document.getElementById('fs-purpose').value.trim();
|
const purpose = document.getElementById('fs-purpose').value.trim();
|
||||||
const personality = document.getElementById('fs-soul').value.trim();
|
const personality = document.getElementById('fs-soul').value.trim();
|
||||||
const initialMemory = document.getElementById('fs-mem').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 (!groupId) { toast(t('wiz.errGroupId'), 'err'); return; }
|
||||||
if (!agentId) { toast(t('wiz.errAgentId'), 'err'); return; }
|
if (!agentId) { toast(t('wiz.errAgentId'), 'err'); return; }
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) { toast(t('wiz.errIdFormat'), '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 {
|
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; }
|
if (r.error) { toast(r.error, 'err'); return; }
|
||||||
toast(t('wiz.created'), 'ok');
|
toast(t('wiz.created'), 'ok');
|
||||||
document.getElementById('addFormArea').innerHTML = '';
|
document.getElementById('addFormArea').innerHTML = '';
|
||||||
@@ -3059,7 +3154,7 @@ function cliCloseAC(){ const b=document.getElementById('cliACBox'); if(b) b.remo
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Stats(使用统计)────────────────────────────────────────
|
// ── Stats (Usage Statistics) ────────────────────────────────
|
||||||
async function loadStats(){
|
async function loadStats(){
|
||||||
try{
|
try{
|
||||||
const days=document.getElementById('statsDaysFilter')?.value||30;
|
const days=document.getElementById('statsDaysFilter')?.value||30;
|
||||||
@@ -3067,16 +3162,24 @@ async function loadStats(){
|
|||||||
const s=r.summary||{};
|
const s=r.summary||{};
|
||||||
document.getElementById('statsTotalInput').textContent=fmtNum(s.totalInputTokens||0);
|
document.getElementById('statsTotalInput').textContent=fmtNum(s.totalInputTokens||0);
|
||||||
document.getElementById('statsTotalOutput').textContent=fmtNum(s.totalOutputTokens||0);
|
document.getElementById('statsTotalOutput').textContent=fmtNum(s.totalOutputTokens||0);
|
||||||
|
document.getElementById('statsCacheRead').textContent=fmtNum(s.totalCacheRead||0);
|
||||||
document.getElementById('statsTotalCost').textContent=s.estimatedCost||'$0';
|
document.getElementById('statsTotalCost').textContent=s.estimatedCost||'$0';
|
||||||
const totalReqs=Object.values(r.byModel||{}).reduce((a,b)=>a+(b.requestCount||0),0);
|
const totalReqs=Object.values(r.byModel||{}).reduce((a,b)=>a+(b.requestCount||0),0);
|
||||||
document.getElementById('statsTotalReqs').textContent=fmtNum(totalReqs);
|
document.getElementById('statsTotalReqs').textContent=fmtNum(totalReqs);
|
||||||
// by model
|
// by model
|
||||||
const bmEl=document.getElementById('statsByModel');
|
const bmEl=document.getElementById('statsByModel');
|
||||||
const bmEntries=Object.entries(r.byModel||{});
|
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">
|
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: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 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>';
|
'</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
|
// by day chart
|
||||||
const chartEl=document.getElementById('statsChart');
|
const chartEl=document.getElementById('statsChart');
|
||||||
const dayEntries=Object.entries(r.byDay||{}).sort((a,b)=>a[0].localeCompare(b[0]));
|
const dayEntries=Object.entries(r.byDay||{}).sort((a,b)=>a[0].localeCompare(b[0]));
|
||||||
@@ -3085,12 +3188,12 @@ async function loadStats(){
|
|||||||
chartEl.innerHTML=dayEntries.map(([day,d])=>{
|
chartEl.innerHTML=dayEntries.map(([day,d])=>{
|
||||||
const total=(d.inputTokens||0)+(d.outputTokens||0);
|
const total=(d.inputTokens||0)+(d.outputTokens||0);
|
||||||
const pct=Math.max(2,total/maxTk*100);
|
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}">
|
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="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 style="font-size:8px;color:var(--muted);margin-top:3px;writing-mode:vertical-rl;transform:rotate(180deg)">' + day.slice(5) + '</div>' +
|
||||||
</div>\`;
|
'</div>';
|
||||||
}).join('');
|
}).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); }
|
function fmtNum(n){ if(n>=1e6) return (n/1e6).toFixed(1)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'K'; return String(n); }
|
||||||
|
|
||||||
@@ -3362,7 +3465,7 @@ async function api(method,path,body){
|
|||||||
if(body) opts.body=JSON.stringify(body);
|
if(body) opts.body=JSON.stringify(body);
|
||||||
const r=await fetch(path,opts);
|
const r=await fetch(path,opts);
|
||||||
const d=await r.json();
|
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;
|
return d;
|
||||||
}
|
}
|
||||||
function openModal(id){document.getElementById(id).classList.add('open');}
|
function openModal(id){document.getElementById(id).classList.add('open');}
|
||||||
|
|||||||
Reference in New Issue
Block a user