From e2f4e88a7a28c065557a7619bda49e8f5c414c23 Mon Sep 17 00:00:00 2001 From: Tao Date: Thu, 26 Feb 2026 00:22:43 +0000 Subject: [PATCH] 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 --- README.md | 239 ++++++++---------------------- openclaw-manager.js | 353 ++++++++++++++++++++++++++++---------------- 2 files changed, 290 insertions(+), 302 deletions(-) diff --git a/README.md b/README.md index 5c74e4b..79b5cc4 100644 --- a/README.md +++ b/README.md @@ -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. - - - -### 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. - - - -### 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. - - - -
-More screenshots - -### New Subagent Wizard -4-step guided wizard to create a sub-agent: basic info → model → personality & memory → confirm. Fully bilingual. - - - -### Model Selector -Rich model dropdown grouped by provider — GitHub Copilot, Anthropic, OpenAI, Google, DeepSeek, Kimi, Groq, Mistral, Together, plus your custom models. - - - -### 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. - - - -### Cron Job Management -View, add, enable/disable, and manually trigger openclaw-related cron tasks. Integrates with NAS backup schedules. - - - -Preset command menu with built-in commands and your personal favorites — one click to run. - - - -Command output streams in real-time. Star frequently used commands for quick access, or use the Manage button to organize favorites. - - -
+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,13 +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 -├── 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 ``` @@ -154,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 @@ -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 -# 首次安装 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`。 -### 界面预览 +### 功能 -#### 模式选择 - +- **Agent 管理** — 添加主 Agent 和子 Agent,树状结构查看,内联切换模型,浏览工作区文件 +- **使用统计** — 从 OpenClaw 会话文件解析真实 Token 用量,按模型/Agent/日期维度展示 +- **模型与认证** — 管理注册模型列表,内置 Provider 认证引导 +- **内置终端** — 实时流式输出,预设命令,收藏夹,Tab 补全 +- **运维面板** — 重启网关、查看日志、健康检查、本地/NAS 备份、Cron 任务管理 +- **双语界面** — 中英文一键切换 -#### Agent 管理 - +### 配置 -#### 内置 CLI 终端 -底部终端面板,支持实时流式输出、预设命令、收藏夹、Tab 补全。 +启动脚本自动检测 `~/.openclaw` 目录。如需指定其他路径: - +```bash +bash start.sh --dir /path/to/.openclaw --port 8080 +``` -
-更多截图 - -#### 新建向导 - - -#### 模型选择 & 操作菜单 -首次安装后,通过 **Actions → Switch OpenClaw Dir** 指向你的 OpenClaw 配置目录即可开始使用。 - - - - -#### Cron 定时任务 - - - - -
- -### 功能一览 - -- **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" -``` diff --git a/openclaw-manager.js b/openclaw-manager.js index 1249413..eca437b 100644 --- a/openclaw-manager.js +++ b/openclaw-manager.js @@ -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; } @@ -1596,16 +1665,19 @@ const MAIN_HTML_BODY = String.raw` -
-
输入 Token
--
-
输出 Token
--
-
估计成本
--
-
请求数
--
+
+
Input Tokens
--
+
Output Tokens
--
+
Cache Read
--
+
Estimated Cost
--
+
Requests
--
-

按日期

+

By Day

-

按模型

-
暂无数据
+

By Model

+
No data
+

By Agent

+
No data
@@ -1896,7 +1968,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 相关的计划任务', @@ -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.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', @@ -2246,9 +2318,15 @@ function buildAddAgentForm() { '
' + '' + '
' + + '
' + + '' + + 'Alphanumeric, underscore, or dash only
' + '
' + '' + '
' + + '
' + + '' + + '
' + '
' + '' + '
' + @@ -2259,10 +2337,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 ? '' : ''; + const botAgents = cfg.filter(a => a.hasOwnBot); + let parentOpts = ''; + if (botAgents.length === 0) { + parentOpts = ''; + } else { + botAgents.forEach(a => { + parentOpts += ''; + }); + } const modelOpts = buildModelOpts('__default__'); return '
' + '

' + t('agents.addSubTitle') + '

' + @@ -2271,6 +2356,8 @@ function buildAddSubForm() { '
  • ' + t('guide.sub.s2') + '
  • ' + '
  • ' + t('guide.sub.s3') + '
  • ' + '' + + '
    ' + + '
    ' + '
    ' + '' + '' + t('wiz.groupHint') + '
    ' + @@ -2293,25 +2380,33 @@ function buildAddSubForm() { '
    '; } -// ── 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 +2414,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 +3154,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 +3162,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])=>\`
    -
    \${esc(m)}
    -
    In: \${fmtNum(d.inputTokens)} · Out: \${fmtNum(d.outputTokens)} · \${d.requestCount} reqs · $\${d.cost}
    -
    \`).join(''):'
    '+t('stats.noData')+'
    '; + 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])=>'
    ' + + '
    ' + esc(m) + '
    ' + + '
    In: ' + fmtNum(d.inputTokens) + ' · Out: ' + fmtNum(d.outputTokens) + ' · Cache: ' + fmtNum(d.cacheRead||0) + ' · ' + d.requestCount + ' reqs · $' + d.cost + '
    ' + + '
    ').join(''):'
    '+t('stats.noData')+'
    '; + // 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])=>'
    ' + + '
    ' + esc(a) + '
    ' + + '
    In: ' + fmtNum(d.inputTokens) + ' · Out: ' + fmtNum(d.outputTokens) + ' · ' + d.requestCount + ' reqs · $' + d.cost + '
    ' + + '
    ').join(''):'
    No data
    '; // by day chart const chartEl=document.getElementById('statsChart'); 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])=>{ const total=(d.inputTokens||0)+(d.outputTokens||0); const pct=Math.max(2,total/maxTk*100); - return \`
    -
    -
    \${day.slice(5)}
    -
    \`; + return '
    ' + + '
    ' + + '
    ' + day.slice(5) + '
    ' + + '
    '; }).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); } @@ -3362,7 +3465,7 @@ async function api(method,path,body){ if(body) opts.body=JSON.stringify(body); const r=await fetch(path,opts); 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');}