diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index a8f517e..944d662 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -361,6 +361,37 @@ .toast.ok{border-color:#2ecc8a55;background:#0a1a10}.toast.err{border-color:#ff527055;background:#200a10} @keyframes tin{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:1}} + /* ══ AGENT STATUS PANEL ══ */ + .as-panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 18px;margin-bottom:16px} + .as-header{display:flex;align-items:center;gap:10px;margin-bottom:12px} + .as-title{font-size:13px;font-weight:700;color:var(--fg)} + .as-gw{font-size:11px;padding:3px 10px;border-radius:999px;margin-left:auto} + .as-gw.ok{background:#0a2018;border:1px solid #2ecc8a44;color:var(--ok)} + .as-gw.err{background:#200a10;border:1px solid #ff527044;color:var(--danger)} + .as-gw.warn{background:#201a08;border:1px solid #f5c84244;color:var(--warn)} + .as-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px} + .as-card{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;position:relative} + .as-card:hover{border-color:var(--acc);background:#0a1228} + .as-card .as-emoji{font-size:22px;margin-bottom:3px} + .as-card .as-label{font-size:12px;font-weight:700;color:var(--fg)} + .as-card .as-role{font-size:10px;color:var(--muted)} + .as-card .as-status{font-size:10px;margin-top:4px} + .as-card .as-time{font-size:9px;color:var(--muted);margin-top:2px} + .as-card .as-dot{position:absolute;top:6px;right:6px;width:8px;height:8px;border-radius:50%} + .as-dot.running{background:#2ecc8a;box-shadow:0 0 6px #2ecc8a88;animation:pulse 1.5s infinite} + .as-dot.idle{background:#4a5568} + .as-dot.offline{background:#ff5270;animation:pulse 1.2s infinite} + .as-dot.unconfigured{background:#6b7280} + .as-wake-btn{font-size:10px;padding:2px 8px;border-radius:6px;border:1px solid var(--acc);color:var(--acc);background:transparent;cursor:pointer;margin-top:6px;transition:background .15s} + .as-wake-btn:hover{background:var(--acc);color:#fff} + .as-wake-btn:disabled{opacity:.4;cursor:not-allowed} + .as-refresh{font-size:11px;padding:4px 12px;border-radius:8px;border:1px solid var(--line);color:var(--muted);background:transparent;cursor:pointer;transition:background .15s} + .as-refresh:hover{background:var(--panel2);color:var(--fg)} + .as-wake-all{font-size:11px;padding:4px 12px;border-radius:8px;border:1px solid var(--warn);color:var(--warn);background:transparent;cursor:pointer;transition:background .15s;margin-left:6px} + .as-wake-all:hover{background:var(--warn);color:#fff} + .as-summary{font-size:11px;color:var(--muted);display:flex;gap:12px;margin-top:10px;padding-top:8px;border-top:1px solid var(--line)} + .as-summary span{display:flex;align-items:center;gap:4px} + /* ══ OFFICIALS ══ */ /* activity bar */ .off-activity{display:flex;align-items:center;gap:8px;padding:8px 14px;background:#0a1228;border:1px solid #1a2a4a;border-radius:10px;margin-bottom:14px;font-size:12px;flex-wrap:wrap} @@ -623,6 +654,7 @@
+
各省部当前承接旨意与执行状态 · 每5秒自动刷新
@@ -922,6 +954,96 @@ function renderEdicts(tasks){ }).join(''); } +/* ══ AGENT STATUS PANEL ══ */ +let _agentsStatusData = null; +let _agentsStatusLoading = false; + +async function loadAgentsStatus(){ + if(_agentsStatusLoading) return; + _agentsStatusLoading = true; + try{ + _agentsStatusData = await fetchJ(API+'/agents-status'); + renderAgentsStatus(_agentsStatusData); + }catch(e){ + document.getElementById('agent-status-panel').innerHTML= + '
⚠️ 无法获取 Agent 状态
'; + }finally{ + _agentsStatusLoading = false; + } +} + +function renderAgentsStatus(data){ + if(!data||!data.ok) return; + const gw = data.gateway||{}; + const agents = data.agents||[]; + // 跳过 main(和 taizi 重复) + const filtered = agents.filter(a=>a.id!=='main'); + const running = filtered.filter(a=>a.status==='running').length; + const idle = filtered.filter(a=>a.status==='idle').length; + const offline = filtered.filter(a=>a.status==='offline').length; + const unconf = filtered.filter(a=>a.status==='unconfigured').length; + const gwCls = gw.probe?'ok':gw.alive?'warn':'err'; + + document.getElementById('agent-status-panel').innerHTML=` +
+
+ 🔌 Agent 在线状态 + Gateway: ${esc(gw.status||'未知')} + + ${offline+unconf>0?``:''} +
+
+ ${filtered.map(a=>{ + const dotCls = a.status; + const canWake = a.status!=='running' && a.status!=='unconfigured' && gw.alive; + return `
+
+
${a.emoji}
+
${esc(a.label)}
+
${esc(a.role)}
+
${esc(a.statusLabel)}
+ ${a.lastActive?`
⏰ ${esc(a.lastActive)}
`:'
无活动记录
'} + ${canWake?``:''} +
`; + }).join('')} +
+
+ ${running} 运行中 + ${idle} 待命 + ${offline?` ${offline} 离线`:''} + ${unconf?` ${unconf} 未配置`:''} + 检测于 ${(data.checkedAt||'').substring(11,19)} +
+
`; +} + +async function wakeAgent(agentId, btn){ + if(btn){ btn.disabled=true; btn.textContent='⏳ 唤醒中...'; } + try{ + const r = await postJ(API+'/agent-wake', {agentId}); + toast(r.message||'唤醒指令已发出','ok'); + // 30秒后自动刷新状态 + setTimeout(()=>loadAgentsStatus(), 30000); + }catch(e){ + toast('唤醒失败: '+e.message,'err'); + if(btn){ btn.disabled=false; btn.textContent='⚡ 唤醒'; } + } +} + +async function wakeAllAgents(){ + if(!_agentsStatusData) return; + const toWake = (_agentsStatusData.agents||[]).filter(a=> + a.id!=='main' && a.status!=='running' && a.status!=='unconfigured' + ); + if(!toWake.length){ toast('所有 Agent 均已在线','ok'); return; } + toast(`正在唤醒 ${toWake.length} 个 Agent...`,'ok',5000); + for(const a of toWake){ + try{ await postJ(API+'/agent-wake', {agentId: a.id}); }catch(e){} + } + toast(`${toWake.length} 个唤醒指令已发出,30秒后刷新状态`,'ok',5000); + setTimeout(()=>loadAgentsStatus(), 30000); +} + /* ══ DEPT MONITOR ══ */ function renderMonitor(tasks){ const DEPTS = [ @@ -2261,6 +2383,7 @@ document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',()=>{ document.getElementById('panel-'+activeTab).classList.add('active'); if(['models','skills'].includes(activeTab))loadAgentConfig(); if(activeTab==='officials')loadOfficials(); + if(activeTab==='monitor')loadAgentsStatus(); if(activeTab==='morning')loadMorning(); if(activeTab==='sessions')renderSessions(liveStatus?liveStatus.tasks:[]); if(activeTab==='memorials')renderMemorials(); @@ -2617,6 +2740,16 @@ async function executeTemplate(e, tplId){ const cmd = buildCommand(tplId); if(!cmd.trim()){ toast('请填写必填参数','err'); return; } const t = TEMPLATES.find(x=>x.id===tplId); + + // 前置校验:检测 Gateway 和关键 Agent 是否在线 + try{ + const st = await fetchJ(API+'/agents-status'); + if(st.ok && st.gateway && !st.gateway.alive){ + toast('⚠️ Gateway 未启动,任务将无法派发!请先运行 openclaw gateway start','err',6000); + if(!confirm('Gateway 未启动,任务创建后将无法自动派发。是否仍然继续?')) return; + } + }catch(ex){/* 检测失败不阻塞创建 */} + // Show the generated command in a confirmation if(!confirm(`确认下旨?\n\n${cmd.substring(0,200)}${cmd.length>200?'…':''}`)) return; // 通过 API 创建真实任务 diff --git a/dashboard/server.py b/dashboard/server.py index c0cee64..3c67868 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -325,6 +325,10 @@ def handle_create_task(title, org='中书省', official='中书令', priority='n # 发送给 main (太子) 而不是 zhongshu,让太子走正常流程分拣→中书省 def dispatch_to_agent(): try: + # 前置检查 Gateway 是否在线 + if not _check_gateway_alive(): + log.warning(f'⚠️ {task_id} 派发跳过: Gateway 未启动') + return msg = ( f'📜 皇上新旨意(已录入看板,请直接处理)\n' f'任务ID: {task_id}\n' @@ -390,6 +394,204 @@ def handle_review_action(task_id, action, comment=''): return {'ok': True, 'message': f'{task_id} {label}'} +# ══ Agent 在线状态检测 ══ + +_AGENT_DEPTS = [ + {'id':'main', 'label':'太子', 'emoji':'🤴', 'role':'太子', 'rank':'储君'}, + {'id':'taizi', 'label':'太子', 'emoji':'🤴', 'role':'太子', 'rank':'储君'}, + {'id':'zhongshu','label':'中书省','emoji':'📜', 'role':'中书令', 'rank':'正一品'}, + {'id':'menxia', 'label':'门下省','emoji':'🔍', 'role':'侍中', 'rank':'正一品'}, + {'id':'shangshu','label':'尚书省','emoji':'📮', 'role':'尚书令', 'rank':'正一品'}, + {'id':'hubu', 'label':'户部', 'emoji':'💰', 'role':'户部尚书', 'rank':'正二品'}, + {'id':'libu', 'label':'礼部', 'emoji':'📝', 'role':'礼部尚书', 'rank':'正二品'}, + {'id':'bingbu', 'label':'兵部', 'emoji':'⚔️', 'role':'兵部尚书', 'rank':'正二品'}, + {'id':'xingbu', 'label':'刑部', 'emoji':'⚖️', 'role':'刑部尚书', 'rank':'正二品'}, + {'id':'gongbu', 'label':'工部', 'emoji':'🔧', 'role':'工部尚书', 'rank':'正二品'}, + {'id':'libu_hr', 'label':'吏部', 'emoji':'👔', 'role':'吏部尚书', 'rank':'正二品'}, + {'id':'zaochao', 'label':'钦天监','emoji':'📰', 'role':'朝报官', 'rank':'正三品'}, +] + + +def _check_gateway_alive(): + """检测 Gateway 进程是否在运行。""" + try: + result = subprocess.run(['pgrep', '-f', 'openclaw-gateway'], + capture_output=True, text=True, timeout=5) + return result.returncode == 0 + except Exception: + return False + + +def _check_gateway_probe(): + """通过 HTTP probe 检测 Gateway 是否响应。""" + try: + from urllib.request import urlopen + resp = urlopen('http://127.0.0.1:18789/', timeout=3) + return resp.status == 200 + except Exception: + return False + + +def _get_agent_session_status(agent_id): + """读取 Agent 的 sessions.json 获取活跃状态。 + 返回: (last_active_ts_ms, session_count, is_busy) + """ + sessions_file = OCLAW_HOME / 'agents' / agent_id / 'sessions' / 'sessions.json' + if not sessions_file.exists() and agent_id == 'taizi': + sessions_file = OCLAW_HOME / 'agents' / 'main' / 'sessions' / 'sessions.json' + if not sessions_file.exists(): + return 0, 0, False + try: + data = json.loads(sessions_file.read_text()) + if not isinstance(data, dict): + return 0, 0, False + session_count = len(data) + last_ts = 0 + for v in data.values(): + ts = v.get('updatedAt', 0) + if isinstance(ts, (int, float)) and ts > last_ts: + last_ts = ts + now_ms = int(datetime.datetime.now().timestamp() * 1000) + age_ms = now_ms - last_ts if last_ts else 9999999999 + is_busy = age_ms <= 2 * 60 * 1000 # 2分钟内视为正在工作 + return last_ts, session_count, is_busy + except Exception: + return 0, 0, False + + +def _check_agent_process(agent_id): + """检测是否有该 Agent 的 openclaw-agent 进程正在运行。""" + try: + result = subprocess.run( + ['pgrep', '-f', f'openclaw.*--agent.*{agent_id}'], + capture_output=True, text=True, timeout=5 + ) + return result.returncode == 0 + except Exception: + return False + + +def _check_agent_workspace(agent_id): + """检查 Agent 工作空间是否存在。""" + ws = OCLAW_HOME / f'workspace-{agent_id}' + return ws.is_dir() + + +def get_agents_status(): + """获取所有 Agent 的在线状态。 + 返回各 Agent 的: + - status: 'running' | 'idle' | 'offline' | 'unconfigured' + - lastActive: 最后活跃时间 + - sessions: 会话数 + - hasWorkspace: 工作空间是否存在 + - processAlive: 是否有进程在运行 + """ + gateway_alive = _check_gateway_alive() + gateway_probe = _check_gateway_probe() if gateway_alive else False + + agents = [] + seen_ids = set() + for dept in _AGENT_DEPTS: + aid = dept['id'] + if aid in seen_ids: + continue + seen_ids.add(aid) + + has_workspace = _check_agent_workspace(aid) + last_ts, sess_count, is_busy = _get_agent_session_status(aid) + process_alive = _check_agent_process(aid) + + # 状态判定 + if not has_workspace: + status = 'unconfigured' + status_label = '❌ 未配置' + elif not gateway_alive: + status = 'offline' + status_label = '🔴 Gateway 离线' + elif process_alive or is_busy: + status = 'running' + status_label = '🟢 运行中' + elif last_ts > 0: + now_ms = int(datetime.datetime.now().timestamp() * 1000) + age_ms = now_ms - last_ts + if age_ms <= 10 * 60 * 1000: # 10分钟内 + status = 'idle' + status_label = '🟡 待命' + elif age_ms <= 3600 * 1000: # 1小时内 + status = 'idle' + status_label = '⚪ 空闲' + else: + status = 'idle' + status_label = '⚪ 休眠' + else: + status = 'idle' + status_label = '⚪ 无记录' + + # 格式化最后活跃时间 + last_active_str = None + if last_ts > 0: + try: + last_active_str = datetime.datetime.fromtimestamp( + last_ts / 1000 + ).strftime('%m-%d %H:%M') + except Exception: + pass + + agents.append({ + 'id': aid, + 'label': dept['label'], + 'emoji': dept['emoji'], + 'role': dept['role'], + 'status': status, + 'statusLabel': status_label, + 'lastActive': last_active_str, + 'lastActiveTs': last_ts, + 'sessions': sess_count, + 'hasWorkspace': has_workspace, + 'processAlive': process_alive, + }) + + return { + 'ok': True, + 'gateway': { + 'alive': gateway_alive, + 'probe': gateway_probe, + 'status': '🟢 运行中' if gateway_probe else ('🟡 进程在但无响应' if gateway_alive else '🔴 未启动'), + }, + 'agents': agents, + 'checkedAt': now_iso(), + } + + +def wake_agent(agent_id, message=''): + """唤醒指定 Agent,发送一条心跳/唤醒消息。""" + if not _SAFE_NAME_RE.match(agent_id): + return {'ok': False, 'error': f'agent_id 非法: {agent_id}'} + if not _check_agent_workspace(agent_id): + return {'ok': False, 'error': f'{agent_id} 工作空间不存在,请先配置'} + if not _check_gateway_alive(): + return {'ok': False, 'error': 'Gateway 未启动,请先运行 openclaw gateway start'} + + # 确定实际 agent id(taizi 用 main) + runtime_id = 'main' if agent_id == 'taizi' else agent_id + msg = message or f'🔔 系统心跳检测 — 请回复 OK 确认在线。当前时间: {now_iso()}' + + def do_wake(): + try: + cmd = ['openclaw', 'agent', '--agent', runtime_id, '-m', msg, '--timeout', '120'] + log.info(f'🔔 唤醒 {agent_id} ({runtime_id})...') + result = subprocess.run(cmd, capture_output=True, text=True, timeout=130) + if result.returncode == 0: + log.info(f'✅ {agent_id} 已唤醒') + else: + log.warning(f'⚠️ {agent_id} 唤醒失败: {result.stderr[:200]}') + except Exception as e: + log.warning(f'⚠️ {agent_id} 唤醒异常: {e}') + threading.Thread(target=do_wake, daemon=True).start() + + return {'ok': True, 'message': f'{agent_id} 唤醒指令已发出,约10-30秒后生效'} + + # ══ Agent 实时活动读取 ══ # 状态 → agent_id 映射 @@ -1201,6 +1403,8 @@ class Handler(BaseHTTPRequestHandler): self.send_json({'ok': False, 'error': 'task_id required'}, 400) else: self.send_json(get_task_activity(task_id)) + elif p == '/api/agents-status': + self.send_json(get_agents_status()) elif p.startswith('/api/agent-activity/'): agent_id = p.replace('/api/agent-activity/', '') if not agent_id or not _SAFE_NAME_RE.match(agent_id): @@ -1355,6 +1559,16 @@ class Handler(BaseHTTPRequestHandler): self.send_json(result) return + if p == '/api/agent-wake': + agent_id = body.get('agentId', '').strip() + message = body.get('message', '').strip() + if not agent_id: + self.send_json({'ok': False, 'error': 'agentId required'}, 400) + return + result = wake_agent(agent_id, message) + self.send_json(result) + return + if p == '/api/set-model': agent_id = body.get('agentId', '').strip() model = body.get('model', '').strip()