@@ -1,6 +1,6 @@
#!/usr/bin/env node
#!/usr/bin/env node
// ================================================================
// ================================================================
// OpenClaw Manager v0.7.1
// OpenClaw Manager v0.8.2
// 跨平台本地管理工具 (Windows / macOS / Linux)
// 跨平台本地管理工具 (Windows / macOS / Linux)
//
//
// 用法:
// 用法:
@@ -24,7 +24,7 @@ const SCRIPT_DIR = __dirname;
const MANAGER _CONFIG = path . join ( SCRIPT _DIR , 'manager-config.json' ) ;
const MANAGER _CONFIG = path . join ( SCRIPT _DIR , 'manager-config.json' ) ;
let PORT = 3333 ;
let PORT = 3333 ;
let HOST = '0.0.0.0' ;
let HOST = '0.0.0.0' ;
const APP _VERSION = '0.7.1 ' ;
const APP _VERSION = '0.8.2 ' ;
// --port 参数
// --port 参数
const portIdx = process . argv . indexOf ( '--port' ) ;
const portIdx = process . argv . indexOf ( '--port' ) ;
if ( portIdx !== - 1 && process . argv [ portIdx + 1 ] ) PORT = parseInt ( process . argv [ portIdx + 1 ] ) || 3333 ;
if ( portIdx !== - 1 && process . argv [ portIdx + 1 ] ) PORT = parseInt ( process . argv [ portIdx + 1 ] ) || 3333 ;
@@ -137,6 +137,16 @@ function resolvePath(p) {
return p . replace ( /^~/ , os . homedir ( ) ) ;
return p . replace ( /^~/ , os . homedir ( ) ) ;
}
}
function resolveAgentWorkspacePath ( cfg , agentId , agent ) {
const defaultsWs = cfg ? . agents ? . defaults ? . workspace || '' ;
const raw = ( agent && agent . workspace ) || ( agentId === 'main' ? defaultsWs : '' ) ;
const expanded = resolvePath ( raw || '' ) ;
if ( expanded ) {
return path . isAbsolute ( expanded ) ? expanded : path . resolve ( OPENCLAW _DIR , expanded ) ;
}
return path . join ( OPENCLAW _DIR , 'workspace' , agentId ) ;
}
async function dirExists ( p ) {
async function dirExists ( p ) {
try { const s = await fsp . stat ( p ) ; return s . isDirectory ( ) ; } catch { return false ; }
try { const s = await fsp . stat ( p ) ; return s . isDirectory ( ) ; } catch { return false ; }
}
}
@@ -613,7 +623,7 @@ async function handleApi(req, res, urlObj, body) {
const cfg = await readConfig ( ) ;
const cfg = await readConfig ( ) ;
const agent = cfg . agents ? . list ? . find ( a => a . id === agentId ) ;
const agent = cfg . agents ? . list ? . find ( a => a . id === agentId ) ;
if ( ! agent ) { res . writeHead ( 404 ) ; res . end ( JSON . stringify ( { error : 'Agent 不存在' } ) ) ; return ; }
if ( ! agent ) { res . writeHead ( 404 ) ; res . end ( JSON . stringify ( { error : 'Agent 不存在' } ) ) ; return ; }
const wsPath = resolvePath ( agent . workspace ) ;
const wsPath = resolveAgentWorkspacePath ( cfg , agentId , agent ) ;
const files = { } ;
const files = { } ;
const fileStats = { } ;
const fileStats = { } ;
try {
try {
@@ -649,7 +659,8 @@ async function handleApi(req, res, urlObj, body) {
const cfg = await readConfig ( ) ;
const cfg = await readConfig ( ) ;
const agent = cfg . agents ? . list ? . find ( a => a . id === agentId ) ;
const agent = cfg . agents ? . list ? . find ( a => a . id === agentId ) ;
if ( ! agent ) { res . writeHead ( 404 ) ; res . end ( JSON . stringify ( { error : 'Agent 不存在' } ) ) ; return ; }
if ( ! agent ) { res . writeHead ( 404 ) ; res . end ( JSON . stringify ( { error : 'Agent 不存在' } ) ) ; return ; }
const wsPath = resolvePath ( agent . workspace ) ;
const wsPath = resolveAgentWorkspacePath ( cfg , agentId , agent ) ;
await fsp . mkdir ( wsPath , { recursive : true } ) ;
await fsp . writeFile ( path . join ( wsPath , fname ) , body . content || '' , 'utf8' ) ;
await fsp . writeFile ( path . join ( wsPath , fname ) , body . content || '' , 'utf8' ) ;
res . writeHead ( 200 ) ;
res . writeHead ( 200 ) ;
res . end ( JSON . stringify ( { ok : true } ) ) ;
res . end ( JSON . stringify ( { ok : true } ) ) ;
@@ -1355,17 +1366,25 @@ async function handleApi(req, res, urlObj, body) {
}
}
} catch ( _ ) { gatewayRunning = 'stopped' ; }
} catch ( _ ) { gatewayRunning = 'stopped' ; }
// HTTP ping g ateway ( port from config or default 3000)
// G ateway port ( from config or default 3000)
let gatewayPort = null ;
let gatewayPort = null ;
let gatewayPing = false ;
try {
try {
if ( cfg && cfg . channels && cfg . channels . telegram ) {
if ( cfg && cfg . channels && cfg . channels . telegram ) {
gatewayPort = cfg . channels . telegram . port || null ;
gatewayPort = cfg . channels . telegram . port || null ;
}
}
if ( ! gatewayPort ) gatewayPort = 3000 ;
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 } ) ;
} catch ( _ ) { }
const code = parseInt ( ( pingResult . stdout || '' ) . trim ( ) ) ;
gatewayPing = code > 0 && code < 500 ;
// Telegram routing/bot counts
let bindingCount = 0 , accountCount = 0 , groupCount = 0 , allowFromCount = 0 , configAgentCount = 0 ;
try {
bindingCount = ( cfg ? . bindings || [ ] ) . length ;
const accounts = cfg ? . channels ? . telegram ? . accounts || { } ;
accountCount = Object . keys ( accounts ) . length ;
groupCount = Object . keys ( cfg ? . channels ? . telegram ? . groups || { } ) . length ;
if ( ! accountCount && cfg ? . channels ? . telegram ? . botToken ) accountCount = 1 ; // legacy single-token config
allowFromCount = ( cfg ? . channels ? . telegram ? . allowFrom || [ ] ) . length ;
configAgentCount = ( cfg ? . agents ? . list || [ ] ) . length ;
} catch ( _ ) { }
} catch ( _ ) { }
// Agent count (main vs sub) & last activity
// Agent count (main vs sub) & last activity
@@ -1408,8 +1427,8 @@ async function handleApi(req, res, urlObj, body) {
res . end ( JSON . stringify ( {
res . end ( JSON . stringify ( {
ok : true ,
ok : true ,
system : { hostname , platform , nodeVer , cpuModel , cpuCores , uptime : sysUptime , totalMem , freeMem , diskTotal , diskUsed , diskFree , dirSize , cpuPercent , loadAvg } ,
system : { hostname , platform , nodeVer , cpuModel , cpuCores , uptime : sysUptime , totalMem , freeMem , diskTotal , diskUsed , diskFree , dirSize , cpuPercent , loadAvg } ,
gateway : { status : gatewayRunning , pid : gatewayPid , port : gatewayPort , ping : gatewayPing } ,
gateway : { status : gatewayRunning , pid : gatewayPid , port : gatewayPort , host : HOST , accountCount , groupCount , bindingCount , allowFromCount } ,
agents : { count : agentCount , mainCount : mainAgentCount , subCount : subAgentCount , lastActivity : lastActivity ? lastActivity . toISOString ( ) : null } ,
agents : { count : agentCount , configCount : configAgentCount , mainCount : mainAgentCount , subCount : subAgentCount , lastActivity : lastActivity ? lastActivity . toISOString ( ) : null } ,
ocmVersion : APP _VERSION ,
ocmVersion : APP _VERSION ,
serverTime : now ,
serverTime : now ,
} ) ) ;
} ) ) ;
@@ -1659,6 +1678,7 @@ select option { background:var(--surface); }
.dash-gauge-label { font-size:12px; color:var(--muted); margin-top:8px; font-weight:500; }
.dash-gauge-label { font-size:12px; color:var(--muted); margin-top:8px; font-weight:500; }
.dash-sections { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; }
.dash-sections { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; }
.dash-card { padding:20px; }
.dash-card { padding:20px; }
.dash-card-wide { grid-column:span 2; }
.dash-card h3 { font-size:14px; font-weight:600; margin-bottom:14px; color:var(--text); }
.dash-card h3 { font-size:14px; font-weight:600; margin-bottom:14px; color:var(--text); }
.dash-notice { margin-top:18px; padding:16px 18px; }
.dash-notice { margin-top:18px; padding:16px 18px; }
.dash-notice h3 { font-size:14px; margin-bottom:10px; }
.dash-notice h3 { font-size:14px; margin-bottom:10px; }
@@ -1672,6 +1692,9 @@ select option { background:var(--surface); }
.dash-indicator.running { background:#22c55e; box-shadow:0 0 6px rgba(34,197,94,.5); }
.dash-indicator.running { background:#22c55e; box-shadow:0 0 6px rgba(34,197,94,.5); }
.dash-indicator.stopped { background:#ef4444; box-shadow:0 0 6px rgba(239,68,68,.5); }
.dash-indicator.stopped { background:#ef4444; box-shadow:0 0 6px rgba(239,68,68,.5); }
.dash-indicator.unknown { background:#f59e0b; }
.dash-indicator.unknown { background:#f59e0b; }
@media (max-width: 980px) {
.dash-card-wide { grid-column:span 1; }
}
/* ── Modal ── */
/* ── Modal ── */
.backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100; display:none; align-items:center; justify-content:center; }
.backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100; display:none; align-items:center; justify-content:center; }
@@ -1735,6 +1758,21 @@ code { font-size:12px; background:rgba(0,0,0,.3); padding:2px 6px; border-radius
.ch-badge { font-size:11px; padding:2px 7px; border-radius:12px; }
.ch-badge { font-size:11px; padding:2px 7px; border-radius:12px; }
.ch-tg { background:rgba(51,144,236,.15); color:#33a0ec; }
.ch-tg { background:rgba(51,144,236,.15); color:#33a0ec; }
.ch-any { background:var(--border); color:var(--muted); }
.ch-any { background:var(--border); color:var(--muted); }
.channels-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.channels-filter-label { font-size:12px; color:var(--muted); }
.channels-filter { min-width:180px; max-width:260px; font-size:12px; padding:6px 10px; }
.routing-group { background:var(--card); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
.routing-group-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:10px 12px; background:rgba(255,255,255,.02); border-bottom:1px solid var(--border); }
.routing-group-title { display:flex; align-items:center; gap:8px; flex-wrap:wrap; min-width:0; }
.routing-group-name { font-size:13px; font-weight:600; color:var(--text); }
.routing-group-count { font-size:11px; color:var(--muted); }
.routing-toggle { font-size:11px; padding:4px 10px; }
.routing-list { display:flex; flex-direction:column; }
.routing-list.collapsed { display:none; }
.routing-row { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 12px; border-bottom:1px solid var(--border); }
.routing-row:last-child { border-bottom:none; }
.routing-row-main { display:flex; align-items:center; gap:8px; flex-wrap:wrap; font-size:12px; min-width:0; }
.routing-row-sub { font-size:11px; color:var(--muted); }
/* NAS backup */
/* NAS backup */
.nas-step { background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:14px; margin-bottom:12px; }
.nas-step { background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:14px; margin-bottom:12px; }
@@ -1816,9 +1854,8 @@ const MAIN_HTML_BODY = String.raw`
<nav>
<nav>
<div class="tab active" data-tab="dashboard"><span data-i18n="tab.dashboard">🏠 Dashboard</span></div>
<div class="tab active" data-tab="dashboard"><span data-i18n="tab.dashboard">🏠 Dashboard</span></div>
<div class="tab" data-tab="agents"><span data-i18n="tab.agents">🤖 Agents</span></div>
<div class="tab" data-tab="agents"><span data-i18n="tab.agents">🤖 Agents</span></div>
<div class="tab" data-tab="channels"><span data-i18n="tab.channels">📡 Channels </span></div>
<div class="tab" data-tab="channels"><span data-i18n="tab.channels">🧭 Routing </span></div>
<div class="tab" data-tab="models"><span data-i18n="tab.models">🧠 模型</span></div>
<div class="tab" data-tab="models"><span data-i18n="tab.models">🧠 模型与认证 </span></div>
<div class="tab" data-tab="auth"><span data-i18n="tab.auth">🔑 认证</span></div>
<div class="tab" data-tab="stats"><span data-i18n="tab.stats">📊 Stats</span></div>
<div class="tab" data-tab="stats"><span data-i18n="tab.stats">📊 Stats</span></div>
<div class="tab" data-tab="cron"><span data-i18n="tab.cron">⏰ Cron</span></div>
<div class="tab" data-tab="cron"><span data-i18n="tab.cron">⏰ Cron</span></div>
<button class="cli-nav-btn" id="cliToggleBtn" onclick="toggleCliPanel()"><span data-i18n="cli.open">⌨️ 终端</span></button>
<button class="cli-nav-btn" id="cliToggleBtn" onclick="toggleCliPanel()"><span data-i18n="cli.open">⌨️ 终端</span></button>
@@ -1848,14 +1885,14 @@ const MAIN_HTML_BODY = String.raw`
<h3>🖥️ System Info</h3>
<h3>🖥️ System Info</h3>
<div class="dash-items" id="dashSysItems"><div class="empty">Loading...</div></div>
<div class="dash-items" id="dashSysItems"><div class="empty">Loading...</div></div>
</div>
</div>
<div class="card dash-card" id="dashGateway ">
<div class="card dash-card dash-card-wide " id="dashAgents ">
<h3>🦀 Gateway</h3>
<div class="dash-items" id="dashGwItems"><div class="empty">Loading...</div></div>
</div>
<div class="card dash-card" id="dashAgents">
<h3>🤖 Agents</h3>
<h3>🤖 Agents</h3>
<div class="dash-items" id="dashAgentItems"><div class="empty">Loading...</div></div>
<div class="dash-items" id="dashAgentItems"><div class="empty">Loading...</div></div>
</div>
</div>
<div class="card dash-card dash-card-wide" id="dashGateway">
<h3>🦀 Gateway</h3>
<div class="dash-items" id="dashGwItems"><div class="empty">Loading...</div></div>
</div>
<div class="card dash-card" id="dashStorage">
<div class="card dash-card" id="dashStorage">
<h3>💾 Storage</h3>
<h3>💾 Storage</h3>
<div class="dash-items" id="dashStorageItems"><div class="empty">Loading...</div></div>
<div class="dash-items" id="dashStorageItems"><div class="empty">Loading...</div></div>
@@ -1879,6 +1916,10 @@ const MAIN_HTML_BODY = String.raw`
<button class="btn-primary" onclick="showAddForm('sub')" data-i18n="agents.addSub">+ Add Sub-Agent</button>
<button class="btn-primary" onclick="showAddForm('sub')" data-i18n="agents.addSub">+ Add Sub-Agent</button>
</div>
</div>
<div id="addFormArea"></div>
<div id="addFormArea"></div>
<p class="hint-text" style="margin:10px 0 14px" data-i18n="agents.hint">
<b>Add Agent</b>: create a top-level agent (a long-lived role). Use this for a new domain like ops/travel/finance, or when you want fully isolated workflows.
<br><b>Add Sub-Agent</b>: create a child agent under a parent. Sub-agents use the parent agent’ s Telegram bot, but each sub-agent has its own workspace, SOUL.md (persona), and MEMORY.md (memory) — preventing context mixing, improving focus, and saving tokens.
</p>
<div class="agents-tree-wrap">
<div class="agents-tree-wrap">
<div id="agentTree"><div class="empty" data-i18n="agents.empty">No Agents</div></div>
<div id="agentTree"><div class="empty" data-i18n="agents.empty">No Agents</div></div>
</div>
</div>
@@ -1887,10 +1928,15 @@ const MAIN_HTML_BODY = String.raw`
<!-- ══ Channels ══════════════════════════════════════════════ -->
<!-- ══ Channels ══════════════════════════════════════════════ -->
<div class="panel" id="panel-channels">
<div class="panel" id="panel-channels">
<div class="sec-hdr">
<div class="sec-hdr">
<h2 data-i18n="channels.title">Channel 绑定 </h2>
<h2 data-i18n="channels.title">Routing Bindings </h2>
<button class="btn-primary" onclick="openAddChannel()" data-i18n="channels.add">+ 添加绑定</button >
<div class="channels-toolbar" >
<span class="channels-filter-label" data-i18n="channels.filterLabel">Agent</span>
<select id="channelsAgentFilter" class="channels-filter" onchange="onChannelFilterChange(this.value)"></select>
<button class="btn-primary" onclick="openAddChannel()" data-i18n="channels.add">+ Add Binding</button>
</div>
</div>
<p class="hint-text" style="margin-bottom:14px" data-i18n="channels.hint">管理 Agent 与频道/群组的绑定关系。绑定顺序决定优先级,排在前面的规则先匹配。</p >
</div >
<p class="hint-text" style="margin-bottom:14px" data-i18n="channels.hint">Advanced routing rules for agent-channel/group bindings and priority order. Use Agents page for daily add/remove workflows.</p>
<p class="hint-text" style="margin-top:-8px;margin-bottom:14px" data-i18n="channels.removeHint">When to use <b>Remove binding</b>: if you bound the wrong agent/chat, the group was recreated/migrated (new peer id), you have duplicates/conflicts, you want to decommission a project sub-agent, or you need to temporarily disconnect routing for safety.</p>
<div class="card-grid" id="channelList"><div class="empty">加载中...</div></div>
<div class="card-grid" id="channelList"><div class="empty">加载中...</div></div>
</div>
</div>
@@ -1916,24 +1962,13 @@ const MAIN_HTML_BODY = String.raw`
</div>
</div>
<div id="fallbackList" class="tag-row" style="margin-top:10px"></div>
<div id="fallbackList" class="tag-row" style="margin-top:10px"></div>
</div>
</div>
</div >
<hr >
<div class="sec-hdr"><h2 style="font-size:14px" data-i18n="auth.title">认证配置</h2></div>
<!-- ══ 认证 ════════════════════════════════════════════════ -->
<div class="panel" id="panel-auth">
<div class="sec-hdr">
<h2 data-i18n="auth.title">认证配置</h2>
</div>
<p class="hint-text" style="margin-bottom:14px" data-i18n="auth.guide">点击 Provider 查看认证步骤。认证需在终端完成,或使用下方 CLI 终端。</p>
<p class="hint-text" style="margin-bottom:14px" data-i18n="auth.guide">点击 Provider 查看认证步骤。认证需在终端完成,或使用下方 CLI 终端。</p>
<!-- Provider 选择网格 -->
<div class="auth-prov-grid" id="authProvGrid" style="margin-bottom:16px"></div>
<div class="auth-prov-grid" id="authProvGrid" style="margin-bottom:16px"></div>
<!-- 选中 Provider 的指引区 -->
<div class="card" id="authActionCard" style="display:none;margin-bottom:16px">
<div class="card" id="authActionCard" style="display:none;margin-bottom:16px">
<div id="authActionContent"></div>
<div id="authActionContent"></div>
</div>
</div>
<!-- 已配置的认证列表 -->
<div class="sec-hdr"><h2 style="font-size:14px" data-i18n="auth.configured">已配置认证</h2></div>
<div class="sec-hdr"><h2 style="font-size:14px" data-i18n="auth.configured">已配置认证</h2></div>
<div class="card-grid" id="authList"><div class="empty">加载中...</div></div>
<div class="card-grid" id="authList"><div class="empty">加载中...</div></div>
</div>
</div>
@@ -1943,8 +1978,9 @@ const MAIN_HTML_BODY = String.raw`
<div class="sec-hdr">
<div class="sec-hdr">
<h2 data-i18n="stats.title">使用统计</h2>
<h2 data-i18n="stats.title">使用统计</h2>
<select id="statsDaysFilter" onchange="loadStats()" style="padding:6px 10px;font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text)">
<select id="statsDaysFilter" onchange="loadStats()" style="padding:6px 10px;font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text)">
<option value="1" selected>1 day</option>
<option value="7">7 days</option>
<option value="7">7 days</option>
<option value="30" selected >30 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="90">90 days</option>
</select>
</select>
</div>
</div>
@@ -2241,10 +2277,12 @@ const MAIN_HTML_SCRIPT = `// ── i18n ─────────────
const I18N = {
const I18N = {
zh: {
zh: {
'tab.dashboard':'🏠 Dashboard',
'tab.dashboard':'🏠 Dashboard',
'tab.agents':'🤖 Agents','tab.channels':'📡 Channels ','tab.models':'🧠 模型','tab.auth':'🔑 认证',
'tab.agents':'🤖 Agents','tab.channels':'🧭 路由 ','tab.models':'🧠 模型与认证 ','tab.auth':'🔑 认证',
'tab.stats':'📊 Stats','tab.cron':'⏰ Cron',
'tab.stats':'📊 Stats','tab.cron':'⏰ Cron',
'agents.title':'Agents','agents.new':'+ 新建 Subagent',
'agents.title':'Agents','agents.new':'+ 新建 Subagent',
'channels.title':'Channel 绑定','channels.add':'+ 添加绑定','channels.hint':'管理 Agent 与频道/群组的 绑定关系。绑定顺序决定优先级 。',
'channels.title':'路由 绑定','channels.add':'+ 添加绑定','channels.hint':'用于高级路由管理:维护 Agent 与频道/群组绑定及优先级顺序。日常增减 Agent 请在 Agents 页面操作 。',
'channels.filterLabel':'Agent','channels.filterAll':'全部 Agent','channels.emptyFiltered':'当前 Agent 下暂无绑定',
'channels.countSuffix':'条绑定','channels.collapse':'折叠','channels.expand':'展开',
'models.title':'模型管理','models.primary':'默认主模型','models.fallback':'Fallback 链',
'models.title':'模型管理','models.primary':'默认主模型','models.fallback':'Fallback 链',
'models.hint':'模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。',
'models.hint':'模型由 openclaw onboard 注册,此处管理主模型和 Fallback 链。',
'models.onlyCliHint':'模型下拉仅显示 openclaw models list 返回的模型。',
'models.onlyCliHint':'模型下拉仅显示 openclaw models list 返回的模型。',
@@ -2283,7 +2321,7 @@ const I18N = {
'actions.setupEmpty':'请填写路径','actions.setupSwitching':'目录已切换,正在刷新...','actions.setupInvalid':'路径无效','actions.setupReqFail':'请求失败: ',
'actions.setupEmpty':'请填写路径','actions.setupSwitching':'目录已切换,正在刷新...','actions.setupInvalid':'路径无效','actions.setupReqFail':'请求失败: ',
'common.loading':'加载中...','common.close':'关闭',
'common.loading':'加载中...','common.close':'关闭',
'btn.save':'保存','btn.delete':'删除','btn.cancel':'取消','btn.add':'添加','btn.remove':'移除',
'btn.save':'保存','btn.delete':'删除','btn.cancel':'取消','btn.add':'添加','btn.remove':'移除',
'agents.addAgent':'+ Add Agent','agents.addSub':'+ Add Sub-Agent',
'agents.addAgent':'+ Add Agent','agents.addSub':'+ Add Sub-Agent','agents.hint':'Add Agent: create a top-level agent (a long-lived role) for a new domain. Add Sub-Agent: create a child under a parent; sub-agents use the parent Telegram bot but have their own workspace + SOUL.md + MEMORY.md, preventing context mixing, improving focus, and saving tokens.',
'agents.addAgentTitle':'Add Agent (Main Bot)','agents.addSubTitle':'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.botToken':'Bot Token','agents.botName':'Bot Name','agents.botNamePh':'My Bot',
'agents.addAgentSubmit':'Create Agent','agents.addSubSubmit':'Create Sub-Agent',
'agents.addAgentSubmit':'Create Agent','agents.addSubSubmit':'Create Sub-Agent',
@@ -2360,10 +2398,12 @@ const I18N = {
},
},
en: {
en: {
'tab.dashboard':'🏠 Dashboard',
'tab.dashboard':'🏠 Dashboard',
'tab.agents':'🤖 Agents','tab.channels':'📡 Channels ','tab.models':'🧠 Models','tab.auth':'🔑 Auth',
'tab.agents':'🤖 Agents','tab.channels':'🧭 Routing ','tab.models':'🧠 Models & Auth ','tab.auth':'🔑 Auth',
'tab.stats':'📊 Stats','tab.cron':'⏰ Cron',
'tab.stats':'📊 Stats','tab.cron':'⏰ Cron',
'agents.title':'Agents','agents.new':'+ New Subagent',
'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 priori ty.',
'channels.title':'Routing Bindings','channels.add':'+ Add Binding','channels.hint':'Advanced routing rules for agent-channel/group bindings and priority order. Use Agents page for daily add/remove workflows.','channels.removeHint':'When to use Remove binding: wrong agent/chat, group recreated (new id), duplicates/conflicts, decommission a sub-agent, or temporarily disconnect routing for safe ty.',
'channels.filterLabel':'Agent','channels.filterAll':'All Agents','channels.emptyFiltered':'No bindings for selected agent',
'channels.countSuffix':'bindings','channels.collapse':'Collapse','channels.expand':'Expand',
'models.title':'Model Management','models.primary':'Default Primary Model','models.fallback':'Fallback Chain',
'models.title':'Model Management','models.primary':'Default Primary Model','models.fallback':'Fallback Chain',
'models.hint':'Models are registered via openclaw onboard. Manage primary model and fallback chain here.',
'models.hint':'Models are registered via openclaw onboard. Manage primary model and fallback chain here.',
'models.onlyCliHint':'Model dropdowns only show IDs returned by openclaw models list.',
'models.onlyCliHint':'Model dropdowns only show IDs returned by openclaw models list.',
@@ -2402,7 +2442,7 @@ const I18N = {
'actions.setupEmpty':'Please enter a path','actions.setupSwitching':'Directory switched, refreshing...','actions.setupInvalid':'Invalid path','actions.setupReqFail':'Request 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',
'common.loading':'Loading...','common.close':'Close',
'btn.save':'Save','btn.delete':'Delete','btn.cancel':'Cancel','btn.add':'Add','btn.remove':'Remove',
'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.addAgent':'+ Add Agent','agents.addSub':'+ Add Sub-Agent','agents.hint':'Add Agent: create a top-level agent (a long-lived role) for a new domain. Add Sub-Agent: create a child under a parent; sub-agents use the parent Telegram bot but have their own workspace + SOUL.md + MEMORY.md, preventing context mixing, improving focus, and saving tokens.',
'agents.addAgentTitle':'Add Agent (Main Bot)','agents.addSubTitle':'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.botToken':'Bot Token','agents.botName':'Bot Name','agents.botNamePh':'My Bot',
'agents.addAgentSubmit':'Create Agent','agents.addSubSubmit':'Create Sub-Agent',
'agents.addAgentSubmit':'Create Agent','agents.addSubSubmit':'Create Sub-Agent',
@@ -2501,6 +2541,7 @@ function applyLang() {
// Re-render model/auth cards so Remove button text updates
// Re-render model/auth cards so Remove button text updates
try{ renderModels(); }catch(_){}
try{ renderModels(); }catch(_){}
try{ renderAuth(); }catch(_){}
try{ renderAuth(); }catch(_){}
try{ renderChannels(); }catch(_){}
// Rebuild presets dropdown if CLI is open
// Rebuild presets dropdown if CLI is open
if(document.getElementById('cliPanel')&&document.getElementById('cliPanel').classList.contains('open')){
if(document.getElementById('cliPanel')&&document.getElementById('cliPanel').classList.contains('open')){
buildCliPresets();
buildCliPresets();
@@ -2542,6 +2583,8 @@ let S = { agents:[], channels:[], models:{}, authProfiles:{}, knownModels:[], mo
let wizCur = 1;
let wizCur = 1;
let logTimer = null;
let logTimer = null;
let selectedAuthProv = null;
let selectedAuthProv = null;
let channelFilterAgent = '__all__';
let routingCollapsedByAgent = {};
// ── 初始化(enterApp 触发) ─────────────────────────────────
// ── 初始化(enterApp 触发) ─────────────────────────────────
async function checkStatus(){
async function checkStatus(){
@@ -2554,7 +2597,10 @@ async function checkStatus(){
}catch{ setDot('err'); document.getElementById('statusTxt').textContent='无法读取配置'; }
}catch{ setDot('err'); document.getElementById('statusTxt').textContent='无法读取配置'; }
}
}
async function loadAll(){ await Promise.all([loadAgents(), loadModels(), loadChannels()]); }
async function loadAll(){
await Promise.all([loadAgents(), loadModels(), loadChannels()]);
try{ renderChannels(); }catch(_){}
}
// ── Dashboard ─────────────────────────────────────────────────
// ── Dashboard ─────────────────────────────────────────────────
let dashLoaded=false;
let dashLoaded=false;
@@ -2613,14 +2659,19 @@ async function loadDashboard(){
const statusIcon='<span class="dash-indicator '+esc(g.status)+'"></span>';
const statusIcon='<span class="dash-indicator '+esc(g.status)+'"></span>';
const statusLabel=g.status==='running'?'Running':g.status==='stopped'?'Stopped':'Unknown';
const statusLabel=g.status==='running'?'Running':g.status==='stopped'?'Stopped':'Unknown';
let gwHtml=dashRow('Status',statusIcon+statusLabel);
let gwHtml=dashRow('Status',statusIcon+statusLabel);
gwHtml+=dashRow('Bind Host',esc(g.host||'—'));
gwHtml+=dashRow('Port',String(g.port||'—'));
gwHtml+=dashRow('Port',String(g.port||'—'));
if(g.pid)gwHtml+=dashRow('PID',g.pid);
if(g.pid)gwHtml+=dashRow('PID',g.pid);
gwHtml+=dashRow('HTTP Ping',g.ping?'<span style="color:#22c55e">✓ Reachable</span>':'<span style="color:#ef4444">✗ Unreachable</span>' );
gwHtml+=dashRow('Telegram Accounts',String(g.accountCount||0) );
gwHtml+=dashRow('Groups',String(g.groupCount||0));
gwHtml+=dashRow('Bindings',String(g.bindingCount||0));
gwHtml+=dashRow('allowFrom',String(g.allowFromCount||0));
document.getElementById('dashGwItems').innerHTML=gwHtml;
document.getElementById('dashGwItems').innerHTML=gwHtml;
// Agents card
// Agents card
let agHtml=dashRow('Main Agents',String(a.main Count||0));
let agHtml=dashRow('Configured Agents',String(a.config Count||0));
agHtml+=dashRow('Runtime Agent Dirs',String(a.count||0));
agHtml+=dashRow('Agent Trees',String(a.mainCount||0));
agHtml+=dashRow('Sub-Agents',String(a.subCount||0));
agHtml+=dashRow('Sub-Agents',String(a.subCount||0));
agHtml+=dashRow('Total',String(a.count));
if(a.lastActivity){
if(a.lastActivity){
const d=new Date(a.lastActivity);
const d=new Date(a.lastActivity);
agHtml+=dashRow('Last Activity',d.toLocaleString('en-AU',{timeZone:'Australia/Brisbane',hour12:false}));
agHtml+=dashRow('Last Activity',d.toLocaleString('en-AU',{timeZone:'Australia/Brisbane',hour12:false}));
@@ -2960,22 +3011,88 @@ async function saveAgentModel(agentId){
}
}
// ── 渲染 Channels ────────────────────────────────────────────
// ── 渲染 Channels ────────────────────────────────────────────
function getChannelAgentChoices(){
const byId={};
(S.agents||[]).forEach(a=>{
if(a&&a.id) byId[a.id]=a.name||a.id;
});
(S.channels||[]).forEach(ch=>{
if(ch&&ch.agentId&&!byId[ch.agentId]) byId[ch.agentId]=ch.agentName||ch.agentId;
});
return Object.keys(byId).sort((a,b)=>a.localeCompare(b)).map(id=>({id,name:byId[id]}));
}
function renderChannelFilter(){
const sel=document.getElementById('channelsAgentFilter');
if(!sel) return;
const choices=getChannelAgentChoices();
if(channelFilterAgent!=='__all__' && !choices.some(c=>c.id===channelFilterAgent)){
channelFilterAgent='__all__';
}
let html= \` <option value="__all__" \$ {channelFilterAgent==='__all__'?' selected':''}> \$ {esc(t('channels.filterAll'))}</option> \` ;
html+=choices.map(c=> \` <option value=" \$ {esc(c.id)}" \$ {channelFilterAgent===c.id?' selected':''}> \$ {esc(c.name)} ( \$ {esc(c.id)})</option> \` ).join('');
sel.innerHTML=html;
}
function onChannelFilterChange(v){
channelFilterAgent=v||'__all__';
renderChannels();
}
function toggleRoutingGroup(agentId){
routingCollapsedByAgent[agentId]=!routingCollapsedByAgent[agentId];
renderChannels();
}
function renderChannels(){
function renderChannels(){
const el=document.getElementById('channelList');
const el=document.getElementById('channelList');
renderChannelFilter();
if(!S.channels.length){el.innerHTML='<div class="empty">'+t('channels.empty')+'</div>';return;}
if(!S.channels.length){el.innerHTML='<div class="empty">'+t('channels.empty')+'</div>';return;}
el.innerHTML=S.channels.map(ch=>{
const rows=channelFilterAgent==='__all__'
? S.channels
: S.channels.filter(ch=>ch.agentId===channelFilterAgent);
if(!rows.length){el.innerHTML='<div class="empty">'+t('channels.emptyFiltered')+'</div>';return;}
const groups={};
rows.forEach(ch=>{
const key=ch.agentId||'unknown';
if(!groups[key]) groups[key]={ agentId:key, agentName:ch.agentName||key, rows:[] };
groups[key].rows.push(ch);
});
const sortedGroups=Object.values(groups).sort((a,b)=>(a.agentName||a.agentId).localeCompare(b.agentName||b.agentId));
el.innerHTML=sortedGroups.map(g=>{
const collapsed=!!routingCollapsedByAgent[g.agentId];
const listCls=collapsed?'routing-list collapsed':'routing-list';
const toggleText=collapsed?t('channels.expand'):t('channels.collapse');
const rowsHtml=g.rows
.sort((a,b)=>(a.idx||0)-(b.idx||0))
.map(ch=>{
const chClass=ch.channel==='telegram'?'ch-tg':'ch-any';
const chClass=ch.channel==='telegram'?'ch-tg':'ch-any';
return \` <div class="card">
const peerMeta=ch.peerId
<div class="card-row">
? \` 📱 Peer ID: <code> \$ {esc(ch.peerId)}</code> \`
<span class="card-title"> \$ {esc(ch.agentName)}</span>
: t('channels.matchAll');
<span class="badge">agent: \$ {esc(ch.agentId)}</span >
return \` <div class="routing-row" >
<div style="min-width:0;flex:1">
<div class="routing-row-main">
<span class="badge"># \$ {ch.idx}</span>
\$ {ch.channel? \` <span class="ch-badge \$ {chClass}"> \$ {esc(ch.channel)}</span> \` :'<span class="ch-badge ch-any">any</span>'}
\$ {ch.channel? \` <span class="ch-badge \$ {chClass}"> \$ {esc(ch.channel)}</span> \` :'<span class="ch-badge ch-any">any</span>'}
\$ {ch.peerKind? \` <span class="badge"> \$ {esc(ch.peerKind)}</span> \` :''}
\$ {ch.peerKind? \` <span class="badge"> \$ {esc(ch.peerKind)}</span> \` :''}
<span class="routing-row-sub"> \$ {peerMeta}</span>
</div>
</div>
</div>
\$ {ch.peerId? \` <div class="card-meta">📱 Peer ID: <code> \$ {esc(ch.peerId)}</code></div> \` :('<div class="card-meta">'+t('channels.matchAll')+'</div>')}
<div class="card-meta" style="font-size:11px"> \$ {t('channels.bindIdx')} \$ {ch.idx}</div>
<div class="card-actions">
<button class="btn-danger" onclick="deleteChannel( \$ {ch.idx},'# \$ {ch.idx} \$ {esc(ch.agentId)}')"> \$ {t('channels.delBinding')}</button>
<button class="btn-danger" onclick="deleteChannel( \$ {ch.idx},'# \$ {ch.idx} \$ {esc(ch.agentId)}')"> \$ {t('channels.delBinding')}</button>
</div> \` ;
}).join('');
return \` <div class="routing-group">
<div class="routing-group-hdr">
<div class="routing-group-title">
<span class="routing-group-name"> \$ {esc(g.agentName)}</span>
<span class="badge">agent: \$ {esc(g.agentId)}</span>
<span class="routing-group-count"> \$ {g.rows.length} \$ {esc(t('channels.countSuffix'))}</span>
</div>
<button class="btn-secondary routing-toggle" onclick="toggleRoutingGroup(decodeURIComponent(' \$ {encodeURIComponent(g.agentId)}'))"> \$ {esc(toggleText)}</button>
</div>
<div class=" \$ {listCls}">
\$ {rowsHtml}
</div>
</div>
</div> \` ;
</div> \` ;
}).join('');
}).join('');
@@ -3607,7 +3724,7 @@ function cliCloseAC(){ const b=document.getElementById('cliACBox'); if(b) b.remo
// ── Stats (Usage Statistics) ────────────────────────────────
// ── Stats (Usage Statistics) ────────────────────────────────
async function loadStats(){
async function loadStats(){
try{
try{
const days=document.getElementById('statsDaysFilter')?.value||30 ;
const days=document.getElementById('statsDaysFilter')?.value||1 ;
const r=await api('GET','/api/stats?days='+days);
const r=await api('GET','/api/stats?days='+days);
const s=r.summary||{};
const s=r.summary||{};
document.getElementById('statsTotalInput').textContent=fmtNum(s.totalInputTokens||0);
document.getElementById('statsTotalInput').textContent=fmtNum(s.totalInputTokens||0);