13 Commits
Author SHA1 Message Date
taodeng 78cb04931d docs: update README for v0.7.x features 2026-02-28 14:10:12 +10:00
taodeng 8a4169ac83 docs: embed redacted screenshots in README 2026-02-28 14:06:55 +10:00
taodeng 4fd70438b8 docs: link to redacted screenshots 2026-02-28 13:22:49 +10:00
taodeng 1680834ab2 docs: add redacted OCM screenshots 2026-02-28 13:21:51 +10:00
taodeng b0c707b74a chore: sync release metadata to v0.7.1 and add telegram safety notes 2026-02-28 07:37:53 +10:00
taodeng 6fe2e2bd3a feat: release v0.7.1 telegram guidance + CLI model dropdown source 2026-02-28 07:14:51 +10:00
taodeng cedade141b updated subagent logic 2026-02-27 19:53:12 +10:00
taodeng 154b863f78 Dashboard redesign 2026-02-27 18:05:24 +10:00
taodengandClaude Opus 4.6 07d816053f docs: update DEVLOG next steps for v0.7+ roadmap
Check off completed v0.6.x items and add new planned features:
agent inline editing, gateway restart button, settings tab, bot connectivity test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:09:06 +00:00
taodengandClaude Opus 4.6 6346ea2748 fix: main agent always root in agent tree + accountId inference (v0.6.6)
main agent in OpenClaw has no explicit binding - it's the default catch-all.
Previous code required explicit binding with accountId && !peer to detect
root agents, causing main and its sub-agents to appear as orphan independents.

Now main is always treated as root, with accountId inferred from first
unclaimed telegram account in channels.telegram.accounts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:04:28 +00:00
taodengandClaude Opus 4.6 69269963d0 fix: Add Agent creates runtime dirs + SOUL.md personality support
- POST /api/agents/bot now creates agents/<id>/sessions/ runtime directory
- POST /api/agents (sub-agent) also creates agents/<id>/sessions/
- Add Agent form now includes Purpose and Personality fields
- Personality keywords generate structured SOUL.md, empty uses default growth prompt
- Notes now remind to restart gateway for new bots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:15:29 +00:00
taodengandClaude Opus 4.6 c6150d117b fix: agents side-by-side layout + main workspace display (v0.6.5)
- Main agents displayed horizontally side-by-side (flex row)
- Sub-agents drop below their respective parent agent
- Main agent now shows defaults.workspace path
- All agents get "View Files" button regardless of root/sub status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:04:48 +00:00
taodengandClaude Opus 4.6 8eb9801f0f fix: agent tree shows each bot as independent root + new layout (v0.6.5)
- Each agent with hasOwnBot is rendered as independent tree root (not all under "main")
- Layout changed: buttons centered top, agent tree below (max 720px centered)
- Added expand/collapse toggle at top of sub-agent branch line
- API returns accountId/parentAccountId for proper tree grouping
- Event delegation for agent tree actions (data-action attributes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:52:23 +00:00
14 changed files with 547 additions and 145 deletions
+96 -3
View File
@@ -1,7 +1,92 @@
# OpenClaw Manager — 开发日志 # OpenClaw Manager — 开发日志
> 最后更新:2026-02-26 > 最后更新:2026-02-27
> 当前版本:v0.6.5 > 当前版本:v0.7.1
---
## v0.7.1 更新日志(2026-02-27
### New Features
**Dashboard Guidance Block (Telegram-first usage + safety)**
- Added a concise guidance card under Dashboard explaining OCM's intended Telegram workflow
- Clarifies prerequisites: users should already have basic OpenClaw CLI experience
- Clarifies purpose: OCM mainly helps visualize and update `openclaw.json` for easier main-agent/sub-agent management and multi-tree structures
- Added explicit Telegram security checklist:
- BotFather `Allow Groups = ON`
- BotFather `Group Privacy = OFF`
- Keep each group private (only you + agent/sub-agents), do not invite others
### Improvements
**Model Dropdown Source — only `openclaw models list`**
- Model dropdown options are now sourced from real CLI output (`openclaw models list`) instead of built-in/static lists or config-only additions
- Applies to all relevant selectors: agent/sub-agent creation forms, agent inline model switcher, primary model selector, and fallback picker
- Added warning message when model list loading/parsing fails
### Technical Notes
- Added model ID parser for CLI output and short-term cache for model list fetches
- `GET /api/models` now returns:
- `knownModels` from CLI
- `modelListError` for UI warning display
- Startup script banners updated to `v0.7.1` (`start.sh` / `start.bat`)
---
## v0.7.0 更新日志(2026-02-27
### New Features
**Dashboard Redesign — Circular Gauge System Health**
- CPU%, RAM%, DISK% displayed as SVG circular gauges with colour-coded arcs (green < 70%, amber < 90%, red ≥ 90%)
- System info card: hostname, OS, Node.js, CPU model, cores, uptime, load average (1/5/15 min)
- Agent overview: separate main agent count and sub-agent count (plus total)
- Auto-refresh toggle: when enabled, dashboard polls `/api/dashboard` every 10 seconds
- Backend now measures CPU usage via `os.cpus()` delta sampling (200ms interval) and returns `loadAvg` from `os.loadavg()`
**Sub-Agent Creation Flow — Step-by-Step Guide Rewrite**
- Expanded from 3 steps to 5 detailed steps covering the full BotFather → Telegram Group → OCM workflow
- Step 1: Create new Bot via `/newbot` in BotFather
- Step 2: Disable Group Privacy via `/mybots` → Bot Settings → Group Privacy → Turn off
- Step 3: Create Telegram group, add Bot (with security warning)
- Step 4: Get Group ID from gateway logs
- Step 5: Fill in the form
- New "Your Telegram User ID" input field — auto-writes to `channels.telegram.allowFrom` whitelist on creation
- Security warning banner: "Do NOT add other people to this group" with explanation about API cost risks
**Setup Page — English Localization**
- All Chinese text in the initial directory selection page translated to English
- Includes: title, description, labels, placeholders, error messages, button text
### Bug Fixes
**Agent tree: sub-agents not grouped under main (old config format)**
- **Symptom**: On machines with openclaw.json created before OCM v0.6 multi-bot support, all sub-agents displayed as independent cards instead of nested under main
- **Root cause**: Old-format bindings lack `accountId` field in sub-agent entries. The tree builder relied on `parentAccountId` to match sub-agents to roots, but old bindings only had `channel` + `peer` — no `accountId`. So `parentAccountId` was null and no parent match was found
- **Fix**: Fallback inference — if a sub-agent has a peer binding but no `accountId`, automatically infer `parentAccountId` from main's binding (`accountId`) or fall back to `'default'`
### Technical Notes
- **CPU usage measurement:** Two `os.cpus()` snapshots 200ms apart, calculating idle-to-total ratio across all cores
- **Gauge rendering:** Pure SVG arcs with `stroke-dasharray` animation, no external libraries. 270° arc (gap at bottom), colour transitions via `gaugeColor()` function
- **allowFrom auto-config:** `POST /api/agents` now accepts optional `telegramUserId` field (numeric string), appends to `channels.telegram.allowFrom[]` if not already present
- **Old-config compatibility:** Sub-agent bindings without `accountId` are now auto-assigned to the main agent's bot, maintaining backward compatibility with configs created before multi-bot support
---
## v0.6.6 更新日志(2026-02-27
### Bug Fixes
**Agent tree: main agent not detected as root**
- **Symptom**: Sub-agents displayed as independent agents instead of under their parent. All agents appeared as orphan roots.
- **Root cause**: `main` agent in OpenClaw is the default catch-all and typically has NO explicit binding in `openclaw.json`. The `hasOwnBot` detection required an explicit binding with `accountId && !peer`, so `main` was classified as a non-root. Without `main` being a root, sub-agents' `parentAccountId` had no matching root to link to.
- **Fix**:
- `main` is now always treated as a root agent regardless of binding existence
- `main`'s `accountId` is inferred from `channels.telegram.accounts`: first unclaimed account (not explicitly bound to another agent), or first account as fallback
- Sub-agents with `parentAccountId` matching `main`'s inferred `accountId` now correctly appear under `main` in the tree
--- ---
@@ -133,13 +218,21 @@
- **`assertBrowserScriptSyntax()`** 在启动时检查 MAIN_HTML_SCRIPT 的 evaluated 值 - **`assertBrowserScriptSyntax()`** 在启动时检查 MAIN_HTML_SCRIPT 的 evaluated 值
- **OpenClaw config 多 bot 格式**`channels.telegram.accounts.<id>.botToken`binding 用 `accountId` 路由 - **OpenClaw config 多 bot 格式**`channels.telegram.accounts.<id>.botToken`binding 用 `accountId` 路由
### 下一步(v0.6.2+ ### 下一步(v0.7+
- [x] Dashboard 首页 tab(系统信息 + OpenClaw health 状态)— done in v0.6.5 - [x] Dashboard 首页 tab(系统信息 + OpenClaw health 状态)— done in v0.6.5
- [x] HTTP 响应加 `Cache-Control: no-store` + version-based cache busting — done in v0.6.5 - [x] HTTP 响应加 `Cache-Control: no-store` + version-based cache busting — done in v0.6.5
- [x] 彻底删除旧 `/api/agents/main` 端点残留(确认已清除)— confirmed in v0.6.5 - [x] 彻底删除旧 `/api/agents/main` 端点残留(确认已清除)— confirmed in v0.6.5
- [x] Agent tree: independent roots per bot + side-by-side layout — done in v0.6.5
- [x] Agent tree: expand/collapse toggle for sub-agents — done in v0.6.5
- [x] Add Agent: creates `agents/<id>/sessions/` runtime dirs + SOUL.md personality — done in v0.6.5
- [x] Agent tree: main agent always root with inferred accountId — done in v0.6.6
- [ ] Dashboard: auto-refresh every 30s when tab is active - [ ] Dashboard: auto-refresh every 30s when tab is active
- [ ] Dashboard: OpenClaw version display (from `openclaw --version`) - [ ] Dashboard: OpenClaw version display (from `openclaw --version`)
- [ ] Agent page: edit agent name, purpose, personality inline
- [ ] Agent page: restart gateway button with status feedback
- [ ] Settings tab: edit openclaw.json key fields via form (model, workspace defaults)
- [ ] Telegram bot connectivity test button (ping bot API from server)
- [ ] DEVLOG.md 中文 → 逐步迁移为英文 - [ ] DEVLOG.md 中文 → 逐步迁移为英文
--- ---
+2 -2
View File
@@ -9,9 +9,9 @@
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>com.openclaw.manager</string> <string>com.openclaw.manager</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.5.0</string> <string>0.7.1</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.5.0</string> <string>0.7.1</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
+59
View File
@@ -25,6 +25,23 @@ Open [http://localhost:3333](http://localhost:3333) in your browser. For remote
## Features ## Features
**Screenshots** — Redacted UI walkthrough (no personal paths, no Telegram IDs):
![](docs/redacted-screenshots/dashboard.jpg)
![](docs/redacted-screenshots/agents.jpg)
![](docs/redacted-screenshots/channels.jpg)
![](docs/redacted-screenshots/models.jpg)
![](docs/redacted-screenshots/auth.jpg)
![](docs/redacted-screenshots/stats.jpg)
![](docs/redacted-screenshots/cron.jpg)
**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. **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. **Usage Statistics** — Real token usage data parsed directly from OpenClaw session files. Breakdown by model, agent, and day with a visual chart.
@@ -37,6 +54,32 @@ Open [http://localhost:3333](http://localhost:3333) in your browser. For remote
**Bilingual UI** — English and Chinese interface with one-click language switching. **Bilingual UI** — English and Chinese interface with one-click language switching.
## What's New (v0.7.x)
- **Dashboard redesign**: circular CPU/RAM/DISK gauges + system/gateway/agent overview, with optional auto-refresh.
- **Sub-agent creation flow**: step-by-step Telegram guide (BotFather → group → get group ID → fill form), with explicit safety warnings.
- **Telegram allowlist helper**: optional “Your Telegram User ID” field can auto-append to `channels.telegram.allowFrom`.
- **Model dropdown = real CLI output**: selectors are sourced from `openclaw models list` (with a warning if parsing fails).
- **Cache-busting**: response headers + version checks help prevent stale frontend after updates.
- **Setup page EN localization**: first-run directory selection page is fully bilingual.
## Telegram Workflow & Safety
OCM is designed primarily for **Telegram-based OpenClaw workflows**:
- Bind Telegram groups to one or more main agents, then create sub-agents under each tree
- Keep each agent's context isolated via independent `workspace`, `SOUL.md`, and `MEMORY.md`
- OCM mainly helps you **safely update `openclaw.json`** via UI instead of manual editing
- Recommended for users who already have basic OpenClaw CLI experience (`onboard`, auth, gateway logs)
Critical Telegram settings:
- In BotFather, keep **Allow Groups = ON**
- In BotFather, set **Group Privacy = OFF**
- For each agent group, keep it private: **only you + that agent/sub-agents**
- Do **not** invite other people to these groups (cost and security risk)
## Requirements ## Requirements
- Node.js 18+ ([download](https://nodejs.org/)) - Node.js 18+ ([download](https://nodejs.org/))
@@ -131,6 +174,22 @@ start.bat # Windows
- **运维面板** — 重启网关、查看日志、健康检查、本地/NAS 备份、Cron 任务管理 - **运维面板** — 重启网关、查看日志、健康检查、本地/NAS 备份、Cron 任务管理
- **双语界面** — 中英文一键切换 - **双语界面** — 中英文一键切换
### Telegram 场景与安全提示
OCM 主要面向 **Telegram 场景**
- 通过群组绑定主 Agent,并在每条树下管理多个 Sub-Agent
- 让每个 Agent 拥有独立 `workspace``SOUL.md``MEMORY.md`
- 通过可视化方式更新 `openclaw.json`,减少手动改配置风险
- 建议使用者已具备基础 OpenClaw CLI 操作经验(如 `onboard`、认证、查看网关日志)
关键安全设置(务必确认):
- BotFather 里 **Allow Groups = ON**
- BotFather 里 **Group Privacy = OFF**
- 每个 Agent 群组只保留“你自己 + 对应 Agent/Sub-Agent”
- 不要邀请其他人进组(会带来安全和 API 费用风险)
### 配置 ### 配置
启动脚本自动检测 `~/.openclaw` 目录。如需指定其他路径: 启动脚本自动检测 `~/.openclaw` 目录。如需指定其他路径:
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

+376 -126
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
// ================================================================ // ================================================================
// OpenClaw Manager v0.6.5 // OpenClaw Manager v0.7.1
// 跨平台本地管理工具 (Windows / macOS / Linux) // 跨平台本地管理工具 (Windows / macOS / Linux)
// //
// 用法: // 用法:
@@ -24,7 +24,7 @@ const SCRIPT_DIR = __dirname;
const MANAGER_CONFIG = path.join(SCRIPT_DIR, 'manager-config.json'); const MANAGER_CONFIG = path.join(SCRIPT_DIR, 'manager-config.json');
let PORT = 3333; let PORT = 3333;
let HOST = '0.0.0.0'; let HOST = '0.0.0.0';
const APP_VERSION = '0.6.5'; const APP_VERSION = '0.7.1';
// --port 参数 // --port 参数
const portIdx = process.argv.indexOf('--port'); const portIdx = process.argv.indexOf('--port');
if (portIdx !== -1 && process.argv[portIdx + 1]) PORT = parseInt(process.argv[portIdx + 1]) || 3333; if (portIdx !== -1 && process.argv[portIdx + 1]) PORT = parseInt(process.argv[portIdx + 1]) || 3333;
@@ -197,6 +197,37 @@ function runOpenclawCmd(args) {
}); });
} }
function parseModelIdsFromCliOutput(raw) {
if (!raw) return [];
const set = new Set();
const isModelId = (s) => /^[a-z0-9][a-z0-9_-]*(?:\/[A-Za-z0-9._:-]+)+$/.test(s);
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const normalized = trimmed.replace(/^[-*]\s+/, '');
const first = normalized.split(/\s+/)[0].replace(/^`|`$/g, '');
if (isModelId(first)) set.add(first);
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}
let MODEL_LIST_CACHE = { ts: 0, knownModels: [], error: '' };
async function getKnownModelsFromCli() {
const now = Date.now();
if (now - MODEL_LIST_CACHE.ts < 30000) return MODEL_LIST_CACHE;
try {
const out = await runOpenclawCmd('models list');
const ids = parseModelIdsFromCliOutput(out);
const knownModels = ids.map(id => ({ id, label: id, group: id.split('/')[0] || 'other' }));
const error = knownModels.length ? '' : 'No model IDs found in `openclaw models list` output';
MODEL_LIST_CACHE = { ts: now, knownModels, error };
return MODEL_LIST_CACHE;
} catch (e) {
MODEL_LIST_CACHE = { ts: now, knownModels: [], error: e.message || 'openclaw models list failed' };
return MODEL_LIST_CACHE;
}
}
// 过滤 ANSI 终端控制码(光标移动、清行、颜色等) // 过滤 ANSI 终端控制码(光标移动、清行、颜色等)
function stripAnsi(str) { function stripAnsi(str) {
return str return str
@@ -345,15 +376,44 @@ async function handleApi(req, res, urlObj, body) {
const defaults = cfg.agents?.defaults || {}; const defaults = cfg.agents?.defaults || {};
const bindings = cfg.bindings || []; const bindings = cfg.bindings || [];
const groups = cfg.channels?.telegram?.groups || {}; const groups = cfg.channels?.telegram?.groups || {};
const defaultWorkspace = defaults.workspace || null;
// Build set of accountIds explicitly claimed by non-main agents (via non-peer binding)
const telegramAccounts = Object.keys(cfg.channels?.telegram?.accounts || {});
const claimedAccounts = new Set(
bindings.filter(b => b.agentId !== 'main' && b.match?.accountId && !b.match?.peer).map(b => b.match.accountId)
);
// For main agent without explicit binding, infer its accountId from first unclaimed telegram account
const mainInferredAccountId = telegramAccounts.find(a => !claimedAccounts.has(a)) || telegramAccounts[0] || null;
const enriched = list.map(a => { const enriched = list.map(a => {
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) // Check if agent has its own bot account (binding with accountId but no peer)
const botBinding = bindings.find(b => b.agentId === a.id && b.match?.accountId && !b.match?.peer); const botBinding = bindings.find(b => b.agentId === a.id && b.match?.accountId && !b.match?.peer);
const hasOwnBot = botBinding ? true : false; // 'main' is always a root agent even without explicit binding
return { ...a, groupId, requireMention: groupId ? (groups[groupId]?.requireMention ?? true) : null, const isMain = a.id === 'main';
effectiveModel: modelVal || defaults.model?.primary || '默认', hasOwnBot }; const hasOwnBot = botBinding ? true : isMain;
const accountId = botBinding?.match?.accountId || (isMain ? mainInferredAccountId : null);
// For sub-agents (with peer match), find which accountId they belong to
const parentBinding = bindings.find(b => b.agentId === a.id && b.match?.accountId);
// Fallback: if sub-agent has peer binding but no accountId, AND there's only one bot (old single-bot config),
// infer parentAccountId from main. If multiple bots exist, leave as orphan (can't guess which bot it belongs to).
let parentAccountId = parentBinding?.match?.accountId || null;
if (!parentAccountId && !hasOwnBot) {
const hasPeerBinding = bindings.find(b => b.agentId === a.id && b.match?.peer);
if (hasPeerBinding) {
const rootBindings = bindings.filter(b => b.match?.accountId && !b.match?.peer);
const rootCount = rootBindings.length + (list.some(x => x.id === 'main') && !rootBindings.some(b => b.agentId === 'main') ? 1 : 0);
if (rootCount <= 1) {
const mainBinding = rootBindings.find(b => b.agentId === 'main') || rootBindings[0];
parentAccountId = mainBinding?.match?.accountId || mainInferredAccountId || 'default';
}
}
}
// Workspace: explicit per-agent, or defaults.workspace for main
const workspace = a.workspace || (isMain ? defaultWorkspace : null);
return { ...a, workspace, groupId, requireMention: groupId ? (groups[groupId]?.requireMention ?? true) : null,
effectiveModel: modelVal || defaults.model?.primary || '默认', hasOwnBot, accountId, parentAccountId };
}); });
res.writeHead(200); res.writeHead(200);
res.end(JSON.stringify({ agents: enriched, defaults })); res.end(JSON.stringify({ agents: enriched, defaults }));
@@ -362,7 +422,7 @@ async function handleApi(req, res, urlObj, body) {
// POST /api/agents/bot — create agent with its own bot token // POST /api/agents/bot — create agent with its own bot token
if (method === 'POST' && pathname === '/api/agents/bot') { if (method === 'POST' && pathname === '/api/agents/bot') {
const { botToken, agentId, name, model, workspace } = body; const { botToken, agentId, name, model, workspace, purpose, personality } = 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;
} }
@@ -418,16 +478,21 @@ async function handleApi(req, res, urlObj, body) {
await fsp.mkdir(wsPath, { recursive: true }); await fsp.mkdir(wsPath, { recursive: true });
await fsp.mkdir(path.join(wsPath, 'memory'), { recursive: true }); await fsp.mkdir(path.join(wsPath, 'memory'), { recursive: true });
const agentName = name || agentId; const agentName = name || agentId;
await fsp.writeFile(path.join(wsPath, 'SOUL.md'), generateSoulMd(agentName, '', ''), 'utf8'); await fsp.writeFile(path.join(wsPath, 'SOUL.md'), generateSoulMd(agentName, purpose || '', personality || ''), 'utf8');
await fsp.writeFile(path.join(wsPath, 'MEMORY.md'), generateMemoryMd(agentName, ''), 'utf8'); await fsp.writeFile(path.join(wsPath, 'MEMORY.md'), generateMemoryMd(agentName, ''), 'utf8');
// Create agents/<id>/ runtime directory (required by OpenClaw gateway)
const agentRuntimeDir = path.join(OPENCLAW_DIR, 'agents', agentId);
await fsp.mkdir(agentRuntimeDir, { recursive: true });
await fsp.mkdir(path.join(agentRuntimeDir, 'sessions'), { recursive: true });
res.writeHead(200); res.writeHead(200);
res.end(JSON.stringify({ res.end(JSON.stringify({
ok: true, agentId, workspacePath: wsPath, configBackup: bakPath, ok: true, agentId, workspacePath: wsPath, configBackup: bakPath,
notes: [ notes: [
'Agent created with its own bot token', 'Agent created with its own bot token',
'Workspace directory created with SOUL.md and MEMORY.md', 'Workspace directory created with SOUL.md and MEMORY.md',
'Runtime directory created at agents/' + agentId + '/',
'Configuration updated and backed up', 'Configuration updated and backed up',
'Changes take effect in ~300ms without restart' 'Restart gateway to load new bot: openclaw gateway restart'
] ]
})); }));
return; return;
@@ -435,7 +500,7 @@ async function handleApi(req, res, urlObj, body) {
// POST /api/agents — create sub-agent (shares parent bot) // 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, parentAgentId } = body; const { agentId, displayName, groupId, workspaceFolder, model, purpose, personality, initialMemory, parentAgentId, telegramUserId } = 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 must contain only alphanumeric characters, underscores, or dashes' })); return; res.writeHead(400); res.end(JSON.stringify({ error: 'Agent ID must contain only alphanumeric characters, underscores, or dashes' })); return;
} }
@@ -474,19 +539,31 @@ async function handleApi(req, res, urlObj, body) {
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 = {};
cfg.channels.telegram.groups[gid] = { requireMention: false }; cfg.channels.telegram.groups[gid] = { requireMention: false };
// Add telegramUserId to allowFrom whitelist if provided
if (telegramUserId && /^\d+$/.test(String(telegramUserId).trim())) {
const uid = parseInt(String(telegramUserId).trim());
if (!cfg.channels.telegram.allowFrom) cfg.channels.telegram.allowFrom = [];
if (!cfg.channels.telegram.allowFrom.includes(uid)) {
cfg.channels.telegram.allowFrom.push(uid);
}
}
const bakPath = await writeConfig(cfg, 'create'); const bakPath = await writeConfig(cfg, 'create');
await fsp.mkdir(wsPath, { recursive: true }); await fsp.mkdir(wsPath, { recursive: true });
await fsp.mkdir(path.join(wsPath, 'memory'), { recursive: true }); await fsp.mkdir(path.join(wsPath, 'memory'), { recursive: true });
const name = displayName || agentId; const name = displayName || agentId;
await fsp.writeFile(path.join(wsPath, 'SOUL.md'), generateSoulMd(name, purpose, personality), 'utf8'); await fsp.writeFile(path.join(wsPath, 'SOUL.md'), generateSoulMd(name, purpose, personality), 'utf8');
await fsp.writeFile(path.join(wsPath, 'MEMORY.md'), generateMemoryMd(name, initialMemory), 'utf8'); await fsp.writeFile(path.join(wsPath, 'MEMORY.md'), generateMemoryMd(name, initialMemory), 'utf8');
// Create agents/<id>/ runtime directory (required by OpenClaw gateway)
const agentRuntimeDir = path.join(OPENCLAW_DIR, 'agents', agentId);
await fsp.mkdir(agentRuntimeDir, { recursive: true });
await fsp.mkdir(path.join(agentRuntimeDir, 'sessions'), { recursive: true });
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: [
'Sub-agent created and shares parent bot', 'Sub-agent created and shares parent bot',
'Workspace directory created with SOUL.md and MEMORY.md', 'Workspace and runtime directories created',
'Configuration updated and backed up', 'Configuration updated and backed up',
'Changes take effect in ~300ms without restart' 'Restart gateway to apply: openclaw gateway restart'
], ],
})); }));
return; return;
@@ -582,13 +659,15 @@ async function handleApi(req, res, urlObj, body) {
// GET /api/models // GET /api/models
if (method === 'GET' && pathname === '/api/models') { if (method === 'GET' && pathname === '/api/models') {
const cfg = await readConfig(); const cfg = await readConfig();
const modelList = await getKnownModelsFromCli();
res.writeHead(200); res.writeHead(200);
res.end(JSON.stringify({ res.end(JSON.stringify({
models: cfg.agents?.defaults?.models || {}, models: cfg.agents?.defaults?.models || {},
authProfiles: cfg.auth?.profiles || {}, authProfiles: cfg.auth?.profiles || {},
primaryModel: cfg.agents?.defaults?.model?.primary || '', primaryModel: cfg.agents?.defaults?.model?.primary || '',
fallbacks: cfg.agents?.defaults?.model?.fallbacks || [], fallbacks: cfg.agents?.defaults?.model?.fallbacks || [],
knownModels: KNOWN_MODELS, knownModels: modelList.knownModels || [],
modelListError:modelList.error || '',
authProviders: AUTH_PROVIDERS, authProviders: AUTH_PROVIDERS,
})); }));
return; return;
@@ -1222,6 +1301,25 @@ async function handleApi(req, res, urlObj, body) {
const cpus = os.cpus(); const cpus = os.cpus();
const cpuModel = cpus.length ? cpus[0].model.trim() : 'Unknown'; const cpuModel = cpus.length ? cpus[0].model.trim() : 'Unknown';
const cpuCores = cpus.length; const cpuCores = cpus.length;
const loadAvg = os.loadavg(); // [1min, 5min, 15min]
// CPU usage % (snapshot via /proc/stat or fallback to loadavg)
let cpuPercent = null;
try {
// Quick estimate: sum idle vs total across all cores from a snapshot
const c1 = os.cpus();
await new Promise(r => setTimeout(r, 200));
const c2 = os.cpus();
let idleDiff = 0, totalDiff = 0;
for (let i = 0; i < c2.length; i++) {
const t1 = c1[i].times, t2 = c2[i].times;
const total1 = t1.user + t1.nice + t1.sys + t1.idle + t1.irq;
const total2 = t2.user + t2.nice + t2.sys + t2.idle + t2.irq;
idleDiff += (t2.idle - t1.idle);
totalDiff += (total2 - total1);
}
cpuPercent = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 100) : 0;
} catch (_) {}
// Disk usage (best-effort, works on macOS/Linux) // Disk usage (best-effort, works on macOS/Linux)
let diskTotal = 0, diskUsed = 0, diskFree = 0; let diskTotal = 0, diskUsed = 0, diskFree = 0;
@@ -1270,9 +1368,21 @@ async function handleApi(req, res, urlObj, body) {
gatewayPing = code > 0 && code < 500; gatewayPing = code > 0 && code < 500;
} catch (_) {} } catch (_) {}
// Agent count & last activity // Agent count (main vs sub) & last activity
let agentCount = 0; let agentCount = 0, mainAgentCount = 0, subAgentCount = 0;
let lastActivity = null; let lastActivity = null;
try {
// Count main vs sub from config
if (cfg && cfg.agents && cfg.agents.list && cfg.bindings) {
const bindings = cfg.bindings || [];
const accounts = cfg.channels?.telegram?.accounts || {};
cfg.agents.list.forEach(a => {
const isMain = a.id === 'main';
const botBinding = bindings.find(b => b.agentId === a.id && b.match?.accountId && !b.match?.peer);
if (isMain || botBinding) mainAgentCount++; else subAgentCount++;
});
}
} catch (_) {}
try { try {
const sessionsBase = path.join(OPENCLAW_DIR, 'agents'); const sessionsBase = path.join(OPENCLAW_DIR, 'agents');
const agentDirs = await fsp.readdir(sessionsBase); const agentDirs = await fsp.readdir(sessionsBase);
@@ -1297,9 +1407,9 @@ async function handleApi(req, res, urlObj, body) {
res.writeHead(200); res.writeHead(200);
res.end(JSON.stringify({ res.end(JSON.stringify({
ok: true, ok: true,
system: { hostname, platform, nodeVer, cpuModel, cpuCores, uptime: sysUptime, totalMem, freeMem, diskTotal, diskUsed, diskFree, dirSize }, system: { hostname, platform, nodeVer, cpuModel, cpuCores, uptime: sysUptime, totalMem, freeMem, diskTotal, diskUsed, diskFree, dirSize, cpuPercent, loadAvg },
gateway: { status: gatewayRunning, pid: gatewayPid, port: gatewayPort, ping: gatewayPing }, gateway: { status: gatewayRunning, pid: gatewayPid, port: gatewayPort, ping: gatewayPing },
agents: { count: agentCount, lastActivity: lastActivity ? lastActivity.toISOString() : null }, agents: { count: agentCount, mainCount: mainAgentCount, subCount: subAgentCount, lastActivity: lastActivity ? lastActivity.toISOString() : null },
ocmVersion: APP_VERSION, ocmVersion: APP_VERSION,
serverTime: now, serverTime: now,
})); }));
@@ -1358,12 +1468,12 @@ async function handleApi(req, res, urlObj, body) {
} }
res.writeHead(404); res.writeHead(404);
res.end(JSON.stringify({ error: '未知 API 路径: ' + pathname })); res.end(JSON.stringify({ error: 'Unknown API path: ' + pathname }));
} }
// ── 安装向导 HTML ────────────────────────────────────────────── // ── 安装向导 HTML ──────────────────────────────────────────────
const SETUP_HTML = `<!DOCTYPE html> const SETUP_HTML = `<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><title>OpenClaw Manager - 初始设置</title> <html lang="en"><head><meta charset="UTF-8"><title>OpenClaw Manager - Setup</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{background:#0f1117;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;} body{background:#0f1117;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;}
@@ -1381,23 +1491,23 @@ const SETUP_HTML = `<!DOCTYPE html>
</style></head> </style></head>
<body><div class="box"> <body><div class="box">
<h1>🦀 OpenClaw Manager</h1> <h1>🦀 OpenClaw Manager</h1>
<p>首次运行,请指定你的 OpenClaw 数据目录(包含 openclaw.json 的文件夹)。</p> <p>First time setup — please specify your OpenClaw data directory (the folder containing openclaw.json).</p>
<label>OpenClaw 目录路径</label> <label>OpenClaw Directory Path</label>
<input id="dir" type="text" placeholder="例如: /Users/yourname/.openclaw ~/.openclaw"> <input id="dir" type="text" placeholder="e.g. /Users/yourname/.openclaw or ~/.openclaw">
<div class="hint">常见位置:<br>macOS / Linux<code>~/.openclaw</code><br>Windows<code>C:\\Users\\yourname\\.openclaw</code></div> <div class="hint">Common locations:<br>macOS / Linux: <code>~/.openclaw</code><br>Windows: <code>C:\\Users\\yourname\\.openclaw</code></div>
<div class="err" id="err"></div> <div class="err" id="err"></div>
<button onclick="save()">确认并进入</button> <button onclick="save()">Confirm &amp; Enter</button>
</div> </div>
<script> <script>
async function save(){ async function save(){
const dir=document.getElementById('dir').value.trim(); const dir=document.getElementById('dir').value.trim();
const err=document.getElementById('err'); const err=document.getElementById('err');
if(!dir){err.textContent='请填写目录路径';err.style.display='block';return;} if(!dir){err.textContent='Please enter a directory path';err.style.display='block';return;}
try{ try{
const r=await fetch('/api/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dir})}); const r=await fetch('/api/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dir})});
const d=await r.json(); const d=await r.json();
if(d.ok){location.reload();}else{err.textContent=d.error||'路径无效';err.style.display='block';} if(d.ok){location.reload();}else{err.textContent=d.error||'Invalid path';err.style.display='block';}
}catch(e){err.textContent='请求失败:'+e.message;err.style.display='block';} }catch(e){err.textContent='Request failed: '+e.message;err.style.display='block';}
} }
document.getElementById('dir').addEventListener('keydown',e=>{if(e.key==='Enter')save();}); document.getElementById('dir').addEventListener('keydown',e=>{if(e.key==='Enter')save();});
</script></body></html>`; </script></body></html>`;
@@ -1466,12 +1576,11 @@ main { padding:20px; max-width:1280px; margin:0 auto; }
.inline-sel { background:var(--bg); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:5px 8px; font-size:12px; } .inline-sel { background:var(--bg); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:5px 8px; font-size:12px; }
.inline-sel:focus { outline:none; border-color:var(--accent); } .inline-sel:focus { outline:none; border-color:var(--accent); }
/* Agents split layout */ /* Agents layout — buttons top, tree below */
.agents-split { display:flex; gap:20px; height:calc(100vh - 160px); } .agents-top-btns { display:flex; gap:10px; justify-content:center; margin-bottom:18px; }
.agents-left { flex:0 0 40%; display:flex; flex-direction:column; gap:12px; overflow-y:auto; } .agents-tree-wrap { max-width:900px; margin:0 auto; overflow-y:auto; }
.agents-right { flex:1; overflow-y:auto; padding-right:4px; } .agents-roots { display:flex; gap:16px; flex-wrap:wrap; }
.agents-left-btns { display:flex; gap:8px; } .agents-roots > .agent-tree-root { flex:1; min-width:320px; }
.agents-left-btns .btn-primary { flex:1; }
/* Add form */ /* Add form */
.add-form { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:16px; } .add-form { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:16px; }
@@ -1488,7 +1597,12 @@ main { padding:20px; max-width:1280px; margin:0 auto; }
.agent-tree-root .tree-main:hover { border-color:var(--accent); } .agent-tree-root .tree-main:hover { border-color:var(--accent); }
.agent-tree-root .tree-main .tree-title { font-size:14px; font-weight:600; display:flex; align-items:center; gap:8px; } .agent-tree-root .tree-main .tree-title { font-size:14px; font-weight:600; display:flex; align-items:center; gap:8px; }
.agent-tree-root .tree-main .tree-meta { font-size:11px; color:var(--muted); margin-top:4px; } .agent-tree-root .tree-main .tree-meta { font-size:11px; color:var(--muted); margin-top:4px; }
.tree-children { margin-left:20px; border-left:2px solid var(--border); padding-left:14px; margin-top:6px; } .tree-children-wrap { position:relative; margin-left:20px; padding-left:14px; margin-top:6px; }
.tree-children-wrap::before { content:''; position:absolute; left:0; top:0; bottom:8px; width:2px; background:var(--border); }
.tree-toggle { position:absolute; left:-8px; top:-4px; width:18px; height:18px; border-radius:50%; background:var(--surface); border:1.5px solid var(--border); color:var(--muted); font-size:12px; line-height:15px; text-align:center; cursor:pointer; z-index:2; padding:0; }
.tree-toggle:hover { border-color:var(--accent); color:var(--accent); }
.tree-children { }
.tree-children.collapsed { display:none; }
.tree-child { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:10px 12px; margin-bottom:8px; } .tree-child { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:10px 12px; margin-bottom:8px; }
.tree-child:hover { border-color:var(--accent); } .tree-child:hover { border-color:var(--accent); }
.tree-child .tree-title { font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px; } .tree-child .tree-title { font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px; }
@@ -1529,9 +1643,27 @@ select option { background:var(--surface); }
.pw-toggle { position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--muted); cursor:pointer; font-size:14px; padding:0; } .pw-toggle { position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--muted); cursor:pointer; font-size:14px; padding:0; }
/* ── Dashboard ── */ /* ── Dashboard ── */
.dash-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .dash-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
.dash-header h2 { font-size:18px; font-weight:600; color:var(--text); margin:0; }
.dash-auto-refresh { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); }
.dash-auto-refresh label { cursor:pointer; display:flex; align-items:center; gap:6px; }
.dash-toggle { position:relative; width:36px; height:20px; }
.dash-toggle input { opacity:0; width:0; height:0; }
.dash-toggle .slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background:var(--border); border-radius:10px; transition:.3s; }
.dash-toggle .slider:before { position:absolute; content:""; height:14px; width:14px; left:3px; bottom:3px; background:#999; border-radius:50%; transition:.3s; }
.dash-toggle input:checked + .slider { background:var(--accent); }
.dash-toggle input:checked + .slider:before { transform:translateX(16px); background:#fff; }
.dash-gauges { display:flex; gap:20px; justify-content:center; flex-wrap:wrap; margin-bottom:24px; }
.dash-gauge-card { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:20px 24px; display:flex; flex-direction:column; align-items:center; min-width:140px; }
.dash-gauge-svg { width:110px; height:110px; }
.dash-gauge-label { font-size:12px; color:var(--muted); margin-top:8px; font-weight:500; }
.dash-sections { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; }
.dash-card { padding:20px; } .dash-card { padding:20px; }
.dash-card h3 { font-size:14px; font-weight:600; margin-bottom:14px; color:var(--text); } .dash-card h3 { font-size:14px; font-weight:600; margin-bottom:14px; color:var(--text); }
.dash-notice { margin-top:18px; padding:16px 18px; }
.dash-notice h3 { font-size:14px; margin-bottom:10px; }
.dash-note-list { margin-left:16px; color:var(--muted); font-size:12px; line-height:1.7; }
.dash-note-list li { margin-bottom:4px; }
.dash-row { display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid var(--border); font-size:12px; } .dash-row { display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid var(--border); font-size:12px; }
.dash-row:last-child { border-bottom:none; } .dash-row:last-child { border-bottom:none; }
.dash-label { color:var(--muted); } .dash-label { color:var(--muted); }
@@ -1540,10 +1672,6 @@ select option { background:var(--surface); }
.dash-indicator.running { background:#22c55e; box-shadow:0 0 6px rgba(34,197,94,.5); } .dash-indicator.running { background:#22c55e; box-shadow:0 0 6px rgba(34,197,94,.5); }
.dash-indicator.stopped { background:#ef4444; box-shadow:0 0 6px rgba(239,68,68,.5); } .dash-indicator.stopped { background:#ef4444; box-shadow:0 0 6px rgba(239,68,68,.5); }
.dash-indicator.unknown { background:#f59e0b; } .dash-indicator.unknown { background:#f59e0b; }
.dash-bar-wrap { width:100%; height:6px; background:var(--border); border-radius:3px; margin-top:4px; }
.dash-bar { height:100%; border-radius:3px; background:var(--accent); transition:width .3s; }
.dash-bar.warn { background:#f59e0b; }
.dash-bar.danger { background:#ef4444; }
/* ── Modal ── */ /* ── Modal ── */
.backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100; display:none; align-items:center; justify-content:center; } .backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100; display:none; align-items:center; justify-content:center; }
@@ -1707,9 +1835,17 @@ const MAIN_HTML_BODY = String.raw`
<!-- ══ Dashboard ════════════════════════════════════════════ --> <!-- ══ Dashboard ════════════════════════════════════════════ -->
<div class="panel active" id="panel-dashboard"> <div class="panel active" id="panel-dashboard">
<div class="dash-grid" id="dashGrid"> <div class="dash-header">
<h2>Dashboard</h2>
<div class="dash-auto-refresh">
<label class="dash-toggle"><input type="checkbox" id="dashAutoRefresh" onchange="toggleDashRefresh(this.checked)"><span class="slider"></span></label>
<span>Auto-refresh</span>
</div>
</div>
<div class="dash-gauges" id="dashGauges"><div class="empty">Loading...</div></div>
<div class="dash-sections">
<div class="card dash-card" id="dashSystem"> <div class="card dash-card" id="dashSystem">
<h3>🖥️ System</h3> <h3>🖥️ System Info</h3>
<div class="dash-items" id="dashSysItems"><div class="empty">Loading...</div></div> <div class="dash-items" id="dashSysItems"><div class="empty">Loading...</div></div>
</div> </div>
<div class="card dash-card" id="dashGateway"> <div class="card dash-card" id="dashGateway">
@@ -1725,25 +1861,28 @@ const MAIN_HTML_BODY = String.raw`
<div class="dash-items" id="dashStorageItems"><div class="empty">Loading...</div></div> <div class="dash-items" id="dashStorageItems"><div class="empty">Loading...</div></div>
</div> </div>
</div> </div>
<div class="card dash-notice">
<h3 data-i18n="dash.noticeTitle">📌 Telegram 使用说明(重要)</h3>
<ul class="dash-note-list">
<li data-i18n="dash.notice1">OCM 主要面向 Telegram 场景:通过群组绑定 Agent,让每个 Agent 拥有独立 Workspace / SOUL.md / MEMORY.md。</li>
<li data-i18n="dash.notice2">使用前请确保你已具备基本 OpenClaw 操作经验;OCM 主要负责可视化更新 openclaw.json,方便增删主 Agent 与 Sub-Agent,并支持多条 Agent 树。</li>
<li data-i18n="dash.notice3">BotFather 中请确认 Allow Groups = ON 且 Group Privacy = OFF,否则 Sub-Agent 可能无法入组或无法响应。</li>
</ul>
<div class="warn-box" style="margin-top:10px" data-i18n="dash.noticeWarn">⚠️ 强烈建议:每个 Agent 群组只保留你自己和该 Agent(或其 Sub-Agent)。不要邀请其他人,把每个组当作私聊空间。</div>
</div>
</div> </div>
<!-- ══ Agents ════════════════════════════════════════════════ --> <!-- ══ Agents ════════════════════════════════════════════════ -->
<div class="panel" id="panel-agents"> <div class="panel" id="panel-agents">
<div class="agents-split"> <div class="agents-top-btns">
<!-- Left: Add forms -->
<div class="agents-left">
<div class="agents-left-btns">
<button class="btn-primary" onclick="showAddForm('agent')" data-i18n="agents.addAgent"> Add Agent</button> <button class="btn-primary" onclick="showAddForm('agent')" data-i18n="agents.addAgent"> Add Agent</button>
<button class="btn-primary" onclick="showAddForm('sub')" data-i18n="agents.addSub"> Add Sub-Agent</button> <button class="btn-primary" onclick="showAddForm('sub')" data-i18n="agents.addSub"> Add Sub-Agent</button>
</div> </div>
<div id="addFormArea"></div> <div id="addFormArea"></div>
</div> <div class="agents-tree-wrap">
<!-- Right: Agent tree -->
<div class="agents-right">
<div id="agentTree"><div class="empty" data-i18n="agents.empty">No Agents</div></div> <div id="agentTree"><div class="empty" data-i18n="agents.empty">No Agents</div></div>
</div> </div>
</div> </div>
</div>
<!-- ══ Channels ══════════════════════════════════════════════ --> <!-- ══ Channels ══════════════════════════════════════════════ -->
<div class="panel" id="panel-channels"> <div class="panel" id="panel-channels">
@@ -1759,6 +1898,8 @@ const MAIN_HTML_BODY = String.raw`
<div class="panel" id="panel-models"> <div class="panel" id="panel-models">
<div class="sec-hdr"><h2 data-i18n="models.title">模型管理</h2></div> <div class="sec-hdr"><h2 data-i18n="models.title">模型管理</h2></div>
<p class="hint-text" style="margin-bottom:14px" data-i18n="models.hint">模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。</p> <p class="hint-text" style="margin-bottom:14px" data-i18n="models.hint">模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。</p>
<p class="hint-text" style="margin-top:-8px;margin-bottom:12px" data-i18n="models.onlyCliHint">模型下拉仅显示 openclaw models list 返回的模型。</p>
<div id="modelListWarn" class="warn-box" style="display:none;margin-bottom:10px"></div>
<div id="primaryModelWarn" class="warn-box" style="display:none;margin-bottom:10px"></div> <div id="primaryModelWarn" class="warn-box" style="display:none;margin-bottom:10px"></div>
<div class="card" style="margin-bottom:12px"> <div class="card" style="margin-bottom:12px">
<div class="card-row"><span style="font-size:13px;font-weight:600" data-i18n="models.primary">默认主模型</span><span class="badge main">primary</span></div> <div class="card-row"><span style="font-size:13px;font-weight:600" data-i18n="models.primary">默认主模型</span><span class="badge main">primary</span></div>
@@ -2106,6 +2247,14 @@ const I18N = {
'channels.title':'Channel 绑定','channels.add':' 添加绑定','channels.hint':'管理 Agent 与频道/群组的绑定关系。绑定顺序决定优先级。', 'channels.title':'Channel 绑定','channels.add':' 添加绑定','channels.hint':'管理 Agent 与频道/群组的绑定关系。绑定顺序决定优先级。',
'models.title':'模型管理','models.primary':'默认主模型','models.fallback':'Fallback 链', 'models.title':'模型管理','models.primary':'默认主模型','models.fallback':'Fallback 链',
'models.hint':'模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。', 'models.hint':'模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。',
'models.onlyCliHint':'模型下拉仅显示 openclaw models list 返回的模型。',
'models.modelListErr':'读取 openclaw models list 失败:',
'models.modelListEmpty':'未从 openclaw models list 解析到模型。请先运行 openclaw onboard 并确认模型可用。',
'dash.noticeTitle':'📌 Telegram 使用说明(重要)',
'dash.notice1':'OCM 主要面向 Telegram:通过群组绑定 Agent,让每个 Agent 拥有独立 Workspace / SOUL.md / MEMORY.md。',
'dash.notice2':'请确保你已有基本 OpenClaw 操作经验。OCM 主要负责可视化更新 openclaw.json,方便增删主 Agent 与 Sub-Agent,并支持多条 Agent 树。',
'dash.notice3':'BotFather 中请确认 Allow Groups = ON 且 Group Privacy = OFF,否则 Sub-Agent 可能无法入组或无法响应。',
'dash.noticeWarn':'⚠️ 强烈建议:每个 Agent 群组只保留你自己和该 Agent(或其 Sub-Agent)。不要邀请其他人,把每个组当作私聊空间。',
'auth.title':'认证配置','auth.configured':'已配置认证', 'auth.title':'认证配置','auth.configured':'已配置认证',
'auth.guide':'点击 Provider 查看认证步骤。认证需在终端完成,或使用下方 CLI 终端。', 'auth.guide':'点击 Provider 查看认证步骤。认证需在终端完成,或使用下方 CLI 终端。',
'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 并回车',
@@ -2144,9 +2293,13 @@ const I18N = {
'guide.agent.s2':'Send <code>/newbot</code> and follow prompts to name your Bot', 'guide.agent.s2':'Send <code>/newbot</code> and follow prompts to name your Bot',
'guide.agent.s3':'Copy the <code>Bot Token</code> from BotFather and paste below', 'guide.agent.s3':'Copy the <code>Bot Token</code> from BotFather and paste below',
'guide.agent.s4':'Send <code>/setprivacy</code> → select your Bot → click <code>Disable</code> (allow Bot to read group messages)', 'guide.agent.s4':'Send <code>/setprivacy</code> → select your Bot → click <code>Disable</code> (allow Bot to read group messages)',
'guide.sub.s1':'Add the Bot to your target Telegram group', 'guide.sub.s1':'在 BotFather 发送 <code>/newbot</code> 创建新 Bot,获取 Bot Token',
'guide.sub.s2':'Send a message in the group, find <code>peer.id</code> (negative number) in gateway logs', 'guide.sub.s2':'在 BotFather 发送 <code>/mybots</code> → 选择 Bot → <b>Bot Settings</b> → <b>Group Privacy</b> → <b>Turn off</b>',
'guide.sub.s3':'Fill in the Group ID and Agent config below', 'guide.sub.s3':'在 Telegram 创建新群组,将 Bot 加入群组(<b>不要加其他人</b>',
'guide.sub.s4':'在群内发一条消息,从 gateway 日志中找到 <code>peer.id</code>(负数)',
'guide.sub.s5':'填写下方表单创建 Sub-Agent',
'guide.sub.warn':'⚠️ 安全提示:请勿将其他人加入此群组,只有你和 Bot 应在群内。否则其他人也能与 Bot 对话并产生 API 费用。',
'wiz.telegramId':'你的 Telegram User ID','wiz.telegramIdHint':'💡 可通过 @userinfobot 获取,填写后自动配置 allowFrom 白名单','wiz.telegramIdPh':'例如: 123456789',
'agents.empty':'暂无 Agent','agents.main':'主 Agent','agents.bound':'已绑群', 'agents.empty':'暂无 Agent','agents.main':'主 Agent','agents.bound':'已绑群',
'agents.saveModel':'保存模型','agents.viewFiles':'查看文件', 'agents.saveModel':'保存模型','agents.viewFiles':'查看文件',
'agents.defaultModel':'使用全局默认','agents.custom':'自定义','agents.noModel':'默认', 'agents.defaultModel':'使用全局默认','agents.custom':'自定义','agents.noModel':'默认',
@@ -2213,6 +2366,14 @@ const I18N = {
'channels.title':'Channel Bindings','channels.add':' Add Binding','channels.hint':'Manage Agent to channel/group bindings. Order determines priority.', 'channels.title':'Channel Bindings','channels.add':' Add Binding','channels.hint':'Manage Agent to channel/group bindings. Order determines priority.',
'models.title':'Model Management','models.primary':'Default Primary Model','models.fallback':'Fallback Chain', 'models.title':'Model Management','models.primary':'Default Primary Model','models.fallback':'Fallback Chain',
'models.hint':'Models are registered via openclaw onboard. Manage primary model and fallback chain here.', 'models.hint':'Models are registered via openclaw onboard. Manage primary model and fallback chain here.',
'models.onlyCliHint':'Model dropdowns only show IDs returned by openclaw models list.',
'models.modelListErr':'Failed to load openclaw models list: ',
'models.modelListEmpty':'No model IDs were parsed from openclaw models list. Run openclaw onboard first.',
'dash.noticeTitle':'📌 Telegram Usage Notes (Important)',
'dash.notice1':'OCM is primarily for Telegram workflows: bind agents to groups so each agent has isolated Workspace / SOUL.md / MEMORY.md.',
'dash.notice2':'Basic OpenClaw CLI experience is required. OCM focuses on visual openclaw.json updates for easier main-agent/sub-agent management and multiple agent trees.',
'dash.notice3':'In BotFather, keep Allow Groups = ON and Group Privacy = OFF; otherwise sub-agents may fail to join or respond.',
'dash.noticeWarn':'⚠️ Strong recommendation: each agent group should include only you and that agent (or its sub-agents). Treat each group like a private chat.',
'auth.title':'Auth Config','auth.configured':'Configured Auth', 'auth.title':'Auth Config','auth.configured':'Configured Auth',
'auth.guide':'Click a Provider for setup instructions. Auth is done in terminal or via the CLI panel below.', 'auth.guide':'Click a Provider for setup instructions. Auth is done in terminal or via the CLI panel below.',
'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',
@@ -2251,9 +2412,13 @@ const I18N = {
'guide.agent.s2':'Send <code>/newbot</code> and follow prompts to name your Bot', 'guide.agent.s2':'Send <code>/newbot</code> and follow prompts to name your Bot',
'guide.agent.s3':'Copy the <code>Bot Token</code> from BotFather and paste below', 'guide.agent.s3':'Copy the <code>Bot Token</code> from BotFather and paste below',
'guide.agent.s4':'Send <code>/setprivacy</code> → select your Bot → click <code>Disable</code> (allow Bot to read group messages)', 'guide.agent.s4':'Send <code>/setprivacy</code> → select your Bot → click <code>Disable</code> (allow Bot to read group messages)',
'guide.sub.s1':'Add the Bot to your target Telegram group', 'guide.sub.s1':'Send <code>/newbot</code> to BotFather to create a new Bot and get the Bot Token',
'guide.sub.s2':'Send a message in the group, find <code>peer.id</code> (negative number) in gateway logs', 'guide.sub.s2':'Send <code>/mybots</code> to BotFather → select your Bot → <b>Bot Settings</b> → <b>Group Privacy</b> → <b>Turn off</b>',
'guide.sub.s3':'Fill in the Group ID and Agent config below', 'guide.sub.s3':'Create a new Telegram group, add the Bot to the group (<b>do NOT add anyone else</b>)',
'guide.sub.s4':'Send a message in the group, find <code>peer.id</code> (negative number) in gateway logs',
'guide.sub.s5':'Fill in the form below to create the Sub-Agent',
'guide.sub.warn':'⚠️ Security: Do NOT add other people to this group. Only you and the Bot should be in the group. Otherwise others can chat with the Bot and incur API costs.',
'wiz.telegramId':'Your Telegram User ID','wiz.telegramIdHint':'💡 Get it from @userinfobot — auto-configures allowFrom whitelist','wiz.telegramIdPh':'e.g. 123456789',
'agents.empty':'No Agents','agents.main':'Main Agent','agents.bound':'Bound', 'agents.empty':'No Agents','agents.main':'Main Agent','agents.bound':'Bound',
'agents.saveModel':'Save Model','agents.viewFiles':'View Files', 'agents.saveModel':'Save Model','agents.viewFiles':'View Files',
'agents.defaultModel':'Use Global Default','agents.custom':'custom','agents.noModel':'Default', 'agents.defaultModel':'Use Global Default','agents.custom':'custom','agents.noModel':'Default',
@@ -2371,7 +2536,7 @@ const LANDING_TEXT = {
}, },
}; };
// ── 全局状态 ──────────────────────────────────────────────── // ── 全局状态 ────────────────────────────────────────────────
let S = { agents:[], channels:[], models:{}, authProfiles:{}, knownModels:[], authProviders:[], primaryModel:'', fallbacks:[] }; let S = { agents:[], channels:[], models:{}, authProfiles:{}, knownModels:[], modelListError:'', authProviders:[], primaryModel:'', fallbacks:[] };
let wizCur = 1; let wizCur = 1;
let logTimer = null; let logTimer = null;
let selectedAuthProv = null; let selectedAuthProv = null;
@@ -2391,24 +2556,56 @@ async function loadAll(){ await Promise.all([loadAgents(), loadModels(), loadCha
// ── Dashboard ───────────────────────────────────────────────── // ── Dashboard ─────────────────────────────────────────────────
let dashLoaded=false; let dashLoaded=false;
let dashRefreshTimer=null;
function fmtBytes(b){if(!b||b<=0)return '—';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<u.length-1){b/=1024;i++;}return b.toFixed(i>0?1:0)+' '+u[i];} function fmtBytes(b){if(!b||b<=0)return '—';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<u.length-1){b/=1024;i++;}return b.toFixed(i>0?1:0)+' '+u[i];}
function fmtUptime(s){const d=Math.floor(s/86400);const h=Math.floor((s%86400)/3600);const m=Math.floor((s%3600)/60);if(d>0)return d+'d '+h+'h '+m+'m';if(h>0)return h+'h '+m+'m';return m+'m';} function fmtUptime(s){const d=Math.floor(s/86400);const h=Math.floor((s%86400)/3600);const m=Math.floor((s%3600)/60);if(d>0)return d+'d '+h+'h '+m+'m';if(h>0)return h+'h '+m+'m';return m+'m';}
function dashRow(label,val){return '<div class="dash-row"><span class="dash-label">'+esc(label)+'</span><span class="dash-val">'+val+'</span></div>';} function dashRow(label,val){return '<div class="dash-row"><span class="dash-label">'+esc(label)+'</span><span class="dash-val">'+val+'</span></div>';}
function dashBar(pct){const cls=pct>90?'danger':pct>70?'warn':'';return '<div class="dash-bar-wrap"><div class="dash-bar '+cls+'" style="width:'+Math.min(pct,100)+'%"></div></div>';} function gaugeColor(pct){if(pct>90)return '#ef4444';if(pct>70)return '#f59e0b';return '#22c55e';}
function buildGaugeSVG(pct,label,sub,color){
const r=46,cx=55,cy=55,sw=8;
const circ=2*Math.PI*r;
const gap=circ*0.25;
const arc=circ-gap;
const filled=arc*(Math.min(pct,100)/100);
const rot=135;
if(!color)color=gaugeColor(pct);
return '<div class="dash-gauge-card">'+
'<svg class="dash-gauge-svg" viewBox="0 0 110 110">'+
'<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="'+sw+'" stroke-dasharray="'+arc+' '+gap+'" stroke-linecap="round" transform="rotate('+rot+' '+cx+' '+cy+')"/>'+
'<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="'+color+'" stroke-width="'+sw+'" stroke-dasharray="'+filled+' '+(circ-filled)+'" stroke-linecap="round" transform="rotate('+rot+' '+cx+' '+cy+')" style="transition:stroke-dasharray .6s ease"/>'+
'<text x="'+cx+'" y="'+cy+'" text-anchor="middle" dy="-2" fill="'+color+'" font-size="22" font-weight="700" font-family="-apple-system,sans-serif">'+Math.round(pct)+'%</text>'+
'<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="10" font-family="-apple-system,sans-serif">'+esc(sub)+'</text>'+
'</svg>'+
'<div class="dash-gauge-label">'+esc(label)+'</div></div>';
}
function toggleDashRefresh(on){
if(dashRefreshTimer){clearInterval(dashRefreshTimer);dashRefreshTimer=null;}
if(on){dashRefreshTimer=setInterval(loadDashboard,10000);}
}
async function loadDashboard(){ async function loadDashboard(){
try{ try{
const r=await api('GET','/api/dashboard'); const r=await api('GET','/api/dashboard');
if(!r.ok)return; if(!r.ok)return;
const s=r.system, g=r.gateway, a=r.agents; const s=r.system, g=r.gateway, a=r.agents;
// System card
const memPct=s.totalMem?((s.totalMem-s.freeMem)/s.totalMem*100):0; const memPct=s.totalMem?((s.totalMem-s.freeMem)/s.totalMem*100):0;
const diskPct=s.diskTotal?(s.diskUsed/s.diskTotal*100):0;
const cpuPct=(s.cpuPercent!==null&&s.cpuPercent!==undefined)?s.cpuPercent:0;
// Gauges
const memUsed=fmtBytes(s.totalMem-s.freeMem)+' / '+fmtBytes(s.totalMem);
const diskUsed=fmtBytes(s.diskUsed)+' / '+fmtBytes(s.diskTotal);
let gaugeHtml=buildGaugeSVG(cpuPct,'CPU',s.cpuCores+' cores');
gaugeHtml+=buildGaugeSVG(memPct,'RAM',memUsed);
gaugeHtml+=buildGaugeSVG(diskPct,'DISK',diskUsed);
document.getElementById('dashGauges').innerHTML=gaugeHtml;
// System info card
const loadStr=s.loadAvg?s.loadAvg.map(function(v){return v.toFixed(2);}).join(' / '):'—';
let sysHtml=dashRow('Hostname',esc(s.hostname)); let sysHtml=dashRow('Hostname',esc(s.hostname));
sysHtml+=dashRow('OS',esc(s.platform)); sysHtml+=dashRow('OS',esc(s.platform));
sysHtml+=dashRow('Node.js',esc(s.nodeVer)); sysHtml+=dashRow('Node.js',esc(s.nodeVer));
sysHtml+=dashRow('CPU',esc(s.cpuModel)+' ('+s.cpuCores+' cores)'); sysHtml+=dashRow('CPU',esc(s.cpuModel));
sysHtml+=dashRow('Cores',String(s.cpuCores));
sysHtml+=dashRow('Uptime',fmtUptime(s.uptime)); sysHtml+=dashRow('Uptime',fmtUptime(s.uptime));
sysHtml+=dashRow('Memory',fmtBytes(s.totalMem-s.freeMem)+' / '+fmtBytes(s.totalMem)+' ('+memPct.toFixed(0)+'%)'); sysHtml+=dashRow('Load Avg (1/5/15m)',loadStr);
sysHtml+=dashBar(memPct);
document.getElementById('dashSysItems').innerHTML=sysHtml; document.getElementById('dashSysItems').innerHTML=sysHtml;
// Gateway card // Gateway card
const statusIcon='<span class="dash-indicator '+esc(g.status)+'"></span>'; const statusIcon='<span class="dash-indicator '+esc(g.status)+'"></span>';
@@ -2419,7 +2616,9 @@ async function loadDashboard(){
gwHtml+=dashRow('HTTP Ping',g.ping?'<span style="color:#22c55e">✓ Reachable</span>':'<span style="color:#ef4444">✗ Unreachable</span>'); gwHtml+=dashRow('HTTP Ping',g.ping?'<span style="color:#22c55e">✓ Reachable</span>':'<span style="color:#ef4444">✗ Unreachable</span>');
document.getElementById('dashGwItems').innerHTML=gwHtml; document.getElementById('dashGwItems').innerHTML=gwHtml;
// Agents card // Agents card
let agHtml=dashRow('Total Agents',String(a.count)); let agHtml=dashRow('Main Agents',String(a.mainCount||0));
agHtml+=dashRow('Sub-Agents',String(a.subCount||0));
agHtml+=dashRow('Total',String(a.count));
if(a.lastActivity){ if(a.lastActivity){
const d=new Date(a.lastActivity); const d=new Date(a.lastActivity);
agHtml+=dashRow('Last Activity',d.toLocaleString('en-AU',{timeZone:'Australia/Brisbane',hour12:false})); agHtml+=dashRow('Last Activity',d.toLocaleString('en-AU',{timeZone:'Australia/Brisbane',hour12:false}));
@@ -2430,13 +2629,8 @@ async function loadDashboard(){
agHtml+=dashRow('Server Time',esc(r.serverTime)); agHtml+=dashRow('Server Time',esc(r.serverTime));
document.getElementById('dashAgentItems').innerHTML=agHtml; document.getElementById('dashAgentItems').innerHTML=agHtml;
// Storage card // Storage card
let stHtml=dashRow('OpenClaw Dir Size',fmtBytes(s.dirSize)); let stHtml=dashRow('OpenClaw Dir',fmtBytes(s.dirSize));
if(s.diskTotal>0){
const diskPct=s.diskUsed/s.diskTotal*100;
stHtml+=dashRow('Disk',fmtBytes(s.diskUsed)+' / '+fmtBytes(s.diskTotal)+' ('+diskPct.toFixed(0)+'%)');
stHtml+=dashBar(diskPct);
stHtml+=dashRow('Disk Free',fmtBytes(s.diskFree)); stHtml+=dashRow('Disk Free',fmtBytes(s.diskFree));
}
document.getElementById('dashStorageItems').innerHTML=stHtml; document.getElementById('dashStorageItems').innerHTML=stHtml;
dashLoaded=true; dashLoaded=true;
}catch(e){console.error('Dashboard load error:',e);} }catch(e){console.error('Dashboard load error:',e);}
@@ -2480,6 +2674,7 @@ async function loadModels(){
const r=await api('GET','/api/models'); const r=await api('GET','/api/models');
S.models=r.models||{}; S.authProfiles=r.authProfiles||{}; S.models=r.models||{}; S.authProfiles=r.authProfiles||{};
S.knownModels=r.knownModels||[]; S.authProviders=r.authProviders||[]; S.knownModels=r.knownModels||[]; S.authProviders=r.authProviders||[];
S.modelListError=r.modelListError||'';
S.primaryModel=r.primaryModel||''; S.fallbacks=r.fallbacks||[]; S.primaryModel=r.primaryModel||''; S.fallbacks=r.fallbacks||[];
renderModels(); renderAuth(); buildModelDropdowns(); renderModels(); renderAuth(); buildModelDropdowns();
}catch(e){ toast('加载模型失败: '+e.message,'error'); } }catch(e){ toast('加载模型失败: '+e.message,'error'); }
@@ -2528,6 +2723,11 @@ function buildAddAgentForm() {
'<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>' +
'<div class="form-group"><label>' + t('wiz.purpose') + '</label>' +
'<textarea id="fa-purpose" placeholder="' + t('wiz.purposePh') + '" rows="2"></textarea></div>' +
'<div class="form-group"><label>' + t('wiz.soul') + ' <span style="color:var(--muted);font-weight:400">' + t('wiz.soulHint') + '</span></label>' +
'<input id="fa-soul" placeholder="' + t('wiz.soulPh') + '">' +
'<span class="hint-text">' + t('wiz.soulTip') + '</span></div>' +
'<div style="display:flex;gap:8px;margin-top:14px">' + '<div style="display:flex;gap:8px;margin-top:14px">' +
'<button class="btn-primary" onclick="submitAddAgent()">' + t('agents.addAgentSubmit') + '</button>' + '<button class="btn-primary" onclick="submitAddAgent()">' + t('agents.addAgentSubmit') + '</button>' +
'<button class="btn-ghost" onclick="clearAddForm()">' + t('btn.cancel') + '</button>' + '<button class="btn-ghost" onclick="clearAddForm()">' + t('btn.cancel') + '</button>' +
@@ -2553,12 +2753,19 @@ function buildAddSubForm() {
'<li>' + t('guide.sub.s1') + '</li>' + '<li>' + t('guide.sub.s1') + '</li>' +
'<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>' + '<li>' + t('guide.sub.s4') + '</li>' +
'<li>' + t('guide.sub.s5') + '</li>' +
'</ol>' +
'<div style="margin-top:10px;padding:10px 12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:8px;font-size:13px;line-height:1.5">' + t('guide.sub.warn') + '</div>' +
'</details>' +
'<div class="form-group"><label>Parent Agent</label>' + '<div class="form-group"><label>Parent Agent</label>' +
'<select id="fs-parent">' + parentOpts + '</select></div>' + '<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>' +
'<div class="form-group"><label>' + t('wiz.telegramId') + '</label>' +
'<input id="fs-tgid" placeholder="' + t('wiz.telegramIdPh') + '">' +
'<span class="hint-text">' + t('wiz.telegramIdHint') + '</span></div>' +
'<div class="form-row">' + '<div class="form-row">' +
'<div class="form-group"><label>' + t('wiz.agentId') + ' <span style="color:var(--muted);font-weight:400">' + t('wiz.agentIdHint') + '</span></label>' + '<div class="form-group"><label>' + t('wiz.agentId') + ' <span style="color:var(--muted);font-weight:400">' + t('wiz.agentIdHint') + '</span></label>' +
'<input id="fs-aid" placeholder="' + t('wiz.agentIdPh') + '"></div>' + '<input id="fs-aid" placeholder="' + t('wiz.agentIdPh') + '"></div>' +
@@ -2585,13 +2792,15 @@ async function submitAddAgent() {
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 workspace = document.getElementById('fa-workspace').value.trim();
const model = document.getElementById('fa-model').value; const model = document.getElementById('fa-model').value;
const purpose = (document.getElementById('fa-purpose')?.value||'').trim();
const personality = (document.getElementById('fa-soul')?.value||'').trim();
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 (!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 (!/^[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; } if (!workspace) { toast('Workspace name is required', 'err'); return; }
try { try {
const payload = { botToken: token, agentId, name, workspace, model: model === '__default__' ? '' : model }; const payload = { botToken: token, agentId, name, workspace, model: model === '__default__' ? '' : model, purpose, personality };
const r = await api('POST', '/api/agents/bot', payload); const r = await api('POST', '/api/agents/bot', payload);
toast('Agent created successfully', 'ok'); toast('Agent created successfully', 'ok');
closePopover(); closePopover();
@@ -2606,6 +2815,7 @@ async function submitAddAgent() {
async function submitAddSub() { async function submitAddSub() {
const parentAgentId = document.getElementById('fs-parent').value.trim(); 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 telegramUserId = (document.getElementById('fs-tgid')?.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();
const model = document.getElementById('fs-model').value; const model = document.getElementById('fs-model').value;
@@ -2616,8 +2826,9 @@ async function submitAddSub() {
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 (telegramUserId && !/^\\d+$/.test(telegramUserId)) { toast('Telegram User ID must be a number', 'err'); return; }
try { try {
const r = await api('POST', '/api/agents', { parentAgentId, 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, telegramUserId });
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 = '';
@@ -2631,63 +2842,93 @@ function renderAgents() {
const el = document.getElementById('agentTree'); const el = document.getElementById('agentTree');
if (!S.agents.length) { el.innerHTML = '<div class="empty">' + t('agents.empty') + '</div>'; return; } if (!S.agents.length) { el.innerHTML = '<div class="empty">' + t('agents.empty') + '</div>'; return; }
// Build tree: main agent as root, subagents as children // Build tree: each hasOwnBot agent is a root, others are sub-agents grouped by parentAccountId
const mainAgent = S.agents.find(a => a.id === 'main'); const roots = S.agents.filter(a => a.hasOwnBot);
const subs = S.agents.filter(a => a.id !== 'main'); const subs = S.agents.filter(a => !a.hasOwnBot);
let html = ''; // Map accountId -> root agent for sub-agent grouping
const rootByAccount = {};
roots.forEach(a => { if (a.accountId) rootByAccount[a.accountId] = a; });
// Main agent tree // Group subs under their parent
if (mainAgent) { const subsByRoot = {};
const orphanSubs = [];
subs.forEach(a => {
const parentAcct = a.parentAccountId;
if (parentAcct && rootByAccount[parentAcct]) {
const rootId = rootByAccount[parentAcct].id;
if (!subsByRoot[rootId]) subsByRoot[rootId] = [];
subsByRoot[rootId].push(a);
} else {
orphanSubs.push(a);
}
});
function agentCard(a, isRoot) {
let h = '';
const icon = isRoot ? '🤖' : '📱';
const cls = isRoot ? 'tree-main' : 'tree-child';
h += '<div class="' + cls + '">';
h += '<div class="tree-title">' + icon + ' ' + esc(a.name || a.id);
if (isRoot) h += ' <span class="badge main">' + (a.id === 'main' ? t('agents.main') : 'Bot') + '</span>';
if (a.groupId) h += ' <span class="badge ok">' + esc(a.groupId) + '</span>';
h += '</div>';
h += '<div class="tree-meta">🧠 ' + esc(a.effectiveModel) + '</div>';
if (a.workspace) h += '<div class="tree-meta">📁 ' + esc(a.workspace) + '</div>';
h += '<div class="tree-actions">';
h += '<select class="inline-sel" id="msel-' + a.id + '">' + buildModelOpts(a.effectiveModel !== t('agents.noModel') ? a.effectiveModel : '__default__') + '</select>';
h += '<button class="btn-secondary" data-action="saveModel" data-id="' + esc(a.id) + '">' + t('agents.saveModel') + '</button>';
h += '<button class="btn-secondary" data-action="viewWs" data-id="' + esc(a.id) + '">' + t('agents.viewFiles') + '</button>';
if (a.id !== 'main') h += '<button class="btn-danger" data-action="delAgent" data-id="' + esc(a.id) + '" data-name="' + esc(a.name || a.id) + '">' + t('btn.delete') + '</button>';
h += '</div></div>';
return h;
}
let html = '<div class="agents-roots">';
// Render each root with its children, side by side
roots.forEach(root => {
const children = subsByRoot[root.id] || [];
html += '<div class="agent-tree-root">'; html += '<div class="agent-tree-root">';
html += '<div class="tree-main">'; html += agentCard(root, true);
html += '<div class="tree-title">🤖 ' + esc(mainAgent.name || 'main') + ' <span class="badge main">' + t('agents.main') + '</span></div>'; if (children.length) {
html += '<div class="tree-meta">🧠 ' + esc(mainAgent.effectiveModel) + '</div>'; const treeId = 'tree-' + root.id;
if (mainAgent.workspace) html += '<div class="tree-meta">📁 ' + esc(mainAgent.workspace) + '</div>'; html += '<div class="tree-children-wrap">';
html += '<div class="tree-actions">'; html += '<button class="tree-toggle" onclick="toggleTree(\\'' + treeId + '\\',this)" title="Expand / Collapse"></button>';
html += '<select class="inline-sel" id="msel-main" onchange="">' + buildModelOpts(mainAgent.effectiveModel !== t('agents.noModel') ? mainAgent.effectiveModel : '__default__') + '</select>'; html += '<div class="tree-children" id="' + treeId + '">';
html += '<button class="btn-secondary" onclick="saveAgentModel(\\'main\\')">' + t('agents.saveModel') + '</button>'; children.forEach(c => { html += agentCard(c, false); });
html += '</div></div>'; html += '</div></div>';
// Sub-agents as children
if (subs.length) {
html += '<div class="tree-children">';
subs.forEach(a => {
html += '<div class="tree-child">';
html += '<div class="tree-title">📱 ' + esc(a.name || a.id);
if (a.groupId) html += ' <span class="badge ok">' + esc(a.groupId) + '</span>';
html += '</div>';
html += '<div class="tree-meta">🧠 ' + esc(a.effectiveModel) + '</div>';
if (a.workspace) html += '<div class="tree-meta">📁 ' + esc(a.workspace) + '</div>';
html += '<div class="tree-actions">';
html += '<select class="inline-sel" id="msel-' + a.id + '" onchange="">' + buildModelOpts(a.effectiveModel !== t('agents.noModel') ? a.effectiveModel : '__default__') + '</select>';
html += '<button class="btn-secondary" onclick="saveAgentModel(\\'' + a.id + '\\')">' + t('agents.saveModel') + '</button>';
html += '<button class="btn-secondary" onclick="viewWorkspace(\\'' + a.id + '\\')">' + t('agents.viewFiles') + '</button>';
html += '<button class="btn-danger" onclick="deleteAgent(\\'' + a.id + '\\',\\'' + esc(a.name || a.id) + '\\')">' + t('btn.delete') + '</button>';
html += '</div></div>';
});
html += '</div>';
} }
html += '</div>'; html += '</div>';
} else if (subs.length) {
// No main agent but has subs (edge case)
subs.forEach(a => {
html += '<div class="agent-tree-root"><div class="tree-child">';
html += '<div class="tree-title">📱 ' + esc(a.name || a.id);
if (a.groupId) html += ' <span class="badge ok">' + esc(a.groupId) + '</span>';
html += '</div>';
html += '<div class="tree-meta">🧠 ' + esc(a.effectiveModel) + '</div>';
if (a.workspace) html += '<div class="tree-meta">📁 ' + esc(a.workspace) + '</div>';
html += '<div class="tree-actions">';
html += '<select class="inline-sel" id="msel-' + a.id + '" onchange="">' + buildModelOpts(a.effectiveModel !== t('agents.noModel') ? a.effectiveModel : '__default__') + '</select>';
html += '<button class="btn-secondary" onclick="saveAgentModel(\\'' + a.id + '\\')">' + t('agents.saveModel') + '</button>';
html += '<button class="btn-secondary" onclick="viewWorkspace(\\'' + a.id + '\\')">' + t('agents.viewFiles') + '</button>';
html += '<button class="btn-danger" onclick="deleteAgent(\\'' + a.id + '\\',\\'' + esc(a.name || a.id) + '\\')">' + t('btn.delete') + '</button>';
html += '</div></div></div>';
}); });
}
// Orphan subs (no matching root — edge case)
orphanSubs.forEach(a => {
html += '<div class="agent-tree-root">';
html += agentCard(a, false);
html += '</div>';
});
html += '</div>';
el.innerHTML = html; el.innerHTML = html;
// Event delegation for agent tree buttons
el.onclick = function(ev) {
const btn = ev.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (action === 'saveModel') saveAgentModel(id);
else if (action === 'viewWs') viewWorkspace(id);
else if (action === 'delAgent') deleteAgent(id, btn.dataset.name);
};
}
function toggleTree(treeId, btn) {
const el = document.getElementById(treeId);
if (!el) return;
el.classList.toggle('collapsed');
btn.textContent = el.classList.contains('collapsed') ? '+' : '';
} }
function buildModelOpts(selected){ function buildModelOpts(selected){
@@ -2702,10 +2943,6 @@ function buildModelOpts(selected){
opts+=\`<option value="\${m.id}"\${selected===m.id?' selected':''}>\${m.label}</option>\`; opts+=\`<option value="\${m.id}"\${selected===m.id?' selected':''}>\${m.label}</option>\`;
}); });
if(lastGroup) opts+=\`</optgroup>\`; if(lastGroup) opts+=\`</optgroup>\`;
Object.keys(S.models).forEach(id=>{
if(!S.knownModels.find(k=>k.id===id))
opts+=\`<option value="\${id}"\${selected===id?' selected':''}>\${id} (\${t('agents.custom')})</option>\`;
});
return opts; return opts;
} }
@@ -2771,6 +3008,19 @@ async function deleteChannel(idx,label){
// ── 渲染模型 ───────────────────────────────────────────────── // ── 渲染模型 ─────────────────────────────────────────────────
function renderModels(){ function renderModels(){
const listWarn=document.getElementById('modelListWarn');
if(listWarn){
if(S.modelListError){
listWarn.style.display='';
listWarn.textContent=t('models.modelListErr')+S.modelListError;
}else if(!S.knownModels.length){
listWarn.style.display='';
listWarn.textContent=t('models.modelListEmpty');
}else{
listWarn.style.display='none';
listWarn.textContent='';
}
}
// 检测 primary model 是否是 API Key(显示修复警告) // 检测 primary model 是否是 API Key(显示修复警告)
const primWarn=document.getElementById('primaryModelWarn'); const primWarn=document.getElementById('primaryModelWarn');
if(primWarn){ if(primWarn){
@@ -2787,7 +3037,7 @@ function renderModels(){
} }
} }
const pSel=document.getElementById('primaryModelSel'); const pSel=document.getElementById('primaryModelSel');
pSel.innerHTML=''; pSel.innerHTML='<option value="">'+(lang==='en'?'(no models loaded)':'(未加载到模型)')+'</option>';
S.knownModels.filter(m=>m.id!=='__default__').forEach(m=>{ S.knownModels.filter(m=>m.id!=='__default__').forEach(m=>{
pSel.innerHTML+=\`<option value="\${m.id}"\${S.primaryModel===m.id?' selected':''}>\${m.label}</option>\`; pSel.innerHTML+=\`<option value="\${m.id}"\${S.primaryModel===m.id?' selected':''}>\${m.label}</option>\`;
}); });
@@ -2828,7 +3078,7 @@ async function savePrimaryModel(){
// 修复被错误设置为 API Key 的主模型 → 重置为第一个已注册模型或留空 // 修复被错误设置为 API Key 的主模型 → 重置为第一个已注册模型或留空
async function fixBadPrimaryModel(){ async function fixBadPrimaryModel(){
// 尝试从已注册模型中取第一个可用 ID // 尝试从已注册模型中取第一个可用 ID
const firstModel = Object.keys(S.models||{}).find(k => isValidModelId(k)); const firstModel = (S.knownModels||[]).map(m=>m.id).find(k => isValidModelId(k));
const resetTo = firstModel || ''; const resetTo = firstModel || '';
if(!confirm(\`将主模型重置为"\${resetTo||'(清空,使用全局默认)'}"\`)) return; if(!confirm(\`将主模型重置为"\${resetTo||'(清空,使用全局默认)'}"\`)) return;
try{ try{
@@ -2982,7 +3232,7 @@ async function refreshAuthOnly(){
try{ try{
const r=await api('GET','/api/models'); const r=await api('GET','/api/models');
S.authProfiles=r.authProfiles||{}; S.authProfiles=r.authProfiles||{};
S.models=r.models||{}; S.knownModels=r.knownModels||[]; S.models=r.models||{}; S.knownModels=r.knownModels||[]; S.modelListError=r.modelListError||'';
renderAuth(); buildModelDropdowns(); renderAuth(); buildModelDropdowns();
}catch(e){ toast((lang==='en'?'Refresh failed: ':'刷新失败: ')+e.message,'error'); } }catch(e){ toast((lang==='en'?'Refresh failed: ':'刷新失败: ')+e.message,'error'); }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "openclaw-manager", "name": "openclaw-manager",
"version": "0.5.0", "version": "0.7.1",
"description": "A local web UI for managing OpenClaw AI agents — no npm install required", "description": "A local web UI for managing OpenClaw AI agents — no npm install required",
"main": "openclaw-manager.js", "main": "openclaw-manager.js",
"scripts": { "scripts": {
+2 -2
View File
@@ -1,6 +1,6 @@
@echo off @echo off
:: ================================================================ :: ================================================================
:: OpenClaw Manager v0.6.0 — Start Script (Windows) :: OpenClaw Manager v0.7.1 — Start Script (Windows)
:: ::
:: Features: :: Features:
:: * Auto-detect Node.js, show install instructions if missing :: * Auto-detect Node.js, show install instructions if missing
@@ -19,7 +19,7 @@ set "MIN_NODE_MAJOR=18"
echo. echo.
echo =================================== echo ===================================
echo OpenClaw Manager v0.6.0 echo OpenClaw Manager v0.7.1
echo ----------------------------------- echo -----------------------------------
echo. echo.
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ================================================================ # ================================================================
# OpenClaw Manager v0.6.0 — Start Script (macOS / Linux) # OpenClaw Manager v0.7.1 — Start Script (macOS / Linux)
# #
# Features: # Features:
# - Auto-detect Node.js, show install instructions if missing # - Auto-detect Node.js, show install instructions if missing
@@ -22,7 +22,7 @@ CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; RESET='\033[0m'
banner() { banner() {
echo "" echo ""
echo -e "${CYAN}${BOLD} 🦀 OpenClaw Manager v0.6.0${RESET}" echo -e "${CYAN}${BOLD} 🦀 OpenClaw Manager v0.7.1${RESET}"
echo -e "${DIM} ─────────────────────────────${RESET}" echo -e "${DIM} ─────────────────────────────${RESET}"
echo "" echo ""
} }