evolclaw-web 1.0.0 → 1.1.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 };
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,6 +148,7 @@ 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 = '';
122
153
  let sessChatMode = false; // false=完整视图,true=对话视图(折叠处理过程)
123
154
 
@@ -128,7 +159,10 @@ function switchView(view) {
128
159
  // 切换时按当前选择恢复订阅
129
160
  if (view === 'msg') subscribe('msg', { aid: msgSel.aid, peer: msgSel.peer });
130
161
  else if (view === 'session') subscribe('session', { sessionId: sessSel.sessionId, project: sessSel.project });
131
- else subscribe('aid', {});
162
+ else if (view === 'cache') subscribe('cache', {});
163
+ else if (view === 'system') subscribe('system', {});
164
+ else if (view === 'triggers') subscribe('triggers', { agent: trigSel.agent });
165
+ else subscribe('agents', {});
132
166
  if (state[view]) renderView(view);
133
167
  }
134
168
 
@@ -139,9 +173,12 @@ function initTabs() {
139
173
  }
140
174
 
141
175
  function renderView(view) {
142
- if (view === 'aid') renderAid(state.aid);
176
+ if (view === 'agents') renderAgents(state.agents);
143
177
  else if (view === 'msg') renderMsg(state.msg);
144
178
  else if (view === 'session') renderSession(state.session);
179
+ else if (view === 'cache') renderCache(state.cache);
180
+ else if (view === 'system') renderSystem(state.system);
181
+ else if (view === 'triggers') renderTriggers(state.triggers);
145
182
  }
146
183
 
147
184
  // ── 工具 ──
@@ -162,34 +199,64 @@ function fmtAgo(ts) {
162
199
  if (s < 86400) return Math.floor(s / 3600) + 'h';
163
200
  return Math.floor(s / 86400) + 'd';
164
201
  }
202
+ // 秒数 → 可读时长(如 3d 2h / 5h 12m / 8m 3s)
203
+ function fmtDur(sec) {
204
+ if (sec == null) return '—';
205
+ const s = Math.floor(Number(sec) || 0);
206
+ const d = Math.floor(s / 86400);
207
+ const h = Math.floor((s % 86400) / 3600);
208
+ const m = Math.floor((s % 3600) / 60);
209
+ const sx = s % 60;
210
+ if (d > 0) return `${d}d ${h}h`;
211
+ if (h > 0) return `${h}h ${m}m`;
212
+ if (m > 0) return `${m}m ${sx}s`;
213
+ return `${sx}s`;
214
+ }
165
215
  function fmtTime(ts) {
166
216
  if (!ts) return '';
167
217
  const d = new Date(ts);
168
218
  const p = (n) => String(n).padStart(2, '0');
169
- return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
219
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
220
+ }
221
+ // semver 比较:-1 (a<b) / 0 / 1 (a>b),剥离 pre-release 标签
222
+ function compareVer(a, b) {
223
+ const pa = String(a).split('-')[0].split('.').map(Number);
224
+ const pb = String(b).split('-')[0].split('.').map(Number);
225
+ const len = Math.max(pa.length, pb.length);
226
+ for (let i = 0; i < len; i++) {
227
+ const na = pa[i] || 0, nb = pb[i] || 0;
228
+ if (na < nb) return -1;
229
+ if (na > nb) return 1;
230
+ }
231
+ return 0;
170
232
  }
171
233
 
172
- // ── AID 视图 ──
173
- function renderAid(data) {
174
- const el = $('#view-aid');
234
+ // ── Agents 视图(旧 AID 页升级:加操作列 + 新建入口)──
235
+ function renderAgents(data) {
236
+ const el = $('#view-agents');
175
237
  if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
238
+ if (_agentBusy) return; // 编辑/操作进行中,跳过轮询重渲染
176
239
  const aids = data.aids || [];
177
240
  const statsByAid = {};
178
241
  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;
179
245
 
180
- let html = '';
246
+ let html = '<div class="agents-toolbar"><button class="ctrl-btn" id="agent-new-btn">+ 新建 Agent</button></div>';
181
247
  if (!data.daemonRunning) {
182
248
  html += '<div class="banner">⚠ EvolClaw 主进程未运行,仅显示最近活动记录</div>';
183
249
  }
184
250
  if (!aids.length) {
185
251
  html += '<div class="empty">暂无 AID</div>';
186
252
  el.innerHTML = html;
253
+ bindAgentsEvents(el);
187
254
  return;
188
255
  }
189
256
 
190
257
  html += '<table><thead><tr>' +
191
258
  '<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>' +
259
+ '<th>入字节</th><th>出字节</th><th>peers</th><th>重连</th><th>最后活动</th><th>最近消息</th><th>操作</th>' +
193
260
  '</tr></thead><tbody>';
194
261
 
195
262
  for (const a of aids) {
@@ -204,6 +271,21 @@ function renderAid(data) {
204
271
  } else if (s.lastSentText) {
205
272
  preview = '↑ ' + shortAid(s.lastSentTo) + ': ' + s.lastSentText;
206
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
+ }
207
289
  html += '<tr>' +
208
290
  `<td><span class="dot ${dotCls}"></span>${esc(status)}</td>` +
209
291
  `<td>${esc(shortAid(a.aid))}${name ? ` <span style="color:var(--dim)">(${esc(name)})</span>` : ''}</td>` +
@@ -213,10 +295,133 @@ function renderAid(data) {
213
295
  `<td>${s.uniquePeerCount ?? a.peerCount ?? 0}</td><td>${a.reconnectCount ?? 0}</td>` +
214
296
  `<td>${fmtAgo(lastTs)}</td>` +
215
297
  `<td class="preview">${esc(preview.replace(/\n/g, ' ').slice(0, 80))}</td>` +
298
+ `<td class="agent-ops-cell">${ops}</td>` +
216
299
  '</tr>';
217
300
  }
218
301
  html += '</tbody></table>';
219
302
  el.innerHTML = html;
303
+ bindAgentsEvents(el);
304
+ }
305
+
306
+ // ── Cache 视图(daemon 统一 FileCache 运行统计)──
307
+ // fmtNum 复用文件内既有定义(千分位缩写)。
308
+ function hitRate(c) {
309
+ const denom = (c.hits || 0) + (c.misses || 0);
310
+ return denom ? (c.hits / denom) : null;
311
+ }
312
+ function fmtPct(r) {
313
+ if (r == null) return '—';
314
+ return (r * 100).toFixed(1) + '%';
315
+ }
316
+ function rateCls(r) {
317
+ if (r == null) return '';
318
+ if (r >= 0.9) return 'on';
319
+ if (r >= 0.6) return 'idle';
320
+ return 'off';
321
+ }
322
+ // group 名按用途归类,给出友好标签:config:<aid> / agent-files:<aid> 提取 aid
323
+ function groupLabel(g) {
324
+ if (g.startsWith('agent-files:')) return { kind: 'agent', label: shortAid(g.slice('agent-files:'.length)), sub: '身份层' };
325
+ if (g.startsWith('config:')) return { kind: 'agent', label: shortAid(g.slice('config:'.length)), sub: 'config' };
326
+ if (g === 'config') return { kind: 'global', label: 'defaults', sub: '全局' };
327
+ if (g === 'relation-prefs') return { kind: 'relation', label: 'relation-prefs', sub: '关系模型偏好' };
328
+ if (g === 'kits') return { kind: 'kits', label: 'kits', sub: 'manifest/fragment/md' };
329
+ return { kind: 'other', label: g, sub: '' };
330
+ }
331
+
332
+ function renderCache(data) {
333
+ const el = $('#view-cache');
334
+ if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
335
+ if (!data.daemonRunning) {
336
+ el.innerHTML = '<div class="banner">⚠ EvolClaw 主进程未运行,无缓存统计可显示</div>';
337
+ return;
338
+ }
339
+ if (!data.supported || !data.stats) {
340
+ el.innerHTML = '<div class="banner">⚠ 当前 EvolClaw 版本不支持 cache-stats(请升级 daemon)</div>';
341
+ return;
342
+ }
343
+ const s = data.stats;
344
+ const t = s.totals;
345
+ const occ = s.occupancy || {};
346
+ // 全部组占用合计
347
+ let totalBytes = 0;
348
+ for (const g in occ) totalBytes += occ[g].bytes || 0;
349
+
350
+ let html = '';
351
+
352
+ // ① 总览卡片
353
+ const rate = hitRate(t);
354
+ html += '<div class="cache-cards">';
355
+ html += card('命中率', fmtPct(rate), rateCls(rate), `${fmtNum(t.hits)} 命中 / ${fmtNum(t.misses)} 未命中`);
356
+ html += card('读取总数', fmtNum(t.gets), '', `${fmtNum(t.hits)} hit · ${fmtNum(t.misses)} miss`);
357
+ html += card('缓存条目', fmtNum(s.size), '', fmtBytes(totalBytes) + ' 近似内存');
358
+ html += card('stat 检查', fmtNum(t.statChecks), '', 'mtime 策略每读一次');
359
+ html += card('重读', fmtNum(t.reReads), '', '带外改后自动重读');
360
+ html += card('驱逐', fmtNum(t.evictions), t.evictions ? 'idle' : '', 'LRU 超限');
361
+ html += card('失效', fmtNum(t.invalidations), '', 'reload/单刷清除');
362
+ html += card('统计起始', fmtAgo(s.since) + ' 前', '', fmtTime(s.since));
363
+ html += '</div>';
364
+
365
+ // ② 按 group 表(每组命中率 + 占用 + 容量水位)
366
+ html += '<h3 class="cache-h">按缓存组</h3>';
367
+ html += '<table><thead><tr>' +
368
+ '<th>组</th><th>类型</th><th>读取</th><th>命中</th><th>未命中</th><th>命中率</th>' +
369
+ '<th>重读</th><th>驱逐</th><th>条目</th><th>内存</th><th>容量</th>' +
370
+ '</tr></thead><tbody>';
371
+ const groups = Object.keys(s.byGroup).sort((a, b) => (s.byGroup[b].gets || 0) - (s.byGroup[a].gets || 0));
372
+ for (const g of groups) {
373
+ const c = s.byGroup[g];
374
+ const o = occ[g] || { size: 0, bytes: 0, cap: null };
375
+ const gl = groupLabel(g);
376
+ const r = hitRate(c);
377
+ let capCell = '—';
378
+ if (o.cap != null) {
379
+ const pct = o.cap ? Math.round((o.size / o.cap) * 100) : 0;
380
+ const cls = pct >= 90 ? 'off' : (pct >= 70 ? 'idle' : 'on');
381
+ capCell = `<span class="dot ${cls}"></span>${o.size}/${o.cap}`;
382
+ }
383
+ html += '<tr>' +
384
+ `<td>${esc(gl.label)}${gl.sub ? ` <span style="color:var(--dim)">${esc(gl.sub)}</span>` : ''}</td>` +
385
+ `<td><span class="tag tag-${gl.kind}">${esc(gl.kind)}</span></td>` +
386
+ `<td>${fmtNum(c.gets)}</td><td>${fmtNum(c.hits)}</td><td>${fmtNum(c.misses)}</td>` +
387
+ `<td><span class="dot ${rateCls(r)}"></span>${fmtPct(r)}</td>` +
388
+ `<td>${fmtNum(c.reReads)}</td><td>${fmtNum(c.evictions)}</td>` +
389
+ `<td>${o.size}</td><td>${fmtBytes(o.bytes)}</td><td>${capCell}</td>` +
390
+ '</tr>';
391
+ }
392
+ html += '</tbody></table>';
393
+
394
+ // ③ 按 policy 表
395
+ html += '<h3 class="cache-h">按策略</h3>';
396
+ html += '<table><thead><tr>' +
397
+ '<th>策略</th><th>读取</th><th>命中</th><th>未命中</th><th>命中率</th><th>stat 检查</th><th>重读</th>' +
398
+ '</tr></thead><tbody>';
399
+ const POLICY_DESC = { 'on-reload': '靠 reload 刷新,平时零检查', 'manual': '显式单刷', 'mtime': '每读 statSync 门控' };
400
+ for (const pol of ['on-reload', 'mtime', 'manual']) {
401
+ const c = s.byPolicy[pol];
402
+ if (!c || !c.gets) continue;
403
+ const r = hitRate(c);
404
+ html += '<tr>' +
405
+ `<td>${esc(pol)} <span style="color:var(--dim)">${esc(POLICY_DESC[pol] || '')}</span></td>` +
406
+ `<td>${fmtNum(c.gets)}</td><td>${fmtNum(c.hits)}</td><td>${fmtNum(c.misses)}</td>` +
407
+ `<td><span class="dot ${rateCls(r)}"></span>${fmtPct(r)}</td>` +
408
+ `<td>${fmtNum(c.statChecks)}</td><td>${fmtNum(c.reReads)}</td>` +
409
+ '</tr>';
410
+ }
411
+ html += '</tbody></table>';
412
+
413
+ html += '<div class="cache-note">注:config/defaults 与关系级 preferences 的读取也已并入本统计;' +
414
+ '渲染后结果(按 vars)不缓存,故不在此列。</div>';
415
+
416
+ el.innerHTML = html;
417
+ }
418
+
419
+ function card(label, value, valCls, sub) {
420
+ return `<div class="cache-card">` +
421
+ `<div class="cc-label">${esc(label)}</div>` +
422
+ `<div class="cc-value ${valCls || ''}">${esc(value)}</div>` +
423
+ `<div class="cc-sub">${esc(sub || '')}</div>` +
424
+ `</div>`;
220
425
  }
221
426
 
222
427
  // ── Messages 视图 ──
@@ -529,7 +734,350 @@ function renderBlocks(blocks) {
529
734
  return out;
530
735
  }
531
736
 
532
- // ── 启动 ──
737
+ // ── 通用 Menu 协议辅助(mResp / toast,供 Agents / System / Triggers 复用)──
738
+
739
+ // 提取 menu.response 的 data/error
740
+ function mResp(r) {
741
+ if (!r) return { error: { code: 'INTERNAL', message: 'no response' } };
742
+ if (r.error) return { error: r.error };
743
+ return { data: r.data };
744
+ }
745
+
746
+ function toast(text, isErr) {
747
+ let el = $('#ctrl-toast');
748
+ if (!el) {
749
+ el = document.createElement('div');
750
+ el.id = 'ctrl-toast';
751
+ el.className = 'ctrl-toast';
752
+ document.body.appendChild(el);
753
+ }
754
+ el.textContent = text;
755
+ el.className = 'ctrl-toast show' + (isErr ? ' err' : '');
756
+ clearTimeout(el._t);
757
+ el._t = setTimeout(() => { el.className = 'ctrl-toast'; }, 2600);
758
+ }
759
+
760
+ // ── Agents 操作 ──
761
+ let _agentBusy = false;
762
+
763
+ function bindAgentsEvents(el) {
764
+ el.querySelector('#agent-new-btn')?.addEventListener('click', agentOpNew);
765
+ el.querySelectorAll('.agent-ops').forEach(div => {
766
+ const aid = div.dataset.aid;
767
+ 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
+ });
777
+ });
778
+ }
779
+
780
+ async function agentOpReload(aid, force = false) {
781
+ _agentBusy = true;
782
+ try {
783
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'reload', args: { aid, force } }));
784
+ if (r.error?.code === 'BUSY') {
785
+ if (confirm(r.error.message + '\n确认强制重载?')) return agentOpReload(aid, true);
786
+ return;
787
+ }
788
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
789
+ toast('✓ 已重载');
790
+ subscribe('agents', {});
791
+ } catch (e) { toast(e.message, true); }
792
+ finally { _agentBusy = false; }
793
+ }
794
+
795
+ async function agentOpToggle(aid, status) {
796
+ const action = status === 'disabled' ? 'enable' : 'disable';
797
+ _agentBusy = true;
798
+ try {
799
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid } }));
800
+ if (r.error?.code === 'BUSY') {
801
+ if (confirm(r.error.message + `\n确认强制${action === 'disable' ? '禁用' : '启用'}?`)) {
802
+ const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid, force: true } }));
803
+ if (r2.error) toast(r2.error.message || r2.error.code, true);
804
+ else { toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`); subscribe('agents', {}); }
805
+ }
806
+ return;
807
+ }
808
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
809
+ toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`);
810
+ subscribe('agents', {});
811
+ } catch (e) { toast(e.message, true); }
812
+ finally { _agentBusy = false; }
813
+ }
814
+
815
+ async function agentOpDelete(aid) {
816
+ if (!confirm(`删除 Agent ${aid}?\n此操作不可恢复。`)) return;
817
+ const purge = confirm('同时清除 agent 数据目录?');
818
+ _agentBusy = true;
819
+ try {
820
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge } }));
821
+ if (r.error?.code === 'BUSY') {
822
+ if (confirm(r.error.message + '\n确认强制删除?')) {
823
+ const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge, force: true } }));
824
+ if (r2.error) toast(r2.error.message || r2.error.code, true);
825
+ else { toast('✓ 已删除'); subscribe('agents', {}); }
826
+ }
827
+ return;
828
+ }
829
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
830
+ toast('✓ 已删除');
831
+ subscribe('agents', {});
832
+ } catch (e) { toast(e.message, true); }
833
+ finally { _agentBusy = false; }
834
+ }
835
+
836
+ async function agentOpNew() {
837
+ const aid = prompt('Agent AID(如 mybot.agentid.pub):');
838
+ if (!aid) return;
839
+ const name = prompt('显示名:') || aid.split('.')[0];
840
+ const baseagent = prompt('后端(claude / codex / gemini):', 'claude') || 'claude';
841
+ _agentBusy = true;
842
+ try {
843
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'create', args: { aid, name, baseagent } }));
844
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
845
+ toast('✓ 创建请求已受理,稍后刷新查看');
846
+ setTimeout(() => subscribe('agents', {}), 3000);
847
+ } catch (e) { toast(e.message, true); }
848
+ finally { _agentBusy = false; }
849
+ }
850
+
851
+ 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
+ ]);
857
+ const q = mResp(qr);
858
+ if (q.error) { toast(q.error.message || q.error.code, true); _agentBusy = false; return; }
859
+ const cfg = q.data;
860
+ // 简单 prompt 编辑表单
861
+ const projectRaw = prompt('项目路径:', cfg.config?.projects?.defaultPath || '');
862
+ const ownersRaw = prompt('Owners(逗号分隔 AID):', (cfg.config?.owners || []).join(', '));
863
+ const patch = {};
864
+ if (projectRaw !== null) patch.projects = { defaultPath: projectRaw };
865
+ if (ownersRaw !== null) patch.owners = ownersRaw.split(',').map(s => s.trim()).filter(Boolean);
866
+ if (Object.keys(patch).length === 0) { _agentBusy = false; return; }
867
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'update', args: { aid, patch } }));
868
+ if (r.error) toast(r.error.message || r.error.code, true);
869
+ else toast('✓ 配置已保存,点「重载」生效');
870
+ } catch (e) { toast(e.message, true); }
871
+ finally { _agentBusy = false; }
872
+ }
873
+
874
+ // ── System 视图 ──
875
+ function renderSystem(data) {
876
+ const el = $('#view-system');
877
+ if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
878
+ const sys = data.system || {};
879
+ const up = data.upgrade;
880
+ const chk = data.check;
881
+
882
+ const vcard = (label, local, upInfo) => {
883
+ let badge = '';
884
+ if (upInfo?.hasUpdate && upInfo.remote) badge = ` <span style="color:var(--accent)">⬆ ${esc(upInfo.remote)}</span>`;
885
+ else if (upInfo?.remote) badge = ` <span style="color:var(--dim)">✓ 最新</span>`;
886
+ return `<div class="cache-card"><div class="card-label">${esc(label)}</div><div class="card-val">${esc(local || '—')}${badge}</div></div>`;
887
+ };
888
+
889
+ let html = '<div class="sys-wrap">';
890
+
891
+ // ① 版本卡
892
+ html += '<div class="cache-cards" style="margin-bottom:16px">';
893
+ html += vcard('evolclaw', sys.version, up?.evolclaw);
894
+ html += vcard('FASTAUN', sys.fastaunVersion, up?.fastaun);
895
+ html += vcard('ECWEB', data.ecwebVersion, up?.ecweb ? {
896
+ remote: up.ecweb.remote,
897
+ hasUpdate: !!(up.ecweb.remote && data.ecwebVersion && compareVer(data.ecwebVersion, up.ecweb.remote) < 0),
898
+ } : null);
899
+ html += `<div class="cache-card"><div class="card-label">NodeJS</div><div class="card-val">${esc(sys.node || '—')}</div></div>`;
900
+ html += `<div class="cache-card"><div class="card-label">运行时间</div><div class="card-val">${esc(fmtDur(sys.uptime))}</div></div>`;
901
+ html += `<div class="cache-card"><div class="card-label">PID</div><div class="card-val">${sys.pid || '—'}</div></div>`;
902
+ html += '</div>';
903
+
904
+ // ② 操作区
905
+ const devHint = up?.devMode ? ' <span style="color:var(--dim);font-size:0.85em">⏭ 开发模式,升级需手动操作</span>' : '';
906
+ html += '<div class="sys-actions" style="margin-bottom:16px">' +
907
+ '<button class="ctrl-btn" id="sys-check-btn">🔍 健康检查</button> ' +
908
+ '<button class="ctrl-btn" id="sys-upgrade-btn">⬆ 检查更新</button> ' +
909
+ '<button class="ctrl-btn danger" id="sys-restart-btn">⟳ 重启服务</button>' +
910
+ devHint +
911
+ '</div>';
912
+
913
+ // ③ 健康快照
914
+ if (chk) {
915
+ 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 小时
929
+ const h = chk.lastHour;
930
+ if (h) {
931
+ 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>`;
936
+ }
937
+ // EvolAgent 健康
938
+ 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
+ }
946
+ html += '</div>';
947
+ }
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
+ }
955
+ html += '</div>';
956
+ }
957
+ html += '</div>';
958
+ }
959
+
960
+ html += '</div>';
961
+ el.innerHTML = html;
962
+ bindSystemEvents(el, data);
963
+ }
964
+
965
+ function bindSystemEvents(el, data) {
966
+ el.querySelector('#sys-check-btn')?.addEventListener('click', async () => {
967
+ try {
968
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'system', action: 'check' }));
969
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
970
+ state.system = { ...(state.system || {}), check: r.data };
971
+ renderSystem(state.system);
972
+ } catch (e) { toast(e.message, true); }
973
+ });
974
+ el.querySelector('#sys-upgrade-btn')?.addEventListener('click', async () => {
975
+ try {
976
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'system', action: 'upgrade' }));
977
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
978
+ state.system = { ...(state.system || {}), upgrade: r.data };
979
+ renderSystem(state.system);
980
+ } catch (e) { toast(e.message, true); }
981
+ });
982
+ el.querySelector('#sys-restart-btn')?.addEventListener('click', async () => {
983
+ if (!confirm('确认重启服务?当前所有连接将断开。')) return;
984
+ try {
985
+ await menuSend({ type: 'menu.action', name: 'system', action: 'restart' });
986
+ toast('重启中…');
987
+ } catch (e) { toast(e.message, true); }
988
+ });
989
+ }
990
+
991
+ // ── Triggers 视图 ──
992
+ function trigStatusBadge(status) {
993
+ const map = {
994
+ active: ['活跃', 'trig-badge-active'],
995
+ fired: ['已触发', 'trig-badge-fired'],
996
+ cancelled: ['已取消', 'trig-badge-cancelled'],
997
+ expired: ['已过期', 'trig-badge-expired'],
998
+ };
999
+ const [label, cls] = map[status] || [status, 'trig-badge-fired'];
1000
+ return `<span class="trig-badge ${cls}">${esc(label)}</span>`;
1001
+ }
1002
+
1003
+ function renderTriggers(data) {
1004
+ if (!data) { $('#view-triggers').innerHTML = '<div class="empty">加载中…</div>'; return; }
1005
+ const agents = data.agents || [];
1006
+ const triggers = data.triggers || [];
1007
+ const selAid = data.selectedAgent;
1008
+
1009
+ // 左列:agent 列表(仿 msg list-item 风格)
1010
+ let aHtml = '<div class="col-title">Agent</div>';
1011
+ if (!agents.length) aHtml += '<div class="empty">暂无 Agent</div>';
1012
+ for (const ag of agents) {
1013
+ const sel = ag.value === selAid ? ' sel' : '';
1014
+ aHtml += `<div class="list-item${sel}" data-aid="${esc(ag.value)}">` +
1015
+ `<div class="name">${esc(ag.label)}</div>` +
1016
+ `<div class="sub">${esc(ag.value)}</div></div>`;
1017
+ }
1018
+ $('#trig-agents').innerHTML = aHtml;
1019
+ $('#trig-agents').querySelectorAll('.list-item').forEach(item => {
1020
+ item.onclick = () => { trigSel.agent = item.dataset.aid; subscribe('triggers', { agent: trigSel.agent }); };
1021
+ });
1022
+
1023
+ // 右列:table,每字段一列
1024
+ const el = $('#trig-table');
1025
+ if (!selAid) { el.innerHTML = '<div class="empty" style="padding:16px">← 选择 Agent 查看触发器</div>'; return; }
1026
+ if (!triggers.length) { el.innerHTML = '<div class="empty" style="padding:16px">该 Agent 暂无触发器</div>'; return; }
1027
+
1028
+ let html = '<table><thead><tr>' +
1029
+ '<th>状态</th><th>名称</th><th>ID</th><th>类型</th><th>表达式</th>' +
1030
+ '<th>上次触发</th><th>下次触发</th><th>触发次数</th><th>失败次数</th><th>最后结果</th><th>Session 策略</th>' +
1031
+ '<th>目标渠道</th><th>渠道 ID</th><th>渠道类型</th>' +
1032
+ '<th>创建者</th><th>创建渠道</th><th>创建时间</th><th>操作</th>' +
1033
+ '</tr></thead><tbody>';
1034
+ for (const t of triggers) {
1035
+ const status = t.status || 'active';
1036
+ const active = status === 'active';
1037
+ html += `<tr class="${active ? '' : 'trig-done'}">` +
1038
+ `<td>${trigStatusBadge(status)}</td>` +
1039
+ `<td>${esc(t.name ?? t.label ?? '')}</td>` +
1040
+ `<td>${esc(t.id ?? t.value ?? '')}</td>` +
1041
+ `<td>${esc(t.scheduleType ?? '')}</td>` +
1042
+ `<td>${t.scheduleType === 'at' && t.scheduleValue ? fmtTime(new Date(t.scheduleValue).getTime()) : esc(t.scheduleValue ?? '')}</td>` +
1043
+ `<td>${t.lastFiredAt ? fmtTime(t.lastFiredAt) : '—'}</td>` +
1044
+ `<td>${t.nextFireAt ? fmtTime(t.nextFireAt) : '—'}</td>` +
1045
+ `<td>${t.fireCount ?? 0}</td>` +
1046
+ `<td>${t.failCount ? `<span style="color:var(--red)">${t.failCount}</span>` : '0'}</td>` +
1047
+ `<td>${t.lastResult ? esc(t.lastResult) : '—'}</td>` +
1048
+ `<td>${esc(t.targetSessionStrategy ?? '')}</td>` +
1049
+ `<td>${esc(t.targetChannel ?? '')}</td>` +
1050
+ `<td>${esc(t.targetChannelId ?? '')}</td>` +
1051
+ `<td>${esc(t.targetChannelType ?? '')}</td>` +
1052
+ `<td>${esc(t.createdByPeerId ?? '')}</td>` +
1053
+ `<td>${esc(t.createdByChannel ?? '')}</td>` +
1054
+ `<td>${t.createdAt ? fmtTime(t.createdAt) : '—'}</td>` +
1055
+ `<td>${active
1056
+ ? `<button class="ctrl-btn danger" data-trigid="${esc(t.id ?? t.value ?? '')}" data-trigname="${esc(t.name ?? t.label ?? '')}">取消</button>`
1057
+ : '—'}</td>` +
1058
+ '</tr>';
1059
+ }
1060
+ html += '</tbody></table>';
1061
+ el.innerHTML = html;
1062
+
1063
+ el.querySelectorAll('button[data-trigid]').forEach(btn => {
1064
+ btn.addEventListener('click', async () => {
1065
+ const nameOrId = btn.dataset.trigid;
1066
+ const label = btn.dataset.trigname;
1067
+ if (!confirm(`取消触发器「${label}」?`)) return;
1068
+ try {
1069
+ const r = mResp(await menuSend({
1070
+ type: 'menu.action', name: 'trigger', action: 'cancel',
1071
+ args: { nameOrId }, agent: selAid,
1072
+ }));
1073
+ if (r.error) toast(r.error.message || r.error.code, true);
1074
+ else { toast('✓ 已取消'); subscribe('triggers', { agent: trigSel.agent }); }
1075
+ } catch (e) { toast(e.message, true); }
1076
+ });
1077
+ });
1078
+ }
1079
+
1080
+
533
1081
  function startApp() {
534
1082
  initTabs();
535
1083
  connect();
@@ -539,17 +1087,373 @@ function startApp() {
539
1087
  };
540
1088
  }
541
1089
 
1090
+ // ── 主题切换 ──
1091
+ function initTheme() {
1092
+ const saved = localStorage.getItem('ecTheme') || 'light';
1093
+ document.documentElement.setAttribute('data-theme', saved);
1094
+ const btn = $('#theme-btn');
1095
+ if (btn) {
1096
+ btn.textContent = saved === 'dark' ? '☀️' : '🌙';
1097
+ btn.onclick = () => {
1098
+ const cur = document.documentElement.getAttribute('data-theme');
1099
+ const next = cur === 'dark' ? 'light' : 'dark';
1100
+ document.documentElement.setAttribute('data-theme', next);
1101
+ localStorage.setItem('ecTheme', next);
1102
+ btn.textContent = next === 'dark' ? '☀️' : '🌙';
1103
+ if (_hourlyChart) { _hourlyChart.dispose(); _hourlyChart = null; }
1104
+ if (_modelChart) { _modelChart.dispose(); _modelChart = null; }
1105
+ loadUsageDashboard();
1106
+ };
1107
+ }
1108
+ }
1109
+
1110
+ // ── Usage Dashboard ──
1111
+ let _hourlyChart = null;
1112
+ let _modelChart = null;
1113
+
1114
+ function fmtTokens(n) {
1115
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
1116
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
1117
+ return String(n);
1118
+ }
1119
+
1120
+ async function loadUsageDashboard() {
1121
+ let data;
1122
+ try {
1123
+ const resp = await fetch('/api/stats/dashboard', {
1124
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1125
+ });
1126
+ if (!resp.ok) data = null;
1127
+ else data = await resp.json();
1128
+ } catch { data = null; }
1129
+
1130
+ // 无数据时渲染默认空状态
1131
+ const t = (data && data.today) ? data.today : { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_hit_rate: 0, call_count: 0 };
1132
+ var cards = $('#usage-cards');
1133
+ if (cards) {
1134
+ cards.innerHTML =
1135
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(t.input_tokens) + '</div><div class="card-label">Input</div></div>' +
1136
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(t.output_tokens) + '</div><div class="card-label">Output</div></div>' +
1137
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(t.cache_read_tokens) + '</div><div class="card-label">Cache Read</div></div>' +
1138
+ '<div class="usage-card"><div class="card-value">' + (t.cache_hit_rate * 100).toFixed(1) + '%</div><div class="card-label">Cache Hit</div></div>' +
1139
+ '<div class="usage-card"><div class="card-value">' + t.call_count + '</div><div class="card-label">Calls</div></div>';
1140
+ }
1141
+
1142
+ // Hourly stacked bar
1143
+ var hourlyEl = $('#usage-hourly-chart');
1144
+ if (hourlyEl && data.hourly && data.hourly.length) {
1145
+ var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1146
+ if (!_hourlyChart) _hourlyChart = echarts.init(hourlyEl, isDark ? 'dark' : null);
1147
+ var hours = data.hourly.map(function(h) { return (h.hour.split(' ')[1] || h.hour); });
1148
+ _hourlyChart.setOption({
1149
+ tooltip: { trigger: 'axis' },
1150
+ legend: { data: ['Input', 'Output', 'Cache'], top: 0, textStyle: { fontSize: 11 } },
1151
+ grid: { top: 30, bottom: 24, left: 50, right: 16 },
1152
+ xAxis: { type: 'category', data: hours, axisLabel: { fontSize: 10 } },
1153
+ yAxis: { type: 'value', axisLabel: { formatter: function(v) { return fmtTokens(v); } } },
1154
+ series: [
1155
+ { name: 'Input', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.input_tokens; }), itemStyle: { color: '#4f6ef7' } },
1156
+ { name: 'Output', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.output_tokens; }), itemStyle: { color: '#38a169' } },
1157
+ { name: 'Cache', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.cache_read_tokens; }), itemStyle: { color: '#dd6b20', opacity: 0.6 } },
1158
+ ]
1159
+ });
1160
+ }
1161
+
1162
+ // Model pie
1163
+ var modelEl = $('#usage-model-chart');
1164
+ if (modelEl && data.top_models && data.top_models.length) {
1165
+ var isDark2 = document.documentElement.getAttribute('data-theme') === 'dark';
1166
+ if (!_modelChart) _modelChart = echarts.init(modelEl, isDark2 ? 'dark' : null);
1167
+ _modelChart.setOption({
1168
+ tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
1169
+ series: [{
1170
+ type: 'pie', radius: ['35%', '70%'], center: ['50%', '55%'],
1171
+ label: { fontSize: 10 },
1172
+ data: data.top_models.map(function(m) { return { name: m.model.split('/').pop(), value: m.total_tokens }; }),
1173
+ }]
1174
+ });
1175
+ }
1176
+
1177
+ // Top peers table
1178
+ var peersEl = $('#usage-top-peers');
1179
+ if (peersEl && data.top_peers && data.top_peers.length) {
1180
+ peersEl.innerHTML =
1181
+ '<thead><tr><th>#</th><th>Peer</th><th>Tokens</th><th>Calls</th></tr></thead>' +
1182
+ '<tbody>' + data.top_peers.map(function(p, i) {
1183
+ return '<tr><td>' + (i + 1) + '</td><td>' + p.peer_key + '</td><td>' + fmtTokens(p.total_tokens) + '</td><td>' + p.call_count + '</td></tr>';
1184
+ }).join('') + '</tbody>';
1185
+ }
1186
+
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
+ }
1194
+
1195
+ // ── Usage Overview(全时段总览)──
1196
+ async function loadUsageOverview() {
1197
+ let data;
1198
+ try {
1199
+ const resp = await fetch('/api/stats/overview', {
1200
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1201
+ });
1202
+ data = resp.ok ? await resp.json() : null;
1203
+ } catch { data = null; }
1204
+
1205
+ const ts = (data && data.token_stats && data.token_stats.all_time) ? data.token_stats.all_time
1206
+ : { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, call_count: 0, cost_usd: 0, cost_cny: 0 };
1207
+ const sessionCount = (data && data.session_count) || 0;
1208
+ const msgIn = (data && data.msg_in) || 0;
1209
+ const msgOut = (data && data.msg_out) || 0;
1210
+ const totalIn = ts.input_tokens + ts.cache_read_tokens;
1211
+ const hitRate = totalIn > 0 ? (ts.cache_read_tokens / totalIn) * 100 : 0;
1212
+
1213
+ const cardsEl = $('#ov-cards');
1214
+ if (cardsEl) {
1215
+ cardsEl.innerHTML = [
1216
+ ovCard(sessionCount, '会话数'),
1217
+ ovCard(msgIn, '收到消息'),
1218
+ ovCard(msgOut, '发出消息'),
1219
+ ovCard(ts.call_count, '模型调用'),
1220
+ ovCard(fmtTokens(ts.input_tokens), '输入 Token'),
1221
+ ovCard(fmtTokens(ts.output_tokens), '输出 Token'),
1222
+ ovCard(fmtTokens(ts.cache_creation_tokens), '缓存创建'),
1223
+ ovCard(fmtTokens(ts.cache_read_tokens), '缓存命中'),
1224
+ ovCard(hitRate.toFixed(1) + '%', '缓存命中率'),
1225
+ ovCard(fmtCost(ts.cost_usd, ts.cost_cny), '总花费'),
1226
+ ].join('');
1227
+ }
1228
+
1229
+ const agentTbl = $('#ov-agent-table');
1230
+ const agents = (data && data.token_stats && data.token_stats.by_agent) || [];
1231
+ if (agentTbl) {
1232
+ if (!agents.length) {
1233
+ agentTbl.innerHTML = '<tbody><tr><td>暂无数据</td></tr></tbody>';
1234
+ } else {
1235
+ agentTbl.innerHTML =
1236
+ '<thead><tr><th>Agent</th><th>调用</th><th>输入</th><th>输出</th><th>缓存创建</th><th>缓存命中</th><th>花费</th></tr></thead>' +
1237
+ '<tbody>' + agents.map(function(a) {
1238
+ var name = a.agent_aid ? a.agent_aid.split('.')[0] : '(unknown)';
1239
+ return '<tr><td title="' + esc(a.agent_aid) + '">' + esc(name) + '</td>' +
1240
+ '<td>' + a.call_count + '</td>' +
1241
+ '<td>' + fmtTokens(a.input_tokens) + '</td>' +
1242
+ '<td>' + fmtTokens(a.output_tokens) + '</td>' +
1243
+ '<td>' + fmtTokens(a.cache_creation_tokens) + '</td>' +
1244
+ '<td>' + fmtTokens(a.cache_read_tokens) + '</td>' +
1245
+ '<td>' + fmtCost(a.cost_usd, a.cost_cny) + '</td></tr>';
1246
+ }).join('') + '</tbody>';
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ function ovCard(value, label) {
1252
+ return '<div class="usage-card"><div class="card-value">' + value + '</div><div class="card-label">' + label + '</div></div>';
1253
+ }
1254
+
1255
+ function fmtCost(usd, cny) {
1256
+ var parts = [];
1257
+ if (usd > 0) parts.push('$' + (usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)));
1258
+ if (cny > 0) parts.push('¥' + (cny < 0.01 ? cny.toFixed(4) : cny.toFixed(2)));
1259
+ return parts.length ? parts.join(' / ') : '$0';
1260
+ }
1261
+
1262
+ // ── Usage subtab switching ──
1263
+ function initUsageSubtabs() {
1264
+ var btns = document.querySelectorAll('.usage-subtab');
1265
+ btns.forEach(function(btn) {
1266
+ btn.addEventListener('click', function() {
1267
+ btns.forEach(function(b) { b.classList.remove('active'); });
1268
+ btn.classList.add('active');
1269
+ var target = btn.getAttribute('data-subview');
1270
+ document.querySelectorAll('.usage-subpanel').forEach(function(p) {
1271
+ p.classList.remove('active');
1272
+ p.style.display = '';
1273
+ });
1274
+ var panel = $('#usage-' + target);
1275
+ if (panel) { panel.classList.add('active'); panel.style.display = ''; }
1276
+ if (target === 'overview') loadUsageOverview();
1277
+ else if (target === 'dashboard') loadUsageDashboard();
1278
+ else if (target === 'explorer') initExplorer();
1279
+ });
1280
+ });
1281
+ }
1282
+
1283
+ // ── Explorer ──
1284
+ var _explorerChart = null;
1285
+ var _explorerInited = false;
1286
+ var _expSelection = { type: null, key: null }; // { type: 'agent'|'peer', key: string } or null
1287
+
1288
+ function initExplorer() {
1289
+ if (_explorerInited) return;
1290
+ _explorerInited = true;
1291
+ var btn = $('#exp-query-btn');
1292
+ if (btn) btn.onclick = runExplorerQuery;
1293
+ // Default date range: last 7 days
1294
+ var now = new Date();
1295
+ var from = new Date(now.getTime() - 7 * 86400000);
1296
+ var fromEl = $('#exp-from');
1297
+ var toEl = $('#exp-to');
1298
+ if (fromEl) fromEl.value = from.toISOString().slice(0, 10);
1299
+ if (toEl) toEl.value = now.toISOString().slice(0, 10);
1300
+ // Load sidebar lists
1301
+ loadExplorerSidebar();
1302
+ }
1303
+
1304
+ async function loadExplorerSidebar() {
1305
+ var token = localStorage.getItem(TOKEN_KEY);
1306
+ var headers = { Authorization: 'Bearer ' + token };
1307
+ try {
1308
+ var [agentsResp, peersResp] = await Promise.all([
1309
+ fetch('/api/stats/agents', { headers }),
1310
+ fetch('/api/stats/peers', { headers }),
1311
+ ]);
1312
+ var agents = agentsResp.ok ? await agentsResp.json() : [];
1313
+ var peers = peersResp.ok ? await peersResp.json() : [];
1314
+ renderExplorerSidebar(agents, peers);
1315
+ } catch {}
1316
+ }
1317
+
1318
+ function renderExplorerSidebar(agents, peers) {
1319
+ var agentList = $('#exp-agent-list');
1320
+ var peerList = $('#exp-peer-list');
1321
+ if (!agentList || !peerList) return;
1322
+
1323
+ // "All" item for agents
1324
+ var allHtml = '<div class="exp-sidebar-item active" data-type="all" data-key="">' +
1325
+ '<span class="item-name">全部</span></div>';
1326
+
1327
+ agentList.innerHTML = allHtml + agents.map(function(a) {
1328
+ var name = a.agent_aid ? a.agent_aid.split('.')[0] : 'unknown';
1329
+ return '<div class="exp-sidebar-item" data-type="agent" data-key="' + escHtml(a.agent_aid) + '">' +
1330
+ '<span class="item-name" title="' + escHtml(a.agent_aid) + '">' + escHtml(name) + '</span>' +
1331
+ '<span class="item-meta">' + fmtTokens(a.input_tokens + a.output_tokens) + '</span></div>';
1332
+ }).join('');
1333
+
1334
+ peerList.innerHTML = peers.map(function(p) {
1335
+ var name = p.peer_key || 'unknown';
1336
+ // 简化显示:去掉 channel# 前缀中的 aun#,保留核心部分
1337
+ var display = name.replace(/^aun#/, '').split('.')[0];
1338
+ return '<div class="exp-sidebar-item" data-type="peer" data-key="' + escHtml(p.peer_key) + '">' +
1339
+ '<span class="item-name" title="' + escHtml(name) + '">' + escHtml(display) + '</span>' +
1340
+ '<span class="item-meta">' + fmtTokens((p.input_tokens || 0) + (p.output_tokens || 0)) + '</span></div>';
1341
+ }).join('');
1342
+
1343
+ // Bind click events
1344
+ var allItems = document.querySelectorAll('#exp-agent-list .exp-sidebar-item, #exp-peer-list .exp-sidebar-item');
1345
+ allItems.forEach(function(el) {
1346
+ el.addEventListener('click', function() {
1347
+ // Clear active from all
1348
+ allItems.forEach(function(x) { x.classList.remove('active'); });
1349
+ el.classList.add('active');
1350
+ var type = el.getAttribute('data-type');
1351
+ var key = el.getAttribute('data-key');
1352
+ if (type === 'all') {
1353
+ _expSelection = { type: null, key: null };
1354
+ $('#exp-selected-name').textContent = '全部';
1355
+ } else {
1356
+ _expSelection = { type: type, key: key };
1357
+ $('#exp-selected-name').textContent = key;
1358
+ }
1359
+ runExplorerQuery();
1360
+ });
1361
+ });
1362
+ }
1363
+
1364
+ function escHtml(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
1365
+
1366
+ async function runExplorerQuery() {
1367
+ var params = new URLSearchParams();
1368
+ var fromEl = $('#exp-from');
1369
+ var toEl = $('#exp-to');
1370
+ if (fromEl && fromEl.value) params.set('from', String(new Date(fromEl.value + 'T00:00:00').getTime()));
1371
+ if (toEl && toEl.value) params.set('to', String(new Date(toEl.value + 'T23:59:59').getTime()));
1372
+ // Inject selection from sidebar
1373
+ if (_expSelection.type === 'agent' && _expSelection.key) params.set('agent', _expSelection.key);
1374
+ if (_expSelection.type === 'peer' && _expSelection.key) params.set('peer', _expSelection.key);
1375
+ var modelEl = $('#exp-model');
1376
+ if (modelEl && modelEl.value) params.set('model', modelEl.value);
1377
+ var granEl = $('#exp-granularity');
1378
+ if (granEl) params.set('granularity', granEl.value);
1379
+
1380
+ var data;
1381
+ try {
1382
+ var resp = await fetch('/api/stats/explorer?' + params.toString(), {
1383
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1384
+ });
1385
+ if (!resp.ok) return;
1386
+ data = await resp.json();
1387
+ } catch { return; }
1388
+
1389
+ // Show/hide detail cards
1390
+ var cardsEl = $('#exp-detail-cards');
1391
+ if (data && data.length) {
1392
+ var totIn = 0, totOut = 0, totCache = 0, totCalls = 0;
1393
+ data.forEach(function(r) { totIn += r.input_tokens; totOut += r.output_tokens; totCache += r.cache_read_tokens; totCalls += r.call_count; });
1394
+ if (cardsEl) {
1395
+ cardsEl.style.display = 'flex';
1396
+ cardsEl.innerHTML =
1397
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(totIn) + '</div><div class="card-label">Input</div></div>' +
1398
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(totOut) + '</div><div class="card-label">Output</div></div>' +
1399
+ '<div class="usage-card"><div class="card-value">' + fmtTokens(totCache) + '</div><div class="card-label">Cache Read</div></div>' +
1400
+ '<div class="usage-card"><div class="card-value">' + totCalls + '</div><div class="card-label">Calls</div></div>';
1401
+ }
1402
+ } else {
1403
+ if (cardsEl) cardsEl.style.display = 'none';
1404
+ }
1405
+
1406
+ if (!data || !data.length) {
1407
+ var tbl = $('#usage-explorer-table');
1408
+ if (tbl) tbl.innerHTML = '<tr><td>No data for selected range.</td></tr>';
1409
+ var chartEl = $('#usage-explorer-chart');
1410
+ if (chartEl && _explorerChart) { _explorerChart.dispose(); _explorerChart = null; }
1411
+ return;
1412
+ }
1413
+
1414
+ // Chart
1415
+ var chartEl = $('#usage-explorer-chart');
1416
+ if (chartEl) {
1417
+ var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1418
+ if (_explorerChart) { _explorerChart.dispose(); _explorerChart = null; }
1419
+ _explorerChart = echarts.init(chartEl, isDark ? 'dark' : null);
1420
+ var periods = data.map(function(r) { return r.period; });
1421
+ _explorerChart.setOption({
1422
+ tooltip: { trigger: 'axis' },
1423
+ legend: { data: ['Input', 'Output'], top: 0, textStyle: { fontSize: 11 } },
1424
+ grid: { top: 30, bottom: 30, left: 60, right: 16 },
1425
+ xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 10, rotate: 30 } },
1426
+ yAxis: { type: 'value', axisLabel: { formatter: function(v) { return fmtTokens(v); } } },
1427
+ series: [
1428
+ { name: 'Input', type: 'line', data: data.map(function(r) { return r.input_tokens; }), smooth: true, areaStyle: { opacity: 0.15 }, itemStyle: { color: '#4f6ef7' } },
1429
+ { name: 'Output', type: 'line', data: data.map(function(r) { return r.output_tokens; }), smooth: true, areaStyle: { opacity: 0.15 }, itemStyle: { color: '#38a169' } },
1430
+ ]
1431
+ });
1432
+ }
1433
+
1434
+ // Table
1435
+ var tbl = $('#usage-explorer-table');
1436
+ if (tbl) {
1437
+ tbl.innerHTML =
1438
+ '<thead><tr><th>Period</th><th>Input</th><th>Output</th><th>Cache↑</th><th>CacheHit</th><th>Calls</th></tr></thead>' +
1439
+ '<tbody>' + data.map(function(r) {
1440
+ return '<tr><td>' + r.period + '</td><td>' + fmtTokens(r.input_tokens) + '</td><td>' + fmtTokens(r.output_tokens) +
1441
+ '</td><td>' + fmtTokens(r.cache_creation_tokens) + '</td><td>' + fmtTokens(r.cache_read_tokens) +
1442
+ '</td><td>' + r.call_count + '</td></tr>';
1443
+ }).join('') + '</tbody>';
1444
+ }
1445
+ }
1446
+
542
1447
  window.addEventListener('DOMContentLoaded', () => {
1448
+ initTheme();
543
1449
  initPairUI();
544
1450
  if (localStorage.getItem(TOKEN_KEY)) {
545
1451
  showApp();
546
1452
  startApp();
1453
+ loadUsageDashboard();
1454
+ loadUsageOverview();
1455
+ initUsageSubtabs();
547
1456
  } else {
548
1457
  showPairPage();
549
1458
  }
550
1459
  });
551
-
552
-
553
-
554
-
555
-