evolclaw-web 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,7 +58,7 @@ let ws = null;
58
58
  let reconnectDelay = 1000;
59
59
  let currentView = 'agents';
60
60
  let pendingSub = null; // 重连后要恢复的订阅
61
- const state = { agents: null, msg: null, session: null, cache: null, system: null, triggers: null };
61
+ const state = { agents: null, msg: null, session: null, cache: null, system: null, triggers: null, monitor: null };
62
62
 
63
63
  function setConnStatus(text, cls) {
64
64
  const el = $('#conn-status');
@@ -150,7 +150,9 @@ let msgSel = { aid: null, peer: null };
150
150
  let sessSel = { sessionId: null, project: null };
151
151
  let trigSel = { agent: null };
152
152
  let sessSearch = '';
153
+ let sessFilterNormal = false; // true=只显示有效会话(userMsgs >= 2)
153
154
  let sessChatMode = false; // false=完整视图,true=对话视图(折叠处理过程)
155
+ let monRange = '2m'; // Monitor 时间窗口:2m / 10m / 1h
154
156
 
155
157
  function switchView(view) {
156
158
  currentView = view;
@@ -162,6 +164,7 @@ function switchView(view) {
162
164
  else if (view === 'cache') subscribe('cache', {});
163
165
  else if (view === 'system') subscribe('system', {});
164
166
  else if (view === 'triggers') subscribe('triggers', { agent: trigSel.agent });
167
+ else if (view === 'monitor') subscribe('monitor', { range: monRange });
165
168
  else subscribe('agents', {});
166
169
  if (state[view]) renderView(view);
167
170
  }
@@ -179,6 +182,7 @@ function renderView(view) {
179
182
  else if (view === 'cache') renderCache(state.cache);
180
183
  else if (view === 'system') renderSystem(state.system);
181
184
  else if (view === 'triggers') renderTriggers(state.triggers);
185
+ else if (view === 'monitor') renderMonitor(state.monitor);
182
186
  }
183
187
 
184
188
  // ── 工具 ──
@@ -231,74 +235,243 @@ function compareVer(a, b) {
231
235
  return 0;
232
236
  }
233
237
 
234
- // ── Agents 视图(旧 AID 页升级:加操作列 + 新建入口)──
238
+ // ── Agents 视图(对齐终端 watch aid:状态点前置 + 名字为主 + 两行 + 工作态着色 + 顶部统计条)──
239
+
240
+ // 逐 AID 异步操作状态(取代全局 _agentBusy):aid → 操作中的描述文字
241
+ const _agentOps = new Map(); // Map<aid, string>
242
+ let _agentBusy = false; // 保留兼容旧引用,不再用于阻塞渲染
243
+ let _agSubtab = 'enabled'; // 'enabled' | 'disabled'
244
+
245
+ // 工作状态徽标:一旦收到过消息就不再回 connected。
246
+ // stopped → connected(仅首次连接无消息时) → idle(收到第一条后) → working → idle ...
247
+ function agentStateBadge(s, agStatus, connStatus) {
248
+ if (agStatus === 'stopped' || connStatus === 'disconnected' || connStatus === 'failed')
249
+ return '<span class="state-badge stopped">停止</span>';
250
+ if (connStatus === 'reconnecting')
251
+ return '<span class="state-badge stopped">重连中</span>';
252
+ if ((s.processing || 0) > 0)
253
+ return '<span class="state-badge working">working</span>';
254
+ // 收到过消息 → 永远是 idle,不再回到 connected
255
+ if ((s.messagesReceived || 0) > 0 || (s.messagesSent || 0) > 0)
256
+ return '<span class="state-badge idle">idle</span>';
257
+ return '<span class="state-badge connected">connected</span>';
258
+ }
259
+
260
+ // 发送方式图标标记
261
+ const MSG_KIND_META = { send: { icon: '💬', label: '回复' }, thought: { icon: '💭', label: '思考' }, inject: { icon: '📥', label: '注入' }, notify: { icon: '🔔', label: '通知' } };
262
+ function msgTagsHtml(kind, encrypt, chatmode) {
263
+ let h = '';
264
+ const km = MSG_KIND_META[kind];
265
+ if (km) h += `<span class="mtag">${km.icon}${km.label}</span>`;
266
+ if (encrypt != null) h += `<span class="mtag">${encrypt ? '🔒密文' : '明文'}</span>`;
267
+ if (chatmode) h += `<span class="mtag">${chatmode === 'proactive' ? '自主' : (chatmode === 'inject' ? '注入' : '响应')}</span>`;
268
+ return h;
269
+ }
270
+
271
+ // 消息行:方向箭头 + 标记 + 对端 + 文字
272
+ function agentPreviewHtml(s) {
273
+ const clip = (t) => esc(String(t).replace(/\n/g, ' ').slice(0, 80));
274
+ const line = (dir, peer, text, kind, encrypt, chatmode) => {
275
+ const arrow = dir === 'in' ? '<span class="arrow-in">↓</span>' : '<span class="arrow-out">↑</span>';
276
+ const tags = msgTagsHtml(kind, encrypt, chatmode);
277
+ const peerHtml = peer ? `<span class="peer">${esc(shortAid(peer))}</span>: ` : '';
278
+ const textCls = dir === 'in' ? 'text-in' : 'text-out';
279
+ return `${arrow}${tags ? ' ' + tags + ' ' : ' '}${peerHtml}<span class="${textCls}">${clip(text)}</span>`;
280
+ };
281
+ if ((s.processing || 0) > 0 && s.lastReceivedText)
282
+ return line('in', s.lastReceivedFrom, s.lastReceivedText, s.lastReceivedKind, s.lastReceivedEncrypt, s.lastReceivedChatmode);
283
+ const recvTs = s.lastReceivedAt || 0, sentTs = s.lastSentAt || 0;
284
+ if (!recvTs && !sentTs) return '';
285
+ if (sentTs > recvTs && s.lastSentText)
286
+ return line('out', s.lastSentTo, s.lastSentText, s.lastSentKind, s.lastSentEncrypt, s.lastSentChatmode);
287
+ if (s.lastReceivedText)
288
+ return line('in', s.lastReceivedFrom, s.lastReceivedText, s.lastReceivedKind, s.lastReceivedEncrypt, s.lastReceivedChatmode);
289
+ return '';
290
+ }
291
+
292
+ // HTML tooltip(最近 N 轮):彩色箭头 + 方式 + 对端 + 文字
293
+ function recentMsgTooltipHtml(recent) {
294
+ if (!recent || !recent.length) return '';
295
+ let h = '<div class="msg-tip">';
296
+ for (const m of recent) {
297
+ const rcls = m.dir === 'in' ? 'tip-row-in' : 'tip-row-out';
298
+ const arrow = m.dir === 'in' ? '↓' : '↑';
299
+ const km = MSG_KIND_META[m.kind];
300
+ const kh = km ? `<span class="tip-kind">${km.icon}${km.label}</span>` : '';
301
+ const enc = m.encrypt != null ? `<span class="tip-flag">${m.encrypt ? '🔒密文' : '明文'}</span>` : '';
302
+ const mode = m.chatmode ? `<span class="tip-flag">${m.chatmode === 'proactive' ? '自主' : (m.chatmode === 'inject' ? '注入' : '响应')}</span>` : '';
303
+ const peer = m.peer ? esc(shortAid(m.peer)) : '';
304
+ const text = esc(String(m.text).replace(/\n/g, ' ').slice(0, 60));
305
+ h += `<div class="tip-row ${rcls}">${arrow}${kh}${enc}${mode} <b>${peer}</b> ${text}</div>`;
306
+ }
307
+ return h + '</div>';
308
+ }
309
+
310
+ // 顶部统计条:Gateway / AIDs total·connected·offline / Messages ↓↑ / Traffic ↓↑ / Version·PID·Uptime
311
+ function agentsStatsBar(data, aids, stats) {
312
+ const connected = aids.filter(a => (a.status || 'connected') === 'connected').length;
313
+ const offline = aids.length - connected;
314
+ let recv = 0, sent = 0, bin = 0, bout = 0;
315
+ for (const s of stats) {
316
+ recv += s.messagesReceived || 0; sent += s.messagesSent || 0;
317
+ bin += s.bytesReceived || 0; bout += s.bytesSent || 0;
318
+ }
319
+ const gws = [...new Set(aids.filter(a => a.gatewayUrl).map(a => a.gatewayUrl))];
320
+ const gw = gws.length ? gws.map(esc).join(', ') : '—';
321
+ const st = data.status || {};
322
+ const pid = st.pid != null ? st.pid : '—';
323
+ const uptime = st.uptime != null ? fmtDur(st.uptime / 1000) : '—';
324
+ const ver = data.version || '—';
325
+
326
+ let h = '<div class="agents-stats">';
327
+ h += `<span class="sg"><span class="sg-k">Gateway</span><span class="sg-gw">${gw}</span></span>`;
328
+ h += `<span class="sg"><span class="sg-k">AIDs</span>${aids.length} total · <span class="num-on">${connected} 在线</span>` +
329
+ `${offline ? ` · <span class="num-off">${offline} 离线</span>` : ''}</span>`;
330
+ h += `<span class="sg"><span class="sg-k">Messages</span><span class="in">↓${recv}</span> <span class="out">↑${sent}</span></span>`;
331
+ h += `<span class="sg"><span class="sg-k">Traffic</span><span class="in">↓${fmtBytes(bin)}</span> <span class="out">↑${fmtBytes(bout)}</span></span>`;
332
+ h += `<span class="sg"><span class="sg-k">Version</span>${esc(ver)} · <span class="sg-k">PID</span>${pid} · <span class="sg-k">Uptime</span>${uptime}</span>`;
333
+ h += '</div>';
334
+ return h;
335
+ }
336
+
337
+ // 操作列 HTML(启用页):停止/启动 + 清空队列(conditional) + ···(禁用/重载/编辑/md/删除)
338
+ function agentOpsHtml(aid, ag, s) {
339
+ if (_agentOps.has(aid)) {
340
+ return `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(aid) || '操作中…')}</span></div>`;
341
+ }
342
+ const queued = s.queued || 0;
343
+ const running = ag.status === 'running';
344
+ let h = `<div class="agent-ops" data-aid="${esc(aid)}" data-status="${esc(ag.status)}">`;
345
+ if (running) h += `<button class="ctrl-btn ops-stop" data-op="stop">停止</button>`;
346
+ else h += `<button class="ctrl-btn ops-start" data-op="start">启动</button>`;
347
+ if (queued > 0) h += `<button class="ctrl-btn ops-clear-queue" data-op="clear-queue" title="清空 ${queued} 条待处理消息">清空队列</button>`;
348
+ h += `<div class="ops-more"><button class="ctrl-btn ops-more-btn" data-op="more">···</button>` +
349
+ `<div class="ops-dropdown">` +
350
+ `<button class="ops-dd-item" data-op="toggle">禁用</button>` +
351
+ `<button class="ops-dd-item" data-op="reload">重载配置</button>` +
352
+ `<button class="ops-dd-item" data-op="edit">编辑配置</button>` +
353
+ `<a class="ops-dd-item" href="https://${esc(aid)}/agent.md" target="_blank" rel="noopener">查看 agent.md ↗</a>` +
354
+ `<button class="ops-dd-item danger" data-op="delete">删除 Agent</button>` +
355
+ `</div></div>`;
356
+ h += '</div>';
357
+ return h;
358
+ }
359
+
235
360
  function renderAgents(data) {
236
361
  const el = $('#view-agents');
237
362
  if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
238
- if (_agentBusy) return; // 编辑/操作进行中,跳过轮询重渲染
363
+ if (el.querySelector('.ops-more.open')) return;
364
+
365
+ const allAgents = data.agents || [];
239
366
  const aids = data.aids || [];
240
367
  const statsByAid = {};
241
368
  for (const s of (data.stats || [])) statsByAid[s.aid] = s;
242
- // aid agent 状态映射(来自 evolagent.list,用于操作列 启用/禁用 二选一)
243
- const agentByAid = {};
244
- for (const ag of (data.agents || [])) agentByAid[ag.aid] = ag;
369
+ const aidConnByAid = {};
370
+ for (const a of aids) aidConnByAid[a.aid] = a;
371
+
372
+ const enabledCount = allAgents.filter(ag => ag.status !== 'disabled').length;
373
+ const disabledCount = allAgents.filter(ag => ag.status === 'disabled').length;
374
+
375
+ // 子标签栏
376
+ let html = '<div class="agents-toolbar">' +
377
+ `<div class="ag-subtabs">` +
378
+ `<button class="ag-subtab${_agSubtab === 'enabled' ? ' active' : ''}" data-subtab="enabled">启用 (${enabledCount})</button>` +
379
+ `<button class="ag-subtab${_agSubtab === 'disabled' ? ' active' : ''}" data-subtab="disabled">禁用 (${disabledCount})</button>` +
380
+ `</div>` +
381
+ `<button class="ctrl-btn" id="agent-new-btn">+ 新建</button>` +
382
+ '</div>';
245
383
 
246
- let html = '<div class="agents-toolbar"><button class="ctrl-btn" id="agent-new-btn">+ 新建 Agent</button></div>';
247
384
  if (!data.daemonRunning) {
248
385
  html += '<div class="banner">⚠ EvolClaw 主进程未运行,仅显示最近活动记录</div>';
249
386
  }
250
- if (!aids.length) {
251
- html += '<div class="empty">暂无 AID</div>';
387
+
388
+ if (_agSubtab === 'disabled') {
389
+ const disabledAgents = allAgents.filter(ag => ag.status === 'disabled');
390
+ if (!disabledAgents.length) {
391
+ html += '<div class="empty">暂无禁用 Agent</div>';
392
+ } else {
393
+ html += '<table><thead><tr><th>Agent</th><th>项目路径</th><th>操作</th></tr></thead><tbody>';
394
+ for (const ag of disabledAgents) {
395
+ const busy = _agentOps.has(ag.aid);
396
+ const ops = busy
397
+ ? `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(ag.aid) || '操作中…')}</span></div>`
398
+ : `<div class="agent-ops" data-aid="${esc(ag.aid)}" data-status="disabled"><button class="ctrl-btn ops-enable" data-op="toggle">启用</button></div>`;
399
+ html += `<tr class="ag-main">` +
400
+ `<td><div class="ag-id"><span class="dot off"></span><span class="ag-id-text"><span class="ag-name">${esc(ag.displayName || shortAid(ag.aid))}</span><span class="ag-aid">${esc(ag.aid)}</span></span></div></td>` +
401
+ `<td style="font-size:11px;font-family:monospace">${esc(ag.projectPath || '—')}</td>` +
402
+ `<td class="agent-ops-cell">${ops}</td></tr>`;
403
+ }
404
+ html += '</tbody></table>';
405
+ }
406
+ el.innerHTML = html;
407
+ bindAgentsEvents(el);
408
+ return;
409
+ }
410
+
411
+ // ── 启用页 ──
412
+ // 按收发消息总数降序排序(活跃的排前面)
413
+ const totalMsgs = (ag) => {
414
+ const s = statsByAid[ag.aid] || {};
415
+ return (s.messagesReceived || 0) + (s.messagesSent || 0);
416
+ };
417
+ const enabledAgents = allAgents.filter(ag => ag.status !== 'disabled')
418
+ .sort((a, b) => totalMsgs(b) - totalMsgs(a));
419
+ if (!enabledAgents.length) {
420
+ html += '<div class="empty">暂无启用 Agent</div>';
252
421
  el.innerHTML = html;
253
422
  bindAgentsEvents(el);
254
423
  return;
255
424
  }
256
425
 
257
426
  html += '<table><thead><tr>' +
258
- '<th>状态</th><th>AID</th><th>收</th><th>发</th><th>系统</th>' +
259
- '<th>入字节</th><th>出字节</th><th>peers</th><th>重连</th><th>最后活动</th><th>最近消息</th><th>操作</th>' +
427
+ '<th>AID</th><th>工作</th><th>队列</th><th>模型</th><th>运行</th><th>收</th><th>发</th>' +
428
+ '<th>入字节</th><th>出字节</th><th>对端数量</th><th>最后活动</th><th>操作</th>' +
260
429
  '</tr></thead><tbody>';
261
430
 
262
- for (const a of aids) {
263
- const s = statsByAid[a.aid] || {};
264
- const status = a.status || (a.lastEvent === 'disconnected' ? 'disconnected' : 'connected');
265
- const dotCls = status === 'connected' ? 'on' : (status === 'reconnecting' ? 'idle' : 'off');
266
- const name = s.selfName || a.agentName || '';
267
- const lastTs = Math.max(s.lastReceivedAt || 0, s.lastSentAt || 0, a.lastActivity || 0);
268
- let preview = '';
269
- if (s.lastReceivedText && (s.lastReceivedAt || 0) >= (s.lastSentAt || 0)) {
270
- preview = '↓ ' + shortAid(s.lastReceivedFrom) + ': ' + s.lastReceivedText;
271
- } else if (s.lastSentText) {
272
- preview = '↑ ' + shortAid(s.lastSentTo) + ': ' + s.lastSentText;
273
- }
274
- // 操作列:能归属到 EvolAgent 的才可操作;否则置灰
275
- const ag = agentByAid[a.aid];
276
- let ops;
277
- if (ag) {
278
- const toggleLabel = ag.status === 'disabled' ? '启用' : '禁用';
279
- ops = `<div class="agent-ops" data-aid="${esc(a.aid)}" data-status="${esc(ag.status)}">` +
280
- `<button class="ctrl-btn" data-op="edit">编辑</button>` +
281
- `<button class="ctrl-btn" data-op="reload">重载</button>` +
282
- `<button class="ctrl-btn" data-op="toggle">${toggleLabel}</button>` +
283
- `<button class="ctrl-btn danger" data-op="delete">删除</button>` +
284
- `<a class="ctrl-btn" href="https://${esc(a.aid)}/agent.md" target="_blank" rel="noopener">md↗</a>` +
285
- `</div>`;
286
- } else {
287
- ops = '<span style="color:var(--dim)">—</span>';
288
- }
289
- html += '<tr>' +
290
- `<td><span class="dot ${dotCls}"></span>${esc(status)}</td>` +
291
- `<td>${esc(shortAid(a.aid))}${name ? ` <span style="color:var(--dim)">(${esc(name)})</span>` : ''}</td>` +
431
+ for (const ag of enabledAgents) {
432
+ const s = statsByAid[ag.aid] || {};
433
+ const conn = aidConnByAid[ag.aid] || {};
434
+ const connStatus = conn.status || (ag.status === 'running' ? 'connected' : 'disconnected');
435
+ const dotCls = connStatus === 'connected' ? 'on' : (connStatus === 'reconnecting' ? 'idle' : 'off');
436
+ const name = s.selfName || ag.displayName || shortAid(ag.aid);
437
+ const uptime = (connStatus === 'connected' && conn.lastConnectedAt) ? fmtDur((Date.now() - conn.lastConnectedAt) / 1000) : '—';
438
+ const lastTs = Math.max(s.lastReceivedAt || 0, s.lastSentAt || 0, ag.lastActivity || 0);
439
+ const preview = agentPreviewHtml(s);
440
+ // 队列数:不含正在处理的那条
441
+ const rawQueued = s.queued || 0;
442
+ const queued = rawQueued;
443
+ const queueCell = queued > 0 ? `<span class="ag-queue-num">${queued}</span>` : '<span style="color:var(--dim)">0</span>';
444
+ const model = ag.model || ag.baseagent || '—';
445
+
446
+ const idCell = `<div class="ag-id"><span class="dot ${dotCls}" title="${esc(connStatus)}"></span>` +
447
+ `<span class="ag-id-text"><span class="ag-name">${esc(name)}</span>` +
448
+ `<span class="ag-aid">${esc(ag.aid)}</span></span></div>`;
449
+
450
+ html += `<tr class="ag-main">` +
451
+ `<td>${idCell}</td>` +
452
+ `<td>${agentStateBadge(s, ag.status, connStatus)}</td>` +
453
+ `<td>${queueCell}</td>` +
454
+ `<td style="font-size:11px;color:var(--dim)">${esc(model)}</td>` +
455
+ `<td>${uptime}</td>` +
292
456
  `<td>${s.messagesReceived ?? 0}</td><td>${s.messagesSent ?? 0}</td>` +
293
- `<td>${s.systemReceived ?? 0}/${s.systemSent ?? 0}</td>` +
294
457
  `<td>${fmtBytes(s.bytesReceived)}</td><td>${fmtBytes(s.bytesSent)}</td>` +
295
- `<td>${s.uniquePeerCount ?? a.peerCount ?? 0}</td><td>${a.reconnectCount ?? 0}</td>` +
458
+ `<td>${s.uniquePeerCount ?? conn.peerCount ?? 0}</td>` +
296
459
  `<td>${fmtAgo(lastTs)}</td>` +
297
- `<td class="preview">${esc(preview.replace(/\n/g, ' ').slice(0, 80))}</td>` +
298
- `<td class="agent-ops-cell">${ops}</td>` +
460
+ `<td class="agent-ops-cell">${agentOpsHtml(ag.aid, ag, s)}</td>` +
299
461
  '</tr>';
462
+ // 自定义 tooltip(HTML,hover 显示)
463
+ const recent = (s.recentMessages || []);
464
+ const tipHtml = recentMsgTooltipHtml(recent);
465
+
466
+ html += `<tr class="ag-sub"><td colspan="12"><div class="ag-info">` +
467
+ (ag.projectPath ? `<div class="ag-path">${esc(ag.projectPath)}</div>` : '') +
468
+ (preview ? `<div class="ag-msg-wrap">${tipHtml}<div class="ag-msg">${preview}</div></div>` : '') +
469
+ '</div></td></tr>';
300
470
  }
301
471
  html += '</tbody></table>';
472
+ if (data.daemonRunning) {
473
+ html += agentsStatsBar(data, aids, data.stats || []);
474
+ }
302
475
  el.innerHTML = html;
303
476
  bindAgentsEvents(el);
304
477
  }
@@ -502,17 +675,19 @@ function renderSession(data) {
502
675
 
503
676
  // 搜索过滤
504
677
  const q = sessSearch.trim().toLowerCase();
505
- const filtered = q
506
- ? transcripts.filter(t => (t.title || '').toLowerCase().includes(q) || (t.firstUser || '').toLowerCase().includes(q))
507
- : transcripts;
678
+ const filtered = transcripts
679
+ .filter(t => !sessFilterNormal || (t.userMsgs || 0) >= 2)
680
+ .filter(t => !q || (t.title || '').toLowerCase().includes(q) || (t.firstUser || '').toLowerCase().includes(q));
508
681
 
509
682
  // 左栏:过滤条 + 列表
510
683
  const projOpts = projects.map(p =>
511
684
  `<option value="${esc(p.encoded)}"${p.encoded === sessSel.project ? ' selected' : ''}>${esc(p.label)} (${p.count})</option>`
512
685
  ).join('');
686
+ const normalCount = transcripts.filter(t => (t.userMsgs || 0) >= 2).length;
513
687
  let listHtml = '<div class="sess-filter">' +
514
688
  `<select id="sess-project">${projOpts}</select>` +
515
689
  `<input id="sess-search" type="text" placeholder="搜索标题/首条消息…" value="${esc(sessSearch)}">` +
690
+ `<button id="sess-filter-btn" class="ctrl-btn${sessFilterNormal ? ' active' : ''}" title="只显示有效会话(≥2 条用户消息)">有效 ${normalCount}</button>` +
516
691
  `<div class="sess-count">${filtered.length} / ${transcripts.length} 个会话</div></div>` +
517
692
  '<div class="sess-items">';
518
693
 
@@ -543,6 +718,8 @@ function renderSession(data) {
543
718
  sessSearch = '';
544
719
  subscribe('session', { project: sessSel.project });
545
720
  };
721
+ const filterBtn = $('#sess-filter-btn');
722
+ if (filterBtn) filterBtn.onclick = () => { sessFilterNormal = !sessFilterNormal; renderSession(state.session); };
546
723
  const searchEl = $('#sess-search');
547
724
  if (searchEl) {
548
725
  searchEl.oninput = () => { sessSearch = searchEl.value; renderSession(state.session); };
@@ -758,65 +935,130 @@ function toast(text, isErr) {
758
935
  }
759
936
 
760
937
  // ── Agents 操作 ──
761
- let _agentBusy = false;
938
+ // _agentBusy 已在 Agents 视图顶部声明,仅 agentOpNew 仍在用)
939
+
940
+ // 设置某 aid 的操作状态并立即刷新对应行的按钮区(不重渲整表)
941
+ function setAgentOp(aid, label) {
942
+ if (label == null) _agentOps.delete(aid); else _agentOps.set(aid, label);
943
+ const cell = document.querySelector(`.agent-ops[data-aid="${CSS.escape(aid)}"], .agent-ops-busy[data-aid="${CSS.escape(aid)}"]`)?.closest('td');
944
+ if (!cell || !state.agents) return;
945
+ const ag = (state.agents.agents || []).find(x => x.aid === aid);
946
+ if (!ag) return;
947
+ if (ag.status === 'disabled') {
948
+ // 禁用页:只有启用按钮 / 操作中态
949
+ cell.innerHTML = _agentOps.has(aid)
950
+ ? `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(aid) || '操作中…')}</span></div>`
951
+ : `<div class="agent-ops" data-aid="${esc(aid)}" data-status="disabled"><button class="ctrl-btn ops-enable" data-op="toggle">启用</button></div>`;
952
+ } else {
953
+ const statsByAid = {};
954
+ for (const s of (state.agents.stats || [])) statsByAid[s.aid] = s;
955
+ cell.innerHTML = agentOpsHtml(aid, ag, statsByAid[aid] || {});
956
+ }
957
+ bindOpsCell(cell, aid, ag.status);
958
+ }
959
+
960
+ function bindOpsCell(cell, aid, status) {
961
+ cell.querySelectorAll('button[data-op]').forEach(btn => {
962
+ btn.addEventListener('click', (e) => {
963
+ const op = btn.dataset.op;
964
+ if (op === 'more') {
965
+ const more = btn.closest('.ops-more');
966
+ const wasOpen = more.classList.contains('open');
967
+ document.querySelectorAll('.ops-more.open').forEach(m => m.classList.remove('open'));
968
+ if (!wasOpen) more.classList.add('open');
969
+ e.stopPropagation();
970
+ return;
971
+ }
972
+ if (op === 'edit') agentOpEdit(aid);
973
+ else if (op === 'reload') agentOpReload(aid);
974
+ else if (op === 'toggle') agentOpToggle(aid, status);
975
+ else if (op === 'delete') agentOpDelete(aid);
976
+ else if (op === 'clear-queue') agentOpClearQueue(aid);
977
+ else if (op === 'stop') agentOpStop(aid);
978
+ else if (op === 'start') agentOpStart(aid);
979
+ else if (op === 'mute') agentOpMute(aid);
980
+ else if (op === 'unmute') agentOpUnmute(aid);
981
+ });
982
+ });
983
+ }
984
+
985
+ // click-outside 关闭下拉:全局只绑一次(避免每次重渲染叠加监听器)
986
+ let _opsOutsideBound = false;
987
+ function ensureOpsOutsideClose() {
988
+ if (_opsOutsideBound) return;
989
+ _opsOutsideBound = true;
990
+ document.addEventListener('click', (e) => {
991
+ if (e.target.closest && e.target.closest('.ops-more')) return; // 点在菜单内不关
992
+ document.querySelectorAll('.ops-more.open').forEach(m => m.classList.remove('open'));
993
+ });
994
+ }
762
995
 
763
996
  function bindAgentsEvents(el) {
764
997
  el.querySelector('#agent-new-btn')?.addEventListener('click', agentOpNew);
998
+ ensureOpsOutsideClose();
999
+ // 子标签切换:仅切视图变量并重渲,不重新订阅
1000
+ el.querySelectorAll('.ag-subtab').forEach(btn => {
1001
+ btn.addEventListener('click', () => {
1002
+ const tab = btn.dataset.subtab;
1003
+ if (tab && tab !== _agSubtab) { _agSubtab = tab; renderAgents(state.agents); }
1004
+ });
1005
+ });
765
1006
  el.querySelectorAll('.agent-ops').forEach(div => {
766
1007
  const aid = div.dataset.aid;
767
1008
  const status = div.dataset.status;
768
- div.querySelectorAll('button[data-op]').forEach(btn => {
769
- btn.addEventListener('click', () => {
770
- const op = btn.dataset.op;
771
- if (op === 'edit') agentOpEdit(aid);
772
- else if (op === 'reload') agentOpReload(aid);
773
- else if (op === 'toggle') agentOpToggle(aid, status);
774
- else if (op === 'delete') agentOpDelete(aid);
775
- });
776
- });
1009
+ bindOpsCell(div.closest('td'), aid, status);
777
1010
  });
778
1011
  }
779
1012
 
1013
+ // 异步操作包装:设置 "操作中" 状态、执行、清除
1014
+ async function withAgentOp(aid, label, fn) {
1015
+ setAgentOp(aid, label);
1016
+ try { await fn(); }
1017
+ finally { setAgentOp(aid, null); }
1018
+ }
1019
+
780
1020
  async function agentOpReload(aid, force = false) {
781
- _agentBusy = true;
782
- try {
1021
+ await withAgentOp(aid, '重载中…', async () => {
783
1022
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'reload', args: { aid, force } }));
784
1023
  if (r.error?.code === 'BUSY') {
785
- if (confirm(r.error.message + '\n确认强制重载?')) return agentOpReload(aid, true);
1024
+ if (confirm(r.error.message + '\n确认强制重载?')) { setAgentOp(aid, null); return agentOpReload(aid, true); }
786
1025
  return;
787
1026
  }
788
1027
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
789
1028
  toast('✓ 已重载');
790
1029
  subscribe('agents', {});
791
- } catch (e) { toast(e.message, true); }
792
- finally { _agentBusy = false; }
1030
+ });
793
1031
  }
794
1032
 
795
1033
  async function agentOpToggle(aid, status) {
796
1034
  const action = status === 'disabled' ? 'enable' : 'disable';
797
- _agentBusy = true;
798
- try {
1035
+ const label = action === 'disable' ? '禁用中…' : '启用中…';
1036
+ await withAgentOp(aid, label, async () => {
799
1037
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid } }));
800
1038
  if (r.error?.code === 'BUSY') {
801
1039
  if (confirm(r.error.message + `\n确认强制${action === 'disable' ? '禁用' : '启用'}?`)) {
802
1040
  const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid, force: true } }));
803
1041
  if (r2.error) toast(r2.error.message || r2.error.code, true);
804
- else { toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`); subscribe('agents', {}); }
1042
+ else {
1043
+ toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`);
1044
+ // 禁用后立即切到禁用页;启用后等数据刷新(agent 需先完成启动才移到启用页)
1045
+ if (action === 'disable') _agSubtab = 'disabled';
1046
+ subscribe('agents', {});
1047
+ }
805
1048
  }
806
1049
  return;
807
1050
  }
808
1051
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
809
1052
  toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`);
1053
+ if (action === 'disable') _agSubtab = 'disabled';
810
1054
  subscribe('agents', {});
811
- } catch (e) { toast(e.message, true); }
812
- finally { _agentBusy = false; }
1055
+ });
813
1056
  }
814
1057
 
815
1058
  async function agentOpDelete(aid) {
816
1059
  if (!confirm(`删除 Agent ${aid}?\n此操作不可恢复。`)) return;
817
1060
  const purge = confirm('同时清除 agent 数据目录?');
818
- _agentBusy = true;
819
- try {
1061
+ await withAgentOp(aid, '删除中…', async () => {
820
1062
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge } }));
821
1063
  if (r.error?.code === 'BUSY') {
822
1064
  if (confirm(r.error.message + '\n确认强制删除?')) {
@@ -829,8 +1071,53 @@ async function agentOpDelete(aid) {
829
1071
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
830
1072
  toast('✓ 已删除');
831
1073
  subscribe('agents', {});
832
- } catch (e) { toast(e.message, true); }
833
- finally { _agentBusy = false; }
1074
+ });
1075
+ }
1076
+
1077
+ async function agentOpClearQueue(aid) {
1078
+ if (!confirm(`清空 ${aid} 的待处理消息队列?`)) return;
1079
+ await withAgentOp(aid, '清空中…', async () => {
1080
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'queue-clear', args: { aid } }));
1081
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1082
+ toast(`✓ 已清空 ${r.data?.cleared ?? 0} 条待处理消息`);
1083
+ subscribe('agents', {});
1084
+ });
1085
+ }
1086
+
1087
+ async function agentOpStop(aid) {
1088
+ await withAgentOp(aid, '停止中…', async () => {
1089
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'stop', args: { aid } }));
1090
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1091
+ toast('✓ 已停止');
1092
+ subscribe('agents', {});
1093
+ });
1094
+ }
1095
+
1096
+ async function agentOpStart(aid) {
1097
+ await withAgentOp(aid, '启动中…', async () => {
1098
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'start', args: { aid } }));
1099
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1100
+ toast('✓ 已启动');
1101
+ subscribe('agents', {});
1102
+ });
1103
+ }
1104
+
1105
+ async function agentOpMute(aid) {
1106
+ await withAgentOp(aid, '禁言中…', async () => {
1107
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'mute', args: { aid } }));
1108
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1109
+ toast('✓ 已禁言');
1110
+ subscribe('agents', {});
1111
+ });
1112
+ }
1113
+
1114
+ async function agentOpUnmute(aid) {
1115
+ await withAgentOp(aid, '解禁中…', async () => {
1116
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'unmute', args: { aid } }));
1117
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1118
+ toast('✓ 已解禁');
1119
+ subscribe('agents', {});
1120
+ });
834
1121
  }
835
1122
 
836
1123
  async function agentOpNew() {
@@ -849,29 +1136,61 @@ async function agentOpNew() {
849
1136
  }
850
1137
 
851
1138
  async function agentOpEdit(aid) {
852
- _agentBusy = true;
853
- try {
854
- const [qr] = await Promise.all([
855
- menuSend({ type: 'menu.query', name: 'agent', args: { aid } }),
856
- ]);
1139
+ await withAgentOp(aid, '查询中…', async () => {
1140
+ const qr = await menuSend({ type: 'menu.query', name: 'agent', args: { aid } });
857
1141
  const q = mResp(qr);
858
- if (q.error) { toast(q.error.message || q.error.code, true); _agentBusy = false; return; }
1142
+ if (q.error) { toast(q.error.message || q.error.code, true); return; }
859
1143
  const cfg = q.data;
860
- // 简单 prompt 编辑表单
1144
+ setAgentOp(aid, null); // 查询完毕先恢复,等用户填完 prompt
861
1145
  const projectRaw = prompt('项目路径:', cfg.config?.projects?.defaultPath || '');
862
1146
  const ownersRaw = prompt('Owners(逗号分隔 AID):', (cfg.config?.owners || []).join(', '));
863
1147
  const patch = {};
864
1148
  if (projectRaw !== null) patch.projects = { defaultPath: projectRaw };
865
1149
  if (ownersRaw !== null) patch.owners = ownersRaw.split(',').map(s => s.trim()).filter(Boolean);
866
- if (Object.keys(patch).length === 0) { _agentBusy = false; return; }
1150
+ if (!Object.keys(patch).length) return;
1151
+ setAgentOp(aid, '保存中…');
867
1152
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'update', args: { aid, patch } }));
868
1153
  if (r.error) toast(r.error.message || r.error.code, true);
869
1154
  else toast('✓ 配置已保存,点「重载」生效');
870
- } catch (e) { toast(e.message, true); }
871
- finally { _agentBusy = false; }
1155
+ });
872
1156
  }
873
1157
 
874
1158
  // ── System 视图 ──
1159
+ function channelHealthRow(c) {
1160
+ const dot = c.connected ? 'on' : (c.aidStatus === 'reconnecting' || c.aidStatus === 'kicked' ? 'idle' : 'off');
1161
+ let meta = '';
1162
+ if (c.aidStatus && c.aidStatus !== 'connected') meta += ` <span style="color:var(--dim)">${esc(c.aidStatus)}</span>`;
1163
+ if (c.reconnectCount > 0) meta += ` <span style="color:var(--dim)">重连 ${c.reconnectCount}</span>`;
1164
+ if (c.flapCount > 0) meta += ` <span style="color:var(--red)">抖动 ${c.flapCount}</span>`;
1165
+ const reason = c.kickReason || c.lastError;
1166
+ if (reason && !c.connected) meta += ` <span style="color:var(--red)" title="${esc(reason)}">"${esc(reason)}"</span>`;
1167
+ return `<div class="ch-row"><span class="dot ${dot}"></span>${esc(c.type)}${c.instName && c.instName !== c.type ? ' ' + esc(c.instName) : ''}${meta}</div>`;
1168
+ }
1169
+
1170
+ function agentHealthCard(ag) {
1171
+ const dot = ag.status === 'running' ? 'on' : ag.status === 'disabled' ? 'idle' : 'off';
1172
+ let h = `<div class="agent-health-card">`;
1173
+ h += `<div class="ahc-head"><span class="dot ${dot}"></span><span class="ahc-aid">${esc(ag.aid || ag.name)}</span><span class="ahc-status">${esc(ag.status)}</span></div>`;
1174
+ // 项目路径
1175
+ if (ag.projectPath) h += `<div class="ahc-row"><span class="ahc-k">项目</span><span class="ahc-v ahc-path" title="${esc(ag.projectPath)}">${esc(ag.projectPath)}</span></div>`;
1176
+ // 后端
1177
+ const backend = [ag.baseagent, ag.model, ag.effort].filter(Boolean).map(esc).join(' · ');
1178
+ h += `<div class="ahc-row"><span class="ahc-k">后端</span><span class="ahc-v">${backend || '—'}</span></div>`;
1179
+ // 渠道
1180
+ let chans = '';
1181
+ for (const c of (ag.channels || [])) chans += channelHealthRow(c);
1182
+ h += `<div class="ahc-row"><span class="ahc-k">渠道</span><span class="ahc-v">${chans || '<span style="color:var(--dim)">无</span>'}</span></div>`;
1183
+ // 负载
1184
+ const load = `${ag.processing ?? 0} 处理中 · ${ag.pending ?? 0} 待处理 · ${ag.activeSessions ?? 0} 会话`;
1185
+ h += `<div class="ahc-row"><span class="ahc-k">负载</span><span class="ahc-v">${load}</span></div>`;
1186
+ // 活动
1187
+ if (ag.lastActivity) h += `<div class="ahc-row"><span class="ahc-k">活动</span><span class="ahc-v">${fmtAgo(ag.lastActivity)} 前</span></div>`;
1188
+ // 错误
1189
+ if (ag.error) h += `<div class="ahc-err">⚠ ${esc(String(ag.error).slice(0, 120))}</div>`;
1190
+ h += '</div>';
1191
+ return h;
1192
+ }
1193
+
875
1194
  function renderSystem(data) {
876
1195
  const el = $('#view-system');
877
1196
  if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
@@ -913,45 +1232,26 @@ function renderSystem(data) {
913
1232
  // ③ 健康快照
914
1233
  if (chk) {
915
1234
  html += '<div class="sys-health">';
916
- // 渠道 + 队列
917
- html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">';
918
- html += '<div class="cache-card" style="min-width:180px"><div class="card-label">渠道健康</div>';
919
- for (const ch of (chk.channels || [])) {
920
- for (const inst of (ch.instances || [])) {
921
- const dot = inst.connected ? 'on' : 'idle';
922
- html += `<div><span class="dot ${dot}"></span>${esc(ch.type)}${inst.name !== ch.type ? ' ' + esc(inst.name) : ''}</div>`;
923
- }
924
- }
925
- html += '</div>';
926
- html += `<div class="cache-card"><div class="card-label">队列</div><div>待处理 ${chk.queue?.pending ?? 0}</div><div>处理中 ${chk.queue?.processing ?? 0}</div></div>`;
927
- html += '</div>';
928
- // 近 1 小时
1235
+ // 队列 + 近 1 小时(数字卡片同一行)
1236
+ html += '<div class="cache-cards" style="margin-bottom:8px">';
1237
+ html += `<div class="cache-card"><div class="card-label">队列</div><div class="card-val">${chk.queue?.pending ?? 0} 待 · ${chk.queue?.processing ?? 0} 处理中</div></div>`;
929
1238
  const h = chk.lastHour;
930
1239
  if (h) {
931
1240
  const errDetail = h.errors > 0 ? ` (${Object.entries(h.errorsByType || {}).map(([t, c]) => `${t}:${c}`).join(', ')})` : '';
932
- html += `<div class="cache-card" style="margin-bottom:8px"><div class="card-label">近 1 小时</div>` +
933
- `<div>收到 ${h.received} · 完成 ${h.completed} · 出错 ${h.errors}${errDetail}</div>` +
934
- `<div>中断 ${h.interrupts}${h.completed > 0 ? ' · 平均 ' + (h.avgResponseMs / 1000).toFixed(1) + 's' : ''}</div>` +
935
- `</div>`;
1241
+ const avg = h.completed > 0 ? ` · 均 ${(h.avgResponseMs / 1000).toFixed(1)}s` : '';
1242
+ html += `<div class="cache-card"><div class="card-label">近 1 小时</div><div class="card-val">收 ${h.received} · ${h.completed} · ${h.errors}${errDetail} · 断 ${h.interrupts}${avg}</div></div>`;
936
1243
  }
937
- // EvolAgent 健康
1244
+ html += '</div>';
1245
+ // 每个 EvolAgent 一张卡片:后端 + 渠道健康 + 负载
938
1246
  if (chk.evolagents?.length) {
939
- html += '<div class="cache-card" style="margin-bottom:8px"><div class="card-label">EvolAgent 健康</div>';
940
- for (const ag of chk.evolagents) {
941
- const dot = ag.status === 'running' ? 'on' : ag.status === 'disabled' ? 'idle' : 'off';
942
- const tasks = ag.activeTasks > 0 ? ` · ${ag.activeTasks} 任务` : '';
943
- const err = ag.error ? ` <span style="color:var(--red)">${esc(ag.error.slice(0, 60))}</span>` : '';
944
- html += `<div><span class="dot ${dot}"></span>${esc(ag.name)} ${esc(ag.status)}${tasks}${err}</div>`;
945
- }
1247
+ html += '<div class="agent-health-grid">';
1248
+ for (const ag of chk.evolagents) html += agentHealthCard(ag);
946
1249
  html += '</div>';
947
1250
  }
948
- // BaseAgent 健康
949
- if (chk.baseagents?.length) {
950
- html += '<div class="cache-card"><div class="card-label">BaseAgent 健康</div>';
951
- for (const ba of chk.baseagents) {
952
- const dot = ba.healthy ? (ba.activeStreams > 0 ? 'on' : 'idle') : 'off';
953
- html += `<div><span class="dot ${dot}"></span>${esc(ba.name)} · 流 ${ba.activeStreams}</div>`;
954
- }
1251
+ // 未归属任何 EvolAgent 的渠道(系统级 / DefaultAgent)
1252
+ if (chk.unownedChannels?.length) {
1253
+ html += '<div class="cache-card" style="margin-top:8px"><div class="card-label">未归属渠道</div>';
1254
+ for (const c of chk.unownedChannels) html += channelHealthRow(c);
955
1255
  html += '</div>';
956
1256
  }
957
1257
  html += '</div>';
@@ -1102,7 +1402,11 @@ function initTheme() {
1102
1402
  btn.textContent = next === 'dark' ? '☀️' : '🌙';
1103
1403
  if (_hourlyChart) { _hourlyChart.dispose(); _hourlyChart = null; }
1104
1404
  if (_modelChart) { _modelChart.dispose(); _modelChart = null; }
1405
+ ['_monCpu', '_monMem', '_monMsg', '_monErr'].forEach(function (k) {
1406
+ if (window[k]) { window[k].dispose(); window[k] = null; }
1407
+ });
1105
1408
  loadUsageDashboard();
1409
+ if (currentView === 'monitor') renderMonitor(state.monitor);
1106
1410
  };
1107
1411
  }
1108
1412
  }
@@ -1184,12 +1488,6 @@ async function loadUsageDashboard() {
1184
1488
  }).join('') + '</tbody>';
1185
1489
  }
1186
1490
 
1187
- // Topbar today cost
1188
- var costEl = $('#today-cost');
1189
- if (costEl) {
1190
- var totalTokens = t.input_tokens + t.output_tokens;
1191
- costEl.textContent = 'Today: ' + fmtTokens(totalTokens) + ' tokens · ' + t.call_count + ' calls';
1192
- }
1193
1491
  }
1194
1492
 
1195
1493
  // ── Usage Overview(全时段总览)──
@@ -1444,6 +1742,220 @@ async function runExplorerQuery() {
1444
1742
  }
1445
1743
  }
1446
1744
 
1745
+ // ── Monitor ──────────────────────────────────────
1746
+ // 绑定时间范围切换按钮(只绑一次)
1747
+ let _monRangeBound = false;
1748
+ function bindMonRangeTabs() {
1749
+ if (_monRangeBound) return;
1750
+ var tabs = document.querySelectorAll('#view-monitor .mon-range');
1751
+ if (!tabs.length) return;
1752
+ tabs.forEach(function (btn) {
1753
+ btn.onclick = function () {
1754
+ monRange = btn.dataset.range;
1755
+ document.querySelectorAll('#view-monitor .mon-range').forEach(function (b) {
1756
+ b.classList.toggle('active', b.dataset.range === monRange);
1757
+ });
1758
+ // 切范围 → 重新订阅(源按 range 返回不同分辨率的 history)
1759
+ subscribe('monitor', { range: monRange });
1760
+ };
1761
+ });
1762
+ _monRangeBound = true;
1763
+ }
1764
+
1765
+ function renderMonitor(data) {
1766
+ var wrap = $('#view-monitor .mon-layout');
1767
+ if (!wrap) return;
1768
+ bindMonRangeTabs();
1769
+ if (!data) { return; }
1770
+ if (!data.daemonRunning) {
1771
+ // 不清空骨架,仅在卡片区提示,避免破坏 toolbar
1772
+ var cardsEl0 = $('#mon-cards');
1773
+ if (cardsEl0) cardsEl0.innerHTML = '<div class="empty" style="grid-column:1/-1">daemon 未运行</div>';
1774
+ return;
1775
+ }
1776
+
1777
+ var s = data.snapshot;
1778
+ // history 是三档分辨率对象 { fine, mid, coarse };按当前范围选一档
1779
+ var rangeKey = { '2m': 'fine', '10m': 'mid', '1h': 'coarse' }[monRange] || 'fine';
1780
+ var hist = data.history || {};
1781
+ var h = Array.isArray(hist) ? hist : (hist[rangeKey] || []);
1782
+ var sys = s.system || {};
1783
+ var lh = (s.stats && s.stats.lastHour) || {};
1784
+ var recentErrs = (s.stats && s.stats.recentErrors) || [];
1785
+ var errRate = (lh.received > 0) ? ((lh.errors / lh.received) * 100).toFixed(1) + '%' : '0%';
1786
+ var agents = s.agents || [];
1787
+ var connected = agents.filter(function (a) { return a.status === 'connected'; }).length;
1788
+
1789
+ // ── Stat cards ──
1790
+ var sysMemPct = (sys.memTotal > 0) ? Math.round((sys.memUsed / sys.memTotal) * 100) : 0;
1791
+ var cards = [
1792
+ ['Uptime', fmtDur(s.uptimeMs / 1000)],
1793
+ ['消息 (1h)', lh.received || 0],
1794
+ ['在线 Agent', connected + '/' + agents.length],
1795
+ ['平均响应', Math.round(lh.avgResponseMs || 0) + 'ms'],
1796
+ ['错误率', errRate],
1797
+ ['进程 CPU', (s.cpuPercent != null ? s.cpuPercent : 0) + '%'],
1798
+ ['系统 CPU', (sys.cpuPercent != null ? sys.cpuPercent : 0) + '%'],
1799
+ ['进程内存', fmtBytes(s.memory ? s.memory.rss : 0)],
1800
+ ['系统内存', sysMemPct + '%'],
1801
+ ];
1802
+ $('#mon-cards').innerHTML = cards.map(function (c) {
1803
+ return '<div class="usage-card"><div class="card-value">' + c[1] + '</div><div class="card-label">' + c[0] + '</div></div>';
1804
+ }).join('');
1805
+
1806
+ var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1807
+ var ts = h.map(function (p) { return new Date(p.ts).toLocaleTimeString(); });
1808
+ var css = function (v) { return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); };
1809
+ var cProc = css('--accent'), cSys = css('--orange');
1810
+
1811
+ // ── CPU dual-line:进程 vs 系统 ──
1812
+ monDualLine('mon-cpu-chart', '_monCpu', ts, isDark, 'CPU 占用',
1813
+ [
1814
+ { name: 'evolclaw 进程', data: h.map(function (p) { return p.procCpu; }), color: cProc },
1815
+ { name: '整机系统', data: h.map(function (p) { return p.sysCpu != null ? p.sysCpu : null; }), color: cSys },
1816
+ ],
1817
+ function (v) { return Number(v).toFixed(1) + '%'; }, [0, 100]);
1818
+
1819
+ // ── Memory dual-line:进程 RSS vs 系统已用 ──
1820
+ monDualLine('mon-mem-chart', '_monMem', ts, isDark, '内存占用',
1821
+ [
1822
+ { name: 'evolclaw RSS', data: h.map(function (p) { return p.procRss; }), color: cProc },
1823
+ { name: '系统已用', data: h.map(function (p) { return p.sysMemUsed != null ? p.sysMemUsed : null; }), color: cSys },
1824
+ ],
1825
+ function (v) { return fmtBytes(v); }, null);
1826
+
1827
+ // ── Message activity bar chart ──
1828
+ var msgEl = $('#mon-msg-chart');
1829
+ if (msgEl) {
1830
+ if (!window._monMsg) window._monMsg = echarts.init(msgEl, isDark ? 'dark' : null);
1831
+ window._monMsg.setOption({
1832
+ title: { text: '近一小时活动', left: 'center', top: 4, textStyle: { fontSize: 12, color: isDark ? '#e6edf3' : '#1a202c' } },
1833
+ tooltip: { trigger: 'axis' },
1834
+ grid: { top: 36, bottom: 24, left: 44, right: 12 },
1835
+ xAxis: { type: 'category', data: ['Received', 'Completed', 'Errors', 'Interrupts', 'ToolErr'], axisLabel: { fontSize: 9 } },
1836
+ yAxis: { type: 'value', minInterval: 1 },
1837
+ series: [{
1838
+ type: 'bar', barWidth: '45%',
1839
+ data: [
1840
+ { value: lh.received || 0, itemStyle: { color: css('--accent') } },
1841
+ { value: lh.completed || 0, itemStyle: { color: css('--green') } },
1842
+ { value: lh.errors || 0, itemStyle: { color: css('--red') } },
1843
+ { value: lh.interrupts || 0, itemStyle: { color: css('--orange') } },
1844
+ { value: lh.toolErrors || 0, itemStyle: { color: css('--blue') } },
1845
+ ],
1846
+ }],
1847
+ animation: false,
1848
+ });
1849
+ }
1850
+
1851
+ // ── Error breakdown donut ──
1852
+ var errEntries = Object.entries(lh.errorsByType || {});
1853
+ var errEl = $('#mon-err-chart');
1854
+ if (errEl) {
1855
+ if (errEntries.length) {
1856
+ if (!window._monErr) window._monErr = echarts.init(errEl, isDark ? 'dark' : null);
1857
+ window._monErr.setOption({
1858
+ title: { text: '错误分布', left: 'center', top: 4, textStyle: { fontSize: 12, color: isDark ? '#e6edf3' : '#1a202c' } },
1859
+ tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
1860
+ series: [{
1861
+ type: 'pie', radius: ['32%', '64%'], center: ['50%', '56%'],
1862
+ label: { fontSize: 10 },
1863
+ data: errEntries.map(function (e) { return { name: e[0], value: e[1] }; }),
1864
+ }],
1865
+ animation: false,
1866
+ });
1867
+ } else {
1868
+ if (window._monErr) { window._monErr.dispose(); window._monErr = null; }
1869
+ errEl.innerHTML = '<div class="empty" style="padding:24px;font-size:12px">近一小时无错误</div>';
1870
+ }
1871
+ }
1872
+
1873
+ // ── Per-agent table ──
1874
+ var dotMap = { connected: 'on', reconnecting: 'idle', aid_blocked: 'idle', kicked: 'off', kicked_no_retry: 'off', failed: 'off', disabled: 'off' };
1875
+ $('#mon-agent-table-wrap').innerHTML =
1876
+ '<div class="mon-section-title">各 Agent 运行状态</div>' +
1877
+ '<table class="usage-table"><thead><tr>' +
1878
+ '<th>Agent</th><th>状态</th><th>收</th><th>发</th><th>流入</th><th>流出</th><th>对端</th><th>队列</th><th>处理中</th>' +
1879
+ '</tr></thead><tbody>' +
1880
+ (agents.length ? agents.map(function (a) {
1881
+ var st = a.stats || {};
1882
+ var dot = dotMap[a.status] || 'off';
1883
+ return '<tr>' +
1884
+ '<td title="' + esc(a.aid) + '">' + esc(a.agentName || shortAid(a.aid)) + '</td>' +
1885
+ '<td><span class="dot ' + dot + '"></span>' + esc(a.status) + '</td>' +
1886
+ '<td>' + (st.messagesReceived || 0) + '</td>' +
1887
+ '<td>' + (st.messagesSent || 0) + '</td>' +
1888
+ '<td>' + fmtBytes(st.bytesReceived || 0) + '</td>' +
1889
+ '<td>' + fmtBytes(st.bytesSent || 0) + '</td>' +
1890
+ '<td>' + (st.uniquePeerCount || 0) + '</td>' +
1891
+ '<td>' + (st.queued || 0) + '</td>' +
1892
+ '<td>' + (st.processing ? '⚙ ' + st.processing : 0) + '</td>' +
1893
+ '</tr>';
1894
+ }).join('') : '<tr><td colspan="9" style="text-align:center;color:var(--dim)">暂无 Agent</td></tr>') +
1895
+ '</tbody></table>';
1896
+
1897
+ // ── Recent errors(替换原 Channels 位置)──
1898
+ $('#mon-err-list').innerHTML =
1899
+ '<div class="mon-section-title">最近错误 <span class="mon-section-sub">(最多 50 条)</span></div>' +
1900
+ (recentErrs.length
1901
+ ? '<div class="mon-err-rows">' + recentErrs.map(function (e) {
1902
+ var who = e.agentName ? shortAid(e.agentName) : '—';
1903
+ var tag = e.kind === 'tool'
1904
+ ? '<span class="mon-err-tag tag-tool">工具</span>'
1905
+ : '<span class="mon-err-tag tag-task">任务</span>';
1906
+ var label = e.kind === 'tool' ? (e.toolName || 'tool') : (e.errorType || 'error');
1907
+ var msg = e.message ? esc(e.message) : '';
1908
+ return '<div class="mon-err-row">' +
1909
+ '<span class="mon-err-time">' + fmtAgo(e.ts) + '</span>' +
1910
+ tag +
1911
+ '<span class="mon-err-aid" title="' + esc(e.agentName || '') + '">' + esc(who) + '</span>' +
1912
+ '<span class="mon-err-kind">' + esc(label) + '</span>' +
1913
+ '<span class="mon-err-msg" title="' + msg + '">' + msg + '</span>' +
1914
+ '</div>';
1915
+ }).join('') + '</div>'
1916
+ : '<div class="empty" style="padding:24px;font-size:12px">暂无错误记录</div>');
1917
+ }
1918
+
1919
+ // 双线时序图(进程 + 系统)。series: [{name,data,color}]
1920
+ function monDualLine(elId, varKey, times, isDark, title, series, fmtY, yRange) {
1921
+ var el = $('#' + elId);
1922
+ if (!el) return;
1923
+ if (!window[varKey]) window[varKey] = echarts.init(el, isDark ? 'dark' : null);
1924
+ window[varKey].setOption({
1925
+ title: { text: title, left: 'center', top: 4, textStyle: { fontSize: 12, color: isDark ? '#e6edf3' : '#1a202c' } },
1926
+ legend: { show: false },
1927
+ tooltip: {
1928
+ trigger: 'axis',
1929
+ formatter: function (params) {
1930
+ var lines = [params[0].axisValue];
1931
+ params.forEach(function (pt) {
1932
+ if (pt.value == null) return;
1933
+ lines.push(pt.marker + pt.seriesName + ': ' + (fmtY ? fmtY(pt.value) : pt.value));
1934
+ });
1935
+ return lines.join('<br/>');
1936
+ },
1937
+ },
1938
+ grid: { top: 36, bottom: 24, left: 56, right: 12 },
1939
+ xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 9 } },
1940
+ yAxis: {
1941
+ type: 'value',
1942
+ min: (yRange ? yRange[0] : 0),
1943
+ max: (yRange ? yRange[1] : undefined),
1944
+ axisLabel: { formatter: fmtY ? function (v) { return fmtY(v); } : '{value}' },
1945
+ },
1946
+ series: series.map(function (sr) {
1947
+ return {
1948
+ name: sr.name, type: 'line', data: sr.data, smooth: true, symbol: 'none',
1949
+ connectNulls: true,
1950
+ lineStyle: { width: 2, color: sr.color },
1951
+ areaStyle: { color: sr.color, opacity: 0.08 },
1952
+ itemStyle: { color: sr.color },
1953
+ };
1954
+ }),
1955
+ animation: false,
1956
+ });
1957
+ }
1958
+
1447
1959
  window.addEventListener('DOMContentLoaded', () => {
1448
1960
  initTheme();
1449
1961
  initPairUI();