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