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.
- package/dist/index.js +11 -4
- package/dist/server.js +187 -6
- package/dist/sources/aid.js +1 -1
- package/dist/sources/cache.js +43 -0
- package/dist/sources/control.js +58 -0
- package/dist/sources/session.js +38 -4
- package/dist/sources/stats.js +348 -0
- package/dist/sources/system.js +51 -0
- package/dist/sources/triggers.js +54 -0
- package/dist/sources/types.js +1 -1
- package/dist/static/app.js +921 -17
- package/dist/static/index.html +95 -3
- package/dist/static/style.css +253 -1
- package/package.json +8 -3
package/dist/static/app.js
CHANGED
|
@@ -56,9 +56,9 @@ function initPairUI() {
|
|
|
56
56
|
// ── WebSocket 客户端(自动重连)──
|
|
57
57
|
let ws = null;
|
|
58
58
|
let reconnectDelay = 1000;
|
|
59
|
-
let currentView = '
|
|
59
|
+
let currentView = 'agents';
|
|
60
60
|
let pendingSub = null; // 重连后要恢复的订阅
|
|
61
|
-
const state = {
|
|
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
|
-
|
|
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('
|
|
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 === '
|
|
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
|
|
174
|
-
const el = $('#view-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
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
|
-
|