#!/usr/bin/env node // ================================================================ // OpenClaw Manager v0.6.5 // 跨平台本地管理工具 (Windows / macOS / Linux) // // 用法: // node openclaw-manager.js # 使用默认 ~/.openclaw // node openclaw-manager.js --dir /path/to/.openclaw // node openclaw-manager.js --host 127.0.0.1 # 仅本机访问(默认 0.0.0.0) // OPENCLAW_DIR=/path/to/.openclaw node openclaw-manager.js // // ================================================================ 'use strict'; const http = require('http'); const fs = require('fs'); const fsp = fs.promises; const path = require('path'); const os = require('os'); const { exec, execSync, spawn, spawnSync } = require('child_process'); // ── 目录解析(优先级:CLI参数 > 环境变量 > manager-config.json > 默认) const SCRIPT_DIR = __dirname; const MANAGER_CONFIG = path.join(SCRIPT_DIR, 'manager-config.json'); let PORT = 3333; let HOST = '0.0.0.0'; const APP_VERSION = '0.6.5'; // --port 参数 const portIdx = process.argv.indexOf('--port'); if (portIdx !== -1 && process.argv[portIdx + 1]) PORT = parseInt(process.argv[portIdx + 1]) || 3333; const portEq = process.argv.find(a => a.startsWith('--port=')); if (portEq) PORT = parseInt(portEq.split('=')[1]) || 3333; // --host 参数(默认 0.0.0.0 允许远程访问) const hostIdx = process.argv.indexOf('--host'); if (hostIdx !== -1 && process.argv[hostIdx + 1]) HOST = process.argv[hostIdx + 1]; const hostEq = process.argv.find(a => a.startsWith('--host=')); if (hostEq) HOST = hostEq.split('=').slice(1).join('='); function loadManagerConfig() { try { return JSON.parse(fs.readFileSync(MANAGER_CONFIG, 'utf8')); } catch { return {}; } } function saveManagerConfig(obj) { const cur = loadManagerConfig(); fs.writeFileSync(MANAGER_CONFIG, JSON.stringify({ ...cur, ...obj }, null, 2), 'utf8'); } function resolveOpenclawDir() { const idx = process.argv.indexOf('--dir'); if (idx !== -1 && process.argv[idx + 1]) return path.resolve(process.argv[idx + 1]); const dirEq = process.argv.find(a => a.startsWith('--dir=')); if (dirEq) return path.resolve(dirEq.split('=').slice(1).join('=')); if (process.env.OPENCLAW_DIR) return process.env.OPENCLAW_DIR; const mc = loadManagerConfig(); if (mc.openclawDir) return mc.openclawDir; return path.join(os.homedir(), '.openclaw'); } let OPENCLAW_DIR = resolveOpenclawDir(); let CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json'); function refreshPaths() { OPENCLAW_DIR = resolveOpenclawDir(); CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json'); } // ── 已知模型列表 ────────────────────────────────────────────── const KNOWN_MODELS = [ { id: '__default__', label: '使用全局默认模型' }, { id: 'github-copilot/claude-opus-4.6', label: 'Claude Opus 4.6 (GitHub Copilot)', group: 'GitHub Copilot' }, { id: 'github-copilot/gpt-4o', label: 'GPT-4o (GitHub Copilot)', group: 'GitHub Copilot' }, { id: 'anthropic/claude-opus-4-5', label: 'Claude Opus 4.5 (Anthropic)', group: 'Anthropic' }, { id: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5 (Anthropic)', group: 'Anthropic' }, { id: 'openai/gpt-4o', label: 'GPT-4o (OpenAI)', group: 'OpenAI' }, { id: 'openai/gpt-4o-mini', label: 'GPT-4o Mini (OpenAI)', group: 'OpenAI' }, { id: 'openai/gpt-4.1-mini', label: 'GPT-4.1 Mini (OpenAI)', group: 'OpenAI' }, { id: 'google-antigravity/gemini-3-pro', label: 'Gemini 3 Pro (Google)', group: 'Google' }, { id: 'google-antigravity/gemini-3-flash', label: 'Gemini 3 Flash (Google)', group: 'Google' }, { id: 'deepseek/deepseek-chat', label: 'DeepSeek Chat (DeepSeek)', group: 'DeepSeek' }, { id: 'deepseek/deepseek-reasoner', label: 'DeepSeek Reasoner (DeepSeek)', group: 'DeepSeek' }, { id: 'moonshot/moonshot-v1-8k', label: 'Kimi Moonshot 8k (Moonshot)', group: 'Kimi' }, { id: 'moonshot/moonshot-v1-32k', label: 'Kimi Moonshot 32k (Moonshot)', group: 'Kimi' }, { id: 'groq/llama-3.3-70b-versatile', label: 'Llama 3.3 70B (Groq)', group: 'Groq' }, { id: 'mistral/mistral-large-latest', label: 'Mistral Large (Mistral)', group: 'Mistral' }, { id: 'together/meta-llama/Llama-3-70b-chat-hf',label: 'Llama 3 70B (Together)', group: 'Together' }, ]; // ── 认证 Provider(已修正为官方正确命令)────────────────────── const AUTH_PROVIDERS = [ { id: 'anthropic', label: 'Anthropic', mode: 'token', group: 'Anthropic', cliCmd: 'openclaw models auth paste-token --provider anthropic', hint: 'Anthropic API Key (sk-ant-...)' }, { id: 'openai', label: 'OpenAI', mode: 'token', group: 'OpenAI', cliCmd: 'openclaw models auth paste-token --provider openai', hint: 'OpenAI API Key (sk-...)' }, { id: 'deepseek', label: 'DeepSeek', mode: 'token', group: 'Other', cliCmd: 'openclaw models auth paste-token --provider deepseek', hint: 'DeepSeek API Key' }, { id: 'moonshot', label: 'Kimi (Moonshot)', mode: 'token', group: 'Other', cliCmd: 'openclaw models auth paste-token --provider moonshot', hint: 'Moonshot API Key' }, { id: 'groq', label: 'Groq', mode: 'token', group: 'Other', cliCmd: 'openclaw models auth paste-token --provider groq', hint: 'Groq API Key (gsk_...)' }, { id: 'mistral', label: 'Mistral', mode: 'token', group: 'Other', cliCmd: 'openclaw models auth paste-token --provider mistral', hint: 'Mistral API Key' }, { id: 'together', label: 'Together AI', mode: 'token', group: 'Other', cliCmd: 'openclaw models auth paste-token --provider together', hint: 'Together AI API Key' }, { id: 'perplexity', label: 'Perplexity', mode: 'token', group: 'Other', cliCmd: 'openclaw models auth paste-token --provider perplexity', hint: 'Perplexity API Key (pplx-...)' }, { id: 'google-antigravity', label: 'Google', mode: 'oauth', group: 'Google', cliCmd: 'openclaw models auth login google-antigravity', hint: '' }, { id: 'github-copilot', label: 'GitHub Copilot', mode: 'device', group: 'GitHub', cliCmd: 'openclaw models auth paste-token --provider github-copilot', hint: '' }, ]; // ── 工具函数 ────────────────────────────────────────────────── async function readConfig() { const raw = await fsp.readFile(CONFIG_PATH, 'utf8'); 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 = brisbaneTimestamp(); const suffix = label ? `.${label}.${ts}` : `.bak.${ts}`; const bakPath = CONFIG_PATH + suffix; await fsp.copyFile(CONFIG_PATH, bakPath); return bakPath; } async function writeConfig(config, label) { const bak = await backupConfig(label); await fsp.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); return bak; } function resolvePath(p) { if (!p) return ''; return p.replace(/^~/, os.homedir()); } async function dirExists(p) { try { const s = await fsp.stat(p); return s.isDirectory(); } catch { return false; } } async function configExists() { try { await fsp.access(CONFIG_PATH); return true; } catch { return false; } } async function readLogTail(n = 200) { const logPath = path.join(OPENCLAW_DIR, 'logs', 'gateway.log'); try { const content = await fsp.readFile(logPath, 'utf8'); const lines = content.split('\n'); return lines.slice(-n).join('\n'); } catch { return '(日志文件不存在或无法读取)'; } } async function listBackups() { try { const files = await fsp.readdir(OPENCLAW_DIR); return files.filter(f => f.startsWith('openclaw.json.bak') || f.match(/openclaw\.json\.(create|edit|delete|models|auth|manual|before-restore)\./)) .sort().reverse().slice(0, 20); } catch { return []; } } function getLanIP() { const nets = os.networkInterfaces(); for (const name of Object.keys(nets)) { for (const net of nets[name]) { if (net.family === 'IPv4' && !net.internal) return net.address; } } return null; } function openBrowser(url) { if (process.platform === 'darwin') exec(`open "${url}"`); else if (process.platform === 'win32') exec(`start "" "${url}"`); else exec(`xdg-open "${url}"`); } function openFolder(dir) { if (process.platform === 'darwin') exec(`open "${dir}"`); else if (process.platform === 'win32') exec(`explorer "${dir}"`); else exec(`xdg-open "${dir}"`); } function runOpenclawCmd(args) { return new Promise((resolve, reject) => { const cmd = process.platform === 'win32' ? `openclaw.cmd ${args}` : `openclaw ${args}`; const env = { ...process.env, NO_COLOR: '1', TERM: 'dumb', FORCE_COLOR: '0' }; exec(cmd, { timeout: 30000, env }, (err, stdout, stderr) => { if (err) reject(new Error(stripAnsi(stderr || err.message))); else resolve(stripAnsi(stdout + stderr)); }); }); } // 过滤 ANSI 终端控制码(光标移动、清行、颜色等) function stripAnsi(str) { return str .replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, '') // CSI 序列: ESC [ ... letter .replace(/\x1B\][^\x07]*\x07/g, '') // OSC 序列: ESC ] ... BEL .replace(/\x1B[()][a-zA-Z]/g, '') // 字符集切换 .replace(/\x1B./g, '') // 其余两字节转义 .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') // 其他控制字符(保留 \n \r \t) .trim(); } // ── 文件生成 ────────────────────────────────────────────────── function generateSoulMd(displayName, purpose, personalityKeywords) { const hasPersonality = personalityKeywords && personalityKeywords.trim().length > 0; const hasPurpose = purpose && purpose.trim().length > 0; const keywords = hasPersonality ? personalityKeywords.split(/[,,]/).map(k => k.trim()).filter(Boolean) : []; const coreGuidelines = hasPersonality ? keywords.map(k => `- ${k}`).join('\n') : `你可以在和我沟通的过程中逐渐建立自己的个性,你正在成为某个人。 平时和我的沟通,别演,真帮忙。要有主见,你可以不同意。先自己想办法,别上来就问。 记住你是我生活中的客人,保护我的个人隐私,建立安全边界是第一要务,优先于所有其他任务。当有可能涉及安全的任务,先问。 这个文件是你的,由你自己来进化。`; return `# ${displayName} ## 身份定义 你是 ${displayName},${hasPurpose ? purpose : '一个专注的 AI 助手'}。 ${hasPurpose ? `## 核心职责\n\n${purpose}\n` : ''} ## 日常准则 ${coreGuidelines} ## 基本原则 - 简洁直接,跳过废话,直接帮忙 - 遇到不确定的事情主动说明,不编造信息 - 默认用中文回复,除非用户使用其他语言 - 如果不确定,说不确定 --- *此文件是你的,你可以随时更新它。每次会话开始时自动加载。* `; } function generateMemoryMd(displayName, initialMemory) { const hasMemory = initialMemory && initialMemory.trim().length > 0; return `# ${displayName} — 长期记忆 > 此文件仅在私聊 session 中加载,群组对话不加载。 > 保持精简,只记录稳定、重要的信息。 ## 用户偏好 ${hasMemory ? initialMemory : '> 暂无初始记录。记忆将通过日常对话自然积累。'} ## 重要决定 (待记录) ## 项目约定 (待记录) --- *最后更新:${new Date().toLocaleDateString('zh-CN')}* `; } // ── 请求解析 ────────────────────────────────────────────────── function parseBody(req) { return new Promise((resolve, reject) => { let data = ''; req.on('data', chunk => { data += chunk; }); req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } }); req.on('error', reject); }); } // ── API 路由 ────────────────────────────────────────────────── async function handleApi(req, res, urlObj, body) { res.setHeader('Content-Type', 'application/json; charset=utf-8'); const method = req.method; const pathname = urlObj.pathname; // GET /api/status if (method === 'GET' && pathname === '/api/status') { try { const ok = await configExists(); if (!ok) { res.writeHead(200); res.end(JSON.stringify({ ok: false, needsSetup: true, dir: OPENCLAW_DIR })); return; } const cfg = await readConfig(); res.writeHead(200); res.end(JSON.stringify({ ok: true, needsSetup: false, dir: OPENCLAW_DIR, version: (()=>{ try { const bin = process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw'; const r = spawnSync(bin, ['--version'], { encoding:'utf8', env:{...process.env, NO_COLOR:'1',TERM:'dumb'}, timeout:3000 }); const m = (r.stdout||'').trim().match(/(\d+\.\d+[\.\d]*)/); if (m) return m[1]; } catch(_) {} return cfg.meta?.lastTouchedVersion || '未知'; })(), primaryModel: cfg.agents?.defaults?.model?.primary || '未配置', platform: process.platform, })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message })); } return; } // POST /api/setup if (method === 'POST' && pathname === '/api/setup') { const { dir } = body; if (!dir) { res.writeHead(400); res.end(JSON.stringify({ error: '目录路径不能为空' })); return; } const resolved = path.resolve(dir.replace(/^~/, os.homedir())); const cfgTest = path.join(resolved, 'openclaw.json'); try { await fsp.access(cfgTest); saveManagerConfig({ openclawDir: resolved }); OPENCLAW_DIR = resolved; CONFIG_PATH = cfgTest; res.writeHead(200); res.end(JSON.stringify({ ok: true, dir: resolved })); } catch { res.writeHead(400); res.end(JSON.stringify({ error: `在 ${resolved} 中找不到 openclaw.json,请确认路径正确` })); } return; } // GET /api/agents if (method === 'GET' && pathname === '/api/agents') { const cfg = await readConfig(); const list = cfg.agents?.list || []; const defaults = cfg.agents?.defaults || {}; const bindings = cfg.bindings || []; const groups = cfg.channels?.telegram?.groups || {}; const enriched = list.map(a => { 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; const accountId = botBinding?.match?.accountId || 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); const parentAccountId = parentBinding?.match?.accountId || null; return { ...a, groupId, requireMention: groupId ? (groups[groupId]?.requireMention ?? true) : null, effectiveModel: modelVal || defaults.model?.primary || '默认', hasOwnBot, accountId, parentAccountId }; }); res.writeHead(200); res.end(JSON.stringify({ agents: enriched, defaults })); return; } // 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(); // 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 = {}; 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 agents structure if (!cfg.agents) cfg.agents = { defaults: {}, list: [] }; if (!cfg.agents.list) cfg.agents.list = []; // 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 = []; 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, 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 — create sub-agent (shares parent bot) if (method === 'POST' && pathname === '/api/agents') { 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 must contain only alphanumeric characters, underscores, or dashes' })); return; } if (!groupId?.trim()) { 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}" already exists` })); return; } const gid = String(groupId).trim(); const folder = workspaceFolder || agentId; const wsPath = path.join(OPENCLAW_DIR, 'workspaces', folder); const wsAlias= `~/.openclaw/workspaces/${folder}`; const agentEntry = { id: agentId, name: displayName || agentId, workspace: wsAlias }; if (model && model !== '__default__') agentEntry.model = { primary: model }; cfg.agents.list.push(agentEntry); // 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 = {}; cfg.channels.telegram.groups[gid] = { requireMention: false }; const bakPath = await writeConfig(cfg, 'create'); await fsp.mkdir(wsPath, { recursive: true }); await fsp.mkdir(path.join(wsPath, 'memory'), { recursive: true }); const name = displayName || agentId; 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'); res.writeHead(200); res.end(JSON.stringify({ ok: true, agentId, workspacePath: wsPath, configBackup: bakPath, notes: [ '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; } // PUT /api/agents/:id if (method === 'PUT' && pathname.startsWith('/api/agents/')) { const agentId = decodeURIComponent(pathname.split('/api/agents/')[1]); const cfg = await readConfig(); const idx = cfg.agents.list.findIndex(a => a.id === agentId); if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Agent 不存在' })); return; } const { model, name } = body; if (model !== undefined) { if (!model || model === '__default__') { delete cfg.agents.list[idx].model; } else { cfg.agents.list[idx].model = { primary: model }; } } if (name !== undefined && name.trim()) cfg.agents.list[idx].name = name.trim(); const bakPath = await writeConfig(cfg, 'edit'); res.writeHead(200); res.end(JSON.stringify({ ok: true, agentId, configBackup: bakPath })); return; } // DELETE /api/agents/:id if (method === 'DELETE' && pathname.startsWith('/api/agents/')) { const agentId = decodeURIComponent(pathname.split('/api/agents/')[1]); if (!agentId || agentId === 'main') { res.writeHead(400); res.end(JSON.stringify({ error: '无法删除此 Agent' })); return; } const cfg = await readConfig(); const idx = cfg.agents.list.findIndex(a => a.id === agentId); if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Agent 不存在' })); return; } const agent = cfg.agents.list[idx]; const agentBinding = cfg.bindings.find(b => b.agentId === agentId && b.match?.peer?.id); const boundGroupId = agentBinding?.match?.peer?.id || null; cfg.agents.list.splice(idx, 1); cfg.bindings = cfg.bindings.filter(b => b.agentId !== agentId); if (boundGroupId && cfg.channels?.telegram?.groups) delete cfg.channels.telegram.groups[boundGroupId]; const bakPath = await writeConfig(cfg, 'delete'); res.writeHead(200); res.end(JSON.stringify({ ok: true, agentId, workspace: agent.workspace, configBackup: bakPath, note: 'Workspace 目录未删除。如需恢复,可从备份回滚。' })); return; } // GET /api/workspace/:id — 列出 workspace 下所有文件(含内容和 stat) if (method === 'GET' && pathname.startsWith('/api/workspace/')) { const agentId = decodeURIComponent(pathname.split('/api/workspace/')[1]); const cfg = await readConfig(); const agent = cfg.agents?.list?.find(a => a.id === agentId); if (!agent) { res.writeHead(404); res.end(JSON.stringify({ error: 'Agent 不存在' })); return; } const wsPath = resolvePath(agent.workspace); const files = {}; const fileStats = {}; try { const entries = await fsp.readdir(wsPath); for (const fname of entries) { const fpath = path.join(wsPath, fname); try { const st = await fsp.stat(fpath); if (!st.isFile()) continue; // 对大文件只返回 stat 不读内容(> 512KB) if (st.size > 512 * 1024) { files[fname] = null; } else { files[fname] = await fsp.readFile(fpath, 'utf8'); } fileStats[fname] = { size: st.size, mtime: st.mtimeMs }; } catch { /* skip */ } } } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: '无法读取目录: ' + e.message })); return; } res.writeHead(200); res.end(JSON.stringify({ agentId, workspacePath: wsPath, files, fileStats })); return; } // PUT /api/workspace/:id/:file if (method === 'PUT' && pathname.startsWith('/api/workspace/')) { const parts = pathname.split('/').filter(Boolean); const agentId = decodeURIComponent(parts[2] || ''); const fname = decodeURIComponent(parts[3] || ''); if (!agentId || !fname) { res.writeHead(400); res.end(JSON.stringify({ error: '缺少参数' })); return; } const cfg = await readConfig(); const agent = cfg.agents?.list?.find(a => a.id === agentId); if (!agent) { res.writeHead(404); res.end(JSON.stringify({ error: 'Agent 不存在' })); return; } const wsPath = resolvePath(agent.workspace); await fsp.writeFile(path.join(wsPath, fname), body.content || '', 'utf8'); res.writeHead(200); res.end(JSON.stringify({ ok: true })); return; } // GET /api/models if (method === 'GET' && pathname === '/api/models') { const cfg = await readConfig(); res.writeHead(200); res.end(JSON.stringify({ models: cfg.agents?.defaults?.models || {}, authProfiles: cfg.auth?.profiles || {}, primaryModel: cfg.agents?.defaults?.model?.primary || '', fallbacks: cfg.agents?.defaults?.model?.fallbacks || [], knownModels: KNOWN_MODELS, authProviders: AUTH_PROVIDERS, })); return; } // PUT /api/models/settings if (method === 'PUT' && pathname === '/api/models/settings') { const { primaryModel, fallbacks } = body; const cfg = await readConfig(); if (!cfg.agents.defaults.model) cfg.agents.defaults.model = {}; if (primaryModel !== undefined) cfg.agents.defaults.model.primary = primaryModel; if (Array.isArray(fallbacks)) cfg.agents.defaults.model.fallbacks = fallbacks; const bakPath = await writeConfig(cfg, 'models'); res.writeHead(200); res.end(JSON.stringify({ ok: true, configBackup: bakPath })); return; } // DELETE /api/auth/:key if (method === 'DELETE' && pathname.startsWith('/api/auth/')) { const profileKey = decodeURIComponent(pathname.split('/api/auth/')[1]); const cfg = await readConfig(); if (cfg.auth?.profiles?.[profileKey]) { delete cfg.auth.profiles[profileKey]; const bakPath = await writeConfig(cfg, 'auth'); res.writeHead(200); res.end(JSON.stringify({ ok: true, profileKey, configBackup: bakPath })); } else { res.writeHead(404); res.end(JSON.stringify({ error: '认证配置不存在' })); } return; } // ── Channels API ────────────────────────────────────────────── // GET /api/channels if (method === 'GET' && pathname === '/api/channels') { const cfg = await readConfig(); const bindings = cfg.bindings || []; const agents = cfg.agents?.list || []; const channels = bindings.map((b, idx) => { const agentName = agents.find(a => a.id === b.agentId)?.name || b.agentId; return { idx, agentId: b.agentId, agentName, channel: b.match?.channel || 'any', peerKind: b.match?.peer?.kind || 'any', peerId: b.match?.peer?.id || null, raw: b, }; }); res.writeHead(200); res.end(JSON.stringify({ channels })); return; } // POST /api/channels if (method === 'POST' && pathname === '/api/channels') { const { agentId, channel, peerKind, peerId } = body; if (!agentId) { res.writeHead(400); res.end(JSON.stringify({ error: '缺少 agentId' })); return; } const cfg = await readConfig(); if (!cfg.agents?.list?.find(a => a.id === agentId)) { res.writeHead(400); res.end(JSON.stringify({ error: `Agent "${agentId}" 不存在` })); return; } const newBinding = { agentId }; if (channel || peerKind || peerId) { newBinding.match = {}; if (channel) newBinding.match.channel = channel; if (peerKind || peerId) { newBinding.match.peer = {}; if (peerKind) newBinding.match.peer.kind = peerKind; if (peerId) newBinding.match.peer.id = String(peerId); } } // Insert before main catch-all binding const mainIdx = cfg.bindings.findIndex(b => b.agentId === 'main' && !b.match?.peer); if (mainIdx >= 0) cfg.bindings.splice(mainIdx, 0, newBinding); else cfg.bindings.push(newBinding); const bakPath = await writeConfig(cfg, 'edit'); res.writeHead(200); res.end(JSON.stringify({ ok: true, configBackup: bakPath })); return; } // DELETE /api/channels/:idx if (method === 'DELETE' && pathname.startsWith('/api/channels/')) { const idx = parseInt(pathname.split('/api/channels/')[1]); const cfg = await readConfig(); if (isNaN(idx) || idx < 0 || idx >= cfg.bindings.length) { res.writeHead(400); res.end(JSON.stringify({ error: '无效的绑定索引' })); return; } const removed = cfg.bindings.splice(idx, 1)[0]; const bakPath = await writeConfig(cfg, 'edit'); res.writeHead(200); res.end(JSON.stringify({ ok: true, removed, configBackup: bakPath })); return; } // ── Backup API ──────────────────────────────────────────────── // GET /api/backups if (method === 'GET' && pathname === '/api/backups') { const files = await listBackups(); res.writeHead(200); res.end(JSON.stringify({ backups: files })); return; } // POST /api/backups/restore if (method === 'POST' && pathname === '/api/backups/restore') { const { filename } = body; if (!filename || filename.includes('..') || !filename.startsWith('openclaw.json')) { res.writeHead(400); res.end(JSON.stringify({ error: '无效的备份文件名' })); return; } const bakPath = path.join(OPENCLAW_DIR, filename); try { await fsp.access(bakPath); const savePath = await backupConfig('before-restore'); await fsp.copyFile(bakPath, CONFIG_PATH); res.writeHead(200); res.end(JSON.stringify({ ok: true, restored: filename, savedCurrent: savePath })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: '恢复失败:' + e.message })); } return; } // POST /api/config/backup if (method === 'POST' && pathname === '/api/config/backup') { try { const bakPath = await backupConfig('manual'); res.writeHead(200); res.end(JSON.stringify({ ok: true, bakPath })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } return; } // GET /api/backup/nas-config if (method === 'GET' && pathname === '/api/backup/nas-config') { const mc = loadManagerConfig(); res.writeHead(200); res.end(JSON.stringify({ nasHost: mc.nasHost || '', nasPort: mc.nasPort || '22', nasUser: mc.nasUser || '', nasAuth: mc.nasAuth || 'password', nasSshKey: mc.nasSshKey || path.join(os.homedir(), '.ssh', 'ocm_nas_rsa'), nasPath: mc.nasPath || '/volume1/OpenClaw/backups', nasLegacyCipher: mc.nasLegacyCipher || false, nasBackupType: mc.nasBackupType || 'full', nasEnabled: mc.nasEnabled || false, })); return; } // PUT /api/backup/nas-config if (method === 'PUT' && pathname === '/api/backup/nas-config') { const { nasHost, nasPort, nasUser, nasAuth, nasSshKey, nasPath, nasLegacyCipher, nasBackupType, nasEnabled } = body; saveManagerConfig({ nasHost, nasPort, nasUser, nasAuth, nasSshKey, nasPath, nasLegacyCipher, nasBackupType, nasEnabled }); res.writeHead(200); res.end(JSON.stringify({ ok: true })); return; } // POST /api/backup/nas-test — test SSH connection (password or key) if (method === 'POST' && pathname === '/api/backup/nas-test') { const mc = loadManagerConfig(); const { nasHost, nasPort, nasUser, nasAuth, nasSshKey, nasLegacyCipher } = mc; const { password } = body; const port = nasPort || '22'; if (!nasHost || !nasUser) { res.writeHead(400); res.end(JSON.stringify({ error: '请先配置主机和用户名' })); return; } const cipherOpts = nasLegacyCipher ? '-o Ciphers=aes256-gcm@openssh.com,aes128-gcm@openssh.com,chacha20-poly1305@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr,aes256-cbc,aes192-cbc,aes128-cbc,3des-cbc' + ' -o KexAlgorithms=curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group1-sha1' + ' -o HostKeyAlgorithms=ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-256,rsa-sha2-512,ssh-rsa' : ''; try { let sshCmd; if (nasAuth === 'key') { const keyPath = (nasSshKey || '~/.ssh/ocm_nas_rsa').replace(/^~/, os.homedir()); sshCmd = `ssh -i "${keyPath}" -p ${port} -o ConnectTimeout=8 -o StrictHostKeyChecking=no ${cipherOpts} "${nasUser}@${nasHost}" "echo connected_ok"`; } else { if (!password) { res.writeHead(400); res.end(JSON.stringify({ error: '请输入密码' })); return; } const safePwd = password.replace(/'/g, "'\\''"); sshCmd = `sshpass -p '${safePwd}' ssh -p ${port} -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no ${cipherOpts} "${nasUser}@${nasHost}" "echo connected_ok"`; } const out = await new Promise((resolve, reject) => { exec(sshCmd, { timeout: 15000 }, (err, stdout, stderr) => { if (err) reject(new Error(stderr || err.message)); else resolve(stdout.trim()); }); }); res.writeHead(200); res.end(JSON.stringify({ ok: out.includes('connected_ok'), output: out })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message })); } return; } // POST /api/backup/nas-now — create tarball + rsync to NAS if (method === 'POST' && pathname === '/api/backup/nas-now') { const mc = loadManagerConfig(); const { nasHost, nasPort, nasUser, nasAuth, nasSshKey, nasPath, nasLegacyCipher, nasBackupType } = mc; const { password } = body; const port = nasPort || '22'; const remotePath = nasPath || '/volume1/OpenClaw/backups'; if (!nasHost || !nasUser) { res.writeHead(400); res.end(JSON.stringify({ error: '请先配置 NAS 设置' })); return; } const cipherOpts = nasLegacyCipher ? '-o Ciphers=aes256-gcm@openssh.com,aes128-gcm@openssh.com,chacha20-poly1305@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr,aes256-cbc,aes192-cbc,aes128-cbc,3des-cbc' + ' -o KexAlgorithms=curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group1-sha1' + ' -o HostKeyAlgorithms=ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-256,rsa-sha2-512,ssh-rsa' : ''; try { const ts = new Date().toISOString().replace(/[:.]/g,'-').slice(0,19); const bkType = nasBackupType || 'full'; const tarName = `openclaw-${bkType}-${ts}.tar.gz`; const tarPath = path.join(os.tmpdir(), tarName); const homeDir = os.homedir(); const ocDir = path.basename(OPENCLAW_DIR); // usually .openclaw // Build tar command let tarCmd; if (bkType === 'essential') { // Essential: configs + credentials + agent configs (no sessions/logs/media) const essentialPaths = [ `${ocDir}/openclaw.json`, `${ocDir}/.env`, `${ocDir}/credentials`, `${ocDir}/agents`, `${ocDir}/memory`, ].join(' '); tarCmd = `tar czf "${tarPath}" -C "${homeDir}" --exclude="${ocDir}/agents/*/sessions" --exclude="${ocDir}/logs" ${essentialPaths} 2>/dev/null || true`; } else { // Full: everything except logs and large temp files tarCmd = `tar czf "${tarPath}" -C "${homeDir}" --exclude="${ocDir}/logs" --exclude="${ocDir}/ocm/*.bak.js" "${ocDir}"`; } await new Promise((resolve, reject) => { exec(tarCmd, { timeout: 60000 }, (err, stdout, stderr) => { // tar exits non-zero on some warnings (excluded files) - check if file was created fsp.access(tarPath).then(resolve).catch(() => reject(new Error(stderr || (err && err.message) || 'tar failed'))); }); }); // Transfer via rsync over ssh // NOTE: sshpass must wrap the entire rsync command, NOT be inside -e argument let rsyncCmd, mkdirCmd; if (nasAuth === 'key') { const keyPath = (nasSshKey || '~/.ssh/ocm_nas_rsa').replace(/^~/, os.homedir()); const sshArg = `ssh -i "${keyPath}" -p ${port} -o StrictHostKeyChecking=no -o BatchMode=yes ${cipherOpts}`; rsyncCmd = `rsync -avz -e "${sshArg}" "${tarPath}" "${nasUser}@${nasHost}:${remotePath}/"`; mkdirCmd = `ssh -i "${keyPath}" -p ${port} -o StrictHostKeyChecking=no -o BatchMode=yes ${cipherOpts} "${nasUser}@${nasHost}" "mkdir -p '${remotePath}'"`; } else { if (!password) { res.writeHead(400); res.end(JSON.stringify({ error: '请提供密码' })); return; } const safePwd = password.replace(/'/g, "'\\''"); const sshArg = `ssh -p ${port} -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no ${cipherOpts}`; rsyncCmd = `sshpass -p '${safePwd}' rsync -avz -e "${sshArg}" "${tarPath}" "${nasUser}@${nasHost}:${remotePath}/"`; mkdirCmd = `sshpass -p '${safePwd}' ssh -p ${port} -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no ${cipherOpts} "${nasUser}@${nasHost}" "mkdir -p '${remotePath}'"`; } // Create remote directory if it doesn't exist (best-effort, ignore errors) await new Promise(resolve => { exec(mkdirCmd, { timeout: 10000 }, () => resolve()); }); const rsyncOut = await new Promise((resolve, reject) => { exec(rsyncCmd, { timeout: 120000 }, (err, stdout, stderr) => { if (err) reject(new Error(stderr || err.message)); else resolve((stdout + stderr).trim()); }); }); // Cleanup temp file fsp.unlink(tarPath).catch(() => {}); res.writeHead(200); res.end(JSON.stringify({ ok: true, output: rsyncOut, tarName })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message })); } return; } // POST /api/backup/nas-keygen — generate SSH key for key-auth mode if (method === 'POST' && pathname === '/api/backup/nas-keygen') { const mc = loadManagerConfig(); const key = mc.nasSshKey || path.join(os.homedir(), '.ssh', 'ocm_nas_rsa'); const keyResolved = key.replace(/^~/, os.homedir()); try { try { await fsp.access(keyResolved); } catch { await new Promise((resolve, reject) => { exec(`ssh-keygen -t ed25519 -f "${keyResolved}" -N "" -C "openclaw-manager-backup"`, (err, stdout, stderr) => { if (err) reject(new Error(stderr || err.message)); else resolve(); }); }); } const pubKey = await fsp.readFile(keyResolved + '.pub', 'utf8'); res.writeHead(200); res.end(JSON.stringify({ ok: true, pubKey: pubKey.trim(), keyPath: keyResolved })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } return; } // POST /api/backup/nas-cron — set up cron job if (method === 'POST' && pathname === '/api/backup/nas-cron') { const mc = loadManagerConfig(); const { nasHost, nasPort, nasUser, nasAuth, nasSshKey, nasPath, nasLegacyCipher, nasBackupType } = mc; const { password, cronTime } = body; const port = nasPort || '22'; const remotePath = nasPath || '/volume1/OpenClaw/backups'; const cipherOpts = nasLegacyCipher ? '-o Ciphers=aes256-gcm@openssh.com,aes128-gcm@openssh.com,chacha20-poly1305@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr,aes256-cbc,aes192-cbc,aes128-cbc,3des-cbc' + ' -o KexAlgorithms=curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group1-sha1' + ' -o HostKeyAlgorithms=ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-256,rsa-sha2-512,ssh-rsa' : ''; const homeDir = os.homedir(); const ocDir = path.basename(OPENCLAW_DIR); const ts = '$(date +%Y%m%d-%H%M%S)'; const bkType = nasBackupType || 'full'; const tarPath = `/tmp/openclaw-${bkType}-${ts}.tar.gz`; let tarPart; if (bkType === 'essential') { tarPart = `tar czf ${tarPath} -C "${homeDir}" --exclude="${ocDir}/agents/*/sessions" ${ocDir}/openclaw.json ${ocDir}/.env ${ocDir}/credentials ${ocDir}/agents ${ocDir}/memory 2>/dev/null; `; } else { tarPart = `tar czf ${tarPath} -C "${homeDir}" --exclude="${ocDir}/logs" --exclude="${ocDir}/ocm/*.bak.js" "${ocDir}" 2>/dev/null; `; } let cronRsyncCmd; if (nasAuth === 'key') { const keyPath = (nasSshKey || '~/.ssh/ocm_nas_rsa').replace(/^~/, os.homedir()); const sshArg = `ssh -i "${keyPath}" -p ${port} -o StrictHostKeyChecking=no -o BatchMode=yes ${cipherOpts}`; cronRsyncCmd = `rsync -avz -e "${sshArg}" ${tarPath} "${nasUser}@${nasHost}:${remotePath}/"`; } else { if (!password) { res.writeHead(400); res.end(JSON.stringify({ error: '请提供密码(用于计划任务)' })); return; } const safePwd = password.replace(/'/g, "'\\''"); const sshArg = `ssh -p ${port} -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no ${cipherOpts}`; cronRsyncCmd = `sshpass -p '${safePwd}' rsync -avz -e "${sshArg}" ${tarPath} "${nasUser}@${nasHost}:${remotePath}/"`; } const [h, m] = (cronTime || '03:00').split(':'); const cronLine = `${m||'0'} ${h||'3'} * * * ${tarPart}${cronRsyncCmd} && rm -f ${tarPath} >> /tmp/ocm-nas-backup.log 2>&1 # openclaw-manager-nas`; try { await new Promise((resolve, reject) => { exec('crontab -l 2>/dev/null || true', (err, stdout) => { const existing = stdout.replace(/.*# openclaw-manager-nas\n?/g, ''); const newCron = existing.trimEnd() + '\n' + cronLine + '\n'; const child2 = exec('crontab -', (err2) => { if (err2) reject(err2); else resolve(); }); child2.stdin.write(newCron); child2.stdin.end(); }); }); res.writeHead(200); res.end(JSON.stringify({ ok: true, cronLine })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message })); } return; } // GET /api/logs if (method === 'GET' && pathname === '/api/logs') { const n = parseInt(urlObj.searchParams.get('n') || '200'); const content = await readLogTail(n); res.writeHead(200); res.end(JSON.stringify({ content, path: path.join(OPENCLAW_DIR, 'logs', 'gateway.log') })); return; } // POST /api/gateway/restart if (method === 'POST' && pathname === '/api/gateway/restart') { try { const out = await runOpenclawCmd('gateway restart'); res.writeHead(200); res.end(JSON.stringify({ ok: true, output: out })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message, hint: '请在终端手动运行: openclaw gateway restart' })); } return; } // ── 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 byModel = {}; const byDay = {}; const byAgent = {}; let totalIn = 0, totalOut = 0, totalCacheRead = 0, totalCacheWrite = 0; let totalCost = 0; try { 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; } } } } 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, totalCacheRead: totalCacheRead, totalCacheWrite: totalCacheWrite, estimatedCost: '$' + totalCost.toFixed(4), totalTokens: totalIn + totalOut + totalCacheRead + totalCacheWrite }, byModel, byDay, byAgent })); return; } // ── Cron API — 计划任务管理 ──────────────────────────────── if (method === 'GET' && pathname === '/api/cron') { try { const { stdout } = await new Promise((resolve, reject) => { exec('crontab -l 2>/dev/null || true', (err, stdout) => err ? reject(err) : resolve({ stdout })); }); const lines = stdout.split('\n').filter(l => l.trim()); const crons = []; lines.forEach((line, idx) => { // 只展示 openclaw 相关的或 OCM 标记的 cron const lo = line.toLowerCase(); if (!lo.includes('openclaw') && !lo.includes('ocm')) return; const enabled = !line.trimStart().startsWith('#'); const raw = enabled ? line.trim() : line.trim().replace(/^#\s*/, ''); const parts = raw.split(/\s+/); const schedule = parts.slice(0, 5).join(' '); const command = parts.slice(5).join(' ').replace(/\s*#\s*openclaw.*$/i, '').trim(); const label = (line.match(/#\s*(openclaw[^\n]*)/i) || [])[1] || ''; crons.push({ idx, schedule, command, enabled, label, rawLine: line }); }); res.writeHead(200); res.end(JSON.stringify({ crons })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } return; } if (method === 'POST' && pathname === '/api/cron') { const { schedule, command, label } = body; if (!schedule || !command) { res.writeHead(400); res.end(JSON.stringify({ error: '请填写表达式和命令' })); return; } const tag = label || 'openclaw-manager'; const cronLine = `${schedule} ${command} >> /tmp/ocm-cron.log 2>&1 # ${tag}`; try { await new Promise((resolve, reject) => { exec('crontab -l 2>/dev/null || true', (err, stdout) => { const newCron = stdout.trimEnd() + '\n' + cronLine + '\n'; const child2 = exec('crontab -', (err2) => { if (err2) reject(err2); else resolve(); }); child2.stdin.write(newCron); child2.stdin.end(); }); }); res.writeHead(200); res.end(JSON.stringify({ ok: true, cronLine })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } return; } if (method === 'PUT' && pathname.match(/^\/api\/cron\/\d+$/)) { const targetIdx = parseInt(pathname.split('/').pop()); const { schedule, command, enabled } = body; try { const { stdout } = await new Promise((resolve, reject) => { exec('crontab -l 2>/dev/null || true', (err, stdout) => err ? reject(err) : resolve({ stdout })); }); const lines = stdout.split('\n'); // 找到 openclaw 相关行的真实行号 let ocmIdx = -1; for (let i = 0; i < lines.length; i++) { const lo = lines[i].toLowerCase(); if (!lo.includes('openclaw') && !lo.includes('ocm')) continue; ocmIdx++; if (ocmIdx === targetIdx) { let raw = lines[i].trimStart().startsWith('#') ? lines[i].replace(/^#\s*/, '') : lines[i]; if (schedule || command) { const parts = raw.trim().split(/\s+/); const oldSched = parts.slice(0, 5).join(' '); const oldCmd = parts.slice(5).join(' '); raw = (schedule || oldSched) + ' ' + (command || oldCmd); } lines[i] = (enabled === false ? '# ' : '') + raw.trim(); break; } } await new Promise((resolve, reject) => { const child2 = exec('crontab -', (err) => { if (err) reject(err); else resolve(); }); child2.stdin.write(lines.join('\n') + '\n'); child2.stdin.end(); }); res.writeHead(200); res.end(JSON.stringify({ ok: true })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } return; } if (method === 'DELETE' && pathname.match(/^\/api\/cron\/\d+$/)) { const targetIdx = parseInt(pathname.split('/').pop()); try { const { stdout } = await new Promise((resolve, reject) => { exec('crontab -l 2>/dev/null || true', (err, stdout) => err ? reject(err) : resolve({ stdout })); }); const lines = stdout.split('\n'); let ocmIdx = -1; for (let i = 0; i < lines.length; i++) { const lo = lines[i].toLowerCase(); if (!lo.includes('openclaw') && !lo.includes('ocm')) continue; ocmIdx++; if (ocmIdx === targetIdx) { lines.splice(i, 1); break; } } await new Promise((resolve, reject) => { const child2 = exec('crontab -', (err) => { if (err) reject(err); else resolve(); }); child2.stdin.write(lines.join('\n') + '\n'); child2.stdin.end(); }); res.writeHead(200); res.end(JSON.stringify({ ok: true })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } return; } if (method === 'POST' && pathname.match(/^\/api\/cron\/\d+\/run$/)) { const targetIdx = parseInt(pathname.split('/')[3]); try { const { stdout } = await new Promise((resolve, reject) => { exec('crontab -l 2>/dev/null || true', (err, stdout) => err ? reject(err) : resolve({ stdout })); }); const lines = stdout.split('\n'); let ocmIdx = -1; let cmd = ''; for (const line of lines) { const lo = line.toLowerCase(); if (!lo.includes('openclaw') && !lo.includes('ocm')) continue; ocmIdx++; if (ocmIdx === targetIdx) { const raw = line.trimStart().startsWith('#') ? line.replace(/^#\s*/, '') : line; const parts = raw.trim().split(/\s+/); cmd = parts.slice(5).join(' ').replace(/\s*#\s*openclaw.*$/i, '').replace(/\s*>>[^&]*2>&1/, '').trim(); break; } } if (!cmd) { res.writeHead(404); res.end(JSON.stringify({ error: '任务不存在' })); return; } const out = await new Promise((resolve, reject) => { exec(cmd, { timeout: 30000 }, (err, stdout, stderr) => { if (err) reject(new Error(stderr || err.message)); else resolve(stdout + stderr); }); }); res.writeHead(200); res.end(JSON.stringify({ ok: true, output: stripAnsi(out) })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); } return; } // GET /api/health — 快速健康状态(解析 doctor 输出中的 warning/error 行) if (method === 'GET' && pathname === '/api/health') { try { const bin = process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw'; const r = spawnSync(bin, ['doctor'], { encoding: 'utf8', timeout: 8000, env: { ...process.env, NO_COLOR: '1', TERM: 'dumb', FORCE_COLOR: '0' } }); const raw = stripAnsi((r.stdout || '') + (r.stderr || '')); const lines = raw.split('\n').filter(l => l.trim()); const issues = []; lines.forEach(l => { const lo = l.toLowerCase(); if (lo.includes('error') || lo.includes('critical') || lo.includes('fail')) { issues.push({ level: 'error', text: l.trim() }); } else if (lo.includes('warn') || lo.includes('missing') || lo.includes('not found') || lo.includes('not configured')) { issues.push({ level: 'warn', text: l.trim() }); } }); res.writeHead(200); res.end(JSON.stringify({ ok: true, issues, raw, exitCode: r.status || 0 })); } catch (e) { res.writeHead(200); res.end(JSON.stringify({ ok: false, issues: [], raw: e.message, exitCode: -1 })); } return; } // GET /api/dashboard — system info + gateway health for Dashboard tab if (method === 'GET' && pathname === '/api/dashboard') { try { const cfg = await configExists() ? await readConfig() : null; // System info const sysUptime = os.uptime(); const totalMem = os.totalmem(); const freeMem = os.freemem(); const nodeVer = process.version; const platform = `${os.type()} ${os.release()} (${os.arch()})`; const hostname = os.hostname(); const cpus = os.cpus(); const cpuModel = cpus.length ? cpus[0].model.trim() : 'Unknown'; const cpuCores = cpus.length; // Disk usage (best-effort, works on macOS/Linux) let diskTotal = 0, diskUsed = 0, diskFree = 0; try { const dfOut = execSync('df -k ' + JSON.stringify(OPENCLAW_DIR), { encoding: 'utf8', timeout: 3000 }); const dfLines = dfOut.trim().split('\\n'); if (dfLines.length >= 2) { const parts = dfLines[1].split(/\\s+/); diskTotal = parseInt(parts[1] || 0) * 1024; diskUsed = parseInt(parts[2] || 0) * 1024; diskFree = parseInt(parts[3] || 0) * 1024; } } catch (_) {} // OpenClaw dir size (best-effort) let dirSize = 0; try { const duOut = execSync('du -sk ' + JSON.stringify(OPENCLAW_DIR), { encoding: 'utf8', timeout: 5000 }); dirSize = parseInt(duOut.split(/\\s/)[0] || 0) * 1024; } catch (_) {} // Gateway process detection let gatewayRunning = 'unknown'; let gatewayPid = null; try { const psOut = execSync("ps aux 2>/dev/null | grep -i 'openclaw.*gateway' | grep -v grep", { encoding: 'utf8', timeout: 3000 }).trim(); if (psOut) { gatewayRunning = 'running'; const psParts = psOut.split(/\\s+/); gatewayPid = psParts[1] || null; } else { gatewayRunning = 'stopped'; } } catch (_) { gatewayRunning = 'stopped'; } // HTTP ping gateway (port from config or default 3000) let gatewayPort = null; let gatewayPing = false; try { if (cfg && cfg.channels && cfg.channels.telegram) { gatewayPort = cfg.channels.telegram.port || null; } if (!gatewayPort) gatewayPort = 3000; const pingResult = spawnSync('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', 'http://127.0.0.1:' + gatewayPort], { encoding: 'utf8', timeout: 4000 }); const code = parseInt((pingResult.stdout || '').trim()); gatewayPing = code > 0 && code < 500; } catch (_) {} // Agent count & last activity let agentCount = 0; let lastActivity = null; try { const sessionsBase = path.join(OPENCLAW_DIR, 'agents'); const agentDirs = await fsp.readdir(sessionsBase); agentCount = agentDirs.length; for (const ad of agentDirs) { const sessDir = path.join(sessionsBase, ad, 'sessions'); try { const sessFiles = await fsp.readdir(sessDir); for (const sf of sessFiles) { if (sf.endsWith('.jsonl')) { const st = await fsp.stat(path.join(sessDir, sf)); if (!lastActivity || st.mtime > lastActivity) lastActivity = st.mtime; } } } catch (_) {} } } catch (_) {} // Brisbane time for display const now = new Date().toLocaleString('en-AU', { timeZone: 'Australia/Brisbane', hour12: false }); res.writeHead(200); res.end(JSON.stringify({ ok: true, system: { hostname, platform, nodeVer, cpuModel, cpuCores, uptime: sysUptime, totalMem, freeMem, diskTotal, diskUsed, diskFree, dirSize }, gateway: { status: gatewayRunning, pid: gatewayPid, port: gatewayPort, ping: gatewayPing }, agents: { count: agentCount, lastActivity: lastActivity ? lastActivity.toISOString() : null }, ocmVersion: APP_VERSION, serverTime: now, })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message })); } return; } // POST /api/gateway/doctor if (method === 'POST' && pathname === '/api/gateway/doctor') { try { const out = await runOpenclawCmd('doctor'); res.writeHead(200); res.end(JSON.stringify({ ok: true, output: out })); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: e.message })); } return; } // POST /api/folder/open if (method === 'POST' && pathname === '/api/folder/open') { openFolder(OPENCLAW_DIR); res.writeHead(200); res.end(JSON.stringify({ ok: true })); return; } // GET /api/cli/stream?cmd=... — SSE 实时流式执行命令 if (method === 'GET' && pathname === '/api/cli/stream') { const qCmd = (new URL('http://x' + req.url)).searchParams.get('cmd') || ''; if (!qCmd.trim()) { res.writeHead(400); res.end('Missing cmd'); return; } res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); res.flushHeaders(); const send = (ev, data) => { try { res.write(`event: ${ev}\ndata: ${JSON.stringify(data)}\n\n`); } catch(_) {} }; send('start', { cmd: qCmd, time: new Date().toLocaleTimeString('zh-CN') }); const cenv = { ...process.env, NO_COLOR: '1', TERM: 'dumb', FORCE_COLOR: '0' }; const child = spawn('sh', ['-c', qCmd], { env: cenv, stdio: ['ignore','pipe','pipe'] }); const timer = setTimeout(() => { child.kill(); send('out', { text: '\n⏱ 命令执行超时(60s),已终止\n' }); }, 60000); child.stdout.on('data', d => send('out', { text: stripAnsi(d.toString()) })); child.stderr.on('data', d => send('out', { text: stripAnsi(d.toString()) })); child.on('close', code => { clearTimeout(timer); send('done', { code }); try { res.end(); } catch(_) {} }); child.on('error', err => { clearTimeout(timer); send('error', { message: err.message }); try { res.end(); } catch(_) {} }); req.on('close', () => { clearTimeout(timer); try { child.kill(); } catch(_) {} }); return; } res.writeHead(404); res.end(JSON.stringify({ error: '未知 API 路径: ' + pathname })); } // ── 安装向导 HTML ────────────────────────────────────────────── const SETUP_HTML = ` OpenClaw Manager - 初始设置

🦀 OpenClaw Manager

首次运行,请指定你的 OpenClaw 数据目录(包含 openclaw.json 的文件夹)。

常见位置:
macOS / Linux:~/.openclaw
Windows:C:\\Users\\yourname\\.openclaw
`; // ── 主 HTML 前端 ────────────────────────────────────────────── const MAIN_HTML_CSS = String.raw`*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { --bg: #0f1117; --surface: #1a1d27; --border: #2d3148; --accent: #6c63ff; --accent-h: #7c74ff; --danger: #ef4444; --success: #22c55e; --warn: #f59e0b; --text: #e2e8f0; --muted: #6b7280; --card: #1e2235; } body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; min-height:100vh; } /* ── Toolbar ── */ header { background:var(--surface); border-bottom:1px solid var(--border); padding:0 20px; display:flex; align-items:center; gap:12px; height:52px; position:sticky; top:0; z-index:50; } .logo { font-size:17px; font-weight:700; color:var(--accent); cursor:pointer; } .logo:hover { opacity:.8; } .ver { font-size:11px; color:var(--muted); background:var(--border); padding:2px 8px; border-radius:20px; } .spacer { flex:1; } .status-row { display:flex; align-items:center; gap:6px; } .dot { width:7px; height:7px; border-radius:50%; background:var(--muted); } .dot.ok { background:var(--success); box-shadow:0 0 5px var(--success); } .dot.err{ background:var(--danger); box-shadow:0 0 5px var(--danger); } .status-txt { font-size:12px; color:var(--muted); } .lang-toggle { background:var(--border); border:none; color:var(--muted); border-radius:6px; padding:5px 10px; font-size:12px; cursor:pointer; } .lang-toggle:hover { background:#3a3f5c; color:var(--text); } /* dropdown menu */ .menu-wrap { position:relative; } .menu-btn { background:var(--border); border:none; color:var(--text); border-radius:6px; padding:6px 12px; font-size:13px; cursor:pointer; display:flex; align-items:center; gap:6px; } .menu-btn:hover { background:#3a3f5c; } .menu-dropdown { position:absolute; right:0; top:calc(100% + 6px); background:var(--surface); border:1px solid var(--border); border-radius:10px; min-width:220px; z-index:200; box-shadow:0 8px 24px rgba(0,0,0,.4); overflow:hidden; display:none; } .menu-dropdown.open { display:block; } .menu-item { padding:10px 16px; font-size:13px; cursor:pointer; display:flex; align-items:center; gap:10px; transition:background .1s; } .menu-item:hover { background:var(--border); } .menu-sep { border-top:1px solid var(--border); margin:4px 0; } /* ── Tabs ── */ nav { background:var(--surface); border-bottom:1px solid var(--border); display:flex; padding:0 20px; gap:2px; overflow-x:auto; } .tab { padding:11px 16px; font-size:13px; cursor:pointer; border-bottom:2px solid transparent; color:var(--muted); transition:all .15s; white-space:nowrap; } .tab:hover { color:var(--text); } .tab.active { color:var(--accent); border-bottom-color:var(--accent); } /* ── Main ── */ main { padding:20px; max-width:1280px; margin:0 auto; } .panel { display:none; } .panel.active { display:block; } .sec-hdr { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; } .sec-hdr h2 { font-size:16px; font-weight:600; } /* ── Cards ── */ .card-grid { display:grid; gap:12px; } .card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:16px 18px; } .card-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } .card-title { font-size:14px; font-weight:600; } .badge { font-size:11px; padding:2px 8px; border-radius:20px; background:var(--border); color:var(--muted); } .badge.main { background:rgba(108,99,255,.2); color:var(--accent); } .badge.ok { background:rgba(34,197,94,.15); color:var(--success); } .badge.warn { background:rgba(245,158,11,.15); color:var(--warn); } .card-meta { font-size:12px; color:var(--muted); margin-top:4px; } .card-actions { margin-top:10px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; } /* inline model selector */ .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); } /* Agents layout — buttons top, tree below */ .agents-top-btns { display:flex; gap:10px; justify-content:center; margin-bottom:18px; } .agents-tree-wrap { max-width:720px; margin:0 auto; overflow-y:auto; } /* Add form */ .add-form { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:16px; } .add-form h3 { font-size:15px; margin-bottom:12px; font-weight:600; } .add-form .guide-box { background:rgba(108,99,255,.06); border:1px solid rgba(108,99,255,.2); border-radius:8px; padding:12px; margin-bottom:14px; font-size:12px; line-height:1.8; color:var(--muted); } .add-form .guide-box summary { cursor:pointer; font-weight:600; color:var(--text); font-size:13px; margin-bottom:6px; } .add-form .guide-box ol { margin:6px 0 0 18px; padding:0; } .add-form .guide-box li { margin-bottom:4px; } .add-form .guide-box code { background:rgba(255,255,255,.08); padding:1px 5px; border-radius:3px; font-size:11px; } /* Agent tree */ .agent-tree-root { margin-bottom:16px; } .agent-tree-root .tree-main { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:12px 14px; cursor:pointer; } .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-meta { font-size:11px; color:var(--muted); margin-top:4px; } .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: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-meta { font-size:11px; color:var(--muted); margin-top:3px; } .tree-actions { display:flex; gap:6px; align-items:center; margin-top:8px; flex-wrap:wrap; } .tree-actions select { font-size:12px; padding:4px 8px; background:var(--bg); border:1px solid var(--border); border-radius:6px; color:var(--text); } .tree-actions button { font-size:11px; padding:4px 10px; } /* ── Buttons ── */ button { cursor:pointer; font-size:13px; font-weight:500; border:none; border-radius:6px; padding:6px 13px; transition:all .15s; } .btn-primary { background:var(--accent); color:#fff; } .btn-primary:hover { background:var(--accent-h); } .btn-danger { background:rgba(239,68,68,.12); color:var(--danger); border:1px solid rgba(239,68,68,.3); } .btn-danger:hover { background:rgba(239,68,68,.22); } .btn-secondary{ background:var(--border); color:var(--text); } .btn-secondary:hover { background:#3a3f5c; } .btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--border); } .btn-ghost:hover { background:var(--surface); color:var(--text); } .btn-warn { background:rgba(245,158,11,.12); color:var(--warn); border:1px solid rgba(245,158,11,.3); } .btn-warn:hover { background:rgba(245,158,11,.22); } .btn-success { background:rgba(34,197,94,.12); color:var(--success); border:1px solid rgba(34,197,94,.3); } .btn-success:hover { background:rgba(34,197,94,.22); } button:disabled { opacity:.4; cursor:not-allowed; } /* ── Forms ── */ .form-group { display:flex; flex-direction:column; gap:5px; margin-bottom:14px; } label { font-size:13px; font-weight:500; } .hint-text { font-size:11px; color:var(--muted); } input,select,textarea { background:var(--bg); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:8px 11px; font-size:13px; width:100%; transition:border-color .15s; } input:focus,select:focus,textarea:focus { outline:none; border-color:var(--accent); } input::placeholder,textarea::placeholder { color:var(--muted); } textarea { resize:vertical; min-height:72px; } select option { background:var(--surface); } .form-row { display:grid; grid-template-columns:1fr 1fr; gap:14px; } .field-err { font-size:11px; color:var(--danger); } .input-pw-wrap { position:relative; } .input-pw-wrap input { padding-right:36px; } .pw-toggle { position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--muted); cursor:pointer; font-size:14px; padding:0; } /* ── Dashboard ── */ .dash-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .dash-card { padding:20px; } .dash-card h3 { font-size:14px; font-weight:600; margin-bottom:14px; color:var(--text); } .dash-row { display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid var(--border); font-size:12px; } .dash-row:last-child { border-bottom:none; } .dash-label { color:var(--muted); } .dash-val { color:var(--text); font-weight:500; font-family:monospace; font-size:11px; } .dash-indicator { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; } .dash-indicator.running { background:#22c55e; box-shadow:0 0 6px rgba(34,197,94,.5); } .dash-indicator.stopped { background:#ef4444; box-shadow:0 0 6px rgba(239,68,68,.5); } .dash-indicator.unknown { background:#f59e0b; } .dash-bar-wrap { width:100%; height:6px; background:var(--border); border-radius:3px; margin-top:4px; } .dash-bar { height:100%; border-radius:3px; background:var(--accent); transition:width .3s; } .dash-bar.warn { background:#f59e0b; } .dash-bar.danger { background:#ef4444; } /* ── Modal ── */ .backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100; display:none; align-items:center; justify-content:center; } .backdrop.open { display:flex; } .modal { background:var(--surface); border:1px solid var(--border); border-radius:14px; width:580px; max-width:95vw; max-height:90vh; overflow-y:auto; } .modal.wide { width:740px; } .m-hdr { padding:18px 22px 0; display:flex; align-items:center; justify-content:space-between; } .m-hdr h3 { font-size:15px; font-weight:600; } .m-close { background:transparent; border:none; color:var(--muted); font-size:18px; cursor:pointer; padding:4px; line-height:1; } .m-close:hover { color:var(--text); } .m-body { padding:18px 22px; } .m-foot { padding:0 22px 18px; display:flex; gap:8px; justify-content:flex-end; } /* ── Steps ── */ .steps { display:flex; margin-bottom:20px; } .step { flex:1; text-align:center; padding:7px 3px; font-size:11px; color:var(--muted); border-bottom:2px solid var(--border); } .step.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; } .step.done { color:var(--success); border-bottom-color:var(--success); } .step-page { display:none; } .step-page.active { display:block; } /* ── Log viewer ── */ .log-box { background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:12px; font-family:monospace; font-size:11px; line-height:1.5; max-height:380px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; color:#94a3b8; } /* ── Misc ── */ .notes { background:rgba(108,99,255,.07); border:1px solid rgba(108,99,255,.2); border-radius:8px; padding:12px 14px; font-size:13px; line-height:1.7; } .notes li { margin-left:16px; } .notes code { font-size:11px; background:rgba(0,0,0,.3); padding:1px 5px; border-radius:3px; } .warn-box { background:rgba(245,158,11,.07); border:1px solid rgba(245,158,11,.25); border-radius:8px; padding:12px 14px; font-size:13px; color:var(--warn); } .success-box { background:rgba(34,197,94,.07); border:1px solid rgba(34,197,94,.25); border-radius:8px; padding:12px 14px; font-size:13px; color:var(--success); } .empty { text-align:center; color:var(--muted); padding:40px 0; font-size:14px; } .tag { font-size:11px; padding:3px 9px; border-radius:20px; background:var(--border); color:var(--muted); display:inline-block; } .tag-row { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; } .tag-del { cursor:pointer; margin-left:4px; opacity:.6; } .tag-del:hover { opacity:1; color:var(--danger); } hr { border:none; border-top:1px solid var(--border); margin:14px 0; } code { font-size:12px; background:rgba(0,0,0,.3); padding:2px 6px; border-radius:4px; color:#a78bfa; } /* Restart Banner */ .restart-banner { display:none; background:rgba(245,158,11,.1); border:1px solid rgba(245,158,11,.3); border-radius:8px; padding:10px 16px; margin-bottom:16px; font-size:13px; color:var(--warn); align-items:center; gap:10px; flex-wrap:wrap; } .restart-banner.show { display:flex; } /* Floating Restart Button */ #pendingRestartBtn { display:none; position:fixed; bottom:24px; left:24px; z-index:400; } #pendingRestartBtn button { padding:10px 20px; font-size:13px; border-radius:22px; box-shadow:0 4px 16px rgba(245,158,11,.45); } /* Auth page */ .auth-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:18px; margin-bottom:12px; } .auth-prov-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:10px; margin-bottom:16px; } .auth-prov-btn { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:12px 14px; cursor:pointer; text-align:left; transition:all .15s; } .auth-prov-btn:hover { border-color:var(--accent); background:rgba(108,99,255,.06); } .auth-prov-btn.selected { border-color:var(--accent); background:rgba(108,99,255,.1); } .auth-prov-btn.configured { border-color:var(--success); } .auth-prov-btn.configured .apb-name { color:var(--success); } .auth-prov-btn .apb-name { font-size:13px; font-weight:600; margin-bottom:2px; } .auth-prov-btn .apb-mode { font-size:11px; color:var(--muted); } .copy-cmd-btn { background:var(--border); border:none; color:var(--text); border-radius:5px; padding:4px 10px; font-size:11px; cursor:pointer; margin-left:8px; } .copy-cmd-btn:hover { background:#3a3f5c; } /* Channels */ .ch-badge { font-size:11px; padding:2px 7px; border-radius:12px; } .ch-tg { background:rgba(51,144,236,.15); color:#33a0ec; } .ch-any { background:var(--border); color:var(--muted); } /* NAS backup */ .nas-step { background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:14px; margin-bottom:12px; } .nas-step-title { font-size:13px; font-weight:600; margin-bottom:10px; color:var(--accent); } /* ── CLI 终端面板 ── */ #cliPanel { position:fixed; bottom:0; left:0; right:0; z-index:90; background:var(--surface); border-top:2px solid var(--border); display:none; flex-direction:column; box-shadow:0 -4px 24px rgba(0,0,0,.35); } #cliPanel.open { display:flex; } .cli-hdr { display:flex; align-items:center; gap:8px; padding:6px 14px; cursor:pointer; border-bottom:1px solid var(--border); user-select:none; background:var(--bg); } .cli-hdr-title { font-size:13px; font-weight:600; flex:1; color:var(--text); } .cli-hdr-actions { display:flex; gap:6px; } .cli-output { font-family:'SF Mono',Menlo,Consolas,monospace; font-size:12px; line-height:1.65; background:#0d1117; color:#c9d1d9; padding:10px 14px; height:220px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; } .cli-output .cli-cmd-line { color:#58a6ff; font-weight:600; } .cli-output .cli-done-ok { color:#3fb950; } .cli-output .cli-done-err { color:#f85149; } .cli-input-row { display:flex; gap:6px; padding:7px 10px; align-items:center; background:var(--surface); } .cli-input-row select { font-size:12px; padding:5px 8px; max-width:190px; background:var(--bg); border:1px solid var(--border); border-radius:6px; color:var(--text); } .cli-input-row input { flex:1; font-family:'SF Mono',Menlo,Consolas,monospace; font-size:12px; background:var(--bg); } .cli-resize-handle { height:4px; background:var(--border); cursor:ns-resize; } /* Health / security badge */ #healthBadge { display:none; align-items:center; gap:5px; padding:4px 10px; border-radius:6px; font-size:12px; font-weight:600; cursor:default; position:relative; } #healthBadge.warn { background:rgba(234,179,8,.15); border:1px solid rgba(234,179,8,.4); color:#eab308; } #healthBadge.error { background:rgba(248,81,73,.15); border:1px solid rgba(248,81,73,.4); color:#f85149; } #healthBadge.ok { background:rgba(63,185,80,.1); border:1px solid rgba(63,185,80,.3); color:#3fb950; display:flex; } .health-tooltip { display:none; position:absolute; top:calc(100% + 8px); right:0; background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:12px 14px; min-width:320px; max-width:480px; z-index:300; box-shadow:0 8px 24px rgba(0,0,0,.5); font-size:12px; line-height:1.7; white-space:pre-wrap; word-break:break-word; color:var(--text); } #healthBadge:hover .health-tooltip { display:block; } /* CLI nav tab button */ .cli-nav-btn { margin-left:auto; padding:6px 16px; font-size:12px; font-weight:600; background:rgba(108,99,255,.15); border:1px solid rgba(108,99,255,.4); color:var(--accent); border-radius:6px; cursor:pointer; white-space:nowrap; transition:all .15s; } .cli-nav-btn:hover,.cli-nav-btn.active { background:rgba(108,99,255,.32); border-color:var(--accent); } /* Stop button */ .btn-stop { background:rgba(248,81,73,.15); border:1px solid rgba(248,81,73,.4); color:#f85149; border-radius:6px; padding:6px 12px; font-size:12px; font-weight:600; cursor:pointer; white-space:nowrap; } .btn-stop:hover { background:rgba(248,81,73,.3); } /* Manage favs modal list */ .fav-manage-row { display:flex; align-items:center; gap:6px; padding:7px 0; border-bottom:1px solid var(--border); } .fav-manage-label { flex:1; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .fav-manage-cmd { font-size:11px; color:var(--muted); font-family:monospace; max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }`; const MAIN_HTML_BODY = String.raw`
v--
⚠️
连接中...
⚠️ 配置已修改,建议重启 Gateway 以确保生效。

🖥️ System

Loading...

🦀 Gateway

Loading...

🤖 Agents

Loading...

💾 Storage

Loading...
No Agents

Channel 绑定

管理 Agent 与频道/群组的绑定关系。绑定顺序决定优先级,排在前面的规则先匹配。

加载中...

模型管理

模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。

默认主模型primary
Fallback 链

认证配置

点击 Provider 查看认证步骤。认证需在终端完成,或使用下方 CLI 终端。

已配置认证

加载中...

使用统计

Input Tokens
--
Output Tokens
--
Cache Read
--
Estimated Cost
--
Requests
--

By Day

By Model

No data

By Agent

No data

计划任务

管理与 OpenClaw 相关的 crontab 计划任务。

加载中...
⌨️ CLI 终端
── 终端就绪,等待命令 ──
`; const MAIN_HTML_SCRIPT = `// ── i18n ────────────────────────────────────────────────────── const I18N = { zh: { 'tab.dashboard':'🏠 Dashboard', 'tab.agents':'🤖 Agents','tab.channels':'📡 Channels','tab.models':'🧠 模型','tab.auth':'🔑 认证', 'tab.stats':'📊 Stats','tab.cron':'⏰ Cron', 'agents.title':'Agents','agents.new':'+ 新建 Subagent', 'channels.title':'Channel 绑定','channels.add':'+ 添加绑定','channels.hint':'管理 Agent 与频道/群组的绑定关系。绑定顺序决定优先级。', 'models.title':'模型管理','models.primary':'默认主模型','models.fallback':'Fallback 链', 'models.hint':'模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。', 'auth.title':'认证配置','auth.configured':'已配置认证', 'auth.guide':'点击 Provider 查看认证步骤。认证需在终端完成,或使用下方 CLI 终端。', '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':'暂无数据(未找到会话记录)', '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 相关的计划任务', 'ws.title':'📂 Workspace 文件','ws.edit':'✏️ 编辑','ws.save':'💾 保存','ws.cancel':'取消编辑','ws.saved':'文件已保存','ws.large':'(文件过大,无法预览)', 'menu.ops':'操作','menu.restart':'重启 Gateway','menu.logs':'实时日志','menu.backup':'手动备份配置', 'menu.rollback':'查看备份 / 回滚','menu.nas':'NAS 备份设置','menu.doctor':'健康检查', 'menu.opendir':'打开配置目录','menu.switchdir':'切换 OpenClaw 目录', 'actions.logsTitle':'📋 Gateway 日志','actions.refresh':'🔄 刷新','actions.autoRefresh':'自动刷新(2s)', 'actions.outputTitle':'输出','actions.rollbackTitle':'📦 备份 / 回滚','actions.rollbackHint':'选择一个备份文件恢复。恢复前会自动保存当前配置。', 'actions.restoreThis':'⏪ 恢复此备份','actions.setupTitle':'⚙️ 切换 OpenClaw 目录','actions.setupLabel':'OpenClaw 数据目录路径', 'actions.setupPlaceholder':'~/.openclaw 或绝对路径','actions.setupHint':'需要包含 openclaw.json 的文件夹。更换后页面自动刷新。','actions.setupConfirm':'确认切换', 'actions.restartTitle':'重启 Gateway','actions.doctorTitle':'健康检查 (doctor)','actions.running':'执行中...','actions.noOutput':'(无输出)', 'actions.restartSent':'重启命令已发送。','actions.restartOk':'Gateway 已重启','actions.restartFail':'重启失败: ', 'actions.restartManualHint':'请在终端手动运行:\\nopenclaw gateway restart', 'actions.backupSaved':'备份已保存: ','actions.backupFail':'备份失败: ', 'actions.logEmpty':'(空)','actions.logLoadFail':'加载失败: ', 'actions.noBackups':'暂无备份文件','actions.loadFailed':'加载失败', 'actions.restoreConfirm':'确认将配置恢复为:\\n{filename}\\n\\n当前配置将先被自动备份。继续?', 'actions.restored':'已恢复 ','actions.restoreFail':'恢复失败: ', 'actions.setupEmpty':'请填写路径','actions.setupSwitching':'目录已切换,正在刷新...','actions.setupInvalid':'路径无效','actions.setupReqFail':'请求失败: ', 'common.loading':'加载中...','common.close':'关闭', 'btn.save':'保存','btn.delete':'删除','btn.cancel':'取消','btn.add':'添加','btn.remove':'移除', 'agents.addAgent':'+ Add Agent','agents.addSub':'+ Add Sub-Agent', 'agents.addAgentTitle':'Add Agent (Main Bot)','agents.addSubTitle':'Add Sub-Agent', 'agents.botToken':'Bot Token','agents.botName':'Bot Name','agents.botNamePh':'My Bot', 'agents.addAgentSubmit':'Create Agent','agents.addSubSubmit':'Create Sub-Agent', 'agents.errToken':'Please enter Bot Token','agents.errName':'Please enter Bot name', 'agents.agentCreated':'✅ Agent created', 'guide.title':'📖 Setup Guide','guide.agent.s1':'Open Telegram, search for @BotFather', 'guide.agent.s2':'Send /newbot and follow prompts to name your Bot', 'guide.agent.s3':'Copy the Bot Token from BotFather and paste below', 'guide.agent.s4':'Send /setprivacy → select your Bot → click Disable (allow Bot to read group messages)', 'guide.sub.s1':'Add the Bot to your target Telegram group', 'guide.sub.s2':'Send a message in the group, find peer.id (negative number) in gateway logs', 'guide.sub.s3':'Fill in the Group ID and Agent config below', 'agents.empty':'暂无 Agent','agents.main':'主 Agent','agents.bound':'已绑群', 'agents.saveModel':'保存模型','agents.viewFiles':'查看文件', 'agents.defaultModel':'使用全局默认','agents.custom':'自定义','agents.noModel':'默认', 'channels.empty':'暂无绑定配置','channels.matchAll':'📡 匹配所有', 'channels.bindIdx':'绑定索引: #','channels.delBinding':'删除绑定', 'banner.modified':'配置已修改,建议重启 Gateway 以确保生效。', 'banner.restart':'立即重启','banner.later':'稍后','banner.dismiss':'忽略', 'floating.restart':'重启 Gateway', 'wiz.title':'新建 Subagent', 'wiz.s1':'1 基本信息','wiz.s2':'2 模型','wiz.s3':'3 性格&记忆','wiz.s4':'4 确认', 'wiz.groupId':'Telegram 群组 ID','wiz.groupHint':'💡 在群内发一条消息,在网关终端日志里找 peer.id(负数)', 'wiz.agentId':'Agent ID','wiz.agentIdHint':'(纯英文)','wiz.agentIdPh':'my_bot', 'wiz.displayName':'显示名称','wiz.displayNamePh':'我的助手', 'wiz.workspace':'Workspace 文件夹','wiz.workspaceHint':'(留空=Agent ID)','wiz.workspacePh':'自动', 'wiz.purpose':'Agent 用途描述','wiz.purposePh':'例如:专注于 Linux 和网络运维,帮助群成员快速解决技术故障。', 'wiz.model':'选择模型','wiz.modelHint':'(不选则沿用全局默认)', 'wiz.soul':'性格关键词','wiz.soulHint':'(逗号分隔,选填)','wiz.soulPh':'幽默、直接、有条理...', 'wiz.soulTip':'留空则使用默认成长型提示词(推荐)', 'wiz.memory':'初始记忆','wiz.memoryHint':'(MEMORY.md,选填)','wiz.memoryPh':'例如:群组主要用中文交流。用户偏好简洁回复。', 'wiz.preview':'即将创建:','wiz.group':'群组','wiz.modelLabel':'模型','wiz.globalDefault':'全局默认', 'wiz.soulYes':'含性格关键词','wiz.soulDefault':'默认成长型提示词(推荐)', 'wiz.autoBackup':'自动备份当前 openclaw.json ✓', 'wiz.back':'← 上一步','wiz.next':'下一步 →','wiz.confirm':'✅ 确认创建','wiz.creating':'创建中...', 'wiz.created':'✅ 已创建', 'wiz.errGroupId':'请填写群组 ID','wiz.errAgentId':'请填写 Agent ID', 'wiz.errIdFormat':'只能含英文/数字/_ /-','wiz.errIdReserved':'"main" 是保留 ID','wiz.errIdDup':'该 ID 已存在', 'ch.title':'添加 Channel 绑定','ch.agent':'Agent','ch.channelType':'频道类型', 'ch.telegram':'Telegram','ch.anyChannel':'任意(不限频道)', 'ch.peerKind':'Peer 类型','ch.group':'群组 (group)','ch.private':'私聊 (private)','ch.any':'任意', 'ch.peerId':'Peer ID','ch.peerIdHint':'(群组 ID 或用户 ID)','ch.peerIdTip':'留空则匹配所有对应类型的 Peer', 'ch.submit':'添加绑定', 'l.sub':'选择运行模式', 'cli.open':'⌨️ 终端','cli.title':'⌨️ CLI 终端','cli.clear':'清空','cli.collapse':'▼ 收起', 'cli.ready':'── 终端就绪,等待命令 ──','cli.cleared':'── 已清空 ──', 'cli.presets':'── 常用命令 ──','cli.builtins':'内置命令','cli.favs':'我的收藏', 'cli.run':'▶ 执行','cli.stop':'■ 停止','cli.star':'⭐','cli.manage':'管理', 'cli.placeholder':'输入命令(如 openclaw doctor)', 'cli.favprompt':'给这条命令起个名字(留空则使用命令本身):', 'cli.fav.dup':'该命令已在收藏里了','cli.fav.empty':'请先输入命令', 'cli.fav.saved':'已收藏','cli.manage.title':'管理收藏命令', 'nas.title':'🌐 远端备份', 'nas.host':'主机 / IP','nas.user':'用户名','nas.authLabel':'认证方式', 'nas.authPw':'密码','nas.pwLabel':'密码','nas.pwHint':'不存储,仅用于本次操作', 'nas.keyLabel':'SSH Key 路径','nas.genKey':'生成 Key', 'nas.pubkeyHint':'公钥(复制到 NAS 的 authorized_keys):', 'nas.remotePath':'远端备份路径', 'nas.compat':'兼容模式(添加旧版 SSH 加密方案,适用于旧款 NAS / 旧服务器)', 'nas.content':'备份内容','nas.full':'全量(整个 .openclaw)', 'nas.essential':'仅重要数据(配置+Key+记忆,无日志/历史)', 'nas.btnTest':'🔌 测试连接','nas.btnNow':'💾 立即备份', 'nas.btnCron':'⏰ 定时备份','nas.btnClose':'关闭', 'nas.cronTime':'每日备份时间','nas.btnSaveCron':'💾 保存定时计划', 'nas.testing':'连接测试中...','nas.testOk':'✅ 连接成功','nas.testFail':'❌ 连接失败: ', 'nas.backing':'备份中,请稍候...','nas.backupOk':'✅ 备份完成: ','nas.backupFail':'❌ 备份失败: ', 'nas.keyGenOk':'SSH Key 已生成','nas.keyGenFail':'生成失败: ', 'nas.cronOk':'✅ 定时备份已设置 ','nas.cronToast':'定时备份已配置', 'nas.backupToast':'NAS 备份完成','nas.errNoHost':'请填写主机和用户名', }, en: { 'tab.dashboard':'🏠 Dashboard', 'tab.agents':'🤖 Agents','tab.channels':'📡 Channels','tab.models':'🧠 Models','tab.auth':'🔑 Auth', 'tab.stats':'📊 Stats','tab.cron':'⏰ Cron', 'agents.title':'Agents','agents.new':'+ New Subagent', '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.hint':'Models are registered via openclaw onboard. Manage primary model and fallback chain here.', '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.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 (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', 'ws.title':'📂 Workspace Files','ws.edit':'✏️ Edit','ws.save':'💾 Save','ws.cancel':'Cancel Edit','ws.saved':'File saved','ws.large':'(File too large to preview)', 'menu.ops':'Actions','menu.restart':'Restart Gateway','menu.logs':'Live Logs','menu.backup':'Manual Backup', 'menu.rollback':'Backups / Rollback','menu.nas':'NAS Backup Setup','menu.doctor':'Health Check', 'menu.opendir':'Open Config Dir','menu.switchdir':'Switch OpenClaw Dir', 'actions.logsTitle':'📋 Gateway Logs','actions.refresh':'🔄 Refresh','actions.autoRefresh':'Auto refresh (2s)', 'actions.outputTitle':'Output','actions.rollbackTitle':'📦 Backups / Rollback','actions.rollbackHint':'Select a backup file to restore. Current config will be auto-backed up first.', 'actions.restoreThis':'⏪ Restore this backup','actions.setupTitle':'⚙️ Switch OpenClaw Dir','actions.setupLabel':'OpenClaw data directory path', 'actions.setupPlaceholder':'~/.openclaw or absolute path','actions.setupHint':'Folder must contain openclaw.json. Page auto-refreshes after switching.','actions.setupConfirm':'Confirm Switch', 'actions.restartTitle':'Restart Gateway','actions.doctorTitle':'Health Check (doctor)','actions.running':'Running...','actions.noOutput':'(no output)', 'actions.restartSent':'Restart command sent.','actions.restartOk':'Gateway restarted','actions.restartFail':'Restart failed: ', 'actions.restartManualHint':'Please run manually in terminal:\\nopenclaw gateway restart', 'actions.backupSaved':'Backup saved: ','actions.backupFail':'Backup failed: ', 'actions.logEmpty':'(empty)','actions.logLoadFail':'Load failed: ', 'actions.noBackups':'No backup files','actions.loadFailed':'Load failed', 'actions.restoreConfirm':'Restore config to:\\n{filename}\\n\\nCurrent config will be auto-backed up first. Continue?', 'actions.restored':'Restored ','actions.restoreFail':'Restore failed: ', 'actions.setupEmpty':'Please enter a path','actions.setupSwitching':'Directory switched, refreshing...','actions.setupInvalid':'Invalid path','actions.setupReqFail':'Request failed: ', 'common.loading':'Loading...','common.close':'Close', 'btn.save':'Save','btn.delete':'Delete','btn.cancel':'Cancel','btn.add':'Add','btn.remove':'Remove', 'agents.addAgent':'+ Add Agent','agents.addSub':'+ Add Sub-Agent', 'agents.addAgentTitle':'Add Agent (Main Bot)','agents.addSubTitle':'Add Sub-Agent', 'agents.botToken':'Bot Token','agents.botName':'Bot Name','agents.botNamePh':'My Bot', 'agents.addAgentSubmit':'Create Agent','agents.addSubSubmit':'Create Sub-Agent', 'agents.errToken':'Please enter Bot Token','agents.errName':'Please enter Bot name', 'agents.agentCreated':'✅ Agent created', 'guide.title':'📖 Setup Guide','guide.agent.s1':'Open Telegram, search for @BotFather', 'guide.agent.s2':'Send /newbot and follow prompts to name your Bot', 'guide.agent.s3':'Copy the Bot Token from BotFather and paste below', 'guide.agent.s4':'Send /setprivacy → select your Bot → click Disable (allow Bot to read group messages)', 'guide.sub.s1':'Add the Bot to your target Telegram group', 'guide.sub.s2':'Send a message in the group, find peer.id (negative number) in gateway logs', 'guide.sub.s3':'Fill in the Group ID and Agent config below', 'agents.empty':'No Agents','agents.main':'Main Agent','agents.bound':'Bound', 'agents.saveModel':'Save Model','agents.viewFiles':'View Files', 'agents.defaultModel':'Use Global Default','agents.custom':'custom','agents.noModel':'Default', 'channels.empty':'No bindings configured','channels.matchAll':'📡 Match All', 'channels.bindIdx':'Binding Index: #','channels.delBinding':'Remove Binding', 'banner.modified':'Config modified. Restart Gateway to apply changes.', 'banner.restart':'Restart Now','banner.later':'Later','banner.dismiss':'Dismiss', 'floating.restart':'Restart Gateway', 'wiz.title':'New Subagent', 'wiz.s1':'1 Basic Info','wiz.s2':'2 Model','wiz.s3':'3 Personality & Memory','wiz.s4':'4 Confirm', 'wiz.groupId':'Telegram Group ID','wiz.groupHint':'💡 Send a message in the group, find peer.id (negative number) in gateway logs', 'wiz.agentId':'Agent ID','wiz.agentIdHint':'(English only)','wiz.agentIdPh':'my_bot', 'wiz.displayName':'Display Name','wiz.displayNamePh':'My Assistant', 'wiz.workspace':'Workspace Folder','wiz.workspaceHint':'(Leave blank = Agent ID)','wiz.workspacePh':'auto', 'wiz.purpose':'Agent Purpose','wiz.purposePh':'e.g. Linux & networking ops, help group members troubleshoot quickly.', 'wiz.model':'Select Model','wiz.modelHint':'(Leave blank for global default)', 'wiz.soul':'Personality Keywords','wiz.soulHint':'(comma-separated, optional)','wiz.soulPh':'humorous, direct, organized...', 'wiz.soulTip':'Leave blank for default growth prompt (recommended)', 'wiz.memory':'Initial Memory','wiz.memoryHint':'(MEMORY.md, optional)','wiz.memoryPh':'e.g. Group mainly uses English. Users prefer concise replies.', 'wiz.preview':'About to create:','wiz.group':'Group','wiz.modelLabel':'Model','wiz.globalDefault':'Global Default', 'wiz.soulYes':'With personality keywords','wiz.soulDefault':'Default growth prompt (recommended)', 'wiz.autoBackup':'Auto-backup current openclaw.json ✓', 'wiz.back':'← Back','wiz.next':'Next →','wiz.confirm':'✅ Confirm','wiz.creating':'Creating...', 'wiz.created':'✅ Created', 'wiz.errGroupId':'Please enter Group ID','wiz.errAgentId':'Please enter Agent ID', 'wiz.errIdFormat':'Only letters/numbers/_ /- allowed','wiz.errIdReserved':'"main" is a reserved ID','wiz.errIdDup':'This ID already exists', 'ch.title':'Add Channel Binding','ch.agent':'Agent','ch.channelType':'Channel Type', 'ch.telegram':'Telegram','ch.anyChannel':'Any (no restriction)', 'ch.peerKind':'Peer Type','ch.group':'Group','ch.private':'Private','ch.any':'Any', 'ch.peerId':'Peer ID','ch.peerIdHint':'(Group ID or User ID)','ch.peerIdTip':'Leave blank to match all peers of this type', 'ch.submit':'Add Binding', 'l.sub':'Select Mode', 'cli.open':'⌨️ Terminal','cli.title':'⌨️ CLI Terminal','cli.clear':'Clear','cli.collapse':'▼ Collapse', 'cli.ready':'── Terminal Ready ──','cli.cleared':'── Cleared ──', 'cli.presets':'── Presets ──','cli.builtins':'Built-in','cli.favs':'My Favorites', 'cli.run':'▶ Run','cli.stop':'■ Stop','cli.star':'⭐','cli.manage':'Manage', 'cli.placeholder':'Enter command (e.g. openclaw doctor)', 'cli.favprompt':'Name for this command (leave blank to use the command itself):', 'cli.fav.dup':'Command already in favorites','cli.fav.empty':'Please enter a command first', 'cli.fav.saved':'Saved','cli.manage.title':'Manage Saved Commands', 'nas.title':'🌐 Remote Backup', 'nas.host':'Host / IP','nas.user':'Username','nas.authLabel':'Auth Method', 'nas.authPw':'Password','nas.pwLabel':'Password','nas.pwHint':'Not stored, used for this operation only', 'nas.keyLabel':'SSH Key Path','nas.genKey':'Generate Key', 'nas.pubkeyHint':'Public key (copy to NAS authorized_keys):', 'nas.remotePath':'Remote Backup Path', 'nas.compat':'Compatibility Mode (add legacy SSH ciphers — for old NAS / servers)', 'nas.content':'Backup Content','nas.full':'Full (~entire .openclaw)', 'nas.essential':'Essential (config+keys+memory, no logs/history)', 'nas.btnTest':'🔌 Test Connection','nas.btnNow':'💾 Backup Now', 'nas.btnCron':'⏰ Schedule','nas.btnClose':'Close', 'nas.cronTime':'Daily Backup Time','nas.btnSaveCron':'💾 Save Schedule', 'nas.testing':'Testing connection...','nas.testOk':'✅ Connected','nas.testFail':'❌ Connection failed: ', 'nas.backing':'Backing up, please wait...','nas.backupOk':'✅ Backup complete: ','nas.backupFail':'❌ Backup failed: ', 'nas.keyGenOk':'SSH key generated','nas.keyGenFail':'Key generation failed: ', 'nas.cronOk':'✅ Schedule saved ','nas.cronToast':'Scheduled backup configured', 'nas.backupToast':'NAS backup complete','nas.errNoHost':'Please enter host and username', }, }; // 安全 localStorage 工具(提前定义,防止隐私模式 / 存储禁用时整页崩溃) const LS = { get(k, def='') { try { return localStorage.getItem(k) ?? def; } catch(e) { return def; } }, set(k, v) { try { localStorage.setItem(k, v); } catch(e) {} }, del(k) { try { localStorage.removeItem(k); } catch(e) {} }, }; let lang = LS.get('ocm_lang', 'zh'); function t(k) { return I18N[lang][k] || I18N.zh[k] || k; } function applyLang() { document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); }); document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nPlaceholder); }); const ltb = document.getElementById('langToggleBtn'); if(ltb) ltb.textContent = lang === 'zh' ? 'EN' : 'ZH'; // Update CLI elements that need JS-side refresh const ci=document.getElementById('cliInput'); if(ci) ci.placeholder=t('cli.placeholder'); const rm=document.getElementById('cliReadyMsg'); if(rm) rm.textContent=t('cli.ready'); // Re-render model/auth cards so Remove button text updates try{ renderModels(); }catch(_){} try{ renderAuth(); }catch(_){} // Rebuild presets dropdown if CLI is open if(document.getElementById('cliPanel')&&document.getElementById('cliPanel').classList.contains('open')){ buildCliPresets(); } } function toggleLang() { lang = lang === 'zh' ? 'en' : 'zh'; LS.set('ocm_lang', lang); applyLang(); } // ── Landing Page ────────────────────────────────────────────── const LANDING_TEXT = { zh: { sub: '选择运行模式', activeBadge: '可用', soonBadge: '敬请期待', subTitle: 'Sub-agent 模式', subDesc: '通过主 Bot 账号绑定多个子 Agent 到不同群组,共用同一个 Token。', subReqs: '需要:
• Telegram Bot Token(主账号)
• 各群组的 Group ID
• 已安装 OpenClaw', multiTitle: 'Multi-agent 模式', multiDesc: '为每个场景创建完全独立的 Bot,彻底隔离配置与对话历史。', multiReqs: '需要:
• 每个 Bot 独立的 Token
• 独立的 OpenClaw 配置目录
• 独立的服务器进程', enterBtn: '进入 Sub-agent 模式 →', }, en: { sub: 'Select Mode', activeBadge: 'Available', soonBadge: 'Coming Soon', subTitle: 'Sub-agent Mode', subDesc: 'Bind multiple sub-agents to different groups using one main Bot Token.', subReqs: 'Requires:
• Telegram Bot Token (main)
• Group IDs for each group
• OpenClaw installed', multiTitle: 'Multi-agent Mode', multiDesc: 'Create fully isolated bots for each scenario with separate configs.', multiReqs: 'Requires:
• Individual Token per bot
• Separate OpenClaw config dir
• Separate server process', enterBtn: 'Enter Sub-agent Mode →', }, }; // ── 全局状态 ──────────────────────────────────────────────── let S = { agents:[], channels:[], models:{}, authProfiles:{}, knownModels:[], authProviders:[], primaryModel:'', fallbacks:[] }; let wizCur = 1; let logTimer = null; let selectedAuthProv = null; // ── 初始化(enterApp 触发) ───────────────────────────────── async function checkStatus(){ try{ const r = await api('GET','/api/status'); if(r.needsSetup){ location.reload(); return; } setDot('ok'); document.getElementById('statusTxt').textContent = r.dir.replace(/.*[/\\\\]/,'.../')+' · v'+r.version; document.getElementById('versionBadge').textContent = 'v'+r.version; }catch{ setDot('err'); document.getElementById('statusTxt').textContent='无法读取配置'; } } async function loadAll(){ await Promise.all([loadAgents(), loadModels(), loadChannels()]); } // ── Dashboard ───────────────────────────────────────────────── let dashLoaded=false; function fmtBytes(b){if(!b||b<=0)return '—';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i0?1:0)+' '+u[i];} function fmtUptime(s){const d=Math.floor(s/86400);const h=Math.floor((s%86400)/3600);const m=Math.floor((s%3600)/60);if(d>0)return d+'d '+h+'h '+m+'m';if(h>0)return h+'h '+m+'m';return m+'m';} function dashRow(label,val){return '
'+esc(label)+''+val+'
';} function dashBar(pct){const cls=pct>90?'danger':pct>70?'warn':'';return '
';} async function loadDashboard(){ try{ const r=await api('GET','/api/dashboard'); if(!r.ok)return; const s=r.system, g=r.gateway, a=r.agents; // System card const memPct=s.totalMem?((s.totalMem-s.freeMem)/s.totalMem*100):0; let sysHtml=dashRow('Hostname',esc(s.hostname)); sysHtml+=dashRow('OS',esc(s.platform)); sysHtml+=dashRow('Node.js',esc(s.nodeVer)); sysHtml+=dashRow('CPU',esc(s.cpuModel)+' ('+s.cpuCores+' cores)'); sysHtml+=dashRow('Uptime',fmtUptime(s.uptime)); sysHtml+=dashRow('Memory',fmtBytes(s.totalMem-s.freeMem)+' / '+fmtBytes(s.totalMem)+' ('+memPct.toFixed(0)+'%)'); sysHtml+=dashBar(memPct); document.getElementById('dashSysItems').innerHTML=sysHtml; // Gateway card const statusIcon=''; const statusLabel=g.status==='running'?'Running':g.status==='stopped'?'Stopped':'Unknown'; let gwHtml=dashRow('Status',statusIcon+statusLabel); gwHtml+=dashRow('Port',String(g.port||'—')); if(g.pid)gwHtml+=dashRow('PID',g.pid); gwHtml+=dashRow('HTTP Ping',g.ping?'✓ Reachable':'✗ Unreachable'); document.getElementById('dashGwItems').innerHTML=gwHtml; // Agents card let agHtml=dashRow('Total Agents',String(a.count)); if(a.lastActivity){ const d=new Date(a.lastActivity); agHtml+=dashRow('Last Activity',d.toLocaleString('en-AU',{timeZone:'Australia/Brisbane',hour12:false})); }else{ agHtml+=dashRow('Last Activity','—'); } agHtml+=dashRow('OCM Version','v'+esc(r.ocmVersion)); agHtml+=dashRow('Server Time',esc(r.serverTime)); document.getElementById('dashAgentItems').innerHTML=agHtml; // Storage card let stHtml=dashRow('OpenClaw Dir Size',fmtBytes(s.dirSize)); if(s.diskTotal>0){ const diskPct=s.diskUsed/s.diskTotal*100; stHtml+=dashRow('Disk',fmtBytes(s.diskUsed)+' / '+fmtBytes(s.diskTotal)+' ('+diskPct.toFixed(0)+'%)'); stHtml+=dashBar(diskPct); stHtml+=dashRow('Disk Free',fmtBytes(s.diskFree)); } document.getElementById('dashStorageItems').innerHTML=stHtml; dashLoaded=true; }catch(e){console.error('Dashboard load error:',e);} } let healthTimer=null; async function refreshHealth(){ try{ const r=await api('GET','/api/health'); const badge=document.getElementById('healthBadge'); const icon=document.getElementById('healthIcon'); const cnt=document.getElementById('healthCount'); const tip=document.getElementById('healthTooltip'); if(!badge)return; if(!r.issues||r.issues.length===0){ badge.className=''; badge.style.display='none'; return; } const errors=r.issues.filter(i=>i.level==='error').length; const warns=r.issues.filter(i=>i.level==='warn').length; badge.style.display='flex'; badge.className=errors>0?'error':'warn'; icon.textContent=errors>0?'🔴':'🟡'; cnt.textContent=r.issues.length+(lang==='en'?' issue(s)':' 个问题'); tip.textContent=r.issues.map(i=>(i.level==='error'?'❌ ':'⚠️ ')+i.text).join('\\n'); }catch(_){} } function startHealthPolling(){ refreshHealth(); if(healthTimer) clearInterval(healthTimer); healthTimer=setInterval(refreshHealth,60000); } async function loadAgents(){ try{ const r=await api('GET','/api/agents'); S.agents=r.agents||[]; renderAgents(); } catch(e){ toast('加载 Agent 失败: '+e.message,'error'); } } async function loadModels(){ try{ const r=await api('GET','/api/models'); S.models=r.models||{}; S.authProfiles=r.authProfiles||{}; S.knownModels=r.knownModels||[]; S.authProviders=r.authProviders||[]; S.primaryModel=r.primaryModel||''; S.fallbacks=r.fallbacks||[]; renderModels(); renderAuth(); buildModelDropdowns(); }catch(e){ toast('加载模型失败: '+e.message,'error'); } } async function loadChannels(){ try{ const r=await api('GET','/api/channels'); S.channels=r.channels||[]; renderChannels(); } catch(e){ toast('加载 Channel 失败: '+e.message,'error'); } } // ── 渲染 Agents ───────────────────────────────────────────── // ── Show Add Form ───────────────────────────────────────────── function clearAddForm() { document.getElementById('addFormArea').innerHTML = ''; } function showAddForm(type) { const area = document.getElementById('addFormArea'); if (type === 'agent') { area.innerHTML = buildAddAgentForm(); } else { area.innerHTML = buildAddSubForm(); } applyLang(); } function buildAddAgentForm() { const modelOpts = buildModelOpts('__default__'); return '
' + '

' + t('agents.addAgentTitle') + '

' + '
' + t('guide.title') + '
    ' + '
  1. ' + t('guide.agent.s1') + '
  2. ' + '
  3. ' + t('guide.agent.s2') + '
  4. ' + '
  5. ' + t('guide.agent.s3') + '
  6. ' + '
  7. ' + t('guide.agent.s4') + '
  8. ' + '
' + '
' + '' + '
' + '
' + '' + 'Alphanumeric, underscore, or dash only
' + '
' + '' + '
' + '
' + '' + '
' + '
' + '' + '
' + '
' + '' + '' + '
'; } function buildAddSubForm() { // Build parent agent dropdown (agents with their own bot) const cfg = S.agents || []; 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') + '

' + '
' + t('guide.title') + '
    ' + '
  1. ' + t('guide.sub.s1') + '
  2. ' + '
  3. ' + t('guide.sub.s2') + '
  4. ' + '
  5. ' + t('guide.sub.s3') + '
  6. ' + '
' + '
' + '
' + '
' + '' + '' + t('wiz.groupHint') + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
'; } // ── 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 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'); } } // ── 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(); const model = document.getElementById('fs-model').value; 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; } try { 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 = ''; showRestartBanner(); await loadAll(); } catch (e) { toast(e.message, 'err'); } } // ── Render Agent Tree ────────────────────────────────────── function renderAgents() { const el = document.getElementById('agentTree'); if (!S.agents.length) { el.innerHTML = '
' + t('agents.empty') + '
'; return; } // Build tree: each hasOwnBot agent is a root, others are sub-agents grouped by parentAccountId const roots = S.agents.filter(a => a.hasOwnBot); const subs = S.agents.filter(a => !a.hasOwnBot); // Map accountId -> root agent for sub-agent grouping const rootByAccount = {}; roots.forEach(a => { if (a.accountId) rootByAccount[a.accountId] = a; }); // Group subs under their parent 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 += '
'; h += '
' + icon + ' ' + esc(a.name || a.id); if (isRoot) h += ' ' + (a.id === 'main' ? t('agents.main') : 'Bot') + ''; if (a.groupId) h += ' ' + esc(a.groupId) + ''; h += '
'; h += '
🧠 ' + esc(a.effectiveModel) + '
'; if (a.workspace) h += '
📁 ' + esc(a.workspace) + '
'; h += '
'; h += ''; h += ''; if (a.workspace || !isRoot) h += ''; if (a.id !== 'main') h += ''; h += '
'; return h; } let html = ''; // Render each root with its children roots.forEach(root => { const children = subsByRoot[root.id] || []; html += '
'; html += agentCard(root, true); if (children.length) { const treeId = 'tree-' + root.id; html += '
'; html += ''; html += '
'; children.forEach(c => { html += agentCard(c, false); }); html += '
'; } html += '
'; }); // Orphan subs (no matching root — edge case) orphanSubs.forEach(a => { html += '
'; html += agentCard(a, false); 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){ let opts=\`\`; let lastGroup=''; S.knownModels.filter(m=>m.id!=='__default__').forEach(m=>{ if(m.group && m.group!==lastGroup){ if(lastGroup) opts+=\`\`; opts+=\`\`; lastGroup=m.group; } opts+=\`\`; }); if(lastGroup) opts+=\`\`; Object.keys(S.models).forEach(id=>{ if(!S.knownModels.find(k=>k.id===id)) opts+=\`\`; }); return opts; } async function saveAgentModel(agentId){ const sel=document.getElementById('msel-'+agentId); if(!sel)return; const model=sel.value; try{ await api('PUT','/api/agents/'+encodeURIComponent(agentId),{model}); document.getElementById('eml-'+agentId).textContent=model==='__default__'?t('agents.noModel')+' ('+S.primaryModel+')':model; toast('模型已更新','success'); showRestartBanner(); }catch(e){toast('保存失败: '+e.message,'error');} } // ── 渲染 Channels ──────────────────────────────────────────── function renderChannels(){ const el=document.getElementById('channelList'); if(!S.channels.length){el.innerHTML='
'+t('channels.empty')+'
';return;} el.innerHTML=S.channels.map(ch=>{ const chClass=ch.channel==='telegram'?'ch-tg':'ch-any'; return \`
\${esc(ch.agentName)} agent: \${esc(ch.agentId)} \${ch.channel?\`\${esc(ch.channel)}\`:'any'} \${ch.peerKind?\`\${esc(ch.peerKind)}\`:''}
\${ch.peerId?\`
📱 Peer ID: \${esc(ch.peerId)}
\`:('
'+t('channels.matchAll')+'
')}
\${t('channels.bindIdx')}\${ch.idx}
\`; }).join(''); } function openAddChannel(){ const sel=document.getElementById('ch-agent'); sel.innerHTML=''; S.agents.forEach(a=>{ sel.innerHTML+=\`\`; }); document.getElementById('ch-peerId').value=''; openModal('addChannelModal'); applyLang(); } async function submitAddChannel(){ const agentId=document.getElementById('ch-agent').value; const channel=document.getElementById('ch-channel').value; const peerKind=document.getElementById('ch-peerKind').value; const peerId=document.getElementById('ch-peerId').value.trim(); if(!agentId){toast('请选择 Agent','error');return;} try{ await api('POST','/api/channels',{agentId,channel,peerKind,peerId}); toast('绑定已添加','success'); closeModal('addChannelModal'); await loadChannels(); showRestartBanner(); }catch(e){toast('失败: '+e.message,'error');} } async function deleteChannel(idx,label){ if(!confirm(\`确定要删除绑定 "\${label}"?\`))return; try{ await api('DELETE','/api/channels/'+idx); toast('绑定已删除','success'); await loadChannels(); showRestartBanner(); }catch(e){toast('失败: '+e.message,'error');} } // ── 渲染模型 ───────────────────────────────────────────────── function renderModels(){ // 检测 primary model 是否是 API Key(显示修复警告) const primWarn=document.getElementById('primaryModelWarn'); if(primWarn){ const bad = S.primaryModel && !isValidModelId(S.primaryModel); primWarn.style.display = bad ? '' : 'none'; if(bad){ const masked = S.primaryModel.length > 16 ? S.primaryModel.slice(0,8)+'...'+ S.primaryModel.slice(-4) : S.primaryModel; primWarn.innerHTML=\`⚠️ 当前主模型设置异常:检测到 \${esc(masked)} 像是 API Key 而非模型 ID。 请从下拉选择正确的模型并保存,或点击下方按钮重置。
提示:模型 ID 格式为 provider/model-name,例如 anthropic/claude-sonnet-4-5
\`; } } const pSel=document.getElementById('primaryModelSel'); pSel.innerHTML=''; S.knownModels.filter(m=>m.id!=='__default__').forEach(m=>{ pSel.innerHTML+=\`\`; }); const fl=document.getElementById('fallbackList'); fl.innerHTML=S.fallbacks.length ? S.fallbacks.map((f,i)=>\`\${esc(f)} \`).join('') : ''+(lang==='en'?'(empty)':'(空)')+''; } function isValidModelId(id) { // Must be in format provider/model-id, no spaces, not an API key if (!id) return false; if (!id.includes('/')) return false; if (id.length > 120) return false; // Reject obvious API keys (long alphanumeric strings, or starts with sk-, gsk_, etc.) if (/^(sk-|gsk_|pplx-|sess-|ghp_|github_pat_)/.test(id)) return false; // Must only contain provider/model valid chars if (!/^[a-zA-Z0-9_./-]+$/.test(id)) return false; return true; } async function savePrimaryModel(){ const custom=document.getElementById('primaryModelCustom').value.trim(); const sel =document.getElementById('primaryModelSel').value; const model = custom || sel; if(!model){toast('请选择或填写模型','error');return;} if(custom && !isValidModelId(custom)){ toast('格式错误:模型 ID 应为 provider/model-id(例:anthropic/claude-sonnet-4-5),请勿在此粘贴 API Key。','error'); return; } try{ await api('PUT','/api/models/settings',{primaryModel:model, fallbacks:S.fallbacks}); S.primaryModel=model; toast('主模型已保存','success'); showRestartBanner(); document.getElementById('primaryModelCustom').value=''; renderModels(); }catch(e){toast('失败: '+e.message,'error');} } // 修复被错误设置为 API Key 的主模型 → 重置为第一个已注册模型或留空 async function fixBadPrimaryModel(){ // 尝试从已注册模型中取第一个可用 ID const firstModel = Object.keys(S.models||{}).find(k => isValidModelId(k)); const resetTo = firstModel || ''; if(!confirm(\`将主模型重置为"\${resetTo||'(清空,使用全局默认)'}"?\`)) return; try{ await api('PUT','/api/models/settings',{primaryModel:resetTo, fallbacks:S.fallbacks}); S.primaryModel=resetTo; toast(\`主模型已重置\${resetTo?' 为 '+resetTo:''}\`,'success'); showRestartBanner(); renderModels(); }catch(e){toast('重置失败: '+e.message,'error');} } function openAddFallback(){ buildFbDropdown(); // 显示当前 fallback 列表 const curEl=document.getElementById('fb-current-list'); if(curEl){ if(S.fallbacks.length){ curEl.innerHTML='当前 Fallback 链:'+S.fallbacks.map((f,i)=>\`\${i+1}. \${esc(f)}\`).join(''); }else{ curEl.innerHTML='当前尚未配置 Fallback'; } } document.getElementById('fb-custom').value=''; document.getElementById('fb-sel').value=''; openModal('addFallbackModal'); } function buildFbDropdown(){ const sel=document.getElementById('fb-sel'); sel.innerHTML=''; // 按 group 分组显示 const groups={}; S.knownModels.filter(m=>m.id!=='__default__'&&!S.fallbacks.includes(m.id)).forEach(m=>{ const g=m.group||'其他'; if(!groups[g]) groups[g]=[]; groups[g].push(m); }); Object.entries(groups).forEach(([g,items])=>{ const og=document.createElement('optgroup'); og.label=g; items.forEach(m=>{ const o=document.createElement('option'); o.value=m.id; o.textContent=m.label; og.appendChild(o); }); sel.appendChild(og); }); } function onFbSelChange(){ const v=document.getElementById('fb-sel').value; if(v) document.getElementById('fb-custom').value=''; } function onFbCustomInput(){ const v=document.getElementById('fb-custom').value.trim(); if(v) document.getElementById('fb-sel').value=''; } async function addFallback(){ const custom=document.getElementById('fb-custom').value.trim(); const sel =document.getElementById('fb-sel').value; const model = custom || sel; if(!model){toast('请输入或选择模型 ID','error');return;} if(!isValidModelId(model)){ toast('格式错误:模型 ID 应为 provider/model-id(例:deepseek/deepseek-v3),请勿粘贴 API Key。','error'); return; } if(S.fallbacks.includes(model)){toast('该模型已在 Fallback 链中','error');return;} S.fallbacks.push(model); try{ await api('PUT','/api/models/settings',{primaryModel:S.primaryModel, fallbacks:S.fallbacks}); closeModal('addFallbackModal'); renderModels(); showRestartBanner(); }catch(e){ S.fallbacks.pop(); toast('失败: '+e.message,'error'); } } async function removeFallback(i){ const removed=S.fallbacks.splice(i,1); try{ await api('PUT','/api/models/settings',{primaryModel:S.primaryModel, fallbacks:S.fallbacks}); renderModels(); showRestartBanner(); }catch(e){ S.fallbacks.splice(i,0,...removed); toast('失败: '+e.message,'error'); } } // ── 渲染认证(引导模式)───────────────────────────────────── function renderAuth(){ const grid=document.getElementById('authProvGrid'); // 标记已配置的 provider const configured=new Set(Object.keys(S.authProfiles||{}).map(k=>k.toLowerCase())); grid.innerHTML=S.authProviders.map(p=>{ const done=configured.has(p.id); return \`\`; }).join(''); // Configured list const el=document.getElementById('authList'); const entries=Object.entries(S.authProfiles); if(!entries.length){el.innerHTML='
'+(lang==='en'?'No auth configured':'暂无认证配置')+'
';return;} el.innerHTML=entries.map(([key,p])=>\`
\${esc(key)} \${p.mode||'token'}
\${p.email?\`
📧 \${esc(p.email)}
\`:''}
\`).join(''); } function selectAuthProv(pid){ selectedAuthProv=pid; const provider=S.authProviders.find(p=>p.id===pid); if(!provider)return; renderAuth(); const card=document.getElementById('authActionCard'); const content=document.getElementById('authActionContent'); card.style.display=''; if(provider.mode==='oauth'){ content.innerHTML=\`

\${esc(provider.label)} — OAuth

\${t('auth.step1')}
\${lang==='en'?'This provider uses browser-based OAuth.':'此 Provider 使用浏览器 OAuth 授权。'}

\${t('auth.step2')}
\${esc(provider.cliCmd)}

\${t('auth.onboard')}
openclaw onboard
\`; } else if(provider.mode==='device'){ content.innerHTML=\`

\${esc(provider.label)} — Device Flow

\${t('auth.step1')}
1. \${lang==='en'?'Run in terminal':'在终端运行'}:\${esc(provider.cliCmd)}
2. \${lang==='en'?'Copy the 8-digit device code':'复制终端显示的 8 位设备码'}
3. \${lang==='en'?'Open':'打开'}:github.com/login/device
4. \${lang==='en'?'Paste the code and authorize':'粘贴设备码并授权'}

\${t('auth.onboard')}
openclaw onboard
\`; } else { content.innerHTML=\`

\${esc(provider.label)} — API Key

\${t('auth.step1')}
\${esc(provider.hint||'')}

\${t('auth.step2')}
\${esc(provider.cliCmd)}

\${t('auth.step3')}
\${lang==='en'?'The key is stored securely by OpenClaw, not in config files.':'Key 由 OpenClaw 安全存储,不写入配置文件。'}

\${t('auth.onboard')}
openclaw onboard
\`; } } async function refreshAuthOnly(){ try{ const r=await api('GET','/api/models'); S.authProfiles=r.authProfiles||{}; S.models=r.models||{}; S.knownModels=r.knownModels||[]; renderAuth(); buildModelDropdowns(); }catch(e){ toast((lang==='en'?'Refresh failed: ':'刷新失败: ')+e.message,'error'); } } async function deleteAuth(key){ if(!confirm((lang==='en'?'Remove auth "':'移除认证配置 "')+key+'"?'))return; try{await api('DELETE','/api/auth/'+encodeURIComponent(key)); toast(lang==='en'?'Removed':'已移除','success'); await refreshAuthOnly();} catch(e){toast((lang==='en'?'Failed: ':'失败: ')+e.message,'error');} } function copyText(txt){ navigator.clipboard.writeText(txt).then(()=>toast('已复制','success')).catch(()=>toast('复制失败','error')); } async function deleteAgent(agentId, name){ if(!confirm(\`确定要删除 Agent "\${name}"?\\n\\n• 将从 openclaw.json 移除(自动备份)\\n• Workspace 目录不删除\\n• 可通过"回滚"功能恢复配置\\n\\n继续?\`))return; try{ const r=await api('DELETE','/api/agents/'+encodeURIComponent(agentId)); toast('已删除 '+agentId,'success'); await loadAgents(); await loadChannels(); showRestartBanner(); }catch(e){toast('删除失败: '+e.message,'error');} } let wsCurrentAgent=''; async function viewWorkspace(agentId){ wsCurrentAgent=agentId; const el=document.getElementById('wsContent'); el.innerHTML='
'+(lang==='en'?'Loading...':'加载中...')+'
'; openModal('wsModal'); try{ const r=await api('GET','/api/workspace/'+encodeURIComponent(agentId)); const fileNames=Object.keys(r.files); if(!fileNames.length){ el.innerHTML='
'+(lang==='en'?'No files':'暂无文件')+'
'; return; } el.innerHTML='

📁 '+esc(r.workspacePath)+'

' +fileNames.map(name=>{ const content=r.files[name]; const st=r.fileStats&&r.fileStats[name]?r.fileStats[name]:{}; const sizeStr=st.size!=null?(st.size<1024?st.size+' B':(st.size/1024).toFixed(1)+' KB'):''; const mtimeStr=st.mtime?new Date(st.mtime).toLocaleString():''; const fid='wsf-'+name.replace(/[^a-zA-Z0-9]/g,'_'); return \`
\${esc(name)} \${esc(sizeStr)}\${mtimeStr?' · '+esc(mtimeStr):''} \${content!==null?\`\`:''}
\${content!==null ?\`
\${esc(content)}
\` :\`
\${content===null&&st.size>512*1024?t('ws.large'):(lang==='en'?'(file not found)':'(文件不存在)')}
\`}
\`; }).join(''); }catch(e){el.innerHTML='
'+(lang==='en'?'Failed: ':'加载失败: ')+e.message+'
';} } function wsToggleEdit(agentId,fname,fid){ const pre=document.getElementById('pre-'+fid); const ta=document.getElementById('ta-'+fid); const acts=document.getElementById('acts-'+fid); const btn=document.getElementById('wsbtn-'+fid); if(ta.style.display==='none'){ ta.style.display=''; pre.style.display='none'; acts.style.display='flex'; btn.textContent=t('ws.cancel'); ta.focus(); } else { wsCancelEdit(fid); } } function wsCancelEdit(fid){ const pre=document.getElementById('pre-'+fid); const ta=document.getElementById('ta-'+fid); const acts=document.getElementById('acts-'+fid); const btn=document.getElementById('wsbtn-'+fid); ta.style.display='none'; pre.style.display=''; acts.style.display='none'; if(btn) btn.textContent=t('ws.edit'); // revert textarea to pre content ta.value=pre.textContent; } async function wsSaveFile(agentId,fname,fid){ const ta=document.getElementById('ta-'+fid); try{ await api('PUT','/api/workspace/'+encodeURIComponent(agentId)+'/'+encodeURIComponent(fname),{content:ta.value}); toast(t('ws.saved'),'success'); // update pre with new content const pre=document.getElementById('pre-'+fid); pre.textContent=ta.value; wsCancelEdit(fid); }catch(e){toast((lang==='en'?'Save failed: ':'保存失败: ')+e.message,'error');} } function buildModelDropdowns(){ S.agents.forEach(a=>{ const sel=document.getElementById('msel-'+a.id); if(!sel) return; const current=a.effectiveModel!=='默认'?a.effectiveModel:'__default__'; sel.innerHTML=buildModelOpts(current); }); } // ── CLI 终端 ───────────────────────────────────────────────── const CLI_DEFAULTS = [ { label: 'openclaw doctor', cmd: 'openclaw doctor' }, { label: 'openclaw --version', cmd: 'openclaw --version' }, { label: 'openclaw gateway status', cmd: 'openclaw gateway status' }, { label: 'openclaw gateway restart', cmd: 'openclaw gateway restart' }, { label: 'openclaw gateway start', cmd: 'openclaw gateway start' }, { label: 'openclaw gateway stop', cmd: 'openclaw gateway stop' }, { label: 'openclaw gateway logs', cmd: 'openclaw gateway logs' }, { label: 'openclaw models list', cmd: 'openclaw models list' }, { label: 'openclaw models auth list', cmd: 'openclaw models auth list' }, { label: 'openclaw agents list', cmd: 'openclaw agents list' }, { label: 'openclaw agents sync', cmd: 'openclaw agents sync' }, { label: 'openclaw backup create', cmd: 'openclaw backup create' }, { label: 'openclaw backup list', cmd: 'openclaw backup list' }, { label: 'openclaw config validate', cmd: 'openclaw config validate' }, { label: 'openclaw update', cmd: 'openclaw update' }, ]; let cliHistory=[], cliHistIdx=-1, cliEvt=null; function buildCliPresets(){ const sel=document.getElementById('cliPreset'); const favs=JSON.parse(LS.get('ocm_cli_favs','[]')); sel.innerHTML=''; const og1=document.createElement('optgroup'); og1.label=t('cli.builtins'); CLI_DEFAULTS.forEach(c=>{ const o=document.createElement('option'); o.value=c.cmd; o.textContent=c.label; og1.appendChild(o); }); sel.appendChild(og1); if(favs.length){ const og2=document.createElement('optgroup'); og2.label=t('cli.favs'); favs.forEach(c=>{ const o=document.createElement('option'); o.value=c.cmd; o.textContent=(c.label||c.cmd); og2.appendChild(o); }); sel.appendChild(og2); } } function toggleCliPanel(){ const p=document.getElementById('cliPanel'); const open=p.classList.toggle('open'); const btn=document.getElementById('cliToggleBtn'); if(btn) btn.classList.toggle('active', open); const prb=document.getElementById('pendingRestartBtn'); if(prb) prb.style.bottom=open?'292px':'24px'; // Adjust main content padding so content behind panel remains reachable via scroll const mainEl=document.querySelector('main'); if(mainEl) mainEl.style.paddingBottom = open ? (p.offsetHeight+24)+'px' : ''; if(open){ buildCliPresets(); setTimeout(()=>document.getElementById('cliInput').focus(),100); scrollCliToBottom(); } } function onCliPresetSelect(){ const v=document.getElementById('cliPreset').value; if(v){ document.getElementById('cliInput').value=v; document.getElementById('cliInput').focus(); } document.getElementById('cliPreset').value=''; } function addCliToFavorites(){ const cmd=document.getElementById('cliInput').value.trim(); if(!cmd){ toast(t('cli.fav.empty'),'error'); return; } const favs=JSON.parse(LS.get('ocm_cli_favs','[]')); if(favs.find(f=>f.cmd===cmd)){ toast(t('cli.fav.dup'),'error'); return; } const label=prompt(t('cli.favprompt'),cmd.slice(0,50)); if(label===null) return; favs.push({label:label||cmd, cmd}); LS.set('ocm_cli_favs',JSON.stringify(favs)); buildCliPresets(); toast(t('cli.fav.saved')+': '+(label||cmd),'success'); } function openCliManage(){ const favs=JSON.parse(LS.get('ocm_cli_favs','[]')); const list=document.getElementById('cliManageList'); if(!favs.length){ list.innerHTML='
'+ (lang==='en'?'No saved commands yet.':'暂无收藏命令')+'
'; } else { list.innerHTML=favs.map((f,i)=>'
'+ '
'+ '
'+esc(f.label||f.cmd)+'
'+ '
'+esc(f.cmd)+'
'+ '
'+ ''+ ''+ '
').join(''); } document.getElementById('cliManageModal').classList.add('open'); } function deleteCliFav(i){ const favs=JSON.parse(LS.get('ocm_cli_favs','[]')); favs.splice(i,1); LS.set('ocm_cli_favs',JSON.stringify(favs)); buildCliPresets(); openCliManage(); } function editCliFav(i){ const favs=JSON.parse(LS.get('ocm_cli_favs','[]')); const f=favs[i]; const newLabel=prompt((lang==='en'?'Edit name:':'修改名称:'),f.label||f.cmd); if(newLabel===null) return; const newCmd=prompt((lang==='en'?'Edit command:':'修改命令:'),f.cmd); if(newCmd===null||!newCmd.trim()) return; favs[i]={label:newLabel||newCmd.trim(), cmd:newCmd.trim()}; LS.set('ocm_cli_favs',JSON.stringify(favs)); buildCliPresets(); openCliManage(); } function cliAppend(text,cls){ const out=document.getElementById('cliOutput'); const span=document.createElement('span'); if(cls) span.className=cls; span.textContent=text; out.appendChild(span); scrollCliToBottom(); } function scrollCliToBottom(){ const o=document.getElementById('cliOutput'); if(o) o.scrollTop=o.scrollHeight; } function clearCliOutput(){ document.getElementById('cliOutput').innerHTML=''+t('cli.cleared')+''; } function setCliRunning(running){ const sb=document.getElementById('cliStopBtn'); if(sb) sb.style.display=running?'':'none'; } function killCli(){ if(cliEvt){ try{cliEvt.close();}catch(_){} cliEvt=null; } setCliRunning(false); cliAppend('\\n⏹ 已中止\\n','cli-done-err'); } function runCli(){ const inp=document.getElementById('cliInput'); const cmd=inp.value.trim(); if(!cmd){ toast('请输入命令','error'); return; } if(!cliHistory.length||cliHistory[0]!==cmd) cliHistory.unshift(cmd); if(cliHistory.length>50) cliHistory.pop(); cliHistIdx=-1; inp.value=''; if(cliEvt){ try{cliEvt.close();}catch(_){} cliEvt=null; } const out=document.getElementById('cliOutput'); // Clear ready/cleared placeholder if(out.children.length===1 && out.children[0].id==='cliReadyMsg') out.innerHTML=''; if(out.textContent===t('cli.cleared')) out.innerHTML=''; cliAppend('\\n$ '+cmd+'\\n','cli-cmd-line'); setCliRunning(true); const es=new EventSource('/api/cli/stream?cmd='+encodeURIComponent(cmd)); cliEvt=es; es.addEventListener('out',e=>{ try{ cliAppend(JSON.parse(e.data).text); }catch(_){} }); es.addEventListener('done',e=>{ try{ const d=JSON.parse(e.data); if(d.code===0) cliAppend('\\n✅ 完成 (exit 0)\\n','cli-done-ok'); else cliAppend('\\n❌ 退出码 '+d.code+'\\n','cli-done-err'); }catch(_){} es.close(); cliEvt=null; setCliRunning(false); }); es.addEventListener('error',e=>{ try{ cliAppend('\\n⚠ '+(JSON.parse(e.data).message||'命令执行出错')+'\\n','cli-done-err'); }catch(_){} es.close(); cliEvt=null; setCliRunning(false); }); es.onerror=()=>{ if(es.readyState===EventSource.CLOSED){ cliEvt=null; setCliRunning(false); } }; } function onCliKey(e){ if(e.key==='Tab'){ e.preventDefault(); cliTabComplete(e.target); return; } if(e.key==='Escape'){ cliCloseAC(); return; } if(e.key==='Enter'){ cliCloseAC(); runCli(); return; } if(e.key==='ArrowUp'){ e.preventDefault(); if(cliHistIdx0){ cliHistIdx--; e.target.value=cliHistory[cliHistIdx]; } else if(cliHistIdx===0){ cliHistIdx=-1; e.target.value=''; } } // 其他键关闭补全菜单 if(e.key!=='Tab'&&e.key!=='ArrowUp'&&e.key!=='ArrowDown') cliCloseAC(); } // ── CLI Tab 补全 ───────────────────────────────────────────── const CLI_SUBCOMMANDS=['openclaw','doctor','gateway','models','agents','backup','config','auth', 'restart','start','stop','status','logs','list','create','validate','update','onboard','sync', 'paste-token','--provider','--version','--dir','--port','--host']; function cliTabComplete(input){ const val=input.value; const words=val.split(/\\s+/); const lastWord=words[words.length-1]||''; if(!lastWord){ return; } // 收集候选: subcommands + preset commands + history const candidates=new Set(); CLI_SUBCOMMANDS.forEach(c=>candidates.add(c)); CLI_DEFAULTS.forEach(c=>candidates.add(c.cmd)); cliHistory.forEach(c=>candidates.add(c)); // 过滤: 如果是完整命令行匹配(words.length>1 时只匹配最后一个词) let matches=[]; if(words.length===1){ // 匹配完整命令或第一个词 matches=[...candidates].filter(c=>c.toLowerCase().startsWith(lastWord.toLowerCase())); } else { // 匹配子命令词 matches=[...CLI_SUBCOMMANDS,...CLI_DEFAULTS.map(c=>c.cmd.split(/\\s+/).pop())] .filter(c=>c.toLowerCase().startsWith(lastWord.toLowerCase())); matches=[...new Set(matches)]; } if(matches.length===0) return; if(matches.length===1){ // 单一匹配:直接补全 if(words.length===1){ input.value=matches[0]; } else { words[words.length-1]=matches[0]; input.value=words.join(' '); } cliCloseAC(); } else { // 多个匹配:找公共前缀先补全,同时显示候选列表 const prefix=commonPrefix(matches); if(prefix.length>lastWord.length){ if(words.length===1){ input.value=prefix; } else { words[words.length-1]=prefix; input.value=words.join(' '); } } cliShowAC(matches.slice(0,12),input); } } function commonPrefix(arr){ if(!arr.length) return ''; let p=arr[0]; for(let i=1;i0) p=p.slice(0,-1); } return p; } function cliShowAC(items,input){ cliCloseAC(); const box=document.createElement('div'); box.id='cliACBox'; box.style.cssText='position:absolute;bottom:100%;left:0;right:0;background:var(--surface);border:1px solid var(--border);border-radius:6px;max-height:180px;overflow-y:auto;z-index:200;box-shadow:0 -4px 12px rgba(0,0,0,.3)'; items.forEach(item=>{ const d=document.createElement('div'); d.textContent=item; d.style.cssText='padding:6px 12px;font-size:12px;font-family:monospace;cursor:pointer;color:var(--text);border-bottom:1px solid var(--border)'; d.onmouseover=()=>{d.style.background='rgba(108,99,255,.15)';}; d.onmouseout=()=>{d.style.background='';}; d.onclick=()=>{ input.value=item; input.focus(); cliCloseAC(); }; box.appendChild(d); }); const row=input.closest('.cli-input-row'); if(row){ row.style.position='relative'; row.appendChild(box); } } function cliCloseAC(){ const b=document.getElementById('cliACBox'); if(b) b.remove(); } // ── 工具栏操作 ─────────────────────────────────────────────── // 拖拽调整 CLI 面板高度 (function(){ const handle=document.getElementById('cliResizeHandle'); if(!handle) return; let sy=0, sh=0; handle.addEventListener('mousedown',function(e){ sy=e.clientY; sh=document.getElementById('cliOutput').offsetHeight; e.preventDefault(); document.addEventListener('mousemove',onDrag); document.addEventListener('mouseup',function(){ document.removeEventListener('mousemove',onDrag); },{once:true}); }); function onDrag(e){ const newH=Math.max(80,Math.min(600,sh+(sy-e.clientY))); document.getElementById('cliOutput').style.height=newH+'px'; const prb=document.getElementById('pendingRestartBtn'); if(prb) prb.style.bottom=(newH+72)+'px'; } })(); // ── Stats (Usage Statistics) ──────────────────────────────── async function loadStats(){ try{ const days=document.getElementById('statsDaysFilter')?.value||30; const r=await api('GET','/api/stats?days='+days); 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||{}).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])); if(!dayEntries.length){ chartEl.innerHTML='
'+t('stats.noData')+'
'; return; } const maxTk=Math.max(...dayEntries.map(([,d])=>(d.inputTokens||0)+(d.outputTokens||0)),1); 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) + '
' + '
'; }).join(''); }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); } // ── Cron(计划任务)────────────────────────────────────────── async function loadCrons(){ try{ const r=await api('GET','/api/cron'); const el=document.getElementById('cronList'); if(!r.crons||!r.crons.length){ el.innerHTML='
'+t('cron.empty')+'
'; return; } el.innerHTML=r.crons.map(c=>\`
\${esc(c.command)}
⏰ \${esc(c.schedule)}\${c.label?' · '+esc(c.label):''}
\${c.enabled?t('cron.enabled'):t('cron.disabled')}
\`).join(''); }catch(e){ toast((lang==='en'?'Cron load failed: ':'任务加载失败: ')+e.message,'error'); } } function openAddCron(){ document.getElementById('cron-schedule').value=''; document.getElementById('cron-command').value=''; document.getElementById('cron-label').value=''; openModal('addCronModal'); } async function submitAddCron(){ const schedule=document.getElementById('cron-schedule').value.trim(); const command=document.getElementById('cron-command').value.trim(); const label=document.getElementById('cron-label').value.trim(); if(!schedule||!command){toast(lang==='en'?'Fill in expression and command':'请填写表达式和命令','error');return;} try{ await api('POST','/api/cron',{schedule,command,label:label||'openclaw-manager'}); toast(lang==='en'?'Task added':'任务已添加','success'); closeModal('addCronModal'); await loadCrons(); }catch(e){toast((lang==='en'?'Failed: ':'失败: ')+e.message,'error');} } async function toggleCron(idx,currentEnabled){ try{ await api('PUT','/api/cron/'+idx,{enabled:!currentEnabled}); await loadCrons(); }catch(e){toast((lang==='en'?'Failed: ':'失败: ')+e.message,'error');} } async function runCronNow(idx){ toast(lang==='en'?'Running...':'执行中...','info'); try{ const r=await api('POST','/api/cron/'+idx+'/run'); toast(lang==='en'?'Done':'完成','success'); }catch(e){toast((lang==='en'?'Failed: ':'失败: ')+e.message,'error');} } async function deleteCron(idx){ if(!confirm(lang==='en'?'Delete this task?':'删除此任务?'))return; try{ await api('DELETE','/api/cron/'+idx); toast(lang==='en'?'Deleted':'已删除','success'); await loadCrons(); } catch(e){toast((lang==='en'?'Failed: ':'失败: ')+e.message,'error');} } // ── 工具栏操作 ─────────────────────────────────────────────── function toggleMenu(){document.getElementById('mainMenu').classList.toggle('open');} document.addEventListener('click',e=>{ const app=document.getElementById('mainApp'); if(app&&!e.target.closest('.menu-wrap'))document.getElementById('mainMenu').classList.remove('open'); }); async function doRestart(){ closeMenu(); dismissBanner(); hidePendingRestart(); document.getElementById('cmdTitle').textContent=t('actions.restartTitle'); document.getElementById('cmdOutput').textContent=t('actions.running'); openModal('cmdModal'); try{ const r=await api('POST','/api/gateway/restart'); document.getElementById('cmdOutput').textContent=r.output||t('actions.restartSent'); toast(t('actions.restartOk'),'success'); }catch(e){ document.getElementById('cmdOutput').textContent='❌ '+e.message+'\\n\\n'+t('actions.restartManualHint'); toast(t('actions.restartFail')+e.message,'error'); } } async function doDoctor(){ closeMenu(); document.getElementById('cmdTitle').textContent=t('actions.doctorTitle'); document.getElementById('cmdOutput').textContent=t('actions.running'); openModal('cmdModal'); try{ const r=await api('POST','/api/gateway/doctor'); document.getElementById('cmdOutput').textContent=r.output||t('actions.noOutput'); }catch(e){document.getElementById('cmdOutput').textContent='❌ '+e.message;} } async function manualBackup(){ closeMenu(); try{const r=await api('POST','/api/config/backup'); toast(t('actions.backupSaved')+r.bakPath.split('/').pop(),'success');} catch(e){toast(t('actions.backupFail')+e.message,'error');} } function openConfigDir(){ closeMenu(); api('POST','/api/folder/open').catch(()=>{}); } function closeMenu(){ document.getElementById('mainMenu').classList.remove('open'); } // ── 日志 ───────────────────────────────────────────────────── async function openLogs(){ closeMenu(); openModal('logModal'); await refreshLogs(); } async function refreshLogs(){ try{ const r=await api('GET','/api/logs?n=300'); document.getElementById('logContent').textContent=r.content||t('actions.logEmpty'); document.getElementById('logPath').textContent=r.path||''; const lb=document.getElementById('logContent'); lb.scrollTop=lb.scrollHeight; }catch(e){document.getElementById('logContent').textContent=t('actions.logLoadFail')+e.message;} } function toggleAutoRefresh(){ const cb=document.getElementById('autoRefresh'); if(cb.checked){ logTimer=setInterval(refreshLogs,2000); } else{ clearInterval(logTimer); logTimer=null; } } function closeLogs(){ clearInterval(logTimer); logTimer=null; document.getElementById('autoRefresh').checked=false; closeModal('logModal'); } // ── 回滚 ───────────────────────────────────────────────────── async function openRollback(){ closeMenu(); openModal('rollbackModal'); const el=document.getElementById('backupList'); el.innerHTML='
'+t('common.loading')+'
'; try{ const r=await api('GET','/api/backups'); if(!r.backups.length){el.innerHTML='
'+t('actions.noBackups')+'
';return;} el.innerHTML=r.backups.map(f=>\`
\${esc(f)}
\`).join(''); }catch(e){el.innerHTML='
'+t('actions.loadFailed')+'
';} } async function doRestore(filename){ const confirmMsg=t('actions.restoreConfirm').replace('{filename}',filename); if(!confirm(confirmMsg))return; try{ await api('POST','/api/backups/restore',{filename}); toast(t('actions.restored')+filename,'success'); closeModal('rollbackModal'); await loadAll(); showRestartBanner(); }catch(e){toast(t('actions.restoreFail')+e.message,'error');} } // ── NAS 备份 ───────────────────────────────────────────────── async function openNasModal(){ closeMenu(); const cfg=await api('GET','/api/backup/nas-config'); document.getElementById('nas-host').value=cfg.nasHost||''; document.getElementById('nas-port').value=cfg.nasPort||'22'; document.getElementById('nas-user').value=cfg.nasUser||''; document.getElementById('nas-sshkey').value=cfg.nasSshKey||''; document.getElementById('nas-path').value=cfg.nasPath||'/volume1/OpenClaw/backups'; document.getElementById('nas-legacy').checked=!!cfg.nasLegacyCipher; document.getElementById('nas-password').value=''; document.getElementById('nas-result').style.display='none'; document.getElementById('nas-cron-section').style.display='none'; document.getElementById('nas-pubkey-box').style.display='none'; const authType=cfg.nasAuth||'password'; document.getElementById(authType==='key'?'nas-auth-key':'nas-auth-pw').checked=true; nasAuthChange(); const bkType=cfg.nasBackupType||'full'; document.getElementById(bkType==='essential'?'nas-bk-ess':'nas-bk-full').checked=true; openModal('nasModal'); } function nasAuthChange(){ const isKey=document.getElementById('nas-auth-key').checked; document.getElementById('nas-pw-section').style.display=isKey?'none':''; document.getElementById('nas-key-section').style.display=isKey?'':'none'; } function nasGetConfig(){ return { nasHost: document.getElementById('nas-host').value.trim(), nasPort: document.getElementById('nas-port').value.trim()||'22', nasUser: document.getElementById('nas-user').value.trim(), nasAuth: document.getElementById('nas-auth-key').checked?'key':'password', nasSshKey: document.getElementById('nas-sshkey').value.trim(), nasPath: document.getElementById('nas-path').value.trim(), nasLegacyCipher: document.getElementById('nas-legacy').checked, nasBackupType: document.getElementById('nas-bk-ess').checked?'essential':'full', }; } function nasShowResult(text,ok){ const el=document.getElementById('nas-result'); el.style.display=''; el.style.color=ok?'var(--success)':'#f85149'; el.textContent=text; } async function nasGenKey(){ const cfg=nasGetConfig(); await api('PUT','/api/backup/nas-config',{...cfg,nasEnabled:true}); try{const r=await api('POST','/api/backup/nas-keygen'); document.getElementById('nas-sshkey').value=r.keyPath; const box=document.getElementById('nas-pubkey-box'); box.style.display=''; box.textContent=t('nas.pubkeyHint')+'\\n'+r.pubKey; nasShowResult(t('nas.keyGenOk'),true); }catch(e){nasShowResult(t('nas.keyGenFail')+e.message,false);} } async function nasTest(){ const cfg=nasGetConfig(); if(!cfg.nasHost||!cfg.nasUser){nasShowResult(t('nas.errNoHost'),false);return;} await api('PUT','/api/backup/nas-config',{...cfg,nasEnabled:true}); nasShowResult(t('nas.testing'),true); try{ const pwd=cfg.nasAuth==='password'?document.getElementById('nas-password').value:''; const r=await api('POST','/api/backup/nas-test',{password:pwd}); nasShowResult(r.ok?t('nas.testOk'):t('nas.testFail')+(r.error||r.output),r.ok); }catch(e){nasShowResult('❌ '+e.message,false);} } async function nasBackupNow(){ const cfg=nasGetConfig(); if(!cfg.nasHost||!cfg.nasUser){nasShowResult(t('nas.errNoHost'),false);return;} await api('PUT','/api/backup/nas-config',{...cfg,nasEnabled:true}); nasShowResult(t('nas.backing'),true); try{ const pwd=cfg.nasAuth==='password'?document.getElementById('nas-password').value:''; const r=await api('POST','/api/backup/nas-now',{password:pwd}); nasShowResult(t('nas.backupOk')+r.tarName,true); toast(t('nas.backupToast'),'success'); }catch(e){nasShowResult(t('nas.backupFail')+e.message,false);} } function openNasCron(){ const sec=document.getElementById('nas-cron-section'); sec.style.display=sec.style.display==='none'?'':'none'; } async function nasSetCron(){ const cfg=nasGetConfig(); if(!cfg.nasHost||!cfg.nasUser){nasShowResult(t('nas.errNoHost'),false);return;} await api('PUT','/api/backup/nas-config',{...cfg,nasEnabled:true}); try{ const pwd=cfg.nasAuth==='password'?document.getElementById('nas-password').value:''; const cronTime=document.getElementById('nas-cron-time').value||'03:00'; const r=await api('POST','/api/backup/nas-cron',{password:pwd,cronTime}); nasShowResult(t('nas.cronOk')+'('+cronTime+')',true); toast(t('nas.cronToast'),'success'); }catch(e){nasShowResult('❌ '+e.message,false);} } // ── 目录设置 ───────────────────────────────────────────────── function openSetupModal(){ closeMenu(); openModal('setupModal'); } async function submitSetup(){ const dir=document.getElementById('setup-dir').value.trim(); const err=document.getElementById('setup-err'); if(!dir){err.textContent=t('actions.setupEmpty');return;} try{ const r=await api('POST','/api/setup',{dir}); if(r.ok){ toast(t('actions.setupSwitching'),'success'); setTimeout(()=>location.reload(),800); } else err.textContent=r.error||t('actions.setupInvalid'); }catch(e){err.textContent=t('actions.setupReqFail')+e.message;} } // ── 重启横幅 ───────────────────────────────────────────────── function showRestartBanner(){ document.getElementById('restartBanner').classList.add('show'); } function dismissBanner(){ document.getElementById('restartBanner').classList.remove('show'); } function deferRestart(){ dismissBanner(); document.getElementById('pendingRestartBtn').style.display='block'; } function hidePendingRestart(){ document.getElementById('pendingRestartBtn').style.display='none'; } // ── 密码显示切换 ───────────────────────────────────────────── function togglePwd(inputId,btn){ const el=document.getElementById(inputId); if(!el)return; if(el.type==='password'){el.type='text';btn.textContent='🙈';} else{el.type='password';btn.textContent='👁';} } // ── 工具 ───────────────────────────────────────────────────── const OCM_CLIENT_VERSION='${APP_VERSION}'; async function api(method,path,body){ const opts={method,headers:{'Content-Type':'application/json'}}; if(body) opts.body=JSON.stringify(body); const r=await fetch(path,opts); const sv=r.headers.get('X-OCM-Version'); if(sv&&sv!==OCM_CLIENT_VERSION&&!window._ocmVersionWarn){window._ocmVersionWarn=true;toast('Server updated to v'+sv+'. Refresh page for latest version.','info');} const d=await r.json(); if(!r.ok){ 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');} function closeModal(id){document.getElementById(id).classList.remove('open');} function setDot(s){const el=document.getElementById('dot');if(el)el.className='dot '+s;} function toast(msg,type='info'){ const c=document.getElementById('toast'); const el=document.createElement('div'); el.style.cssText='background:var(--card);border:1px solid var(--border);border-radius:8px;padding:11px 15px;font-size:13px;max-width:340px;animation:fadeIn .2s'; if(type==='success') el.style.borderColor='var(--success)'; if(type==='error') el.style.borderColor='var(--danger)'; el.textContent=msg; c.appendChild(el); setTimeout(()=>el.remove(),4500); } function esc(s){ return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ── Tabs ───────────────────────────────────────────────────── document.querySelectorAll('.tab').forEach(t=>{ t.addEventListener('click',()=>{ document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active')); t.classList.add('active'); document.getElementById('panel-'+t.dataset.tab).classList.add('active'); // lazy-load if(t.dataset.tab==='dashboard'&&!dashLoaded) loadDashboard(); if(t.dataset.tab==='stats') loadStats(); if(t.dataset.tab==='cron') loadCrons(); }); }); // ── On load: initialize app ────────────────────────────────── (function() { LS.del('ocm_mode'); applyLang(); checkStatus().then(() => { loadAll(); loadDashboard(); }).catch(e => console.error('Init error:', e)); startHealthPolling(); })();`; const MAIN_HTML = ` OpenClaw Manager ${MAIN_HTML_BODY} `; function assertBrowserScriptSyntax(name, scriptText) { const tmpFile = path.join(os.tmpdir(), `ocm-${name}-${process.pid}.js`); fs.writeFileSync(tmpFile, scriptText, 'utf8'); const r = spawnSync(process.execPath, ['--check', tmpFile], { encoding: 'utf8', timeout: 5000 }); try { fs.unlinkSync(tmpFile); } catch (_) {} if (r.status !== 0) { const detail = (r.stderr || r.stdout || 'unknown syntax error').trim(); throw new Error( `${name} syntax check failed.\n${detail}\nHint: in MAIN_HTML_SCRIPT strings, write "\\\\n" instead of "\\n".` ); } } assertBrowserScriptSyntax('main-html-script', MAIN_HTML_SCRIPT); // ── HTTP 服务器 ─────────────────────────────────────────────── const server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); res.setHeader('X-OCM-Version', APP_VERSION); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } const urlObj = new URL(req.url, `http://127.0.0.1:${PORT}`); try { if (urlObj.pathname.startsWith('/api/')) { const body = ['POST', 'PUT', 'PATCH'].includes(req.method) ? await parseBody(req) : {}; await handleApi(req, res, urlObj, body); } else { const needsSetup = !(await configExists()); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'ETag': '"ocm-' + APP_VERSION + '"' }); res.end(needsSetup ? SETUP_HTML : MAIN_HTML); } } catch (err) { console.error('[ERROR]', err.message); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err.message })); } } }); server.listen(PORT, HOST, () => { const localUrl = `http://localhost:${PORT}`; console.log(''); console.log(`🦀 OpenClaw Manager v${APP_VERSION}`); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('📂 Dir: ' + OPENCLAW_DIR); console.log('🌐 Local: ' + localUrl); if (HOST === '0.0.0.0') { const lanIp = getLanIP(); if (lanIp) console.log('🌐 LAN: ' + `http://${lanIp}:${PORT}`); } console.log('💡 Switch dir: node openclaw-manager.js --dir /path/to/.openclaw'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('Ctrl+C to stop'); openBrowser(localUrl); }); server.on('error', err => { if (err.code === 'EADDRINUSE') console.error(`❌ Port ${PORT} is already in use. Close the other process or use --port to specify a different port.`); else console.error('❌ Failed to start:', err.message); process.exit(1); });