Channel 绑定
管理 Agent 与频道/群组的绑定关系。绑定顺序决定优先级,排在前面的规则先匹配。
模型管理
模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。
认证配置
点击 Provider 查看认证步骤。认证需在终端完成,或使用下方 CLI 终端。
已配置认证
使用统计
按日期
按模型
计划任务
管理与 OpenClaw 相关的 crontab 计划任务。
#!/usr/bin/env node // ================================================================ // OpenClaw Manager v0.6.0 // 跨平台本地管理工具 (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.0'; // --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); } async function backupConfig(label) { const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); 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); return { ...a, groupId, requireMention: groupId ? (groups[groupId]?.requireMention ?? true) : null, effectiveModel: modelVal || defaults.model?.primary || '默认' }; }); res.writeHead(200); res.end(JSON.stringify({ agents: enriched, defaults })); return; } // POST /api/agents/main — create or update main agent (set Bot Token) if (method === 'POST' && pathname === '/api/agents/main') { const { botToken, name, model } = body; if (!botToken || !botToken.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'Bot Token is required' })); return; } const cfg = await readConfig(); // Set bot token if (!cfg.channels) cfg.channels = {}; if (!cfg.channels.telegram) cfg.channels.telegram = {}; cfg.channels.telegram.botToken = botToken.trim(); cfg.channels.telegram.enabled = true; // Ensure main agent exists if (!cfg.agents) cfg.agents = { defaults: {}, list: [] }; if (!cfg.agents.list) cfg.agents.list = []; let mainAgent = cfg.agents.list.find(a => a.id === 'main'); if (!mainAgent) { mainAgent = { id: 'main' }; cfg.agents.list.push(mainAgent); } if (name) mainAgent.name = name.trim(); if (model) mainAgent.model = { primary: model }; // Ensure main catch-all binding exists if (!cfg.bindings) cfg.bindings = []; const hasMainBinding = cfg.bindings.some(b => b.agentId === 'main' && !b.match?.peer); if (!hasMainBinding) { cfg.bindings.push({ agentId: 'main', match: { channel: 'telegram', accountId: 'default' } }); } const bakPath = await writeConfig(cfg, 'create'); res.writeHead(200); res.end(JSON.stringify({ ok: true, configBackup: bakPath })); return; } // POST /api/agents if (method === 'POST' && pathname === '/api/agents') { const { agentId, displayName, groupId, workspaceFolder, model, purpose, personality, initialMemory } = body; if (!agentId || !/^[a-zA-Z0-9_-]+$/.test(agentId)) { res.writeHead(400); res.end(JSON.stringify({ error: 'Agent ID 只能包含英文字母、数字、_ 或 -' })); return; } if (agentId.toLowerCase() === 'main') { res.writeHead(400); res.end(JSON.stringify({ error: '"main" 是系统保留 ID' })); return; } if (!groupId?.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: '群组 ID 不能为空' })); return; } const cfg = await readConfig(); if (cfg.agents.list.some(a => a.id === agentId)) { res.writeHead(400); res.end(JSON.stringify({ error: `Agent ID "${agentId}" 已存在` })); 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); const newBinding = { agentId, match: { channel: 'telegram', peer: { kind: 'group', id: gid } } }; const mainIdx = cfg.bindings.findIndex(b => b.agentId === 'main' && !b.match?.peer); if (mainIdx >= 0) cfg.bindings.splice(mainIdx, 0, newBinding); else cfg.bindings.unshift(newBinding); 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: [ '✅ openclaw.json 已更新(自动备份)', '✅ Workspace 已创建(SOUL.md + MEMORY.md)', '🔄 配置约 300ms 后自动生效,无需重启网关', '📱 请在 Telegram 群内发送 /new 切换到新 Agent', '⚠️ 若群内无回复,请确认 BotFather 已关闭隐私模式(/setprivacy → Disable)', ], })); 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 用量统计 ────────────────────────────── if (method === 'GET' && pathname === '/api/stats') { const days = parseInt(urlObj.searchParams.get('days') || '30'); const cutoff = Date.now() - days * 86400000; const logPath = path.join(OPENCLAW_DIR, 'logs', 'gateway.log'); const byModel = {}; const byDay = {}; let totalIn = 0, totalOut = 0; try { const content = await fsp.readFile(logPath, 'utf8'); const lines = content.split('\n'); for (const line of lines) { // 尝试解析 JSON 日志行 let obj = null; try { const jm = line.match(/\{[^{}]*"(tokens|usage|input_tokens|output_tokens)"[^{}]*\}/); if (jm) obj = JSON.parse(jm[0]); } catch { /* not JSON */ } // 尝试解析文本格式: "input_tokens: 123, output_tokens: 456" if (!obj) { const inM = line.match(/input_tokens[:\s=]+(\d+)/i); const outM = line.match(/output_tokens[:\s=]+(\d+)/i); if (inM || outM) { obj = {}; if (inM) obj.input_tokens = parseInt(inM[1]); if (outM) obj.output_tokens = parseInt(outM[1]); } } if (!obj) continue; // 提取时间戳 let ts = null; const tsM = line.match(/(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})/); if (tsM) ts = new Date(tsM[1]).getTime(); if (ts && ts < cutoff) continue; // 提取模型 const model = obj.model || (line.match(/model[:\s="]+([a-zA-Z0-9_./-]+)/i) || [])[1] || 'unknown'; const inTk = obj.input_tokens || obj.prompt_tokens || 0; const outTk = obj.output_tokens || obj.completion_tokens || 0; totalIn += inTk; totalOut += outTk; if (!byModel[model]) byModel[model] = { inputTokens: 0, outputTokens: 0, requestCount: 0 }; byModel[model].inputTokens += inTk; byModel[model].outputTokens += outTk; byModel[model].requestCount++; const dayKey = ts ? new Date(ts).toISOString().slice(0, 10) : 'unknown'; if (!byDay[dayKey]) byDay[dayKey] = { inputTokens: 0, outputTokens: 0, requestCount: 0 }; byDay[dayKey].inputTokens += inTk; byDay[dayKey].outputTokens += outTk; byDay[dayKey].requestCount++; } } catch { /* log file may not exist */ } // 默认模型定价(每百万 token,USD) const mc = loadManagerConfig(); const pricing = mc.modelPricing || {}; const defaultPricing = { input: 3, output: 15 }; // $3/M in, $15/M out function calcCost(model, inTk, outTk) { const p = pricing[model] || defaultPricing; return ((inTk * (p.input || 3)) + (outTk * (p.output || 15))) / 1000000; } Object.entries(byModel).forEach(([m, d]) => { d.cost = calcCost(m, d.inputTokens, d.outputTokens).toFixed(4); }); Object.entries(byDay).forEach(([d, v]) => { v.cost = calcCost('', v.inputTokens, v.outputTokens).toFixed(4); }); const totalCost = calcCost('', totalIn, totalOut); res.writeHead(200); res.end(JSON.stringify({ summary: { totalInputTokens: totalIn, totalOutputTokens: totalOut, estimatedCost: '$' + totalCost.toFixed(4) }, byModel, byDay })); 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; } // 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 数据目录(包含 openclaw.json 的文件夹)。
~/.openclawC:\\Users\\yourname\\.openclaw管理 Agent 与频道/群组的绑定关系。绑定顺序决定优先级,排在前面的规则先匹配。
模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。
点击 Provider 查看认证步骤。认证需在终端完成,或使用下方 CLI 终端。
管理与 OpenClaw 相关的 crontab 计划任务。
openclaw models list 查看可用模型 ID选择一个备份文件恢复。恢复前会自动保存当前配置。
需要包含 openclaw.json 的文件夹。更换后页面自动刷新。
@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.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 (log empty or no token records)',
'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: '需要:\${esc(masked)} 像是 API Key 而非模型 ID。
请从下拉选择正确的模型并保存,或点击下方按钮重置。\${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='\${esc(provider.label)} — OAuth
\${esc(provider.cliCmd)}
openclaw onboard
\${esc(provider.label)} — Device Flow
\${esc(provider.cliCmd)}
openclaw onboard
\${esc(provider.label)} — API Key
\${esc(provider.cliCmd)}
openclaw onboard
📁 '+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 \`\`; }).join(''); }catch(e){el.innerHTML='