evolclaw-web 1.0.1 → 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.
@@ -56,9 +56,9 @@ function initPairUI() {
56
56
  // ── WebSocket 客户端(自动重连)──
57
57
  let ws = null;
58
58
  let reconnectDelay = 1000;
59
- let currentView = 'aid';
59
+ let currentView = 'agents';
60
60
  let pendingSub = null; // 重连后要恢复的订阅
61
- const state = { aid: null, msg: null, session: 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');
@@ -83,8 +83,22 @@ function connect() {
83
83
  try { msg = JSON.parse(ev.data); } catch { return; }
84
84
  if (msg.type === 'pong') return;
85
85
  if (msg.type === 'error') { console.warn('server error:', msg.message); return; }
86
+ if (msg.type === 'menu.response') {
87
+ const pend = _menuPending[msg.requestId];
88
+ if (pend) { delete _menuPending[msg.requestId]; pend.resolve(msg.data); }
89
+ return;
90
+ }
86
91
  if (msg.type === 'snapshot' || msg.type === 'delta') {
87
- state[msg.view] = msg.data;
92
+ // system 视图保留客户端写入的 check/upgrade,防止 3s 轮询覆盖
93
+ if (msg.view === 'system' && state.system) {
94
+ state.system = {
95
+ ...msg.data,
96
+ check: state.system.check ?? msg.data.check,
97
+ upgrade: state.system.upgrade ?? msg.data.upgrade,
98
+ };
99
+ } else {
100
+ state[msg.view] = msg.data;
101
+ }
88
102
  if (msg.view === currentView) renderView(currentView);
89
103
  }
90
104
  };
@@ -110,6 +124,22 @@ function subscribe(view, params) {
110
124
  }
111
125
  }
112
126
 
127
+ // ── Menu 写请求(update/action):经 WS menu 消息,requestId 配对响应 ──
128
+ const _menuPending = {};
129
+ let _menuSeq = 0;
130
+ function menuSend(payload) {
131
+ return new Promise((resolve, reject) => {
132
+ if (!ws || ws.readyState !== WebSocket.OPEN) { reject(new Error('未连接')); return; }
133
+ const requestId = 'ecw-' + (++_menuSeq);
134
+ const withId = { ...payload, id: payload.id || requestId };
135
+ _menuPending[requestId] = { resolve, reject };
136
+ setTimeout(() => {
137
+ if (_menuPending[requestId]) { delete _menuPending[requestId]; reject(new Error('timeout')); }
138
+ }, 6000);
139
+ ws.send(JSON.stringify({ type: 'menu', requestId, payload: withId }));
140
+ });
141
+ }
142
+
113
143
  // 心跳
114
144
  setInterval(() => {
115
145
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
@@ -118,8 +148,11 @@ setInterval(() => {
118
148
  // ── Tab 切换 ──
119
149
  let msgSel = { aid: null, peer: null };
120
150
  let sessSel = { sessionId: null, project: null };
151
+ let trigSel = { agent: null };
121
152
  let sessSearch = '';
153
+ let sessFilterNormal = false; // true=只显示有效会话(userMsgs >= 2)
122
154
  let sessChatMode = false; // false=完整视图,true=对话视图(折叠处理过程)
155
+ let monRange = '2m'; // Monitor 时间窗口:2m / 10m / 1h
123
156
 
124
157
  function switchView(view) {
125
158
  currentView = view;
@@ -128,7 +161,11 @@ function switchView(view) {
128
161
  // 切换时按当前选择恢复订阅
129
162
  if (view === 'msg') subscribe('msg', { aid: msgSel.aid, peer: msgSel.peer });
130
163
  else if (view === 'session') subscribe('session', { sessionId: sessSel.sessionId, project: sessSel.project });
131
- else subscribe('aid', {});
164
+ else if (view === 'cache') subscribe('cache', {});
165
+ else if (view === 'system') subscribe('system', {});
166
+ else if (view === 'triggers') subscribe('triggers', { agent: trigSel.agent });
167
+ else if (view === 'monitor') subscribe('monitor', { range: monRange });
168
+ else subscribe('agents', {});
132
169
  if (state[view]) renderView(view);
133
170
  }
134
171
 
@@ -139,9 +176,13 @@ function initTabs() {
139
176
  }
140
177
 
141
178
  function renderView(view) {
142
- if (view === 'aid') renderAid(state.aid);
179
+ if (view === 'agents') renderAgents(state.agents);
143
180
  else if (view === 'msg') renderMsg(state.msg);
144
181
  else if (view === 'session') renderSession(state.session);
182
+ else if (view === 'cache') renderCache(state.cache);
183
+ else if (view === 'system') renderSystem(state.system);
184
+ else if (view === 'triggers') renderTriggers(state.triggers);
185
+ else if (view === 'monitor') renderMonitor(state.monitor);
145
186
  }
146
187
 
147
188
  // ── 工具 ──
@@ -162,61 +203,398 @@ function fmtAgo(ts) {
162
203
  if (s < 86400) return Math.floor(s / 3600) + 'h';
163
204
  return Math.floor(s / 86400) + 'd';
164
205
  }
206
+ // 秒数 → 可读时长(如 3d 2h / 5h 12m / 8m 3s)
207
+ function fmtDur(sec) {
208
+ if (sec == null) return '—';
209
+ const s = Math.floor(Number(sec) || 0);
210
+ const d = Math.floor(s / 86400);
211
+ const h = Math.floor((s % 86400) / 3600);
212
+ const m = Math.floor((s % 3600) / 60);
213
+ const sx = s % 60;
214
+ if (d > 0) return `${d}d ${h}h`;
215
+ if (h > 0) return `${h}h ${m}m`;
216
+ if (m > 0) return `${m}m ${sx}s`;
217
+ return `${sx}s`;
218
+ }
165
219
  function fmtTime(ts) {
166
220
  if (!ts) return '';
167
221
  const d = new Date(ts);
168
222
  const p = (n) => String(n).padStart(2, '0');
169
- return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
223
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
224
+ }
225
+ // semver 比较:-1 (a<b) / 0 / 1 (a>b),剥离 pre-release 标签
226
+ function compareVer(a, b) {
227
+ const pa = String(a).split('-')[0].split('.').map(Number);
228
+ const pb = String(b).split('-')[0].split('.').map(Number);
229
+ const len = Math.max(pa.length, pb.length);
230
+ for (let i = 0; i < len; i++) {
231
+ const na = pa[i] || 0, nb = pb[i] || 0;
232
+ if (na < nb) return -1;
233
+ if (na > nb) return 1;
234
+ }
235
+ return 0;
236
+ }
237
+
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 '';
170
290
  }
171
291
 
172
- // ── AID 视图 ──
173
- function renderAid(data) {
174
- const el = $('#view-aid');
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
+
360
+ function renderAgents(data) {
361
+ const el = $('#view-agents');
175
362
  if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
363
+ if (el.querySelector('.ops-more.open')) return;
364
+
365
+ const allAgents = data.agents || [];
176
366
  const aids = data.aids || [];
177
367
  const statsByAid = {};
178
368
  for (const s of (data.stats || [])) statsByAid[s.aid] = s;
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>';
179
383
 
180
- let html = '';
181
384
  if (!data.daemonRunning) {
182
385
  html += '<div class="banner">⚠ EvolClaw 主进程未运行,仅显示最近活动记录</div>';
183
386
  }
184
- if (!aids.length) {
185
- 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
+ }
186
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>';
421
+ el.innerHTML = html;
422
+ bindAgentsEvents(el);
187
423
  return;
188
424
  }
189
425
 
190
426
  html += '<table><thead><tr>' +
191
- '<th>状态</th><th>AID</th><th>收</th><th>发</th><th>系统</th>' +
192
- '<th>入字节</th><th>出字节</th><th>peers</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>' +
193
429
  '</tr></thead><tbody>';
194
430
 
195
- for (const a of aids) {
196
- const s = statsByAid[a.aid] || {};
197
- const status = a.status || (a.lastEvent === 'disconnected' ? 'disconnected' : 'connected');
198
- const dotCls = status === 'connected' ? 'on' : (status === 'reconnecting' ? 'idle' : 'off');
199
- const name = s.selfName || a.agentName || '';
200
- const lastTs = Math.max(s.lastReceivedAt || 0, s.lastSentAt || 0, a.lastActivity || 0);
201
- let preview = '';
202
- if (s.lastReceivedText && (s.lastReceivedAt || 0) >= (s.lastSentAt || 0)) {
203
- preview = '↓ ' + shortAid(s.lastReceivedFrom) + ': ' + s.lastReceivedText;
204
- } else if (s.lastSentText) {
205
- preview = '↑ ' + shortAid(s.lastSentTo) + ': ' + s.lastSentText;
206
- }
207
- html += '<tr>' +
208
- `<td><span class="dot ${dotCls}"></span>${esc(status)}</td>` +
209
- `<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>` +
210
456
  `<td>${s.messagesReceived ?? 0}</td><td>${s.messagesSent ?? 0}</td>` +
211
- `<td>${s.systemReceived ?? 0}/${s.systemSent ?? 0}</td>` +
212
457
  `<td>${fmtBytes(s.bytesReceived)}</td><td>${fmtBytes(s.bytesSent)}</td>` +
213
- `<td>${s.uniquePeerCount ?? a.peerCount ?? 0}</td><td>${a.reconnectCount ?? 0}</td>` +
458
+ `<td>${s.uniquePeerCount ?? conn.peerCount ?? 0}</td>` +
214
459
  `<td>${fmtAgo(lastTs)}</td>` +
215
- `<td class="preview">${esc(preview.replace(/\n/g, ' ').slice(0, 80))}</td>` +
460
+ `<td class="agent-ops-cell">${agentOpsHtml(ag.aid, ag, s)}</td>` +
216
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>';
217
470
  }
218
471
  html += '</tbody></table>';
472
+ if (data.daemonRunning) {
473
+ html += agentsStatsBar(data, aids, data.stats || []);
474
+ }
219
475
  el.innerHTML = html;
476
+ bindAgentsEvents(el);
477
+ }
478
+
479
+ // ── Cache 视图(daemon 统一 FileCache 运行统计)──
480
+ // fmtNum 复用文件内既有定义(千分位缩写)。
481
+ function hitRate(c) {
482
+ const denom = (c.hits || 0) + (c.misses || 0);
483
+ return denom ? (c.hits / denom) : null;
484
+ }
485
+ function fmtPct(r) {
486
+ if (r == null) return '—';
487
+ return (r * 100).toFixed(1) + '%';
488
+ }
489
+ function rateCls(r) {
490
+ if (r == null) return '';
491
+ if (r >= 0.9) return 'on';
492
+ if (r >= 0.6) return 'idle';
493
+ return 'off';
494
+ }
495
+ // group 名按用途归类,给出友好标签:config:<aid> / agent-files:<aid> 提取 aid
496
+ function groupLabel(g) {
497
+ if (g.startsWith('agent-files:')) return { kind: 'agent', label: shortAid(g.slice('agent-files:'.length)), sub: '身份层' };
498
+ if (g.startsWith('config:')) return { kind: 'agent', label: shortAid(g.slice('config:'.length)), sub: 'config' };
499
+ if (g === 'config') return { kind: 'global', label: 'defaults', sub: '全局' };
500
+ if (g === 'relation-prefs') return { kind: 'relation', label: 'relation-prefs', sub: '关系模型偏好' };
501
+ if (g === 'kits') return { kind: 'kits', label: 'kits', sub: 'manifest/fragment/md' };
502
+ return { kind: 'other', label: g, sub: '' };
503
+ }
504
+
505
+ function renderCache(data) {
506
+ const el = $('#view-cache');
507
+ if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
508
+ if (!data.daemonRunning) {
509
+ el.innerHTML = '<div class="banner">⚠ EvolClaw 主进程未运行,无缓存统计可显示</div>';
510
+ return;
511
+ }
512
+ if (!data.supported || !data.stats) {
513
+ el.innerHTML = '<div class="banner">⚠ 当前 EvolClaw 版本不支持 cache-stats(请升级 daemon)</div>';
514
+ return;
515
+ }
516
+ const s = data.stats;
517
+ const t = s.totals;
518
+ const occ = s.occupancy || {};
519
+ // 全部组占用合计
520
+ let totalBytes = 0;
521
+ for (const g in occ) totalBytes += occ[g].bytes || 0;
522
+
523
+ let html = '';
524
+
525
+ // ① 总览卡片
526
+ const rate = hitRate(t);
527
+ html += '<div class="cache-cards">';
528
+ html += card('命中率', fmtPct(rate), rateCls(rate), `${fmtNum(t.hits)} 命中 / ${fmtNum(t.misses)} 未命中`);
529
+ html += card('读取总数', fmtNum(t.gets), '', `${fmtNum(t.hits)} hit · ${fmtNum(t.misses)} miss`);
530
+ html += card('缓存条目', fmtNum(s.size), '', fmtBytes(totalBytes) + ' 近似内存');
531
+ html += card('stat 检查', fmtNum(t.statChecks), '', 'mtime 策略每读一次');
532
+ html += card('重读', fmtNum(t.reReads), '', '带外改后自动重读');
533
+ html += card('驱逐', fmtNum(t.evictions), t.evictions ? 'idle' : '', 'LRU 超限');
534
+ html += card('失效', fmtNum(t.invalidations), '', 'reload/单刷清除');
535
+ html += card('统计起始', fmtAgo(s.since) + ' 前', '', fmtTime(s.since));
536
+ html += '</div>';
537
+
538
+ // ② 按 group 表(每组命中率 + 占用 + 容量水位)
539
+ html += '<h3 class="cache-h">按缓存组</h3>';
540
+ html += '<table><thead><tr>' +
541
+ '<th>组</th><th>类型</th><th>读取</th><th>命中</th><th>未命中</th><th>命中率</th>' +
542
+ '<th>重读</th><th>驱逐</th><th>条目</th><th>内存</th><th>容量</th>' +
543
+ '</tr></thead><tbody>';
544
+ const groups = Object.keys(s.byGroup).sort((a, b) => (s.byGroup[b].gets || 0) - (s.byGroup[a].gets || 0));
545
+ for (const g of groups) {
546
+ const c = s.byGroup[g];
547
+ const o = occ[g] || { size: 0, bytes: 0, cap: null };
548
+ const gl = groupLabel(g);
549
+ const r = hitRate(c);
550
+ let capCell = '—';
551
+ if (o.cap != null) {
552
+ const pct = o.cap ? Math.round((o.size / o.cap) * 100) : 0;
553
+ const cls = pct >= 90 ? 'off' : (pct >= 70 ? 'idle' : 'on');
554
+ capCell = `<span class="dot ${cls}"></span>${o.size}/${o.cap}`;
555
+ }
556
+ html += '<tr>' +
557
+ `<td>${esc(gl.label)}${gl.sub ? ` <span style="color:var(--dim)">${esc(gl.sub)}</span>` : ''}</td>` +
558
+ `<td><span class="tag tag-${gl.kind}">${esc(gl.kind)}</span></td>` +
559
+ `<td>${fmtNum(c.gets)}</td><td>${fmtNum(c.hits)}</td><td>${fmtNum(c.misses)}</td>` +
560
+ `<td><span class="dot ${rateCls(r)}"></span>${fmtPct(r)}</td>` +
561
+ `<td>${fmtNum(c.reReads)}</td><td>${fmtNum(c.evictions)}</td>` +
562
+ `<td>${o.size}</td><td>${fmtBytes(o.bytes)}</td><td>${capCell}</td>` +
563
+ '</tr>';
564
+ }
565
+ html += '</tbody></table>';
566
+
567
+ // ③ 按 policy 表
568
+ html += '<h3 class="cache-h">按策略</h3>';
569
+ html += '<table><thead><tr>' +
570
+ '<th>策略</th><th>读取</th><th>命中</th><th>未命中</th><th>命中率</th><th>stat 检查</th><th>重读</th>' +
571
+ '</tr></thead><tbody>';
572
+ const POLICY_DESC = { 'on-reload': '靠 reload 刷新,平时零检查', 'manual': '显式单刷', 'mtime': '每读 statSync 门控' };
573
+ for (const pol of ['on-reload', 'mtime', 'manual']) {
574
+ const c = s.byPolicy[pol];
575
+ if (!c || !c.gets) continue;
576
+ const r = hitRate(c);
577
+ html += '<tr>' +
578
+ `<td>${esc(pol)} <span style="color:var(--dim)">${esc(POLICY_DESC[pol] || '')}</span></td>` +
579
+ `<td>${fmtNum(c.gets)}</td><td>${fmtNum(c.hits)}</td><td>${fmtNum(c.misses)}</td>` +
580
+ `<td><span class="dot ${rateCls(r)}"></span>${fmtPct(r)}</td>` +
581
+ `<td>${fmtNum(c.statChecks)}</td><td>${fmtNum(c.reReads)}</td>` +
582
+ '</tr>';
583
+ }
584
+ html += '</tbody></table>';
585
+
586
+ html += '<div class="cache-note">注:config/defaults 与关系级 preferences 的读取也已并入本统计;' +
587
+ '渲染后结果(按 vars)不缓存,故不在此列。</div>';
588
+
589
+ el.innerHTML = html;
590
+ }
591
+
592
+ function card(label, value, valCls, sub) {
593
+ return `<div class="cache-card">` +
594
+ `<div class="cc-label">${esc(label)}</div>` +
595
+ `<div class="cc-value ${valCls || ''}">${esc(value)}</div>` +
596
+ `<div class="cc-sub">${esc(sub || '')}</div>` +
597
+ `</div>`;
220
598
  }
221
599
 
222
600
  // ── Messages 视图 ──
@@ -297,17 +675,19 @@ function renderSession(data) {
297
675
 
298
676
  // 搜索过滤
299
677
  const q = sessSearch.trim().toLowerCase();
300
- const filtered = q
301
- ? transcripts.filter(t => (t.title || '').toLowerCase().includes(q) || (t.firstUser || '').toLowerCase().includes(q))
302
- : 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));
303
681
 
304
682
  // 左栏:过滤条 + 列表
305
683
  const projOpts = projects.map(p =>
306
684
  `<option value="${esc(p.encoded)}"${p.encoded === sessSel.project ? ' selected' : ''}>${esc(p.label)} (${p.count})</option>`
307
685
  ).join('');
686
+ const normalCount = transcripts.filter(t => (t.userMsgs || 0) >= 2).length;
308
687
  let listHtml = '<div class="sess-filter">' +
309
688
  `<select id="sess-project">${projOpts}</select>` +
310
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>` +
311
691
  `<div class="sess-count">${filtered.length} / ${transcripts.length} 个会话</div></div>` +
312
692
  '<div class="sess-items">';
313
693
 
@@ -338,6 +718,8 @@ function renderSession(data) {
338
718
  sessSearch = '';
339
719
  subscribe('session', { project: sessSel.project });
340
720
  };
721
+ const filterBtn = $('#sess-filter-btn');
722
+ if (filterBtn) filterBtn.onclick = () => { sessFilterNormal = !sessFilterNormal; renderSession(state.session); };
341
723
  const searchEl = $('#sess-search');
342
724
  if (searchEl) {
343
725
  searchEl.oninput = () => { sessSearch = searchEl.value; renderSession(state.session); };
@@ -529,7 +911,473 @@ function renderBlocks(blocks) {
529
911
  return out;
530
912
  }
531
913
 
532
- // ── 启动 ──
914
+ // ── 通用 Menu 协议辅助(mResp / toast,供 Agents / System / Triggers 复用)──
915
+
916
+ // 提取 menu.response 的 data/error
917
+ function mResp(r) {
918
+ if (!r) return { error: { code: 'INTERNAL', message: 'no response' } };
919
+ if (r.error) return { error: r.error };
920
+ return { data: r.data };
921
+ }
922
+
923
+ function toast(text, isErr) {
924
+ let el = $('#ctrl-toast');
925
+ if (!el) {
926
+ el = document.createElement('div');
927
+ el.id = 'ctrl-toast';
928
+ el.className = 'ctrl-toast';
929
+ document.body.appendChild(el);
930
+ }
931
+ el.textContent = text;
932
+ el.className = 'ctrl-toast show' + (isErr ? ' err' : '');
933
+ clearTimeout(el._t);
934
+ el._t = setTimeout(() => { el.className = 'ctrl-toast'; }, 2600);
935
+ }
936
+
937
+ // ── Agents 操作 ──
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
+ }
995
+
996
+ function bindAgentsEvents(el) {
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
+ });
1006
+ el.querySelectorAll('.agent-ops').forEach(div => {
1007
+ const aid = div.dataset.aid;
1008
+ const status = div.dataset.status;
1009
+ bindOpsCell(div.closest('td'), aid, status);
1010
+ });
1011
+ }
1012
+
1013
+ // 异步操作包装:设置 "操作中" 状态、执行、清除
1014
+ async function withAgentOp(aid, label, fn) {
1015
+ setAgentOp(aid, label);
1016
+ try { await fn(); }
1017
+ finally { setAgentOp(aid, null); }
1018
+ }
1019
+
1020
+ async function agentOpReload(aid, force = false) {
1021
+ await withAgentOp(aid, '重载中…', async () => {
1022
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'reload', args: { aid, force } }));
1023
+ if (r.error?.code === 'BUSY') {
1024
+ if (confirm(r.error.message + '\n确认强制重载?')) { setAgentOp(aid, null); return agentOpReload(aid, true); }
1025
+ return;
1026
+ }
1027
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1028
+ toast('✓ 已重载');
1029
+ subscribe('agents', {});
1030
+ });
1031
+ }
1032
+
1033
+ async function agentOpToggle(aid, status) {
1034
+ const action = status === 'disabled' ? 'enable' : 'disable';
1035
+ const label = action === 'disable' ? '禁用中…' : '启用中…';
1036
+ await withAgentOp(aid, label, async () => {
1037
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid } }));
1038
+ if (r.error?.code === 'BUSY') {
1039
+ if (confirm(r.error.message + `\n确认强制${action === 'disable' ? '禁用' : '启用'}?`)) {
1040
+ const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid, force: true } }));
1041
+ if (r2.error) toast(r2.error.message || r2.error.code, true);
1042
+ else {
1043
+ toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`);
1044
+ // 禁用后立即切到禁用页;启用后等数据刷新(agent 需先完成启动才移到启用页)
1045
+ if (action === 'disable') _agSubtab = 'disabled';
1046
+ subscribe('agents', {});
1047
+ }
1048
+ }
1049
+ return;
1050
+ }
1051
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1052
+ toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`);
1053
+ if (action === 'disable') _agSubtab = 'disabled';
1054
+ subscribe('agents', {});
1055
+ });
1056
+ }
1057
+
1058
+ async function agentOpDelete(aid) {
1059
+ if (!confirm(`删除 Agent ${aid}?\n此操作不可恢复。`)) return;
1060
+ const purge = confirm('同时清除 agent 数据目录?');
1061
+ await withAgentOp(aid, '删除中…', async () => {
1062
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge } }));
1063
+ if (r.error?.code === 'BUSY') {
1064
+ if (confirm(r.error.message + '\n确认强制删除?')) {
1065
+ const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge, force: true } }));
1066
+ if (r2.error) toast(r2.error.message || r2.error.code, true);
1067
+ else { toast('✓ 已删除'); subscribe('agents', {}); }
1068
+ }
1069
+ return;
1070
+ }
1071
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1072
+ toast('✓ 已删除');
1073
+ subscribe('agents', {});
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
+ });
1121
+ }
1122
+
1123
+ async function agentOpNew() {
1124
+ const aid = prompt('Agent AID(如 mybot.agentid.pub):');
1125
+ if (!aid) return;
1126
+ const name = prompt('显示名:') || aid.split('.')[0];
1127
+ const baseagent = prompt('后端(claude / codex / gemini):', 'claude') || 'claude';
1128
+ _agentBusy = true;
1129
+ try {
1130
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'create', args: { aid, name, baseagent } }));
1131
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1132
+ toast('✓ 创建请求已受理,稍后刷新查看');
1133
+ setTimeout(() => subscribe('agents', {}), 3000);
1134
+ } catch (e) { toast(e.message, true); }
1135
+ finally { _agentBusy = false; }
1136
+ }
1137
+
1138
+ async function agentOpEdit(aid) {
1139
+ await withAgentOp(aid, '查询中…', async () => {
1140
+ const qr = await menuSend({ type: 'menu.query', name: 'agent', args: { aid } });
1141
+ const q = mResp(qr);
1142
+ if (q.error) { toast(q.error.message || q.error.code, true); return; }
1143
+ const cfg = q.data;
1144
+ setAgentOp(aid, null); // 查询完毕先恢复,等用户填完 prompt
1145
+ const projectRaw = prompt('项目路径:', cfg.config?.projects?.defaultPath || '');
1146
+ const ownersRaw = prompt('Owners(逗号分隔 AID):', (cfg.config?.owners || []).join(', '));
1147
+ const patch = {};
1148
+ if (projectRaw !== null) patch.projects = { defaultPath: projectRaw };
1149
+ if (ownersRaw !== null) patch.owners = ownersRaw.split(',').map(s => s.trim()).filter(Boolean);
1150
+ if (!Object.keys(patch).length) return;
1151
+ setAgentOp(aid, '保存中…');
1152
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'update', args: { aid, patch } }));
1153
+ if (r.error) toast(r.error.message || r.error.code, true);
1154
+ else toast('✓ 配置已保存,点「重载」生效');
1155
+ });
1156
+ }
1157
+
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
+
1194
+ function renderSystem(data) {
1195
+ const el = $('#view-system');
1196
+ if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
1197
+ const sys = data.system || {};
1198
+ const up = data.upgrade;
1199
+ const chk = data.check;
1200
+
1201
+ const vcard = (label, local, upInfo) => {
1202
+ let badge = '';
1203
+ if (upInfo?.hasUpdate && upInfo.remote) badge = ` <span style="color:var(--accent)">⬆ ${esc(upInfo.remote)}</span>`;
1204
+ else if (upInfo?.remote) badge = ` <span style="color:var(--dim)">✓ 最新</span>`;
1205
+ return `<div class="cache-card"><div class="card-label">${esc(label)}</div><div class="card-val">${esc(local || '—')}${badge}</div></div>`;
1206
+ };
1207
+
1208
+ let html = '<div class="sys-wrap">';
1209
+
1210
+ // ① 版本卡
1211
+ html += '<div class="cache-cards" style="margin-bottom:16px">';
1212
+ html += vcard('evolclaw', sys.version, up?.evolclaw);
1213
+ html += vcard('FASTAUN', sys.fastaunVersion, up?.fastaun);
1214
+ html += vcard('ECWEB', data.ecwebVersion, up?.ecweb ? {
1215
+ remote: up.ecweb.remote,
1216
+ hasUpdate: !!(up.ecweb.remote && data.ecwebVersion && compareVer(data.ecwebVersion, up.ecweb.remote) < 0),
1217
+ } : null);
1218
+ html += `<div class="cache-card"><div class="card-label">NodeJS</div><div class="card-val">${esc(sys.node || '—')}</div></div>`;
1219
+ html += `<div class="cache-card"><div class="card-label">运行时间</div><div class="card-val">${esc(fmtDur(sys.uptime))}</div></div>`;
1220
+ html += `<div class="cache-card"><div class="card-label">PID</div><div class="card-val">${sys.pid || '—'}</div></div>`;
1221
+ html += '</div>';
1222
+
1223
+ // ② 操作区
1224
+ const devHint = up?.devMode ? ' <span style="color:var(--dim);font-size:0.85em">⏭ 开发模式,升级需手动操作</span>' : '';
1225
+ html += '<div class="sys-actions" style="margin-bottom:16px">' +
1226
+ '<button class="ctrl-btn" id="sys-check-btn">🔍 健康检查</button> ' +
1227
+ '<button class="ctrl-btn" id="sys-upgrade-btn">⬆ 检查更新</button> ' +
1228
+ '<button class="ctrl-btn danger" id="sys-restart-btn">⟳ 重启服务</button>' +
1229
+ devHint +
1230
+ '</div>';
1231
+
1232
+ // ③ 健康快照
1233
+ if (chk) {
1234
+ html += '<div class="sys-health">';
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>`;
1238
+ const h = chk.lastHour;
1239
+ if (h) {
1240
+ const errDetail = h.errors > 0 ? ` (${Object.entries(h.errorsByType || {}).map(([t, c]) => `${t}:${c}`).join(', ')})` : '';
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>`;
1243
+ }
1244
+ html += '</div>';
1245
+ // 每个 EvolAgent 一张卡片:后端 + 渠道健康 + 负载
1246
+ if (chk.evolagents?.length) {
1247
+ html += '<div class="agent-health-grid">';
1248
+ for (const ag of chk.evolagents) html += agentHealthCard(ag);
1249
+ html += '</div>';
1250
+ }
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);
1255
+ html += '</div>';
1256
+ }
1257
+ html += '</div>';
1258
+ }
1259
+
1260
+ html += '</div>';
1261
+ el.innerHTML = html;
1262
+ bindSystemEvents(el, data);
1263
+ }
1264
+
1265
+ function bindSystemEvents(el, data) {
1266
+ el.querySelector('#sys-check-btn')?.addEventListener('click', async () => {
1267
+ try {
1268
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'system', action: 'check' }));
1269
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1270
+ state.system = { ...(state.system || {}), check: r.data };
1271
+ renderSystem(state.system);
1272
+ } catch (e) { toast(e.message, true); }
1273
+ });
1274
+ el.querySelector('#sys-upgrade-btn')?.addEventListener('click', async () => {
1275
+ try {
1276
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'system', action: 'upgrade' }));
1277
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1278
+ state.system = { ...(state.system || {}), upgrade: r.data };
1279
+ renderSystem(state.system);
1280
+ } catch (e) { toast(e.message, true); }
1281
+ });
1282
+ el.querySelector('#sys-restart-btn')?.addEventListener('click', async () => {
1283
+ if (!confirm('确认重启服务?当前所有连接将断开。')) return;
1284
+ try {
1285
+ await menuSend({ type: 'menu.action', name: 'system', action: 'restart' });
1286
+ toast('重启中…');
1287
+ } catch (e) { toast(e.message, true); }
1288
+ });
1289
+ }
1290
+
1291
+ // ── Triggers 视图 ──
1292
+ function trigStatusBadge(status) {
1293
+ const map = {
1294
+ active: ['活跃', 'trig-badge-active'],
1295
+ fired: ['已触发', 'trig-badge-fired'],
1296
+ cancelled: ['已取消', 'trig-badge-cancelled'],
1297
+ expired: ['已过期', 'trig-badge-expired'],
1298
+ };
1299
+ const [label, cls] = map[status] || [status, 'trig-badge-fired'];
1300
+ return `<span class="trig-badge ${cls}">${esc(label)}</span>`;
1301
+ }
1302
+
1303
+ function renderTriggers(data) {
1304
+ if (!data) { $('#view-triggers').innerHTML = '<div class="empty">加载中…</div>'; return; }
1305
+ const agents = data.agents || [];
1306
+ const triggers = data.triggers || [];
1307
+ const selAid = data.selectedAgent;
1308
+
1309
+ // 左列:agent 列表(仿 msg list-item 风格)
1310
+ let aHtml = '<div class="col-title">Agent</div>';
1311
+ if (!agents.length) aHtml += '<div class="empty">暂无 Agent</div>';
1312
+ for (const ag of agents) {
1313
+ const sel = ag.value === selAid ? ' sel' : '';
1314
+ aHtml += `<div class="list-item${sel}" data-aid="${esc(ag.value)}">` +
1315
+ `<div class="name">${esc(ag.label)}</div>` +
1316
+ `<div class="sub">${esc(ag.value)}</div></div>`;
1317
+ }
1318
+ $('#trig-agents').innerHTML = aHtml;
1319
+ $('#trig-agents').querySelectorAll('.list-item').forEach(item => {
1320
+ item.onclick = () => { trigSel.agent = item.dataset.aid; subscribe('triggers', { agent: trigSel.agent }); };
1321
+ });
1322
+
1323
+ // 右列:table,每字段一列
1324
+ const el = $('#trig-table');
1325
+ if (!selAid) { el.innerHTML = '<div class="empty" style="padding:16px">← 选择 Agent 查看触发器</div>'; return; }
1326
+ if (!triggers.length) { el.innerHTML = '<div class="empty" style="padding:16px">该 Agent 暂无触发器</div>'; return; }
1327
+
1328
+ let html = '<table><thead><tr>' +
1329
+ '<th>状态</th><th>名称</th><th>ID</th><th>类型</th><th>表达式</th>' +
1330
+ '<th>上次触发</th><th>下次触发</th><th>触发次数</th><th>失败次数</th><th>最后结果</th><th>Session 策略</th>' +
1331
+ '<th>目标渠道</th><th>渠道 ID</th><th>渠道类型</th>' +
1332
+ '<th>创建者</th><th>创建渠道</th><th>创建时间</th><th>操作</th>' +
1333
+ '</tr></thead><tbody>';
1334
+ for (const t of triggers) {
1335
+ const status = t.status || 'active';
1336
+ const active = status === 'active';
1337
+ html += `<tr class="${active ? '' : 'trig-done'}">` +
1338
+ `<td>${trigStatusBadge(status)}</td>` +
1339
+ `<td>${esc(t.name ?? t.label ?? '')}</td>` +
1340
+ `<td>${esc(t.id ?? t.value ?? '')}</td>` +
1341
+ `<td>${esc(t.scheduleType ?? '')}</td>` +
1342
+ `<td>${t.scheduleType === 'at' && t.scheduleValue ? fmtTime(new Date(t.scheduleValue).getTime()) : esc(t.scheduleValue ?? '')}</td>` +
1343
+ `<td>${t.lastFiredAt ? fmtTime(t.lastFiredAt) : '—'}</td>` +
1344
+ `<td>${t.nextFireAt ? fmtTime(t.nextFireAt) : '—'}</td>` +
1345
+ `<td>${t.fireCount ?? 0}</td>` +
1346
+ `<td>${t.failCount ? `<span style="color:var(--red)">${t.failCount}</span>` : '0'}</td>` +
1347
+ `<td>${t.lastResult ? esc(t.lastResult) : '—'}</td>` +
1348
+ `<td>${esc(t.targetSessionStrategy ?? '')}</td>` +
1349
+ `<td>${esc(t.targetChannel ?? '')}</td>` +
1350
+ `<td>${esc(t.targetChannelId ?? '')}</td>` +
1351
+ `<td>${esc(t.targetChannelType ?? '')}</td>` +
1352
+ `<td>${esc(t.createdByPeerId ?? '')}</td>` +
1353
+ `<td>${esc(t.createdByChannel ?? '')}</td>` +
1354
+ `<td>${t.createdAt ? fmtTime(t.createdAt) : '—'}</td>` +
1355
+ `<td>${active
1356
+ ? `<button class="ctrl-btn danger" data-trigid="${esc(t.id ?? t.value ?? '')}" data-trigname="${esc(t.name ?? t.label ?? '')}">取消</button>`
1357
+ : '—'}</td>` +
1358
+ '</tr>';
1359
+ }
1360
+ html += '</tbody></table>';
1361
+ el.innerHTML = html;
1362
+
1363
+ el.querySelectorAll('button[data-trigid]').forEach(btn => {
1364
+ btn.addEventListener('click', async () => {
1365
+ const nameOrId = btn.dataset.trigid;
1366
+ const label = btn.dataset.trigname;
1367
+ if (!confirm(`取消触发器「${label}」?`)) return;
1368
+ try {
1369
+ const r = mResp(await menuSend({
1370
+ type: 'menu.action', name: 'trigger', action: 'cancel',
1371
+ args: { nameOrId }, agent: selAid,
1372
+ }));
1373
+ if (r.error) toast(r.error.message || r.error.code, true);
1374
+ else { toast('✓ 已取消'); subscribe('triggers', { agent: trigSel.agent }); }
1375
+ } catch (e) { toast(e.message, true); }
1376
+ });
1377
+ });
1378
+ }
1379
+
1380
+
533
1381
  function startApp() {
534
1382
  initTabs();
535
1383
  connect();
@@ -539,17 +1387,585 @@ function startApp() {
539
1387
  };
540
1388
  }
541
1389
 
1390
+ // ── 主题切换 ──
1391
+ function initTheme() {
1392
+ const saved = localStorage.getItem('ecTheme') || 'light';
1393
+ document.documentElement.setAttribute('data-theme', saved);
1394
+ const btn = $('#theme-btn');
1395
+ if (btn) {
1396
+ btn.textContent = saved === 'dark' ? '☀️' : '🌙';
1397
+ btn.onclick = () => {
1398
+ const cur = document.documentElement.getAttribute('data-theme');
1399
+ const next = cur === 'dark' ? 'light' : 'dark';
1400
+ document.documentElement.setAttribute('data-theme', next);
1401
+ localStorage.setItem('ecTheme', next);
1402
+ btn.textContent = next === 'dark' ? '☀️' : '🌙';
1403
+ if (_hourlyChart) { _hourlyChart.dispose(); _hourlyChart = null; }
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
+ });
1408
+ loadUsageDashboard();
1409
+ if (currentView === 'monitor') renderMonitor(state.monitor);
1410
+ };
1411
+ }
1412
+ }
1413
+
1414
+ // ── Usage Dashboard ──
1415
+ let _hourlyChart = null;
1416
+ let _modelChart = null;
1417
+
1418
+ function fmtTokens(n) {
1419
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
1420
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
1421
+ return String(n);
1422
+ }
1423
+
1424
+ async function loadUsageDashboard() {
1425
+ let data;
1426
+ try {
1427
+ const resp = await fetch('/api/stats/dashboard', {
1428
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1429
+ });
1430
+ if (!resp.ok) data = null;
1431
+ else data = await resp.json();
1432
+ } catch { data = null; }
1433
+
1434
+ // 无数据时渲染默认空状态
1435
+ const t = (data && data.today) ? data.today : { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_hit_rate: 0, call_count: 0 };
1436
+ var cards = $('#usage-cards');
1437
+ if (cards) {
1438
+ cards.innerHTML =
1439
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(t.input_tokens) + '</div><div class="card-label">Input</div></div>' +
1440
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(t.output_tokens) + '</div><div class="card-label">Output</div></div>' +
1441
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(t.cache_read_tokens) + '</div><div class="card-label">Cache Read</div></div>' +
1442
+ '<div class="usage-card"><div class="card-value">' + (t.cache_hit_rate * 100).toFixed(1) + '%</div><div class="card-label">Cache Hit</div></div>' +
1443
+ '<div class="usage-card"><div class="card-value">' + t.call_count + '</div><div class="card-label">Calls</div></div>';
1444
+ }
1445
+
1446
+ // Hourly stacked bar
1447
+ var hourlyEl = $('#usage-hourly-chart');
1448
+ if (hourlyEl && data.hourly && data.hourly.length) {
1449
+ var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1450
+ if (!_hourlyChart) _hourlyChart = echarts.init(hourlyEl, isDark ? 'dark' : null);
1451
+ var hours = data.hourly.map(function(h) { return (h.hour.split(' ')[1] || h.hour); });
1452
+ _hourlyChart.setOption({
1453
+ tooltip: { trigger: 'axis' },
1454
+ legend: { data: ['Input', 'Output', 'Cache'], top: 0, textStyle: { fontSize: 11 } },
1455
+ grid: { top: 30, bottom: 24, left: 50, right: 16 },
1456
+ xAxis: { type: 'category', data: hours, axisLabel: { fontSize: 10 } },
1457
+ yAxis: { type: 'value', axisLabel: { formatter: function(v) { return fmtTokens(v); } } },
1458
+ series: [
1459
+ { name: 'Input', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.input_tokens; }), itemStyle: { color: '#4f6ef7' } },
1460
+ { name: 'Output', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.output_tokens; }), itemStyle: { color: '#38a169' } },
1461
+ { name: 'Cache', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.cache_read_tokens; }), itemStyle: { color: '#dd6b20', opacity: 0.6 } },
1462
+ ]
1463
+ });
1464
+ }
1465
+
1466
+ // Model pie
1467
+ var modelEl = $('#usage-model-chart');
1468
+ if (modelEl && data.top_models && data.top_models.length) {
1469
+ var isDark2 = document.documentElement.getAttribute('data-theme') === 'dark';
1470
+ if (!_modelChart) _modelChart = echarts.init(modelEl, isDark2 ? 'dark' : null);
1471
+ _modelChart.setOption({
1472
+ tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
1473
+ series: [{
1474
+ type: 'pie', radius: ['35%', '70%'], center: ['50%', '55%'],
1475
+ label: { fontSize: 10 },
1476
+ data: data.top_models.map(function(m) { return { name: m.model.split('/').pop(), value: m.total_tokens }; }),
1477
+ }]
1478
+ });
1479
+ }
1480
+
1481
+ // Top peers table
1482
+ var peersEl = $('#usage-top-peers');
1483
+ if (peersEl && data.top_peers && data.top_peers.length) {
1484
+ peersEl.innerHTML =
1485
+ '<thead><tr><th>#</th><th>Peer</th><th>Tokens</th><th>Calls</th></tr></thead>' +
1486
+ '<tbody>' + data.top_peers.map(function(p, i) {
1487
+ return '<tr><td>' + (i + 1) + '</td><td>' + p.peer_key + '</td><td>' + fmtTokens(p.total_tokens) + '</td><td>' + p.call_count + '</td></tr>';
1488
+ }).join('') + '</tbody>';
1489
+ }
1490
+
1491
+ }
1492
+
1493
+ // ── Usage Overview(全时段总览)──
1494
+ async function loadUsageOverview() {
1495
+ let data;
1496
+ try {
1497
+ const resp = await fetch('/api/stats/overview', {
1498
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1499
+ });
1500
+ data = resp.ok ? await resp.json() : null;
1501
+ } catch { data = null; }
1502
+
1503
+ const ts = (data && data.token_stats && data.token_stats.all_time) ? data.token_stats.all_time
1504
+ : { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, call_count: 0, cost_usd: 0, cost_cny: 0 };
1505
+ const sessionCount = (data && data.session_count) || 0;
1506
+ const msgIn = (data && data.msg_in) || 0;
1507
+ const msgOut = (data && data.msg_out) || 0;
1508
+ const totalIn = ts.input_tokens + ts.cache_read_tokens;
1509
+ const hitRate = totalIn > 0 ? (ts.cache_read_tokens / totalIn) * 100 : 0;
1510
+
1511
+ const cardsEl = $('#ov-cards');
1512
+ if (cardsEl) {
1513
+ cardsEl.innerHTML = [
1514
+ ovCard(sessionCount, '会话数'),
1515
+ ovCard(msgIn, '收到消息'),
1516
+ ovCard(msgOut, '发出消息'),
1517
+ ovCard(ts.call_count, '模型调用'),
1518
+ ovCard(fmtTokens(ts.input_tokens), '输入 Token'),
1519
+ ovCard(fmtTokens(ts.output_tokens), '输出 Token'),
1520
+ ovCard(fmtTokens(ts.cache_creation_tokens), '缓存创建'),
1521
+ ovCard(fmtTokens(ts.cache_read_tokens), '缓存命中'),
1522
+ ovCard(hitRate.toFixed(1) + '%', '缓存命中率'),
1523
+ ovCard(fmtCost(ts.cost_usd, ts.cost_cny), '总花费'),
1524
+ ].join('');
1525
+ }
1526
+
1527
+ const agentTbl = $('#ov-agent-table');
1528
+ const agents = (data && data.token_stats && data.token_stats.by_agent) || [];
1529
+ if (agentTbl) {
1530
+ if (!agents.length) {
1531
+ agentTbl.innerHTML = '<tbody><tr><td>暂无数据</td></tr></tbody>';
1532
+ } else {
1533
+ agentTbl.innerHTML =
1534
+ '<thead><tr><th>Agent</th><th>调用</th><th>输入</th><th>输出</th><th>缓存创建</th><th>缓存命中</th><th>花费</th></tr></thead>' +
1535
+ '<tbody>' + agents.map(function(a) {
1536
+ var name = a.agent_aid ? a.agent_aid.split('.')[0] : '(unknown)';
1537
+ return '<tr><td title="' + esc(a.agent_aid) + '">' + esc(name) + '</td>' +
1538
+ '<td>' + a.call_count + '</td>' +
1539
+ '<td>' + fmtTokens(a.input_tokens) + '</td>' +
1540
+ '<td>' + fmtTokens(a.output_tokens) + '</td>' +
1541
+ '<td>' + fmtTokens(a.cache_creation_tokens) + '</td>' +
1542
+ '<td>' + fmtTokens(a.cache_read_tokens) + '</td>' +
1543
+ '<td>' + fmtCost(a.cost_usd, a.cost_cny) + '</td></tr>';
1544
+ }).join('') + '</tbody>';
1545
+ }
1546
+ }
1547
+ }
1548
+
1549
+ function ovCard(value, label) {
1550
+ return '<div class="usage-card"><div class="card-value">' + value + '</div><div class="card-label">' + label + '</div></div>';
1551
+ }
1552
+
1553
+ function fmtCost(usd, cny) {
1554
+ var parts = [];
1555
+ if (usd > 0) parts.push('$' + (usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)));
1556
+ if (cny > 0) parts.push('¥' + (cny < 0.01 ? cny.toFixed(4) : cny.toFixed(2)));
1557
+ return parts.length ? parts.join(' / ') : '$0';
1558
+ }
1559
+
1560
+ // ── Usage subtab switching ──
1561
+ function initUsageSubtabs() {
1562
+ var btns = document.querySelectorAll('.usage-subtab');
1563
+ btns.forEach(function(btn) {
1564
+ btn.addEventListener('click', function() {
1565
+ btns.forEach(function(b) { b.classList.remove('active'); });
1566
+ btn.classList.add('active');
1567
+ var target = btn.getAttribute('data-subview');
1568
+ document.querySelectorAll('.usage-subpanel').forEach(function(p) {
1569
+ p.classList.remove('active');
1570
+ p.style.display = '';
1571
+ });
1572
+ var panel = $('#usage-' + target);
1573
+ if (panel) { panel.classList.add('active'); panel.style.display = ''; }
1574
+ if (target === 'overview') loadUsageOverview();
1575
+ else if (target === 'dashboard') loadUsageDashboard();
1576
+ else if (target === 'explorer') initExplorer();
1577
+ });
1578
+ });
1579
+ }
1580
+
1581
+ // ── Explorer ──
1582
+ var _explorerChart = null;
1583
+ var _explorerInited = false;
1584
+ var _expSelection = { type: null, key: null }; // { type: 'agent'|'peer', key: string } or null
1585
+
1586
+ function initExplorer() {
1587
+ if (_explorerInited) return;
1588
+ _explorerInited = true;
1589
+ var btn = $('#exp-query-btn');
1590
+ if (btn) btn.onclick = runExplorerQuery;
1591
+ // Default date range: last 7 days
1592
+ var now = new Date();
1593
+ var from = new Date(now.getTime() - 7 * 86400000);
1594
+ var fromEl = $('#exp-from');
1595
+ var toEl = $('#exp-to');
1596
+ if (fromEl) fromEl.value = from.toISOString().slice(0, 10);
1597
+ if (toEl) toEl.value = now.toISOString().slice(0, 10);
1598
+ // Load sidebar lists
1599
+ loadExplorerSidebar();
1600
+ }
1601
+
1602
+ async function loadExplorerSidebar() {
1603
+ var token = localStorage.getItem(TOKEN_KEY);
1604
+ var headers = { Authorization: 'Bearer ' + token };
1605
+ try {
1606
+ var [agentsResp, peersResp] = await Promise.all([
1607
+ fetch('/api/stats/agents', { headers }),
1608
+ fetch('/api/stats/peers', { headers }),
1609
+ ]);
1610
+ var agents = agentsResp.ok ? await agentsResp.json() : [];
1611
+ var peers = peersResp.ok ? await peersResp.json() : [];
1612
+ renderExplorerSidebar(agents, peers);
1613
+ } catch {}
1614
+ }
1615
+
1616
+ function renderExplorerSidebar(agents, peers) {
1617
+ var agentList = $('#exp-agent-list');
1618
+ var peerList = $('#exp-peer-list');
1619
+ if (!agentList || !peerList) return;
1620
+
1621
+ // "All" item for agents
1622
+ var allHtml = '<div class="exp-sidebar-item active" data-type="all" data-key="">' +
1623
+ '<span class="item-name">全部</span></div>';
1624
+
1625
+ agentList.innerHTML = allHtml + agents.map(function(a) {
1626
+ var name = a.agent_aid ? a.agent_aid.split('.')[0] : 'unknown';
1627
+ return '<div class="exp-sidebar-item" data-type="agent" data-key="' + escHtml(a.agent_aid) + '">' +
1628
+ '<span class="item-name" title="' + escHtml(a.agent_aid) + '">' + escHtml(name) + '</span>' +
1629
+ '<span class="item-meta">' + fmtTokens(a.input_tokens + a.output_tokens) + '</span></div>';
1630
+ }).join('');
1631
+
1632
+ peerList.innerHTML = peers.map(function(p) {
1633
+ var name = p.peer_key || 'unknown';
1634
+ // 简化显示:去掉 channel# 前缀中的 aun#,保留核心部分
1635
+ var display = name.replace(/^aun#/, '').split('.')[0];
1636
+ return '<div class="exp-sidebar-item" data-type="peer" data-key="' + escHtml(p.peer_key) + '">' +
1637
+ '<span class="item-name" title="' + escHtml(name) + '">' + escHtml(display) + '</span>' +
1638
+ '<span class="item-meta">' + fmtTokens((p.input_tokens || 0) + (p.output_tokens || 0)) + '</span></div>';
1639
+ }).join('');
1640
+
1641
+ // Bind click events
1642
+ var allItems = document.querySelectorAll('#exp-agent-list .exp-sidebar-item, #exp-peer-list .exp-sidebar-item');
1643
+ allItems.forEach(function(el) {
1644
+ el.addEventListener('click', function() {
1645
+ // Clear active from all
1646
+ allItems.forEach(function(x) { x.classList.remove('active'); });
1647
+ el.classList.add('active');
1648
+ var type = el.getAttribute('data-type');
1649
+ var key = el.getAttribute('data-key');
1650
+ if (type === 'all') {
1651
+ _expSelection = { type: null, key: null };
1652
+ $('#exp-selected-name').textContent = '全部';
1653
+ } else {
1654
+ _expSelection = { type: type, key: key };
1655
+ $('#exp-selected-name').textContent = key;
1656
+ }
1657
+ runExplorerQuery();
1658
+ });
1659
+ });
1660
+ }
1661
+
1662
+ function escHtml(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
1663
+
1664
+ async function runExplorerQuery() {
1665
+ var params = new URLSearchParams();
1666
+ var fromEl = $('#exp-from');
1667
+ var toEl = $('#exp-to');
1668
+ if (fromEl && fromEl.value) params.set('from', String(new Date(fromEl.value + 'T00:00:00').getTime()));
1669
+ if (toEl && toEl.value) params.set('to', String(new Date(toEl.value + 'T23:59:59').getTime()));
1670
+ // Inject selection from sidebar
1671
+ if (_expSelection.type === 'agent' && _expSelection.key) params.set('agent', _expSelection.key);
1672
+ if (_expSelection.type === 'peer' && _expSelection.key) params.set('peer', _expSelection.key);
1673
+ var modelEl = $('#exp-model');
1674
+ if (modelEl && modelEl.value) params.set('model', modelEl.value);
1675
+ var granEl = $('#exp-granularity');
1676
+ if (granEl) params.set('granularity', granEl.value);
1677
+
1678
+ var data;
1679
+ try {
1680
+ var resp = await fetch('/api/stats/explorer?' + params.toString(), {
1681
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1682
+ });
1683
+ if (!resp.ok) return;
1684
+ data = await resp.json();
1685
+ } catch { return; }
1686
+
1687
+ // Show/hide detail cards
1688
+ var cardsEl = $('#exp-detail-cards');
1689
+ if (data && data.length) {
1690
+ var totIn = 0, totOut = 0, totCache = 0, totCalls = 0;
1691
+ data.forEach(function(r) { totIn += r.input_tokens; totOut += r.output_tokens; totCache += r.cache_read_tokens; totCalls += r.call_count; });
1692
+ if (cardsEl) {
1693
+ cardsEl.style.display = 'flex';
1694
+ cardsEl.innerHTML =
1695
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(totIn) + '</div><div class="card-label">Input</div></div>' +
1696
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(totOut) + '</div><div class="card-label">Output</div></div>' +
1697
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(totCache) + '</div><div class="card-label">Cache Read</div></div>' +
1698
+ '<div class="usage-card"><div class="card-value">' + totCalls + '</div><div class="card-label">Calls</div></div>';
1699
+ }
1700
+ } else {
1701
+ if (cardsEl) cardsEl.style.display = 'none';
1702
+ }
1703
+
1704
+ if (!data || !data.length) {
1705
+ var tbl = $('#usage-explorer-table');
1706
+ if (tbl) tbl.innerHTML = '<tr><td>No data for selected range.</td></tr>';
1707
+ var chartEl = $('#usage-explorer-chart');
1708
+ if (chartEl && _explorerChart) { _explorerChart.dispose(); _explorerChart = null; }
1709
+ return;
1710
+ }
1711
+
1712
+ // Chart
1713
+ var chartEl = $('#usage-explorer-chart');
1714
+ if (chartEl) {
1715
+ var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1716
+ if (_explorerChart) { _explorerChart.dispose(); _explorerChart = null; }
1717
+ _explorerChart = echarts.init(chartEl, isDark ? 'dark' : null);
1718
+ var periods = data.map(function(r) { return r.period; });
1719
+ _explorerChart.setOption({
1720
+ tooltip: { trigger: 'axis' },
1721
+ legend: { data: ['Input', 'Output'], top: 0, textStyle: { fontSize: 11 } },
1722
+ grid: { top: 30, bottom: 30, left: 60, right: 16 },
1723
+ xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 10, rotate: 30 } },
1724
+ yAxis: { type: 'value', axisLabel: { formatter: function(v) { return fmtTokens(v); } } },
1725
+ series: [
1726
+ { name: 'Input', type: 'line', data: data.map(function(r) { return r.input_tokens; }), smooth: true, areaStyle: { opacity: 0.15 }, itemStyle: { color: '#4f6ef7' } },
1727
+ { name: 'Output', type: 'line', data: data.map(function(r) { return r.output_tokens; }), smooth: true, areaStyle: { opacity: 0.15 }, itemStyle: { color: '#38a169' } },
1728
+ ]
1729
+ });
1730
+ }
1731
+
1732
+ // Table
1733
+ var tbl = $('#usage-explorer-table');
1734
+ if (tbl) {
1735
+ tbl.innerHTML =
1736
+ '<thead><tr><th>Period</th><th>Input</th><th>Output</th><th>Cache↑</th><th>CacheHit</th><th>Calls</th></tr></thead>' +
1737
+ '<tbody>' + data.map(function(r) {
1738
+ return '<tr><td>' + r.period + '</td><td>' + fmtTokens(r.input_tokens) + '</td><td>' + fmtTokens(r.output_tokens) +
1739
+ '</td><td>' + fmtTokens(r.cache_creation_tokens) + '</td><td>' + fmtTokens(r.cache_read_tokens) +
1740
+ '</td><td>' + r.call_count + '</td></tr>';
1741
+ }).join('') + '</tbody>';
1742
+ }
1743
+ }
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
+
542
1959
  window.addEventListener('DOMContentLoaded', () => {
1960
+ initTheme();
543
1961
  initPairUI();
544
1962
  if (localStorage.getItem(TOKEN_KEY)) {
545
1963
  showApp();
546
1964
  startApp();
1965
+ loadUsageDashboard();
1966
+ loadUsageOverview();
1967
+ initUsageSubtabs();
547
1968
  } else {
548
1969
  showPairPage();
549
1970
  }
550
1971
  });
551
-
552
-
553
-
554
-
555
-