evolclaw-web 1.1.0 → 1.2.2

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.
@@ -2,10 +2,609 @@
2
2
 
3
3
  const $ = (sel) => document.querySelector(sel);
4
4
  const TOKEN_KEY = 'ecWatchToken';
5
+ const LANG_KEY = 'ecWatchLang';
6
+ const VIEW_KEY = 'ecWatchCurrentView';
7
+
8
+ // ── 国际化 (i18n) ──
9
+ const translations = {
10
+ 'zh-CN': {
11
+ // Tabs
12
+ 'tab.agents': '智能体',
13
+ 'tab.messages': '消息',
14
+ 'tab.sessions': '会话',
15
+ 'tab.triggers': '触发器',
16
+ 'tab.cache': '缓存',
17
+ 'tab.system': '系统',
18
+ 'tab.gateway': '智能体网关',
19
+ 'tab.usage': '用量',
20
+ 'tab.monitor': '监控',
21
+
22
+ // Status
23
+ 'status.connecting': '连接中…',
24
+ 'status.connected': '已连接',
25
+ 'status.disconnected': '已断开',
26
+ 'status.reconnecting': '重连中',
27
+ 'status.stopped': '停止',
28
+ 'status.idle': 'idle',
29
+ 'status.working': 'working',
30
+
31
+ // Actions
32
+ 'action.logout': '退出',
33
+ 'action.pair': '配对',
34
+ 'action.stop': '停止',
35
+ 'action.start': '启动',
36
+ 'action.enable': '启用',
37
+ 'action.disable': '禁用',
38
+ 'action.reload': '重载配置',
39
+ 'action.edit': '编辑配置',
40
+ 'action.delete': '删除 Agent',
41
+ 'action.clearQueue': '清空队列',
42
+ 'action.new': '+ 新建',
43
+ 'action.query': '查询',
44
+
45
+ // Pair page
46
+ 'pair.title': '🔭 EvolClaw Watch',
47
+ 'pair.hint': '输入终端显示的 6 位配对码',
48
+ 'pair.placeholder': '000000',
49
+ 'pair.error.length': '请输入 6 位配对码',
50
+ 'pair.error.failed': '配对失败',
51
+ 'pair.error.network': '网络错误',
52
+ 'pair.error.tokenInvalid': 'token 已失效,请重新配对',
53
+
54
+ // Common
55
+ 'common.loading': '加载中…',
56
+ 'common.empty': '暂无数据',
57
+ 'common.noData': '暂无',
58
+ 'common.operating': '操作中…',
59
+ 'common.buildTime': '构建时间',
60
+
61
+ // Agents view
62
+ 'agents.subtitle.enabled': '启用',
63
+ 'agents.subtitle.disabled': '禁用',
64
+ 'agents.daemonStopped': '⚠ EvolClaw 主进程未运行,仅显示最近活动记录',
65
+ 'agents.empty.disabled': '暂无禁用 Agent',
66
+ 'agents.empty.enabled': '暂无启用 Agent',
67
+ 'agents.stats.gateway': 'Gateway',
68
+ 'agents.stats.aids': 'AIDs',
69
+ 'agents.stats.total': 'total',
70
+ 'agents.stats.online': '在线',
71
+ 'agents.stats.offline': '离线',
72
+ 'agents.stats.messages': 'Messages',
73
+ 'agents.stats.traffic': 'Traffic',
74
+ 'agents.stats.version': 'Version',
75
+ 'agents.stats.pid': 'PID',
76
+ 'agents.stats.uptime': 'Uptime',
77
+
78
+ // Agent table headers
79
+ 'agents.th.agent': 'Agent',
80
+ 'agents.th.aid': 'AID',
81
+ 'agents.th.work': '工作',
82
+ 'agents.th.queue': '队列',
83
+ 'agents.th.model': '模型',
84
+ 'agents.th.runtime': '运行',
85
+ 'agents.th.received': '收',
86
+ 'agents.th.sent': '发',
87
+ 'agents.th.bytesIn': '入字节',
88
+ 'agents.th.bytesOut': '出字节',
89
+ 'agents.th.peerCount': '对端数量',
90
+ 'agents.th.lastActivity': '最后活动',
91
+ 'agents.th.operations': '操作',
92
+ 'agents.th.projectPath': '项目路径',
93
+
94
+ // Agent operations
95
+ 'agents.op.stopping': '停止中…',
96
+ 'agents.op.starting': '启动中…',
97
+ 'agents.op.reloading': '重载中…',
98
+ 'agents.op.disabling': '禁用中…',
99
+ 'agents.op.enabling': '启用中…',
100
+ 'agents.op.deleting': '删除中…',
101
+ 'agents.op.stopped': '✓ 已停止',
102
+ 'agents.op.started': '✓ 已启动',
103
+ 'agents.op.reloaded': '✓ 已重载',
104
+ 'agents.op.disabled': '✓ 已禁用',
105
+ 'agents.op.enabled': '✓ 已启用',
106
+ 'agents.op.deleted': '✓ 已删除',
107
+ 'agents.op.saved': '✓ 配置已保存,点「重载」生效',
108
+ 'agents.op.confirmReload': '确认强制重载?',
109
+ 'agents.op.confirmToggle': '确认强制',
110
+ 'agents.op.confirmDelete': '删除 Agent {aid}?\n此操作不可恢复。',
111
+ 'agents.op.confirmForceDelete': '确认强制删除?',
112
+ 'agents.op.confirmClearQueue': '清空 {aid} 的待处理消息队列?',
113
+ 'agents.op.clearQueueTitle': '清空 {count} 条待处理消息',
114
+ 'agents.op.viewAgentMd': '查看 agent.md ↗',
115
+
116
+ // Messages view
117
+ 'messages.colTitle.aid': 'AID',
118
+ 'messages.colTitle.peers': 'Peers',
119
+ 'messages.colTitle.all': 'All',
120
+ 'messages.empty.selectAid': '← 选择一个 AID',
121
+ 'messages.empty.selectToView': '选择 AID 查看消息',
122
+ 'messages.empty.noMessages': '暂无消息',
123
+ 'messages.tag.group': '群聊',
124
+ 'messages.tag.encrypted': '🔒密文',
125
+ 'messages.tag.plain': '明文',
126
+ 'messages.tag.proactive': '自主',
127
+ 'messages.tag.inject': '注入',
128
+ 'messages.tag.responsive': '响应',
129
+ 'messages.msgKind.reply': '回复',
130
+ 'messages.msgKind.thought': '思考',
131
+ 'messages.msgKind.inject': '注入',
132
+ 'messages.msgKind.notify': '通知',
133
+ 'messages.msgType.thought': '思考',
134
+ 'messages.msgType.image': '图片',
135
+ 'messages.msgType.file': '文件',
136
+ 'messages.msgType.command': '命令',
137
+
138
+ // Sessions view
139
+ 'sessions.filter.normal': '🔍 仅有效',
140
+ 'sessions.filter.chat': '💬 对话',
141
+ 'sessions.search.placeholder': '🔎 搜索 peer / 内容',
142
+ 'sessions.empty.noMatch': '无匹配会话',
143
+ 'sessions.empty.noSessions': '该项目暂无会话',
144
+ 'sessions.empty.noContent': '该会话暂无内容',
145
+ 'sessions.header.project': '项目',
146
+ 'sessions.header.session': '会话',
147
+ 'sessions.stat.context': '📐 {tokens} ctx',
148
+ 'sessions.stat.cost': '💰 ${cost}',
149
+ 'sessions.turnType.modelOutput': '模型输出',
150
+ 'sessions.turnType.toolUse': '工具使用',
151
+ 'sessions.turnType.toolResult': '工具结果',
152
+ 'sessions.turnType.msgSend': '发送消息',
153
+
154
+ // Cache view
155
+ 'cache.daemonStopped': '⚠ EvolClaw 主进程未运行,无缓存统计可显示',
156
+ 'cache.notSupported': '⚠ 当前 EvolClaw 版本不支持 cache-stats(请升级 daemon)',
157
+ 'cache.card.hitRate': '命中率',
158
+ 'cache.card.reads': '读取总数',
159
+ 'cache.card.entries': '缓存条目',
160
+ 'cache.card.statChecks': 'stat 检查',
161
+ 'cache.card.reReads': '重读',
162
+ 'cache.card.evictions': '驱逐',
163
+ 'cache.card.invalidations': '失效',
164
+ 'cache.card.since': '统计起始',
165
+ 'cache.card.ago': '前',
166
+ 'cache.card.memory': '近似内存',
167
+ 'cache.card.hit': '命中',
168
+ 'cache.card.miss': '未命中',
169
+ 'cache.section.byGroup': '按缓存组',
170
+ 'cache.section.byPolicy': '按策略',
171
+ 'cache.th.group': '组',
172
+ 'cache.th.type': '类型',
173
+ 'cache.th.reads': '读取',
174
+ 'cache.th.hits': '命中',
175
+ 'cache.th.misses': '未命中',
176
+ 'cache.th.hitRate': '命中率',
177
+ 'cache.th.reReads': '重读',
178
+ 'cache.th.evictions': '驱逐',
179
+ 'cache.th.entries': '条目',
180
+ 'cache.th.memory': '内存',
181
+ 'cache.th.capacity': '容量',
182
+ 'cache.th.policy': '策略',
183
+ 'cache.th.statChecks': 'stat 检查',
184
+ 'cache.note': '注:config/defaults 与关系级 preferences 的读取也已并入本统计;渲染后结果(按 vars)不缓存,故不在此列。',
185
+ 'cache.policy.onReload': '靠 reload 刷新,平时零检查',
186
+ 'cache.policy.manual': '显式单刷',
187
+ 'cache.policy.mtime': '每读 statSync 门控',
188
+
189
+ // Monitor view
190
+ 'monitor.toolbar.timeRange': '时间范围',
191
+ 'monitor.range.2m': '2 分钟',
192
+ 'monitor.range.10m': '10 分钟',
193
+ 'monitor.range.1h': '1 小时',
194
+ 'monitor.legend.process': 'evolclaw 进程',
195
+ 'monitor.legend.system': '整机系统',
196
+
197
+ // Usage view
198
+ 'usage.subtab.overview': '总览',
199
+ 'usage.subtab.explorer': '详细统计',
200
+ 'usage.overview.range.today': '今日',
201
+ 'usage.overview.range.week': '本周',
202
+ 'usage.overview.range.lastWeek': '上周',
203
+ 'usage.overview.range.month': '本月',
204
+ 'usage.overview.range.last30': '最近30天',
205
+ 'usage.overview.range.custom': '自定义',
206
+ 'usage.card.input': '输入',
207
+ 'usage.card.output': '输出',
208
+ 'usage.card.cacheRead': '缓存读取',
209
+ 'usage.card.cacheHit': '缓存命中',
210
+ 'usage.card.calls': '调用',
211
+ 'usage.card.sessionCount': '会话数',
212
+ 'usage.card.msgIn': '收到消息',
213
+ 'usage.card.msgOut': '发出消息',
214
+ 'usage.card.modelCalls': '模型调用',
215
+ 'usage.card.inputTokens': '输入 Token',
216
+ 'usage.card.outputTokens': '输出 Token',
217
+ 'usage.card.cacheCreation': '缓存创建',
218
+ 'usage.card.cacheHitTokens': '缓存命中',
219
+ 'usage.card.cacheHitRate': '缓存命中率',
220
+ 'usage.card.totalCost': '总花费',
221
+ 'usage.card.costOfficial': '官方价格',
222
+ 'usage.card.costGateway': '网关价格',
223
+ 'usage.card.sessionInfo': '会话信息',
224
+ 'usage.card.usageInfo': '用量信息',
225
+ 'usage.card.costInfo': '花费信息',
226
+ 'usage.detail.title': '模型访问明细',
227
+ 'usage.detail.agent': '智能体',
228
+ 'usage.detail.model': '模型',
229
+ 'usage.detail.error': '查询失败',
230
+ 'usage.detail.th.time': '时间',
231
+ 'usage.detail.th.agent': '智能体',
232
+ 'usage.detail.th.peer': 'Peer',
233
+ 'usage.detail.th.model': '模型',
234
+ 'usage.detail.th.input': '输入',
235
+ 'usage.detail.th.output': '输出',
236
+ 'usage.detail.th.cacheCreation': '缓存创建',
237
+ 'usage.detail.th.cacheRead': '缓存读取',
238
+ 'usage.detail.th.costOfficial': '官方价格',
239
+ 'usage.detail.th.costGateway': '网关价格',
240
+ 'usage.detail.pageSize': '每页',
241
+ 'usage.detail.prevPage': '上一页',
242
+ 'usage.detail.nextPage': '下一页',
243
+ 'usage.detail.pagination': '显示 {start}-{end} / 共 {total} 条 (第 {page}/{totalPages} 页)',
244
+ 'usage.overview.title': '按 Agent 汇总(全时段)',
245
+ 'usage.overview.noData': '暂无数据',
246
+ 'usage.overview.th.agent': '智能体',
247
+ 'usage.overview.th.calls': '调用',
248
+ 'usage.overview.th.input': '输入',
249
+ 'usage.overview.th.output': '输出',
250
+ 'usage.overview.th.cacheCreation': '缓存创建',
251
+ 'usage.overview.th.cacheHit': '缓存命中',
252
+ 'usage.overview.th.cacheHitRate': '命中率',
253
+ 'usage.overview.th.costOfficial': '官方价格',
254
+ 'usage.overview.th.costGateway': '网关价格',
255
+ 'usage.overview.th.cost': '花费',
256
+ 'usage.dashboard.title.topPeers': 'Top Peers (Today)',
257
+ 'usage.dashboard.th.rank': '#',
258
+ 'usage.dashboard.th.peer': 'Peer',
259
+ 'usage.dashboard.th.tokens': 'Tokens',
260
+ 'usage.dashboard.th.calls': 'Calls',
261
+ 'usage.explorer.sidebar.agents': '智能体',
262
+ 'usage.explorer.sidebar.peers': '对端智能体',
263
+ 'usage.explorer.chatType.group': '群聊',
264
+ 'usage.explorer.chatType.private': '单聊',
265
+ 'usage.explorer.memberCount': '人',
266
+ 'usage.explorer.selectHint': '请从左侧选择 Agent 或 Peer',
267
+ 'usage.explorer.all': '全部',
268
+ 'usage.explorer.filter.from': 'From',
269
+ 'usage.explorer.filter.to': 'To',
270
+ 'usage.explorer.filter.model': 'Model',
271
+ 'usage.explorer.filter.granularity': '粒度',
272
+ 'usage.explorer.filter.granularity.hour': 'Hour',
273
+ 'usage.explorer.filter.granularity.day': 'Day',
274
+ 'usage.explorer.filter.granularity.week': 'Week',
275
+ 'usage.explorer.filter.granularity.month': 'Month',
276
+ 'usage.explorer.results': 'Results',
277
+ 'usage.explorer.noData': 'No data for selected range.',
278
+ 'usage.explorer.th.period': 'Period',
279
+ 'usage.explorer.th.input': 'Input',
280
+ 'usage.explorer.th.output': 'Output',
281
+ 'usage.explorer.th.cacheCreation': 'Cache↑',
282
+ 'usage.explorer.th.cacheHit': 'CacheHit',
283
+ 'usage.explorer.th.calls': 'Calls',
284
+ },
285
+ 'en-US': {
286
+ // Tabs
287
+ 'tab.agents': 'Agents',
288
+ 'tab.messages': 'Messages',
289
+ 'tab.sessions': 'Sessions',
290
+ 'tab.triggers': 'Triggers',
291
+ 'tab.cache': 'Cache',
292
+ 'tab.system': 'System',
293
+ 'tab.gateway': 'Agent Gateway',
294
+ 'tab.usage': 'Usage',
295
+ 'tab.monitor': 'Monitor',
296
+
297
+ // Status
298
+ 'status.connecting': 'Connecting…',
299
+ 'status.connected': 'Connected',
300
+ 'status.disconnected': 'Disconnected',
301
+ 'status.reconnecting': 'Reconnecting',
302
+ 'status.stopped': 'Stopped',
303
+ 'status.idle': 'Idle',
304
+ 'status.working': 'Working',
305
+
306
+ // Actions
307
+ 'action.logout': 'Logout',
308
+ 'action.pair': 'Pair',
309
+ 'action.stop': 'Stop',
310
+ 'action.start': 'Start',
311
+ 'action.enable': 'Enable',
312
+ 'action.disable': 'Disable',
313
+ 'action.reload': 'Reload Config',
314
+ 'action.edit': 'Edit Config',
315
+ 'action.delete': 'Delete Agent',
316
+ 'action.clearQueue': 'Clear Queue',
317
+ 'action.new': '+ New',
318
+ 'action.query': 'Query',
319
+
320
+ // Pair page
321
+ 'pair.title': '🔭 EvolClaw Watch',
322
+ 'pair.hint': 'Enter the 6-digit pairing code shown in terminal',
323
+ 'pair.placeholder': '000000',
324
+ 'pair.error.length': 'Please enter 6-digit pairing code',
325
+ 'pair.error.failed': 'Pairing failed',
326
+ 'pair.error.network': 'Network error',
327
+ 'pair.error.tokenInvalid': 'Token expired, please pair again',
328
+
329
+ // Common
330
+ 'common.loading': 'Loading…',
331
+ 'common.empty': 'No data',
332
+ 'common.noData': 'N/A',
333
+ 'common.operating': 'Operating…',
334
+ 'common.buildTime': 'Build Time',
335
+
336
+ // Agents view
337
+ 'agents.subtitle.enabled': 'Enabled',
338
+ 'agents.subtitle.disabled': 'Disabled',
339
+ 'agents.daemonStopped': '⚠ EvolClaw daemon not running, showing recent activity only',
340
+ 'agents.empty.disabled': 'No disabled agents',
341
+ 'agents.empty.enabled': 'No enabled agents',
342
+ 'agents.stats.gateway': 'Gateway',
343
+ 'agents.stats.aids': 'AIDs',
344
+ 'agents.stats.total': 'total',
345
+ 'agents.stats.online': 'online',
346
+ 'agents.stats.offline': 'offline',
347
+ 'agents.stats.messages': 'Messages',
348
+ 'agents.stats.traffic': 'Traffic',
349
+ 'agents.stats.version': 'Version',
350
+ 'agents.stats.pid': 'PID',
351
+ 'agents.stats.uptime': 'Uptime',
352
+
353
+ // Agent table headers
354
+ 'agents.th.agent': 'Agent',
355
+ 'agents.th.aid': 'AID',
356
+ 'agents.th.work': 'Work',
357
+ 'agents.th.queue': 'Queue',
358
+ 'agents.th.model': 'Model',
359
+ 'agents.th.runtime': 'Runtime',
360
+ 'agents.th.received': 'Recv',
361
+ 'agents.th.sent': 'Sent',
362
+ 'agents.th.bytesIn': 'Bytes In',
363
+ 'agents.th.bytesOut': 'Bytes Out',
364
+ 'agents.th.peerCount': 'Peers',
365
+ 'agents.th.lastActivity': 'Last Activity',
366
+ 'agents.th.operations': 'Operations',
367
+ 'agents.th.projectPath': 'Project Path',
368
+
369
+ // Agent operations
370
+ 'agents.op.stopping': 'Stopping…',
371
+ 'agents.op.starting': 'Starting…',
372
+ 'agents.op.reloading': 'Reloading…',
373
+ 'agents.op.disabling': 'Disabling…',
374
+ 'agents.op.enabling': 'Enabling…',
375
+ 'agents.op.deleting': 'Deleting…',
376
+ 'agents.op.stopped': '✓ Stopped',
377
+ 'agents.op.started': '✓ Started',
378
+ 'agents.op.reloaded': '✓ Reloaded',
379
+ 'agents.op.disabled': '✓ Disabled',
380
+ 'agents.op.enabled': '✓ Enabled',
381
+ 'agents.op.deleted': '✓ Deleted',
382
+ 'agents.op.saved': '✓ Config saved, click "Reload" to apply',
383
+ 'agents.op.confirmReload': 'Force reload?',
384
+ 'agents.op.confirmToggle': 'Force',
385
+ 'agents.op.confirmDelete': 'Delete agent {aid}?\nThis cannot be undone.',
386
+ 'agents.op.confirmForceDelete': 'Force delete?',
387
+ 'agents.op.confirmClearQueue': 'Clear pending message queue for {aid}?',
388
+ 'agents.op.clearQueueTitle': 'Clear {count} pending messages',
389
+ 'agents.op.viewAgentMd': 'View agent.md ↗',
390
+
391
+ // Messages view
392
+ 'messages.colTitle.aid': 'AID',
393
+ 'messages.colTitle.peers': 'Peers',
394
+ 'messages.colTitle.all': 'All',
395
+ 'messages.empty.selectAid': '← Select an AID',
396
+ 'messages.empty.selectToView': 'Select AID to view messages',
397
+ 'messages.empty.noMessages': 'No messages',
398
+ 'messages.tag.group': 'Group',
399
+ 'messages.tag.encrypted': '🔒Encrypted',
400
+ 'messages.tag.plain': 'Plain',
401
+ 'messages.tag.proactive': 'Proactive',
402
+ 'messages.tag.inject': 'Inject',
403
+ 'messages.tag.responsive': 'Responsive',
404
+ 'messages.msgKind.reply': 'Reply',
405
+ 'messages.msgKind.thought': 'Thought',
406
+ 'messages.msgKind.inject': 'Inject',
407
+ 'messages.msgKind.notify': 'Notify',
408
+ 'messages.msgType.thought': 'Thought',
409
+ 'messages.msgType.image': 'Image',
410
+ 'messages.msgType.file': 'File',
411
+ 'messages.msgType.command': 'Command',
412
+
413
+ // Sessions view
414
+ 'sessions.filter.normal': '🔍 Valid Only',
415
+ 'sessions.filter.chat': '💬 Chat',
416
+ 'sessions.search.placeholder': '🔎 Search peer / content',
417
+ 'sessions.empty.noMatch': 'No matching sessions',
418
+ 'sessions.empty.noSessions': 'No sessions in this project',
419
+ 'sessions.empty.noContent': 'No content in this session',
420
+ 'sessions.header.project': 'Project',
421
+ 'sessions.header.session': 'Session',
422
+ 'sessions.stat.context': '📐 {tokens} ctx',
423
+ 'sessions.stat.cost': '💰 ${cost}',
424
+ 'sessions.turnType.modelOutput': 'Model Output',
425
+ 'sessions.turnType.toolUse': 'Tool Use',
426
+ 'sessions.turnType.toolResult': 'Tool Result',
427
+ 'sessions.turnType.msgSend': 'Send Message',
428
+
429
+ // Cache view
430
+ 'cache.daemonStopped': '⚠ EvolClaw daemon not running, no cache stats available',
431
+ 'cache.notSupported': '⚠ Current EvolClaw version does not support cache-stats (please upgrade daemon)',
432
+ 'cache.card.hitRate': 'Hit Rate',
433
+ 'cache.card.reads': 'Total Reads',
434
+ 'cache.card.entries': 'Cache Entries',
435
+ 'cache.card.statChecks': 'Stat Checks',
436
+ 'cache.card.reReads': 'Re-reads',
437
+ 'cache.card.evictions': 'Evictions',
438
+ 'cache.card.invalidations': 'Invalidations',
439
+ 'cache.card.since': 'Stats Since',
440
+ 'cache.card.ago': 'ago',
441
+ 'cache.card.memory': 'approx memory',
442
+ 'cache.card.hit': 'hit',
443
+ 'cache.card.miss': 'miss',
444
+ 'cache.section.byGroup': 'By Cache Group',
445
+ 'cache.section.byPolicy': 'By Policy',
446
+ 'cache.th.group': 'Group',
447
+ 'cache.th.type': 'Type',
448
+ 'cache.th.reads': 'Reads',
449
+ 'cache.th.hits': 'Hits',
450
+ 'cache.th.misses': 'Misses',
451
+ 'cache.th.hitRate': 'Hit Rate',
452
+ 'cache.th.reReads': 'Re-reads',
453
+ 'cache.th.evictions': 'Evictions',
454
+ 'cache.th.entries': 'Entries',
455
+ 'cache.th.memory': 'Memory',
456
+ 'cache.th.capacity': 'Capacity',
457
+ 'cache.th.policy': 'Policy',
458
+ 'cache.th.statChecks': 'Stat Checks',
459
+ 'cache.note': 'Note: Reads of config/defaults and relation-level preferences are included; rendered results (by vars) are not cached and not shown here.',
460
+ 'cache.policy.onReload': 'Refresh on reload, zero checks normally',
461
+ 'cache.policy.manual': 'Explicit single refresh',
462
+ 'cache.policy.mtime': 'statSync gate on each read',
463
+
464
+ // Monitor view
465
+ 'monitor.toolbar.timeRange': 'Time Range',
466
+ 'monitor.range.2m': '2 minutes',
467
+ 'monitor.range.10m': '10 minutes',
468
+ 'monitor.range.1h': '1 hour',
469
+ 'monitor.legend.process': 'evolclaw process',
470
+ 'monitor.legend.system': 'system',
471
+
472
+ // Usage view
473
+ 'usage.subtab.overview': 'Overview',
474
+ 'usage.subtab.explorer': 'Detailed Statistics',
475
+ 'usage.overview.range.today': 'Today',
476
+ 'usage.overview.range.week': 'This Week',
477
+ 'usage.overview.range.lastWeek': 'Last Week',
478
+ 'usage.overview.range.month': 'This Month',
479
+ 'usage.overview.range.last30': 'Last 30 Days',
480
+ 'usage.overview.range.custom': 'Custom',
481
+ 'usage.card.input': 'Input',
482
+ 'usage.card.output': 'Output',
483
+ 'usage.card.cacheRead': 'Cache Read',
484
+ 'usage.card.cacheHit': 'Cache Hit',
485
+ 'usage.card.calls': 'Calls',
486
+ 'usage.card.sessionCount': 'Sessions',
487
+ 'usage.card.msgIn': 'Received Messages',
488
+ 'usage.card.msgOut': 'Sent Messages',
489
+ 'usage.card.modelCalls': 'Model Calls',
490
+ 'usage.card.inputTokens': 'Input Tokens',
491
+ 'usage.card.outputTokens': 'Output Tokens',
492
+ 'usage.card.cacheCreation': 'Cache Creation',
493
+ 'usage.card.cacheHitTokens': 'Cache Hit',
494
+ 'usage.card.cacheHitRate': 'Cache Hit Rate',
495
+ 'usage.card.totalCost': 'Total Cost',
496
+ 'usage.card.costOfficial': 'Official Price',
497
+ 'usage.card.costGateway': 'Gateway Price',
498
+ 'usage.card.sessionInfo': 'Session Info',
499
+ 'usage.card.usageInfo': 'Usage Info',
500
+ 'usage.card.costInfo': 'Cost Info',
501
+ 'usage.detail.title': 'Model Access Details',
502
+ 'usage.detail.agent': 'Agent',
503
+ 'usage.detail.model': 'Model',
504
+ 'usage.detail.error': 'Query failed',
505
+ 'usage.detail.th.time': 'Time',
506
+ 'usage.detail.th.agent': 'Agent',
507
+ 'usage.detail.th.peer': 'Peer',
508
+ 'usage.detail.th.model': 'Model',
509
+ 'usage.detail.th.input': 'Input',
510
+ 'usage.detail.th.output': 'Output',
511
+ 'usage.detail.th.cacheCreation': 'Cache Creation',
512
+ 'usage.detail.th.cacheRead': 'Cache Read',
513
+ 'usage.detail.th.costOfficial': 'Official',
514
+ 'usage.detail.th.costGateway': 'Gateway',
515
+ 'usage.detail.pageSize': 'Per page',
516
+ 'usage.detail.prevPage': 'Previous',
517
+ 'usage.detail.nextPage': 'Next',
518
+ 'usage.detail.pagination': 'Showing {start}-{end} of {total} (Page {page}/{totalPages})',
519
+ 'usage.overview.title': 'Summary by Agent (All Time)',
520
+ 'usage.overview.noData': 'No data',
521
+ 'usage.overview.th.agent': 'Agent',
522
+ 'usage.overview.th.calls': 'Calls',
523
+ 'usage.overview.th.input': 'Input',
524
+ 'usage.overview.th.output': 'Output',
525
+ 'usage.overview.th.cacheCreation': 'Cache Creation',
526
+ 'usage.overview.th.cacheHit': 'Cache Hit',
527
+ 'usage.overview.th.cacheHitRate': 'Hit Rate',
528
+ 'usage.overview.th.costOfficial': 'Official Price',
529
+ 'usage.overview.th.costGateway': 'Gateway Price',
530
+ 'usage.overview.th.cost': 'Cost',
531
+ 'usage.dashboard.title.topPeers': 'Top Peers (Today)',
532
+ 'usage.dashboard.th.rank': '#',
533
+ 'usage.dashboard.th.peer': 'Peer',
534
+ 'usage.dashboard.th.tokens': 'Tokens',
535
+ 'usage.dashboard.th.calls': 'Calls',
536
+ 'usage.explorer.sidebar.agents': 'Agents',
537
+ 'usage.explorer.sidebar.peers': 'Peers',
538
+ 'usage.explorer.chatType.group': 'Group',
539
+ 'usage.explorer.chatType.private': 'Private',
540
+ 'usage.explorer.memberCount': ' members',
541
+ 'usage.explorer.selectHint': 'Select an Agent or Peer from the left',
542
+ 'usage.explorer.all': 'All',
543
+ 'usage.explorer.filter.from': 'From',
544
+ 'usage.explorer.filter.to': 'To',
545
+ 'usage.explorer.filter.model': 'Model',
546
+ 'usage.explorer.filter.granularity': 'Granularity',
547
+ 'usage.explorer.filter.granularity.hour': 'Hour',
548
+ 'usage.explorer.filter.granularity.day': 'Day',
549
+ 'usage.explorer.filter.granularity.week': 'Week',
550
+ 'usage.explorer.filter.granularity.month': 'Month',
551
+ 'usage.explorer.results': 'Results',
552
+ 'usage.explorer.noData': 'No data for selected range.',
553
+ 'usage.explorer.th.period': 'Period',
554
+ 'usage.explorer.th.input': 'Input',
555
+ 'usage.explorer.th.output': 'Output',
556
+ 'usage.explorer.th.cacheCreation': 'Cache↑',
557
+ 'usage.explorer.th.cacheHit': 'CacheHit',
558
+ 'usage.explorer.th.calls': 'Calls',
559
+ }
560
+ };
561
+
562
+ let currentLang = localStorage.getItem(LANG_KEY) || 'zh-CN';
563
+
564
+ function t(key) {
565
+ return translations[currentLang]?.[key] || key;
566
+ }
567
+
568
+ function updateI18n() {
569
+ // 处理元素文本内容
570
+ document.querySelectorAll('[data-i18n]').forEach(el => {
571
+ const key = el.getAttribute('data-i18n');
572
+ const text = t(key);
573
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
574
+ el.placeholder = text;
575
+ } else if (el.tagName === 'OPTION') {
576
+ el.textContent = text;
577
+ } else {
578
+ el.textContent = text;
579
+ }
580
+ });
581
+ // 处理 title 属性(单独的 data-i18n-title)
582
+ document.querySelectorAll('[data-i18n-title]').forEach(el => {
583
+ const key = el.getAttribute('data-i18n-title');
584
+ el.title = t(key);
585
+ });
586
+ // 更新 html lang 属性
587
+ document.documentElement.lang = currentLang;
588
+ }
589
+
590
+ function toggleLang() {
591
+ currentLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN';
592
+ localStorage.setItem(LANG_KEY, currentLang);
593
+ updateI18n();
594
+ // 强制重新渲染当前视图
595
+ if (state[currentView]) renderView(currentView);
596
+ }
597
+
598
+ // ── 基础路径 ──
599
+ // 本地直连时页面在 "/",经 AUN Service Proxy 时页面在 "/ecweb/"。
600
+ // 取当前页面所在目录(含尾斜杠)作为所有 API/WS 的前缀,使绝对路径在两种
601
+ // 部署下都正确(proxy-server 用首段路径选服务,前缀不能丢)。
602
+ const BASE = location.pathname.replace(/[^/]*$/, '');
603
+ const apiUrl = (p) => BASE + p.replace(/^\/+/, '');
5
604
 
6
605
  // ── 配对 ──
7
606
  async function pair(code) {
8
- const resp = await fetch('/api/pair', {
607
+ const resp = await fetch(apiUrl('api/pair'), {
9
608
  method: 'POST',
10
609
  headers: { 'Content-Type': 'application/json' },
11
610
  body: JSON.stringify({ code }),
@@ -13,6 +612,19 @@ async function pair(code) {
13
612
  return resp.json();
14
613
  }
15
614
 
615
+ // 本地直连免配对:用空码探测,服务端若判定本地直连会直接发 token。
616
+ // 远程(隧道/真远程)会返回配对码错误,此时回落到配对页。
617
+ async function tryLocalAutoPair() {
618
+ try {
619
+ const res = await pair('');
620
+ if (res && res.ok && res.token) {
621
+ localStorage.setItem(TOKEN_KEY, res.token);
622
+ return true;
623
+ }
624
+ } catch {}
625
+ return false;
626
+ }
627
+
16
628
  function showPairPage(hint) {
17
629
  if (ws) { try { ws.close(); } catch {} ws = null; }
18
630
  $('#pair-page').style.display = 'flex';
@@ -31,7 +643,7 @@ function initPairUI() {
31
643
  const err = $('#pair-error');
32
644
  const submit = async () => {
33
645
  const code = input.value.trim();
34
- if (code.length !== 6) { err.textContent = '请输入 6 位配对码'; return; }
646
+ if (code.length !== 6) { err.textContent = t('pair.error.length'); return; }
35
647
  btn.disabled = true; err.textContent = '';
36
648
  try {
37
649
  const res = await pair(code);
@@ -40,10 +652,10 @@ function initPairUI() {
40
652
  showApp();
41
653
  startApp();
42
654
  } else {
43
- err.textContent = res.reason || '配对失败';
655
+ err.textContent = res.reason || t('pair.error.failed');
44
656
  }
45
657
  } catch {
46
- err.textContent = '网络错误';
658
+ err.textContent = t('pair.error.network');
47
659
  } finally {
48
660
  btn.disabled = false;
49
661
  }
@@ -56,9 +668,9 @@ function initPairUI() {
56
668
  // ── WebSocket 客户端(自动重连)──
57
669
  let ws = null;
58
670
  let reconnectDelay = 1000;
59
- let currentView = 'agents';
671
+ let currentView = localStorage.getItem(VIEW_KEY) || 'agents';
60
672
  let pendingSub = null; // 重连后要恢复的订阅
61
- const state = { agents: null, msg: null, session: null, cache: null, system: null, triggers: null };
673
+ const state = { agents: null, msg: null, session: null, cache: null, system: null, triggers: null, monitor: null, gateway: null };
62
674
 
63
675
  function setConnStatus(text, cls) {
64
676
  const el = $('#conn-status');
@@ -70,10 +682,10 @@ function connect() {
70
682
  const token = localStorage.getItem(TOKEN_KEY);
71
683
  if (!token) { showPairPage(); return; }
72
684
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
73
- ws = new WebSocket(`${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`);
685
+ ws = new WebSocket(`${proto}://${location.host}${BASE}ws?token=${encodeURIComponent(token)}`);
74
686
 
75
687
  ws.onopen = () => {
76
- setConnStatus('● 已连接', 'ok');
688
+ setConnStatus('● ' + t('status.connected'), 'ok');
77
689
  reconnectDelay = 1000;
78
690
  subscribe(currentView, pendingSub || {});
79
691
  };
@@ -106,10 +718,10 @@ function connect() {
106
718
  ws.onclose = (ev) => {
107
719
  if (ev.code === 4001) {
108
720
  localStorage.removeItem(TOKEN_KEY);
109
- showPairPage('token 已失效,请重新配对');
721
+ showPairPage(t('pair.error.tokenInvalid'));
110
722
  return;
111
723
  }
112
- setConnStatus('○ 重连中…', 'err');
724
+ setConnStatus('○ ' + t('status.reconnecting') + '…', 'err');
113
725
  setTimeout(connect, reconnectDelay);
114
726
  reconnectDelay = Math.min(reconnectDelay * 1.5, 15000);
115
727
  };
@@ -150,10 +762,13 @@ let msgSel = { aid: null, peer: null };
150
762
  let sessSel = { sessionId: null, project: null };
151
763
  let trigSel = { agent: null };
152
764
  let sessSearch = '';
765
+ let sessFilterNormal = false; // true=只显示有效会话(userMsgs >= 2)
153
766
  let sessChatMode = false; // false=完整视图,true=对话视图(折叠处理过程)
767
+ let monRange = '2m'; // Monitor 时间窗口:2m / 10m / 1h
154
768
 
155
769
  function switchView(view) {
156
770
  currentView = view;
771
+ localStorage.setItem(VIEW_KEY, view);
157
772
  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.view === view));
158
773
  document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + view));
159
774
  // 切换时按当前选择恢复订阅
@@ -162,6 +777,8 @@ function switchView(view) {
162
777
  else if (view === 'cache') subscribe('cache', {});
163
778
  else if (view === 'system') subscribe('system', {});
164
779
  else if (view === 'triggers') subscribe('triggers', { agent: trigSel.agent });
780
+ else if (view === 'monitor') subscribe('monitor', { range: monRange });
781
+ else if (view === 'gateway') subscribe('gateway', {});
165
782
  else subscribe('agents', {});
166
783
  if (state[view]) renderView(view);
167
784
  }
@@ -179,6 +796,8 @@ function renderView(view) {
179
796
  else if (view === 'cache') renderCache(state.cache);
180
797
  else if (view === 'system') renderSystem(state.system);
181
798
  else if (view === 'triggers') renderTriggers(state.triggers);
799
+ else if (view === 'monitor') renderMonitor(state.monitor);
800
+ else if (view === 'gateway') renderGateway(state.gateway);
182
801
  }
183
802
 
184
803
  // ── 工具 ──
@@ -231,74 +850,326 @@ function compareVer(a, b) {
231
850
  return 0;
232
851
  }
233
852
 
234
- // ── Agents 视图(旧 AID 页升级:加操作列 + 新建入口)──
853
+ // ── Agents 视图(对齐终端 watch aid:状态点前置 + 名字为主 + 两行 + 工作态着色 + 顶部统计条)──
854
+
855
+ // 逐 AID 异步操作状态(取代全局 _agentBusy):aid → 操作中的描述文字
856
+ const _agentOps = new Map(); // Map<aid, string>
857
+ let _agentBusy = false; // 保留兼容旧引用,不再用于阻塞渲染
858
+ let _agSubtab = 'enabled'; // 'enabled' | 'disabled'
859
+
860
+ // 工作状态徽标:一旦收到过消息就不再回 connected。
861
+ // stopped → connected(仅首次连接无消息时) → idle(收到第一条后) → working → idle ...
862
+ function agentStateBadge(s, agStatus, connStatus) {
863
+ if (agStatus === 'stopped' || connStatus === 'disconnected' || connStatus === 'failed')
864
+ return `<span class="state-badge stopped">${t('status.stopped')}</span>`;
865
+ if (connStatus === 'reconnecting')
866
+ return `<span class="state-badge stopped">${t('status.reconnecting')}</span>`;
867
+ if ((s.processing || 0) > 0)
868
+ return `<span class="state-badge working">${t('status.working')}</span>`;
869
+ // 收到过消息 → 永远是 idle,不再回到 connected
870
+ if ((s.messagesReceived || 0) > 0 || (s.messagesSent || 0) > 0)
871
+ return `<span class="state-badge idle">${t('status.idle')}</span>`;
872
+ return `<span class="state-badge connected">${t('status.connected')}</span>`;
873
+ }
874
+
875
+ // 发送方式图标标记
876
+ const MSG_KIND_META = {
877
+ send: { icon: '💬', label: () => t('messages.msgKind.reply') },
878
+ thought: { icon: '💭', label: () => t('messages.msgKind.thought') },
879
+ inject: { icon: '📥', label: () => t('messages.msgKind.inject') },
880
+ notify: { icon: '🔔', label: () => t('messages.msgKind.notify') }
881
+ };
882
+ // 消息详情流用:jsonl 持久化的 msgType 词汇(text 为普通回复,不另标)
883
+ const MSG_TYPE_META = {
884
+ thought: { icon: '💭', label: () => t('messages.msgType.thought') },
885
+ image: { icon: '🖼️', label: () => t('messages.msgType.image') },
886
+ file: { icon: '📎', label: () => t('messages.msgType.file') },
887
+ command: { icon: '⌘', label: () => t('messages.msgType.command') }
888
+ };
889
+ function msgTagsHtml(kind, encrypt, chatmode, dir) {
890
+ let h = '';
891
+ // 'send' 仅出向才是「回复」;入向是用户输入,不打回复标记
892
+ const km = (kind === 'send' && dir === 'in') ? null : MSG_KIND_META[kind];
893
+ if (km) h += `<span class="mtag${kind === 'send' ? ' mtag-reply' : ''}">${km.icon}${km.label()}</span>`;
894
+ if (encrypt != null) h += `<span class="mtag">${encrypt ? t('messages.tag.encrypted') : t('messages.tag.plain')}</span>`;
895
+ if (chatmode) h += `<span class="mtag">${chatmode === 'proactive' ? t('messages.tag.proactive') : (chatmode === 'inject' ? t('messages.tag.inject') : t('messages.tag.responsive'))}</span>`;
896
+ return h;
897
+ }
898
+
899
+ // 消息行:方向箭头 + 标记 + 对端 + 文字
900
+ function agentPreviewHtml(s) {
901
+ const clip = (t) => esc(String(t).replace(/\n/g, ' ').slice(0, 80));
902
+ const line = (dir, peer, text, kind, encrypt, chatmode) => {
903
+ const arrow = dir === 'in' ? '<span class="arrow-in">↓</span>' : '<span class="arrow-out">↑</span>';
904
+ const tags = msgTagsHtml(kind, encrypt, chatmode, dir);
905
+ const peerHtml = peer ? `<span class="peer">${esc(shortAid(peer))}</span>: ` : '';
906
+ const textCls = dir === 'in' ? 'text-in' : 'text-out';
907
+ return `${arrow}${tags ? ' ' + tags + ' ' : ' '}${peerHtml}<span class="${textCls}">${clip(text)}</span>`;
908
+ };
909
+ if ((s.processing || 0) > 0 && s.lastReceivedText)
910
+ return line('in', s.lastReceivedFrom, s.lastReceivedText, s.lastReceivedKind, s.lastReceivedEncrypt, s.lastReceivedChatmode);
911
+ const recvTs = s.lastReceivedAt || 0, sentTs = s.lastSentAt || 0;
912
+ if (!recvTs && !sentTs) return '';
913
+ if (sentTs > recvTs && s.lastSentText)
914
+ return line('out', s.lastSentTo, s.lastSentText, s.lastSentKind, s.lastSentEncrypt, s.lastSentChatmode);
915
+ if (s.lastReceivedText)
916
+ return line('in', s.lastReceivedFrom, s.lastReceivedText, s.lastReceivedKind, s.lastReceivedEncrypt, s.lastReceivedChatmode);
917
+ return '';
918
+ }
919
+
920
+ // HTML tooltip(最近 N 轮):时间 + 彩色箭头 + 方式 + 对端 + 文字
921
+ // 渲染为隐藏的内容持有节点(.msg-tip-src);实际展示由 initMsgTipFloat 的浮层负责
922
+ function recentMsgTooltipHtml(recent) {
923
+ if (!recent || !recent.length) return '';
924
+ let h = '<div class="msg-tip-src">';
925
+ for (const m of recent) {
926
+ const rcls = m.dir === 'in' ? 'tip-row-in' : 'tip-row-out';
927
+ const arrow = m.dir === 'in' ? '↓' : '↑';
928
+ // 'send' 仅出向才是「回复」;入向是用户输入,不打回复标记
929
+ const km = (m.kind === 'send' && m.dir === 'in') ? null : MSG_KIND_META[m.kind];
930
+ const kh = km ? `<span class="tip-kind${m.kind === 'send' ? ' tip-kind-reply' : ''}">${km.icon}${km.label()}</span>` : '';
931
+ const enc = m.encrypt != null ? `<span class="tip-flag">${m.encrypt ? t('messages.tag.encrypted') : t('messages.tag.plain')}</span>` : '';
932
+ const mode = m.chatmode ? `<span class="tip-flag">${m.chatmode === 'proactive' ? t('messages.tag.proactive') : (m.chatmode === 'inject' ? t('messages.tag.inject') : t('messages.tag.responsive'))}</span>` : '';
933
+ const peer = m.peer ? esc(shortAid(m.peer)) : '';
934
+ const text = esc(String(m.text).replace(/\n/g, ' ').slice(0, 140));
935
+ const time = m.ts ? `<span class="tip-time">${fmtTime(m.ts)}</span>` : '';
936
+ h += `<div class="tip-row ${rcls}">${time}${arrow}${kh}${enc}${mode} <b>${peer}</b> ${text}</div>`;
937
+ }
938
+ return h + '</div>';
939
+ }
940
+
941
+ // 单例浮层 tooltip:固定定位、自动翻转上下、横向夹取,确保始终在可视区域内;
942
+ // 鼠标可移动到 tooltip 上而不消失(延迟隐藏 + 进入取消)。
943
+ function initMsgTipFloat() {
944
+ if (initMsgTipFloat._done) return;
945
+ initMsgTipFloat._done = true;
946
+
947
+ let floatEl = null, hideTimer = null, curWrap = null;
948
+ const GAP = 8, MARGIN = 8;
949
+
950
+ function ensureFloat() {
951
+ if (floatEl) return floatEl;
952
+ floatEl = document.createElement('div');
953
+ floatEl.id = 'msg-tip-float';
954
+ floatEl.className = 'msg-tip';
955
+ document.body.appendChild(floatEl);
956
+ floatEl.addEventListener('mouseenter', cancelHide);
957
+ floatEl.addEventListener('mouseleave', scheduleHide);
958
+ return floatEl;
959
+ }
960
+ function cancelHide() { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }
961
+ function scheduleHide() { cancelHide(); hideTimer = setTimeout(hideNow, 180); }
962
+ function hideNow() { cancelHide(); curWrap = null; if (floatEl) floatEl.classList.remove('show'); }
963
+
964
+ function position(wrap) {
965
+ const f = floatEl;
966
+ const r = wrap.getBoundingClientRect();
967
+ const vw = document.documentElement.clientWidth;
968
+ const vh = document.documentElement.clientHeight;
969
+ const fw = f.offsetWidth, fh = f.offsetHeight;
970
+ // 纵向:优先放上方;上方放不下则放下方;都放不下则在视口内夹取
971
+ let top;
972
+ if (r.top - GAP - fh >= MARGIN) top = r.top - GAP - fh;
973
+ else if (r.bottom + GAP + fh <= vh - MARGIN) top = r.bottom + GAP;
974
+ else top = Math.max(MARGIN, Math.min(vh - MARGIN - fh, r.top - GAP - fh));
975
+ // 横向:对齐左缘,超出右界则左移,再夹取左界
976
+ let left = r.left;
977
+ if (left + fw > vw - MARGIN) left = vw - MARGIN - fw;
978
+ if (left < MARGIN) left = MARGIN;
979
+ f.style.top = Math.round(top) + 'px';
980
+ f.style.left = Math.round(left) + 'px';
981
+ }
982
+
983
+ function show(wrap) {
984
+ const src = wrap.querySelector('.msg-tip-src');
985
+ if (!src || !src.innerHTML.trim()) return;
986
+ const f = ensureFloat();
987
+ cancelHide();
988
+ if (curWrap !== wrap) { f.innerHTML = src.innerHTML; curWrap = wrap; }
989
+ f.classList.add('show');
990
+ position(wrap);
991
+ }
992
+
993
+ document.addEventListener('mouseover', (e) => {
994
+ const wrap = e.target.closest && e.target.closest('.ag-msg-wrap');
995
+ if (wrap) show(wrap);
996
+ });
997
+ document.addEventListener('mouseout', (e) => {
998
+ const wrap = e.target.closest && e.target.closest('.ag-msg-wrap');
999
+ if (!wrap) return;
1000
+ const to = e.relatedTarget;
1001
+ if (to && (wrap.contains(to) || (floatEl && floatEl.contains(to)))) return;
1002
+ scheduleHide();
1003
+ });
1004
+ // 滚动时隐藏,避免浮层与行脱节
1005
+ window.addEventListener('scroll', hideNow, true);
1006
+ }
1007
+
1008
+ // 顶部统计条:Gateway / AIDs total·connected·offline / Messages ↓↑ / Traffic ↓↑ / Version·PID·Uptime
1009
+ function agentsStatsBar(data, aids, stats) {
1010
+ const connected = aids.filter(a => (a.status || 'connected') === 'connected').length;
1011
+ const offline = aids.length - connected;
1012
+ let recv = 0, sent = 0, bin = 0, bout = 0;
1013
+ for (const s of stats) {
1014
+ recv += s.messagesReceived || 0; sent += s.messagesSent || 0;
1015
+ bin += s.bytesReceived || 0; bout += s.bytesSent || 0;
1016
+ }
1017
+ const gws = [...new Set(aids.filter(a => a.gatewayUrl).map(a => a.gatewayUrl))];
1018
+ const gw = gws.length ? gws.map(esc).join(', ') : '—';
1019
+ const st = data.status || {};
1020
+ const pid = st.pid != null ? st.pid : '—';
1021
+ const uptime = st.uptime != null ? fmtDur(st.uptime / 1000) : '—';
1022
+ const ver = data.version || '—';
1023
+
1024
+ let h = '<div class="agents-stats">';
1025
+ h += `<span class="sg"><span class="sg-k">${t('agents.stats.gateway')}</span><span class="sg-gw">${gw}</span></span>`;
1026
+ h += `<span class="sg"><span class="sg-k">${t('agents.stats.aids')}</span>${aids.length} ${t('agents.stats.total')} · <span class="num-on">${connected} ${t('agents.stats.online')}</span>` +
1027
+ `${offline ? ` · <span class="num-off">${offline} ${t('agents.stats.offline')}</span>` : ''}</span>`;
1028
+ h += `<span class="sg"><span class="sg-k">${t('agents.stats.messages')}</span><span class="in">↓${recv}</span> <span class="out">↑${sent}</span></span>`;
1029
+ h += `<span class="sg"><span class="sg-k">${t('agents.stats.traffic')}</span><span class="in">↓${fmtBytes(bin)}</span> <span class="out">↑${fmtBytes(bout)}</span></span>`;
1030
+ h += `<span class="sg"><span class="sg-k">${t('agents.stats.version')}</span>${esc(ver)} · <span class="sg-k">${t('agents.stats.pid')}</span>${pid} · <span class="sg-k">${t('agents.stats.uptime')}</span>${uptime}</span>`;
1031
+ h += '</div>';
1032
+ return h;
1033
+ }
1034
+
1035
+ // 操作列 HTML(启用页):停止/启动 + 清空队列(conditional) + ···(禁用/重载/编辑/md/删除)
1036
+ function agentOpsHtml(aid, ag, s) {
1037
+ if (_agentOps.has(aid)) {
1038
+ return `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(aid) || t('common.operating'))}</span></div>`;
1039
+ }
1040
+ const queued = s.queued || 0;
1041
+ const running = ag.status === 'running';
1042
+ let h = `<div class="agent-ops" data-aid="${esc(aid)}" data-status="${esc(ag.status)}">`;
1043
+ if (running) h += `<button class="ctrl-btn ops-stop" data-op="stop">${t('action.stop')}</button>`;
1044
+ else h += `<button class="ctrl-btn ops-start" data-op="start">${t('action.start')}</button>`;
1045
+ if (queued > 0) h += `<button class="ctrl-btn ops-clear-queue" data-op="clear-queue" title="${t('agents.op.clearQueueTitle').replace('{count}', queued)}">${t('action.clearQueue')}</button>`;
1046
+ h += `<div class="ops-more"><button class="ctrl-btn ops-more-btn" data-op="more">···</button>` +
1047
+ `<div class="ops-dropdown">` +
1048
+ `<button class="ops-dd-item" data-op="toggle">${t('action.disable')}</button>` +
1049
+ `<button class="ops-dd-item" data-op="reload">${t('action.reload')}</button>` +
1050
+ `<button class="ops-dd-item" data-op="edit">${t('action.edit')}</button>` +
1051
+ `<a class="ops-dd-item" href="https://${esc(aid)}/agent.md" target="_blank" rel="noopener">${t('agents.op.viewAgentMd')}</a>` +
1052
+ `<button class="ops-dd-item danger" data-op="delete">${t('action.delete')}</button>` +
1053
+ `</div></div>`;
1054
+ h += '</div>';
1055
+ return h;
1056
+ }
1057
+
235
1058
  function renderAgents(data) {
236
1059
  const el = $('#view-agents');
237
- if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
238
- if (_agentBusy) return; // 编辑/操作进行中,跳过轮询重渲染
1060
+ if (!data) { el.innerHTML = `<div class="empty">${t('common.loading')}</div>`; return; }
1061
+ if (el.querySelector('.ops-more.open')) return;
1062
+
1063
+ const allAgents = data.agents || [];
239
1064
  const aids = data.aids || [];
240
1065
  const statsByAid = {};
241
1066
  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;
1067
+ const aidConnByAid = {};
1068
+ for (const a of aids) aidConnByAid[a.aid] = a;
1069
+
1070
+ const enabledCount = allAgents.filter(ag => ag.status !== 'disabled').length;
1071
+ const disabledCount = allAgents.filter(ag => ag.status === 'disabled').length;
1072
+
1073
+ // 子标签栏
1074
+ let html = '<div class="agents-toolbar">' +
1075
+ `<div class="ag-subtabs">` +
1076
+ `<button class="ag-subtab${_agSubtab === 'enabled' ? ' active' : ''}" data-subtab="enabled">${t('agents.subtitle.enabled')} (${enabledCount})</button>` +
1077
+ `<button class="ag-subtab${_agSubtab === 'disabled' ? ' active' : ''}" data-subtab="disabled">${t('agents.subtitle.disabled')} (${disabledCount})</button>` +
1078
+ `</div>` +
1079
+ `<button class="ctrl-btn" id="agent-new-btn">${t('action.new')}</button>` +
1080
+ '</div>';
245
1081
 
246
- let html = '<div class="agents-toolbar"><button class="ctrl-btn" id="agent-new-btn">+ 新建 Agent</button></div>';
247
1082
  if (!data.daemonRunning) {
248
- html += '<div class="banner">⚠ EvolClaw 主进程未运行,仅显示最近活动记录</div>';
1083
+ html += `<div class="banner">${t('agents.daemonStopped')}</div>`;
249
1084
  }
250
- if (!aids.length) {
251
- html += '<div class="empty">暂无 AID</div>';
1085
+
1086
+ if (_agSubtab === 'disabled') {
1087
+ const disabledAgents = allAgents.filter(ag => ag.status === 'disabled');
1088
+ if (!disabledAgents.length) {
1089
+ html += `<div class="empty">${t('agents.empty.disabled')}</div>`;
1090
+ } else {
1091
+ html += `<table><thead><tr><th>${t('agents.th.agent')}</th><th>${t('agents.th.projectPath')}</th><th>${t('agents.th.operations')}</th></tr></thead><tbody>`;
1092
+ for (const ag of disabledAgents) {
1093
+ const busy = _agentOps.has(ag.aid);
1094
+ const ops = busy
1095
+ ? `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(ag.aid) || t('common.operating'))}</span></div>`
1096
+ : `<div class="agent-ops" data-aid="${esc(ag.aid)}" data-status="disabled"><button class="ctrl-btn ops-enable" data-op="toggle">${t('action.enable')}</button></div>`;
1097
+ html += `<tr class="ag-main">` +
1098
+ `<td><div class="ag-id"><span class="dot off"></span><span class="ag-id-text"><span class="ag-name">${esc(ag.displayName || shortAid(ag.aid))}</span><span class="ag-aid">${esc(ag.aid)}</span></span></div></td>` +
1099
+ `<td style="font-size:11px;font-family:monospace">${esc(ag.projectPath || '—')}</td>` +
1100
+ `<td class="agent-ops-cell">${ops}</td></tr>`;
1101
+ }
1102
+ html += '</tbody></table>';
1103
+ }
1104
+ el.innerHTML = html;
1105
+ bindAgentsEvents(el);
1106
+ return;
1107
+ }
1108
+
1109
+ // ── 启用页 ──
1110
+ // 按收发消息总数降序排序(活跃的排前面)
1111
+ const totalMsgs = (ag) => {
1112
+ const s = statsByAid[ag.aid] || {};
1113
+ return (s.messagesReceived || 0) + (s.messagesSent || 0);
1114
+ };
1115
+ const enabledAgents = allAgents.filter(ag => ag.status !== 'disabled')
1116
+ .sort((a, b) => totalMsgs(b) - totalMsgs(a));
1117
+ if (!enabledAgents.length) {
1118
+ html += `<div class="empty">${t('agents.empty.enabled')}</div>`;
252
1119
  el.innerHTML = html;
253
1120
  bindAgentsEvents(el);
254
1121
  return;
255
1122
  }
256
1123
 
257
1124
  html += '<table><thead><tr>' +
258
- '<th>状态</th><th>AID</th><th>收</th><th>发</th><th>系统</th>' +
259
- '<th>入字节</th><th>出字节</th><th>peers</th><th>重连</th><th>最后活动</th><th>最近消息</th><th>操作</th>' +
1125
+ `<th>${t('agents.th.aid')}</th><th>${t('agents.th.work')}</th><th>${t('agents.th.queue')}</th><th>${t('agents.th.model')}</th><th>${t('agents.th.runtime')}</th><th>${t('agents.th.received')}</th><th>${t('agents.th.sent')}</th>` +
1126
+ `<th>${t('agents.th.bytesIn')}</th><th>${t('agents.th.bytesOut')}</th><th>${t('agents.th.peerCount')}</th><th>${t('agents.th.lastActivity')}</th><th>${t('agents.th.operations')}</th>` +
260
1127
  '</tr></thead><tbody>';
261
1128
 
262
- for (const a of aids) {
263
- const s = statsByAid[a.aid] || {};
264
- const status = a.status || (a.lastEvent === 'disconnected' ? 'disconnected' : 'connected');
265
- const dotCls = status === 'connected' ? 'on' : (status === 'reconnecting' ? 'idle' : 'off');
266
- const name = s.selfName || a.agentName || '';
267
- const lastTs = Math.max(s.lastReceivedAt || 0, s.lastSentAt || 0, a.lastActivity || 0);
268
- let preview = '';
269
- if (s.lastReceivedText && (s.lastReceivedAt || 0) >= (s.lastSentAt || 0)) {
270
- preview = '↓ ' + shortAid(s.lastReceivedFrom) + ': ' + s.lastReceivedText;
271
- } else if (s.lastSentText) {
272
- preview = '↑ ' + shortAid(s.lastSentTo) + ': ' + s.lastSentText;
273
- }
274
- // 操作列:能归属到 EvolAgent 的才可操作;否则置灰
275
- const ag = agentByAid[a.aid];
276
- let ops;
277
- if (ag) {
278
- const toggleLabel = ag.status === 'disabled' ? '启用' : '禁用';
279
- ops = `<div class="agent-ops" data-aid="${esc(a.aid)}" data-status="${esc(ag.status)}">` +
280
- `<button class="ctrl-btn" data-op="edit">编辑</button>` +
281
- `<button class="ctrl-btn" data-op="reload">重载</button>` +
282
- `<button class="ctrl-btn" data-op="toggle">${toggleLabel}</button>` +
283
- `<button class="ctrl-btn danger" data-op="delete">删除</button>` +
284
- `<a class="ctrl-btn" href="https://${esc(a.aid)}/agent.md" target="_blank" rel="noopener">md↗</a>` +
285
- `</div>`;
286
- } else {
287
- ops = '<span style="color:var(--dim)">—</span>';
288
- }
289
- html += '<tr>' +
290
- `<td><span class="dot ${dotCls}"></span>${esc(status)}</td>` +
291
- `<td>${esc(shortAid(a.aid))}${name ? ` <span style="color:var(--dim)">(${esc(name)})</span>` : ''}</td>` +
1129
+ for (const ag of enabledAgents) {
1130
+ const s = statsByAid[ag.aid] || {};
1131
+ const conn = aidConnByAid[ag.aid] || {};
1132
+ const connStatus = conn.status || (ag.status === 'running' ? 'connected' : 'disconnected');
1133
+ const dotCls = connStatus === 'connected' ? 'on' : (connStatus === 'reconnecting' ? 'idle' : 'off');
1134
+ const name = s.selfName || ag.displayName || shortAid(ag.aid);
1135
+ const uptime = (connStatus === 'connected' && conn.lastConnectedAt) ? fmtDur((Date.now() - conn.lastConnectedAt) / 1000) : '—';
1136
+ const lastTs = Math.max(s.lastReceivedAt || 0, s.lastSentAt || 0, ag.lastActivity || 0);
1137
+ const preview = agentPreviewHtml(s);
1138
+ // 队列数:不含正在处理的那条
1139
+ const rawQueued = s.queued || 0;
1140
+ const queued = rawQueued;
1141
+ const queueCell = queued > 0 ? `<span class="ag-queue-num">${queued}</span>` : '<span style="color:var(--dim)">0</span>';
1142
+ const model = ag.model || ag.baseagent || '—';
1143
+
1144
+ const idCell = `<div class="ag-id"><span class="dot ${dotCls}" title="${esc(connStatus)}"></span>` +
1145
+ `<span class="ag-id-text"><span class="ag-name">${esc(name)}</span>` +
1146
+ `<span class="ag-aid">${esc(ag.aid)}</span></span></div>`;
1147
+
1148
+ html += `<tr class="ag-main">` +
1149
+ `<td>${idCell}</td>` +
1150
+ `<td>${agentStateBadge(s, ag.status, connStatus)}</td>` +
1151
+ `<td>${queueCell}</td>` +
1152
+ `<td style="font-size:11px;color:var(--dim)">${esc(model)}</td>` +
1153
+ `<td>${uptime}</td>` +
292
1154
  `<td>${s.messagesReceived ?? 0}</td><td>${s.messagesSent ?? 0}</td>` +
293
- `<td>${s.systemReceived ?? 0}/${s.systemSent ?? 0}</td>` +
294
1155
  `<td>${fmtBytes(s.bytesReceived)}</td><td>${fmtBytes(s.bytesSent)}</td>` +
295
- `<td>${s.uniquePeerCount ?? a.peerCount ?? 0}</td><td>${a.reconnectCount ?? 0}</td>` +
1156
+ `<td>${s.uniquePeerCount ?? conn.peerCount ?? 0}</td>` +
296
1157
  `<td>${fmtAgo(lastTs)}</td>` +
297
- `<td class="preview">${esc(preview.replace(/\n/g, ' ').slice(0, 80))}</td>` +
298
- `<td class="agent-ops-cell">${ops}</td>` +
1158
+ `<td class="agent-ops-cell">${agentOpsHtml(ag.aid, ag, s)}</td>` +
299
1159
  '</tr>';
1160
+ // 自定义 tooltip(HTML,hover 显示)
1161
+ const recent = (s.recentMessages || []);
1162
+ const tipHtml = recentMsgTooltipHtml(recent);
1163
+
1164
+ html += `<tr class="ag-sub"><td colspan="12"><div class="ag-info">` +
1165
+ (ag.projectPath ? `<div class="ag-path">${esc(ag.projectPath)}</div>` : '') +
1166
+ (preview ? `<div class="ag-msg-wrap">${tipHtml}<div class="ag-msg">${preview}</div></div>` : '') +
1167
+ '</div></td></tr>';
300
1168
  }
301
1169
  html += '</tbody></table>';
1170
+ if (data.daemonRunning) {
1171
+ html += agentsStatsBar(data, aids, data.stats || []);
1172
+ }
302
1173
  el.innerHTML = html;
303
1174
  bindAgentsEvents(el);
304
1175
  }
@@ -331,17 +1202,17 @@ function groupLabel(g) {
331
1202
 
332
1203
  function renderCache(data) {
333
1204
  const el = $('#view-cache');
334
- if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
1205
+ if (!data) { el.innerHTML = `<div class="empty">${t('common.loading')}</div>`; return; }
335
1206
  if (!data.daemonRunning) {
336
- el.innerHTML = '<div class="banner">⚠ EvolClaw 主进程未运行,无缓存统计可显示</div>';
1207
+ el.innerHTML = `<div class="banner">${t('cache.daemonStopped')}</div>`;
337
1208
  return;
338
1209
  }
339
1210
  if (!data.supported || !data.stats) {
340
- el.innerHTML = '<div class="banner">⚠ 当前 EvolClaw 版本不支持 cache-stats(请升级 daemon)</div>';
1211
+ el.innerHTML = `<div class="banner">${t('cache.notSupported')}</div>`;
341
1212
  return;
342
1213
  }
343
1214
  const s = data.stats;
344
- const t = s.totals;
1215
+ const tot = s.totals;
345
1216
  const occ = s.occupancy || {};
346
1217
  // 全部组占用合计
347
1218
  let totalBytes = 0;
@@ -350,23 +1221,23 @@ function renderCache(data) {
350
1221
  let html = '';
351
1222
 
352
1223
  // ① 总览卡片
353
- const rate = hitRate(t);
1224
+ const rate = hitRate(tot);
354
1225
  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));
1226
+ html += card(t('cache.card.hitRate'), fmtPct(rate), rateCls(rate), `${fmtNum(tot.hits)} ${t('cache.card.hit')} / ${fmtNum(tot.misses)} ${t('cache.card.miss')}`);
1227
+ html += card(t('cache.card.reads'), fmtNum(tot.gets), '', `${fmtNum(tot.hits)} ${t('cache.card.hit')} · ${fmtNum(tot.misses)} ${t('cache.card.miss')}`);
1228
+ html += card(t('cache.card.entries'), fmtNum(s.size), '', fmtBytes(totalBytes) + ' ' + t('cache.card.memory'));
1229
+ html += card(t('cache.card.statChecks'), fmtNum(tot.statChecks), '', 'mtime ' + t('cache.policy.mtime'));
1230
+ html += card(t('cache.card.reReads'), fmtNum(tot.reReads), '', t('cache.policy.manual'));
1231
+ html += card(t('cache.card.evictions'), fmtNum(tot.evictions), tot.evictions ? 'idle' : '', 'LRU');
1232
+ html += card(t('cache.card.invalidations'), fmtNum(tot.invalidations), '', 'reload');
1233
+ html += card(t('cache.card.since'), fmtAgo(s.since) + ' ' + t('cache.card.ago'), '', fmtTime(s.since));
363
1234
  html += '</div>';
364
1235
 
365
1236
  // ② 按 group 表(每组命中率 + 占用 + 容量水位)
366
- html += '<h3 class="cache-h">按缓存组</h3>';
1237
+ html += `<h3 class="cache-h">${t('cache.section.byGroup')}</h3>`;
367
1238
  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>' +
1239
+ `<th>${t('cache.th.group')}</th><th>${t('cache.th.type')}</th><th>${t('cache.th.reads')}</th><th>${t('cache.th.hits')}</th><th>${t('cache.th.misses')}</th><th>${t('cache.th.hitRate')}</th>` +
1240
+ `<th>${t('cache.th.reReads')}</th><th>${t('cache.th.evictions')}</th><th>${t('cache.th.entries')}</th><th>${t('cache.th.memory')}</th><th>${t('cache.th.capacity')}</th>` +
370
1241
  '</tr></thead><tbody>';
371
1242
  const groups = Object.keys(s.byGroup).sort((a, b) => (s.byGroup[b].gets || 0) - (s.byGroup[a].gets || 0));
372
1243
  for (const g of groups) {
@@ -392,11 +1263,15 @@ function renderCache(data) {
392
1263
  html += '</tbody></table>';
393
1264
 
394
1265
  // ③ 按 policy 表
395
- html += '<h3 class="cache-h">按策略</h3>';
1266
+ html += `<h3 class="cache-h">${t('cache.section.byPolicy')}</h3>`;
396
1267
  html += '<table><thead><tr>' +
397
- '<th>策略</th><th>读取</th><th>命中</th><th>未命中</th><th>命中率</th><th>stat 检查</th><th>重读</th>' +
1268
+ `<th>${t('cache.th.policy')}</th><th>${t('cache.th.reads')}</th><th>${t('cache.th.hits')}</th><th>${t('cache.th.misses')}</th><th>${t('cache.th.hitRate')}</th><th>${t('cache.th.statChecks')}</th><th>${t('cache.th.reReads')}</th>` +
398
1269
  '</tr></thead><tbody>';
399
- const POLICY_DESC = { 'on-reload': '靠 reload 刷新,平时零检查', 'manual': '显式单刷', 'mtime': '每读 statSync 门控' };
1270
+ const POLICY_DESC = {
1271
+ 'on-reload': t('cache.policy.onReload'),
1272
+ 'manual': t('cache.policy.manual'),
1273
+ 'mtime': t('cache.policy.mtime')
1274
+ };
400
1275
  for (const pol of ['on-reload', 'mtime', 'manual']) {
401
1276
  const c = s.byPolicy[pol];
402
1277
  if (!c || !c.gets) continue;
@@ -410,8 +1285,7 @@ function renderCache(data) {
410
1285
  }
411
1286
  html += '</tbody></table>';
412
1287
 
413
- html += '<div class="cache-note">注:config/defaults 与关系级 preferences 的读取也已并入本统计;' +
414
- '渲染后结果(按 vars)不缓存,故不在此列。</div>';
1288
+ html += `<div class="cache-note">${t('cache.note')}</div>`;
415
1289
 
416
1290
  el.innerHTML = html;
417
1291
  }
@@ -432,7 +1306,7 @@ function renderMsg(data) {
432
1306
  const messages = data.messages || [];
433
1307
 
434
1308
  // 左:AID 列表
435
- let aidsHtml = '<div class="col-title">AID</div>';
1309
+ let aidsHtml = `<div class="col-title">${t('messages.colTitle.aid')}</div>`;
436
1310
  for (const a of aids) {
437
1311
  const sel = a.aid === msgSel.aid ? ' sel' : '';
438
1312
  aidsHtml += `<div class="list-item${sel}" data-aid="${esc(a.aid)}">` +
@@ -445,10 +1319,10 @@ function renderMsg(data) {
445
1319
  });
446
1320
 
447
1321
  // 中:对端列表
448
- let peersHtml = '<div class="col-title">Peers</div>';
1322
+ let peersHtml = `<div class="col-title">${t('messages.colTitle.peers')}</div>`;
449
1323
  if (msgSel.aid) {
450
1324
  const allSel = msgSel.peer === null ? ' sel' : '';
451
- peersHtml += `<div class="list-item${allSel}" data-peer=""><div class="name">All</div>` +
1325
+ peersHtml += `<div class="list-item${allSel}" data-peer=""><div class="name">${t('messages.colTitle.all')}</div>` +
452
1326
  `<div class="sub">${peers.length} peers</div></div>`;
453
1327
  for (const p of peers) {
454
1328
  const sel = p.peerId === msgSel.peer ? ' sel' : '';
@@ -457,7 +1331,7 @@ function renderMsg(data) {
457
1331
  `<div class="sub">↓${p.inbound} ↑${p.outbound} · ${fmtAgo(p.lastAt)}</div></div>`;
458
1332
  }
459
1333
  } else {
460
- peersHtml += '<div class="empty">← 选择一个 AID</div>';
1334
+ peersHtml += `<div class="empty">${t('messages.empty.selectAid')}</div>`;
461
1335
  }
462
1336
  $('#msg-peers').innerHTML = peersHtml;
463
1337
  $('#msg-peers').querySelectorAll('.list-item').forEach(item => {
@@ -466,7 +1340,7 @@ function renderMsg(data) {
466
1340
 
467
1341
  // 右:消息流
468
1342
  const stream = $('#msg-stream');
469
- if (!msgSel.aid) { stream.innerHTML = '<div class="empty">选择 AID 查看消息</div>'; return; }
1343
+ if (!msgSel.aid) { stream.innerHTML = `<div class="empty">${t('messages.empty.selectToView')}</div>`; return; }
470
1344
  const atBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 60;
471
1345
  let msgHtml = '';
472
1346
  for (const m of messages) {
@@ -474,15 +1348,19 @@ function renderMsg(data) {
474
1348
  const arrow = m.dir === 'in' ? '↓' : '↑';
475
1349
  const from = shortAid(m.from), to = shortAid(m.to);
476
1350
  const tags = [];
477
- if (m.chatType === 'group') tags.push('群聊');
478
- if (m.encrypt != null) tags.push(m.encrypt ? '密文' : '明文');
479
- if (m.chatmode) tags.push(m.chatmode === 'proactive' ? '自主' : '响应');
480
- const tagHtml = tags.map(t => `<span class="tag">${esc(t)}</span>`).join('');
1351
+ if (m.chatType === 'group') tags.push(t('messages.tag.group'));
1352
+ // 消息详情流的 kind 来自 jsonl msgType(text/thought/image/file/command),
1353
+ // agents 页内存态的 MsgKind(send/thought/inject/notify)不是同一套词汇。
1354
+ const mt = MSG_TYPE_META[m.msgType];
1355
+ if (mt) tags.push(`${mt.icon}${mt.label()}`);
1356
+ if (m.encrypt != null) tags.push(m.encrypt ? t('messages.tag.encrypted') : t('messages.tag.plain'));
1357
+ if (m.chatmode) tags.push(m.chatmode === 'proactive' ? t('messages.tag.proactive') : (m.chatmode === 'inject' ? t('messages.tag.inject') : t('messages.tag.responsive')));
1358
+ const tagHtml = tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('');
481
1359
  msgHtml += `<div class="bubble ${cls}">` +
482
1360
  `<div class="meta">${fmtTime(m.ts)} ${arrow} ${esc(from)}→${esc(to)}${tagHtml}</div>` +
483
1361
  `<div class="body">${esc(m.content)}</div></div>`;
484
1362
  }
485
- stream.innerHTML = msgHtml || '<div class="empty">暂无消息</div>';
1363
+ stream.innerHTML = msgHtml || `<div class="empty">${t('messages.empty.noMessages')}</div>`;
486
1364
  if (atBottom) stream.scrollTop = stream.scrollHeight;
487
1365
  }
488
1366
 
@@ -502,17 +1380,19 @@ function renderSession(data) {
502
1380
 
503
1381
  // 搜索过滤
504
1382
  const q = sessSearch.trim().toLowerCase();
505
- const filtered = q
506
- ? transcripts.filter(t => (t.title || '').toLowerCase().includes(q) || (t.firstUser || '').toLowerCase().includes(q))
507
- : transcripts;
1383
+ const filtered = transcripts
1384
+ .filter(t => !sessFilterNormal || (t.userMsgs || 0) >= 2)
1385
+ .filter(t => !q || (t.title || '').toLowerCase().includes(q) || (t.firstUser || '').toLowerCase().includes(q));
508
1386
 
509
1387
  // 左栏:过滤条 + 列表
510
1388
  const projOpts = projects.map(p =>
511
1389
  `<option value="${esc(p.encoded)}"${p.encoded === sessSel.project ? ' selected' : ''}>${esc(p.label)} (${p.count})</option>`
512
1390
  ).join('');
1391
+ const normalCount = transcripts.filter(t => (t.userMsgs || 0) >= 2).length;
513
1392
  let listHtml = '<div class="sess-filter">' +
514
1393
  `<select id="sess-project">${projOpts}</select>` +
515
1394
  `<input id="sess-search" type="text" placeholder="搜索标题/首条消息…" value="${esc(sessSearch)}">` +
1395
+ `<button id="sess-filter-btn" class="ctrl-btn${sessFilterNormal ? ' active' : ''}" title="只显示有效会话(≥2 条用户消息)">有效 ${normalCount}</button>` +
516
1396
  `<div class="sess-count">${filtered.length} / ${transcripts.length} 个会话</div></div>` +
517
1397
  '<div class="sess-items">';
518
1398
 
@@ -543,6 +1423,8 @@ function renderSession(data) {
543
1423
  sessSearch = '';
544
1424
  subscribe('session', { project: sessSel.project });
545
1425
  };
1426
+ const filterBtn = $('#sess-filter-btn');
1427
+ if (filterBtn) filterBtn.onclick = () => { sessFilterNormal = !sessFilterNormal; renderSession(state.session); };
546
1428
  const searchEl = $('#sess-search');
547
1429
  if (searchEl) {
548
1430
  searchEl.oninput = () => { sessSearch = searchEl.value; renderSession(state.session); };
@@ -758,79 +1640,189 @@ function toast(text, isErr) {
758
1640
  }
759
1641
 
760
1642
  // ── Agents 操作 ──
761
- let _agentBusy = false;
1643
+ // _agentBusy 已在 Agents 视图顶部声明,仅 agentOpNew 仍在用)
1644
+
1645
+ // 设置某 aid 的操作状态并立即刷新对应行的按钮区(不重渲整表)
1646
+ function setAgentOp(aid, label) {
1647
+ if (label == null) _agentOps.delete(aid); else _agentOps.set(aid, label);
1648
+ const cell = document.querySelector(`.agent-ops[data-aid="${CSS.escape(aid)}"], .agent-ops-busy[data-aid="${CSS.escape(aid)}"]`)?.closest('td');
1649
+ if (!cell || !state.agents) return;
1650
+ const ag = (state.agents.agents || []).find(x => x.aid === aid);
1651
+ if (!ag) return;
1652
+ if (ag.status === 'disabled') {
1653
+ // 禁用页:只有启用按钮 / 操作中态
1654
+ cell.innerHTML = _agentOps.has(aid)
1655
+ ? `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(aid) || t('common.operating'))}</span></div>`
1656
+ : `<div class="agent-ops" data-aid="${esc(aid)}" data-status="disabled"><button class="ctrl-btn ops-enable" data-op="toggle">${t('action.enable')}</button></div>`;
1657
+ } else {
1658
+ const statsByAid = {};
1659
+ for (const s of (state.agents.stats || [])) statsByAid[s.aid] = s;
1660
+ cell.innerHTML = agentOpsHtml(aid, ag, statsByAid[aid] || {});
1661
+ }
1662
+ bindOpsCell(cell, aid, ag.status);
1663
+ }
1664
+
1665
+ function bindOpsCell(cell, aid, status) {
1666
+ cell.querySelectorAll('button[data-op]').forEach(btn => {
1667
+ btn.addEventListener('click', (e) => {
1668
+ const op = btn.dataset.op;
1669
+ if (op === 'more') {
1670
+ const more = btn.closest('.ops-more');
1671
+ const wasOpen = more.classList.contains('open');
1672
+ document.querySelectorAll('.ops-more.open').forEach(m => m.classList.remove('open'));
1673
+ if (!wasOpen) more.classList.add('open');
1674
+ e.stopPropagation();
1675
+ return;
1676
+ }
1677
+ if (op === 'edit') agentOpEdit(aid);
1678
+ else if (op === 'reload') agentOpReload(aid);
1679
+ else if (op === 'toggle') agentOpToggle(aid, status);
1680
+ else if (op === 'delete') agentOpDelete(aid);
1681
+ else if (op === 'clear-queue') agentOpClearQueue(aid);
1682
+ else if (op === 'stop') agentOpStop(aid);
1683
+ else if (op === 'start') agentOpStart(aid);
1684
+ else if (op === 'mute') agentOpMute(aid);
1685
+ else if (op === 'unmute') agentOpUnmute(aid);
1686
+ });
1687
+ });
1688
+ }
1689
+
1690
+ // click-outside 关闭下拉:全局只绑一次(避免每次重渲染叠加监听器)
1691
+ let _opsOutsideBound = false;
1692
+ function ensureOpsOutsideClose() {
1693
+ if (_opsOutsideBound) return;
1694
+ _opsOutsideBound = true;
1695
+ document.addEventListener('click', (e) => {
1696
+ if (e.target.closest && e.target.closest('.ops-more')) return; // 点在菜单内不关
1697
+ document.querySelectorAll('.ops-more.open').forEach(m => m.classList.remove('open'));
1698
+ });
1699
+ }
762
1700
 
763
1701
  function bindAgentsEvents(el) {
764
1702
  el.querySelector('#agent-new-btn')?.addEventListener('click', agentOpNew);
1703
+ ensureOpsOutsideClose();
1704
+ // 子标签切换:仅切视图变量并重渲,不重新订阅
1705
+ el.querySelectorAll('.ag-subtab').forEach(btn => {
1706
+ btn.addEventListener('click', () => {
1707
+ const tab = btn.dataset.subtab;
1708
+ if (tab && tab !== _agSubtab) { _agSubtab = tab; renderAgents(state.agents); }
1709
+ });
1710
+ });
765
1711
  el.querySelectorAll('.agent-ops').forEach(div => {
766
1712
  const aid = div.dataset.aid;
767
1713
  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
- });
1714
+ bindOpsCell(div.closest('td'), aid, status);
777
1715
  });
778
1716
  }
779
1717
 
1718
+ // 异步操作包装:设置 "操作中" 状态、执行、清除
1719
+ async function withAgentOp(aid, label, fn) {
1720
+ setAgentOp(aid, label);
1721
+ try { await fn(); }
1722
+ finally { setAgentOp(aid, null); }
1723
+ }
1724
+
780
1725
  async function agentOpReload(aid, force = false) {
781
- _agentBusy = true;
782
- try {
1726
+ await withAgentOp(aid, t('agents.op.reloading'), async () => {
783
1727
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'reload', args: { aid, force } }));
784
1728
  if (r.error?.code === 'BUSY') {
785
- if (confirm(r.error.message + '\n确认强制重载?')) return agentOpReload(aid, true);
1729
+ if (confirm(r.error.message + '\n' + t('agents.op.confirmReload'))) { setAgentOp(aid, null); return agentOpReload(aid, true); }
786
1730
  return;
787
1731
  }
788
1732
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
789
- toast('✓ 已重载');
1733
+ toast(t('agents.op.reloaded'));
790
1734
  subscribe('agents', {});
791
- } catch (e) { toast(e.message, true); }
792
- finally { _agentBusy = false; }
1735
+ });
793
1736
  }
794
1737
 
795
1738
  async function agentOpToggle(aid, status) {
796
1739
  const action = status === 'disabled' ? 'enable' : 'disable';
797
- _agentBusy = true;
798
- try {
1740
+ const label = action === 'disable' ? t('agents.op.disabling') : t('agents.op.enabling');
1741
+ await withAgentOp(aid, label, async () => {
799
1742
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid } }));
800
1743
  if (r.error?.code === 'BUSY') {
801
- if (confirm(r.error.message + `\n确认强制${action === 'disable' ? '禁用' : '启用'}?`)) {
1744
+ if (confirm(r.error.message + `\n${t('agents.op.confirmToggle')}${action === 'disable' ? t('action.disable') : t('action.enable')}?`)) {
802
1745
  const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid, force: true } }));
803
1746
  if (r2.error) toast(r2.error.message || r2.error.code, true);
804
- else { toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`); subscribe('agents', {}); }
1747
+ else {
1748
+ toast(action === 'disable' ? t('agents.op.disabled') : t('agents.op.enabled'));
1749
+ // 禁用后立即切到禁用页;启用后等数据刷新(agent 需先完成启动才移到启用页)
1750
+ if (action === 'disable') _agSubtab = 'disabled';
1751
+ subscribe('agents', {});
1752
+ }
805
1753
  }
806
1754
  return;
807
1755
  }
808
1756
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
809
- toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`);
1757
+ toast(action === 'disable' ? t('agents.op.disabled') : t('agents.op.enabled'));
1758
+ if (action === 'disable') _agSubtab = 'disabled';
810
1759
  subscribe('agents', {});
811
- } catch (e) { toast(e.message, true); }
812
- finally { _agentBusy = false; }
1760
+ });
813
1761
  }
814
1762
 
815
1763
  async function agentOpDelete(aid) {
816
- if (!confirm(`删除 Agent ${aid}?\n此操作不可恢复。`)) return;
1764
+ if (!confirm(t('agents.op.confirmDelete').replace('{aid}', aid))) return;
817
1765
  const purge = confirm('同时清除 agent 数据目录?');
818
- _agentBusy = true;
819
- try {
1766
+ await withAgentOp(aid, t('agents.op.deleting'), async () => {
820
1767
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge } }));
821
1768
  if (r.error?.code === 'BUSY') {
822
- if (confirm(r.error.message + '\n确认强制删除?')) {
1769
+ if (confirm(r.error.message + '\n' + t('agents.op.confirmForceDelete'))) {
823
1770
  const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge, force: true } }));
824
1771
  if (r2.error) toast(r2.error.message || r2.error.code, true);
825
- else { toast('✓ 已删除'); subscribe('agents', {}); }
1772
+ else { toast(t('agents.op.deleted')); subscribe('agents', {}); }
826
1773
  }
827
1774
  return;
828
1775
  }
829
1776
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
830
- toast('✓ 已删除');
1777
+ toast(t('agents.op.deleted'));
831
1778
  subscribe('agents', {});
832
- } catch (e) { toast(e.message, true); }
833
- finally { _agentBusy = false; }
1779
+ });
1780
+ }
1781
+
1782
+ async function agentOpClearQueue(aid) {
1783
+ if (!confirm(t('agents.op.confirmClearQueue').replace('{aid}', aid))) return;
1784
+ await withAgentOp(aid, t('common.operating'), async () => {
1785
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'queue-clear', args: { aid } }));
1786
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1787
+ toast(`✓ ${r.data?.cleared ?? 0} messages cleared`);
1788
+ subscribe('agents', {});
1789
+ });
1790
+ }
1791
+
1792
+ async function agentOpStop(aid) {
1793
+ await withAgentOp(aid, t('agents.op.stopping'), async () => {
1794
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'stop', args: { aid } }));
1795
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1796
+ toast(t('agents.op.stopped'));
1797
+ subscribe('agents', {});
1798
+ });
1799
+ }
1800
+
1801
+ async function agentOpStart(aid) {
1802
+ await withAgentOp(aid, t('agents.op.starting'), async () => {
1803
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'start', args: { aid } }));
1804
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1805
+ toast(t('agents.op.started'));
1806
+ subscribe('agents', {});
1807
+ });
1808
+ }
1809
+
1810
+ async function agentOpMute(aid) {
1811
+ await withAgentOp(aid, '禁言中…', async () => {
1812
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'mute', args: { aid } }));
1813
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1814
+ toast('✓ 已禁言');
1815
+ subscribe('agents', {});
1816
+ });
1817
+ }
1818
+
1819
+ async function agentOpUnmute(aid) {
1820
+ await withAgentOp(aid, '解禁中…', async () => {
1821
+ const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'unmute', args: { aid } }));
1822
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
1823
+ toast('✓ 已解禁');
1824
+ subscribe('agents', {});
1825
+ });
834
1826
  }
835
1827
 
836
1828
  async function agentOpNew() {
@@ -849,29 +1841,61 @@ async function agentOpNew() {
849
1841
  }
850
1842
 
851
1843
  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
- ]);
1844
+ await withAgentOp(aid, t('common.operating'), async () => {
1845
+ const qr = await menuSend({ type: 'menu.query', name: 'agent', args: { aid } });
857
1846
  const q = mResp(qr);
858
- if (q.error) { toast(q.error.message || q.error.code, true); _agentBusy = false; return; }
1847
+ if (q.error) { toast(q.error.message || q.error.code, true); return; }
859
1848
  const cfg = q.data;
860
- // 简单 prompt 编辑表单
1849
+ setAgentOp(aid, null); // 查询完毕先恢复,等用户填完 prompt
861
1850
  const projectRaw = prompt('项目路径:', cfg.config?.projects?.defaultPath || '');
862
1851
  const ownersRaw = prompt('Owners(逗号分隔 AID):', (cfg.config?.owners || []).join(', '));
863
1852
  const patch = {};
864
1853
  if (projectRaw !== null) patch.projects = { defaultPath: projectRaw };
865
1854
  if (ownersRaw !== null) patch.owners = ownersRaw.split(',').map(s => s.trim()).filter(Boolean);
866
- if (Object.keys(patch).length === 0) { _agentBusy = false; return; }
1855
+ if (!Object.keys(patch).length) return;
1856
+ setAgentOp(aid, t('common.operating'));
867
1857
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'update', args: { aid, patch } }));
868
1858
  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; }
1859
+ else toast(t('agents.op.saved'));
1860
+ });
872
1861
  }
873
1862
 
874
1863
  // ── System 视图 ──
1864
+ function channelHealthRow(c) {
1865
+ const dot = c.connected ? 'on' : (c.aidStatus === 'reconnecting' || c.aidStatus === 'kicked' ? 'idle' : 'off');
1866
+ let meta = '';
1867
+ if (c.aidStatus && c.aidStatus !== 'connected') meta += ` <span style="color:var(--dim)">${esc(c.aidStatus)}</span>`;
1868
+ if (c.reconnectCount > 0) meta += ` <span style="color:var(--dim)">重连 ${c.reconnectCount}</span>`;
1869
+ if (c.flapCount > 0) meta += ` <span style="color:var(--red)">抖动 ${c.flapCount}</span>`;
1870
+ const reason = c.kickReason || c.lastError;
1871
+ if (reason && !c.connected) meta += ` <span style="color:var(--red)" title="${esc(reason)}">"${esc(reason)}"</span>`;
1872
+ return `<div class="ch-row"><span class="dot ${dot}"></span>${esc(c.type)}${c.instName && c.instName !== c.type ? ' ' + esc(c.instName) : ''}${meta}</div>`;
1873
+ }
1874
+
1875
+ function agentHealthCard(ag) {
1876
+ const dot = ag.status === 'running' ? 'on' : ag.status === 'disabled' ? 'idle' : 'off';
1877
+ let h = `<div class="agent-health-card">`;
1878
+ h += `<div class="ahc-head"><span class="dot ${dot}"></span><span class="ahc-aid">${esc(ag.aid || ag.name)}</span><span class="ahc-status">${esc(ag.status)}</span></div>`;
1879
+ // 项目路径
1880
+ if (ag.projectPath) h += `<div class="ahc-row"><span class="ahc-k">项目</span><span class="ahc-v ahc-path" title="${esc(ag.projectPath)}">${esc(ag.projectPath)}</span></div>`;
1881
+ // 后端
1882
+ const backend = [ag.baseagent, ag.model, ag.effort].filter(Boolean).map(esc).join(' · ');
1883
+ h += `<div class="ahc-row"><span class="ahc-k">后端</span><span class="ahc-v">${backend || '—'}</span></div>`;
1884
+ // 渠道
1885
+ let chans = '';
1886
+ for (const c of (ag.channels || [])) chans += channelHealthRow(c);
1887
+ h += `<div class="ahc-row"><span class="ahc-k">渠道</span><span class="ahc-v">${chans || '<span style="color:var(--dim)">无</span>'}</span></div>`;
1888
+ // 负载
1889
+ const load = `${ag.processing ?? 0} 处理中 · ${ag.pending ?? 0} 待处理`;
1890
+ h += `<div class="ahc-row"><span class="ahc-k">负载</span><span class="ahc-v">${load}</span></div>`;
1891
+ // 活动
1892
+ if (ag.lastActivity) h += `<div class="ahc-row"><span class="ahc-k">活动</span><span class="ahc-v">${fmtAgo(ag.lastActivity)} 前</span></div>`;
1893
+ // 错误
1894
+ if (ag.error) h += `<div class="ahc-err">⚠ ${esc(String(ag.error).slice(0, 120))}</div>`;
1895
+ h += '</div>';
1896
+ return h;
1897
+ }
1898
+
875
1899
  function renderSystem(data) {
876
1900
  const el = $('#view-system');
877
1901
  if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
@@ -913,45 +1937,26 @@ function renderSystem(data) {
913
1937
  // ③ 健康快照
914
1938
  if (chk) {
915
1939
  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 小时
1940
+ // 队列 + 近 1 小时(数字卡片同一行)
1941
+ html += '<div class="cache-cards" style="margin-bottom:8px">';
1942
+ html += `<div class="cache-card"><div class="card-label">队列</div><div class="card-val">${chk.queue?.pending ?? 0} 待 · ${chk.queue?.processing ?? 0} 处理中</div></div>`;
929
1943
  const h = chk.lastHour;
930
1944
  if (h) {
931
1945
  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>`;
1946
+ const avg = h.completed > 0 ? ` · 均 ${(h.avgResponseMs / 1000).toFixed(1)}s` : '';
1947
+ html += `<div class="cache-card"><div class="card-label">近 1 小时</div><div class="card-val">收 ${h.received} · ${h.completed} · ${h.errors}${errDetail} · 断 ${h.interrupts}${avg}</div></div>`;
936
1948
  }
937
- // EvolAgent 健康
1949
+ html += '</div>';
1950
+ // 每个 EvolAgent 一张卡片:后端 + 渠道健康 + 负载
938
1951
  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
- }
1952
+ html += '<div class="agent-health-grid">';
1953
+ for (const ag of chk.evolagents) html += agentHealthCard(ag);
946
1954
  html += '</div>';
947
1955
  }
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
- }
1956
+ // 未归属任何 EvolAgent 的渠道(系统级 / DefaultAgent)
1957
+ if (chk.unownedChannels?.length) {
1958
+ html += '<div class="cache-card" style="margin-top:8px"><div class="card-label">未归属渠道</div>';
1959
+ for (const c of chk.unownedChannels) html += channelHealthRow(c);
955
1960
  html += '</div>';
956
1961
  }
957
1962
  html += '</div>';
@@ -988,6 +1993,276 @@ function bindSystemEvents(el, data) {
988
1993
  });
989
1994
  }
990
1995
 
1996
+ // ── Gateway 视图(网关 = baseagent 后端接入配置) ──
1997
+ // 数据来自 daemon menu.query name=gateway(apiKey 已掩码)。
1998
+ // 写操作走 menuSend({name:'gateway', ...}):update/test/delete。
1999
+
2000
+ // 各 baseagent 类型的可编辑字段定义(驱动编辑表单与展示)
2001
+ const GATEWAY_FIELDS = {
2002
+ claude: [
2003
+ { key: 'baseUrl', label: 'Base URL', placeholder: 'https://gateway.example.com(留空=官方)' },
2004
+ { key: 'model', label: '默认模型', placeholder: 'opus / sonnet / claude-...' },
2005
+ { key: 'effort', label: 'Effort', placeholder: 'low / medium / high / xhigh / max' },
2006
+ ],
2007
+ codex: [
2008
+ { key: 'baseUrl', label: 'Base URL', placeholder: 'https://gateway.example.com(留空=官方)' },
2009
+ { key: 'model', label: '默认模型', placeholder: 'gpt-5.2-codex / ...' },
2010
+ { key: 'effort', label: 'Effort', placeholder: 'low / medium / high' },
2011
+ { key: 'reasoning', label: 'Reasoning', placeholder: '(可选)' },
2012
+ ],
2013
+ gemini: [
2014
+ { key: 'model', label: '默认模型', placeholder: 'gemini-2.5-flash / ...' },
2015
+ { key: 'mode', label: '模式', placeholder: 'cli / sdk' },
2016
+ { key: 'cliPath', label: 'CLI 路径', placeholder: 'gemini' },
2017
+ { key: 'project', label: 'GCP Project', placeholder: '(Vertex 用)' },
2018
+ { key: 'location', label: 'Location', placeholder: 'us-central1' },
2019
+ ],
2020
+ };
2021
+
2022
+ const GATEWAY_TYPE_ICON = { claude: '🟣', codex: '🟢', gemini: '🔵' };
2023
+
2024
+ // 标记每条网关的运行时测试结果:`${scope}#${type}` → { ok, latency, modelCount, error }
2025
+ const _gwTest = new Map();
2026
+ let _gwEditing = null; // 当前编辑中的网关 key(`${scope}#${type}`)或 'new'
2027
+
2028
+ function gwKey(scope, type) { return scope + '#' + type; }
2029
+
2030
+ function renderGateway(data) {
2031
+ const el = $('#view-gateway');
2032
+
2033
+ if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
2034
+ if (data.error) {
2035
+ el.innerHTML = `<div class="empty">⚠ ${esc(data.error)}</div>`;
2036
+ return;
2037
+ }
2038
+ const gateways = data.gateways || [];
2039
+ const scopes = data.scopes || ['defaults'];
2040
+
2041
+ // 按 scope 分组
2042
+ const byScope = new Map();
2043
+ for (const s of scopes) byScope.set(s, []);
2044
+ for (const g of gateways) {
2045
+ if (!byScope.has(g.scope)) byScope.set(g.scope, []);
2046
+ byScope.get(g.scope).push(g);
2047
+ }
2048
+
2049
+ let html = '<div class="gw-wrap">';
2050
+
2051
+ html += '<div class="gw-intro">网关 = 各 AI 后端(baseagent)的接入配置。Base URL 即网关地址,留空走官方端点。' +
2052
+ '此处为只读展示,配置请通过配置文件(defaults.json / agents/&lt;aid&gt;/config.json)管理。</div>';
2053
+
2054
+ // 只展示全局默认配置块(用于编辑)
2055
+ for (const [scope, list] of byScope) {
2056
+ if (scope !== 'defaults') continue; // 跳过 per-agent 原始配置块(已在下方 effective 展示)
2057
+ const scopeLabel = '🌐 全局默认 (defaults)';
2058
+ html += `<div class="gw-scope">`;
2059
+ html += `<div class="gw-scope-head"><span class="gw-scope-title">${scopeLabel}</span></div>`;
2060
+ html += '<div class="gw-cards">';
2061
+ if (!list.length) {
2062
+ html += '<div class="empty" style="padding:12px">该作用域暂无网关配置</div>';
2063
+ } else {
2064
+ for (const g of list) html += gatewayCard(g);
2065
+ }
2066
+ html += '</div></div>';
2067
+ }
2068
+
2069
+ // ── Agent 使用配置(effective):紧凑表格 + 编辑按钮 ──
2070
+ const effective = data.effective || [];
2071
+ if (effective.length > 0) {
2072
+ html += '<div class="gw-effective-section">';
2073
+ html += '<div class="gw-scope-head"><span class="gw-scope-title">📋 Agent 网关配置</span></div>';
2074
+ html += '<table class="gw-eff-table"><thead><tr>' +
2075
+ '<th>Agent</th><th>Base Agent</th><th>Base URL</th><th>模型</th><th>API Key</th><th>Effort</th><th>来源</th>' +
2076
+ '</tr></thead><tbody>';
2077
+ for (const eff of effective) {
2078
+ const f = eff.fields || {};
2079
+ const blockSrc = eff.blockSource || 'defaults';
2080
+ const srcCls = blockSrc === 'agent' ? 'gw-src-agent' : 'gw-src-defaults';
2081
+ const srcLabel = blockSrc === 'agent' ? '⚡ agent' : '🔗 默认';
2082
+ const baseUrlVal = f.baseUrl?.value || '';
2083
+ const modelVal = f.model?.value || '';
2084
+ const keyVal = f.apiKey?.value || '';
2085
+ const effortVal = f.effort?.value || '';
2086
+
2087
+ html += `<tr class="gw-eff-tr${blockSrc === 'defaults' ? ' gw-eff-tr-inherited' : ''}">` +
2088
+ `<td class="gw-eff-td-aid" title="${esc(eff.aid)}">${esc(shortAid(eff.aid))}</td>` +
2089
+ `<td>${GATEWAY_TYPE_ICON[eff.type] || ''} ${esc(eff.type)}</td>` +
2090
+ `<td class="gw-eff-td-url" title="${esc(baseUrlVal)}">${baseUrlVal ? esc(baseUrlVal) : '<span class="gw-dim">官方</span>'}</td>` +
2091
+ `<td>${modelVal ? esc(modelVal) : '<span class="gw-dim">—</span>'}</td>` +
2092
+ `<td>${keyVal ? esc(keyVal) : '<span class="gw-dim">—</span>'}</td>` +
2093
+ `<td>${effortVal ? esc(effortVal) : '<span class="gw-dim">—</span>'}</td>` +
2094
+ `<td><span class="gw-eff-src-tag ${srcCls}">${srcLabel}</span></td>` +
2095
+ `</tr>`;
2096
+ }
2097
+ html += '</tbody></table></div>';
2098
+ }
2099
+
2100
+ html += '</div>';
2101
+ el.innerHTML = html;
2102
+ bindGatewayEvents(el, data);
2103
+ }
2104
+
2105
+ function gatewayCard(g) {
2106
+ const key = gwKey(g.scope, g.type);
2107
+ const icon = GATEWAY_TYPE_ICON[g.type] || '⚙';
2108
+ const test = _gwTest.get(key);
2109
+
2110
+ // 连通性测试状态点
2111
+ let dot = '<span class="gw-dot gw-dot-unknown" title="未测试"></span>';
2112
+ if (test) {
2113
+ if (test.ok) dot = `<span class="gw-dot gw-dot-ok" title="${test.latency}ms · ${test.modelCount} 模型"></span>`;
2114
+ else dot = `<span class="gw-dot gw-dot-err" title="${esc(test.error || '失败')}"></span>`;
2115
+ }
2116
+
2117
+ // API Key 展示
2118
+ let keyHtml;
2119
+ if (!g.apiKeyMask) keyHtml = '<span class="gw-dim">未配置</span>';
2120
+ else if (g.apiKeyIsEnvRef) keyHtml = `<code class="gw-env">${esc(g.apiKeyMask)}</code>`;
2121
+ else keyHtml = '<span class="gw-dim" title="明文密钥已隐藏,建议改用 $ENV 引用">*** (明文)</span>';
2122
+
2123
+ const rows = [];
2124
+ rows.push(['Base URL', g.baseUrl ? esc(g.baseUrl) : '<span class="gw-dim">官方端点</span>']);
2125
+ rows.push(['默认模型', g.model ? esc(g.model) : '<span class="gw-dim">—</span>']);
2126
+ rows.push(['API Key', keyHtml]);
2127
+ if (g.effort) rows.push(['Effort', esc(g.effort)]);
2128
+ if (g.reasoning) rows.push(['Reasoning', esc(g.reasoning)]);
2129
+ if (g.mode) rows.push(['模式', esc(g.mode)]);
2130
+ if (g.cliPath) rows.push(['CLI 路径', esc(g.cliPath)]);
2131
+ if (g.project) rows.push(['Project', esc(g.project)]);
2132
+ if (g.location) rows.push(['Location', esc(g.location)]);
2133
+
2134
+ let html = `<div class="gw-card" data-key="${esc(key)}">`;
2135
+ html += `<div class="gw-card-head">${dot}<span class="gw-card-icon">${icon}</span>` +
2136
+ `<span class="gw-card-title">${esc(g.name)}</span>` +
2137
+ `<span class="gw-card-type">${esc(g.type)}</span></div>`;
2138
+ html += '<div class="gw-card-body">';
2139
+ for (const [label, val] of rows) {
2140
+ html += `<div class="gw-row"><span class="gw-row-label">${esc(label)}</span><span class="gw-row-val">${val}</span></div>`;
2141
+ }
2142
+ html += '</div>';
2143
+ // 卡片操作按钮已移除(只读模式)
2144
+ html += '</div>';
2145
+ return html;
2146
+ }
2147
+
2148
+ // ── Gateway 编辑/操作弹窗函数(已禁用,网关配置现为只读展示)──
2149
+ // 如需恢复网关编辑功能,取消下方注释即可。
2150
+
2151
+ /*
2152
+ // 编辑/新增弹窗
2153
+ function openGatewayEditor(scope, type, existing, scopes) {
2154
+ const isNew = !existing;
2155
+ const fields = GATEWAY_FIELDS[type] || GATEWAY_FIELDS.claude;
2156
+
2157
+ let html = '<div class="gw-modal-backdrop" id="gw-modal-backdrop"><div class="gw-modal">';
2158
+ html += `<div class="gw-modal-head">${isNew ? '添加网关' : '编辑网关'}</div>`;
2159
+ html += '<div class="gw-modal-body">';
2160
+
2161
+ // scope 选择(新增时可选,编辑时锁定)
2162
+ html += '<label class="gw-field"><span class="gw-field-label">作用域</span>';
2163
+ if (isNew) {
2164
+ html += '<select id="gw-f-scope">';
2165
+ for (const s of (scopes || ['defaults'])) {
2166
+ const lbl = s === 'defaults' ? '全局默认' : shortAid(s);
2167
+ html += `<option value="${esc(s)}"${s === scope ? ' selected' : ''}>${esc(lbl)}</option>`;
2168
+ }
2169
+ html += '</select>';
2170
+ } else {
2171
+ html += `<input id="gw-f-scope" type="text" value="${esc(scope)}" disabled>`;
2172
+ }
2173
+ html += '</label>';
2174
+
2175
+ // type 选择(新增时可选,编辑时锁定)
2176
+ html += '<label class="gw-field"><span class="gw-field-label">后端类型</span>';
2177
+ if (isNew) {
2178
+ html += '<select id="gw-f-type">';
2179
+ for (const t of ['claude', 'codex', 'gemini']) {
2180
+ html += `<option value="${t}"${t === type ? ' selected' : ''}>${t}</option>`;
2181
+ }
2182
+ html += '</select>';
2183
+ } else {
2184
+ html += `<input id="gw-f-type" type="text" value="${esc(type)}" disabled>`;
2185
+ }
2186
+ html += '</label>';
2187
+
2188
+ // 动态字段
2189
+ html += '<div id="gw-dyn-fields">';
2190
+ for (const f of fields) {
2191
+ const val = existing ? (existing[f.key] || '') : '';
2192
+ html += `<label class="gw-field"><span class="gw-field-label">${esc(f.label)}</span>` +
2193
+ `<input class="gw-dyn" data-key="${esc(f.key)}" type="text" value="${esc(val)}" placeholder="${esc(f.placeholder || '')}"></label>`;
2194
+ }
2195
+ html += '</div>';
2196
+
2197
+ // API Key(仅 $ENV 引用)
2198
+ const curKey = existing && existing.apiKeyIsEnvRef ? existing.apiKeyMask : '';
2199
+ html += '<label class="gw-field"><span class="gw-field-label">API Key 引用</span>' +
2200
+ `<input id="gw-f-apikey" type="text" value="${esc(curKey)}" placeholder="$ENV:ANTHROPIC_AUTH_TOKEN(留空不改)"></label>`;
2201
+ html += '<div class="gw-hint">仅支持环境变量引用,格式 <code>$ENV:变量名</code>。明文密钥请写入环境变量后引用。</div>';
2202
+
2203
+ html += '</div>'; // body
2204
+ html += '<div class="gw-modal-actions">' +
2205
+ '<button class="ctrl-btn" id="gw-cancel">取消</button> ' +
2206
+ '<button class="ctrl-btn primary" id="gw-save">保存</button>' +
2207
+ '</div>';
2208
+ html += '</div></div>';
2209
+
2210
+ const wrap = document.createElement('div');
2211
+ wrap.innerHTML = html;
2212
+ document.body.appendChild(wrap.firstChild);
2213
+
2214
+ const backdrop = $('#gw-modal-backdrop');
2215
+ const close = () => { try { backdrop.remove(); } catch {} };
2216
+ backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
2217
+ $('#gw-cancel').onclick = close;
2218
+
2219
+ // 新增时切换 type 重建动态字段
2220
+ if (isNew) {
2221
+ $('#gw-f-type').onchange = (e) => {
2222
+ const newType = e.target.value;
2223
+ const dyn = $('#gw-dyn-fields');
2224
+ const fs2 = GATEWAY_FIELDS[newType] || GATEWAY_FIELDS.claude;
2225
+ dyn.innerHTML = fs2.map(f =>
2226
+ `<label class="gw-field"><span class="gw-field-label">${esc(f.label)}</span>` +
2227
+ `<input class="gw-dyn" data-key="${esc(f.key)}" type="text" value="" placeholder="${esc(f.placeholder || '')}"></label>`
2228
+ ).join('');
2229
+ };
2230
+ }
2231
+
2232
+ $('#gw-save').onclick = async () => {
2233
+ const fScope = $('#gw-f-scope').value;
2234
+ const fType = $('#gw-f-type').value;
2235
+ const patch = {};
2236
+ document.querySelectorAll('#gw-dyn-fields .gw-dyn').forEach(inp => {
2237
+ patch[inp.dataset.key] = inp.value.trim();
2238
+ });
2239
+ const apiKey = $('#gw-f-apikey').value.trim();
2240
+ if (apiKey) {
2241
+ if (!apiKey.startsWith('$ENV:')) { toast('API Key 必须是 $ENV:变量名 引用', true); return; }
2242
+ patch.apiKey = apiKey;
2243
+ }
2244
+ try {
2245
+ const r = mResp(await menuSend({
2246
+ type: 'menu.update', name: 'gateway',
2247
+ value: JSON.stringify({ scope: fScope, type: fType, patch }),
2248
+ }));
2249
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
2250
+ toast(r.data && r.data.reloaded ? '已保存并重载' : '已保存(未重载)');
2251
+ close();
2252
+ subscribe('gateway', {}); // 刷新
2253
+ } catch (e) { toast(e.message, true); }
2254
+ };
2255
+ }
2256
+ */
2257
+
2258
+ // openAgentSelectModal、showGatewayConfigModal、showPriceEditModal 等函数已移除(只读模式)
2259
+ // 如需恢复,取消上方注释块即可。
2260
+
2261
+ function bindGatewayEvents(el, data) {
2262
+ // 已移除所有编辑操作事件绑定(网关配置现为只读展示)
2263
+ void el; void data;
2264
+ }
2265
+
991
2266
  // ── Triggers 视图 ──
992
2267
  function trigStatusBadge(status) {
993
2268
  const map = {
@@ -1080,6 +2355,8 @@ function renderTriggers(data) {
1080
2355
 
1081
2356
  function startApp() {
1082
2357
  initTabs();
2358
+ // 恢复保存的 tab 视图
2359
+ switchView(currentView);
1083
2360
  connect();
1084
2361
  $('#logout-btn').onclick = () => {
1085
2362
  localStorage.removeItem(TOKEN_KEY);
@@ -1102,154 +2379,488 @@ function initTheme() {
1102
2379
  btn.textContent = next === 'dark' ? '☀️' : '🌙';
1103
2380
  if (_hourlyChart) { _hourlyChart.dispose(); _hourlyChart = null; }
1104
2381
  if (_modelChart) { _modelChart.dispose(); _modelChart = null; }
1105
- loadUsageDashboard();
2382
+ ['_monCpu', '_monMem', '_monMsg', '_monErr'].forEach(function (k) {
2383
+ if (window[k]) { window[k].dispose(); window[k] = null; }
2384
+ });
2385
+ if (currentView === 'monitor') renderMonitor(state.monitor);
1106
2386
  };
1107
2387
  }
1108
2388
  }
1109
2389
 
1110
- // ── Usage Dashboard ──
1111
- let _hourlyChart = null;
1112
- let _modelChart = null;
1113
-
2390
+ // ── Usage 相关函数 ──
1114
2391
  function fmtTokens(n) {
1115
2392
  if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
1116
2393
  if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
1117
2394
  return String(n);
1118
2395
  }
1119
2396
 
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
- });
2397
+ // ── Usage Overview(总览,支持日期范围筛选)──
2398
+ let _ovCurrentRange = 'today'; // 当前选择的范围
2399
+
2400
+ async function loadUsageOverview(rangeType, customFrom, customTo) {
2401
+ rangeType = rangeType || _ovCurrentRange;
2402
+ _ovCurrentRange = rangeType;
2403
+
2404
+ // 计算日期范围
2405
+ let fromTs, toTs;
2406
+ const now = new Date();
2407
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
2408
+ const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
2409
+
2410
+ switch (rangeType) {
2411
+ case 'today':
2412
+ fromTs = todayStart;
2413
+ toTs = todayEnd;
2414
+ break;
2415
+ case 'week': // 本周(周一到今天)
2416
+ const dayOfWeek = now.getDay() || 7; // 周日=7
2417
+ const weekStart = new Date(todayStart - (dayOfWeek - 1) * 86400000);
2418
+ fromTs = weekStart.getTime();
2419
+ toTs = todayEnd;
2420
+ break;
2421
+ case 'lastWeek': // 上周(上周一到上周日)
2422
+ const lastWeekEnd = new Date(todayStart - now.getDay() * 86400000);
2423
+ const lastWeekStart = new Date(lastWeekEnd.getTime() - 6 * 86400000);
2424
+ fromTs = lastWeekStart.getTime();
2425
+ toTs = new Date(lastWeekEnd.getTime() + 86400000 - 1).getTime();
2426
+ break;
2427
+ case 'month': // 本月
2428
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
2429
+ fromTs = monthStart.getTime();
2430
+ toTs = todayEnd;
2431
+ break;
2432
+ case 'last30': // 最近30天
2433
+ fromTs = todayStart - 29 * 86400000;
2434
+ toTs = todayEnd;
2435
+ break;
2436
+ case 'custom': // 自定义
2437
+ if (customFrom && customTo) {
2438
+ // 支持 datetime-local 输入,直接解析时间戳
2439
+ fromTs = new Date(customFrom).getTime();
2440
+ toTs = new Date(customTo).getTime();
2441
+ } else {
2442
+ fromTs = todayStart;
2443
+ toTs = todayEnd;
2444
+ }
2445
+ break;
2446
+ default:
2447
+ fromTs = null;
2448
+ toTs = null;
1175
2449
  }
1176
2450
 
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
- }
2451
+ // 保存当前时间范围到全局变量,供详细统计使用
2452
+ window._currentOverviewTimeRange = { fromTs, toTs, rangeType, customFrom, customTo };
1186
2453
 
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';
2454
+ // 时间范围变化后,刷新明细的模型列表与查询结果(重置到第一页)
2455
+ if ($('#detail-model')) {
2456
+ const detailPageEl = $('#detail-page');
2457
+ if (detailPageEl) detailPageEl.value = '1';
2458
+ loadDetailModelList();
2459
+ queryDetailUsage();
1192
2460
  }
1193
- }
1194
2461
 
1195
- // ── Usage Overview(全时段总览)──
1196
- async function loadUsageOverview() {
1197
2462
  let data;
1198
2463
  try {
1199
- const resp = await fetch('/api/stats/overview', {
2464
+ const params = new URLSearchParams();
2465
+ if (fromTs) params.set('from', String(fromTs));
2466
+ if (toTs) params.set('to', String(toTs));
2467
+
2468
+ const resp = await fetch(apiUrl('api/stats/overview?' + params.toString()), {
1200
2469
  headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1201
2470
  });
1202
2471
  data = resp.ok ? await resp.json() : null;
1203
2472
  } catch { data = null; }
1204
2473
 
1205
2474
  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 };
2475
+ : { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, call_count: 0, cost_official_usd: 0, cost_official_cny: 0, cost_usd: 0, cost_cny: 0 };
1207
2476
  const sessionCount = (data && data.session_count) || 0;
1208
2477
  const msgIn = (data && data.msg_in) || 0;
1209
2478
  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;
2479
+ // 新的缓存命中率计算规则:缓存命中 / (缓存命中 + 缓存写入 + 输入token + 输出token)
2480
+ const totalTokens = ts.cache_read_tokens + ts.cache_creation_tokens + ts.input_tokens + ts.output_tokens;
2481
+ const hitRate = totalTokens > 0 ? (ts.cache_read_tokens / totalTokens) * 100 : 0;
1212
2482
 
1213
2483
  const cardsEl = $('#ov-cards');
1214
2484
  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('');
2485
+ // 合并相关信息到大卡片中
2486
+ const sessionCard = makeMultiValueCard([
2487
+ { label: t('usage.card.sessionCount'), value: sessionCount },
2488
+ { label: t('usage.card.msgIn'), value: msgIn },
2489
+ { label: t('usage.card.msgOut'), value: msgOut }
2490
+ ], t('usage.card.sessionInfo'), 'session-group');
2491
+
2492
+ const usageCard = makeMultiValueCard([
2493
+ { label: t('usage.card.modelCalls'), value: ts.call_count },
2494
+ { label: t('usage.card.inputTokens'), value: fmtTokens(ts.input_tokens) },
2495
+ { label: t('usage.card.outputTokens'), value: fmtTokens(ts.output_tokens) },
2496
+ { label: t('usage.card.cacheCreation'), value: fmtTokens(ts.cache_creation_tokens) },
2497
+ { label: t('usage.card.cacheHitTokens'), value: fmtTokens(ts.cache_read_tokens) },
2498
+ { label: t('usage.card.cacheHitRate'), value: hitRate.toFixed(1) + '%' }
2499
+ ], t('usage.card.usageInfo'), 'usage-group');
2500
+
2501
+ const costCard = makeMultiValueCard([
2502
+ { label: t('usage.card.costOfficial'), value: fmtCost(ts.cost_official_usd, ts.cost_official_cny) },
2503
+ { label: t('usage.card.costGateway'), value: fmtCost(ts.cost_usd, ts.cost_cny) }
2504
+ ], t('usage.card.costInfo'), 'cost-group');
2505
+
2506
+ cardsEl.innerHTML = sessionCard + usageCard + costCard;
1227
2507
  }
1228
2508
 
1229
2509
  const agentTbl = $('#ov-agent-table');
1230
2510
  const agents = (data && data.token_stats && data.token_stats.by_agent) || [];
1231
2511
  if (agentTbl) {
1232
2512
  if (!agents.length) {
1233
- agentTbl.innerHTML = '<tbody><tr><td>暂无数据</td></tr></tbody>';
2513
+ agentTbl.innerHTML = '<tbody><tr><td>' + t('usage.overview.noData') + '</td></tr></tbody>';
1234
2514
  } else {
1235
2515
  agentTbl.innerHTML =
1236
- '<thead><tr><th>Agent</th><th>调用</th><th>输入</th><th>输出</th><th>缓存创建</th><th>缓存命中</th><th>花费</th></tr></thead>' +
2516
+ '<thead><tr><th>' + t('usage.overview.th.agent') + '</th><th>' + t('usage.overview.th.calls') + '</th><th>' + t('usage.overview.th.input') + '</th><th>' + t('usage.overview.th.output') + '</th><th>' + t('usage.overview.th.cacheCreation') + '</th><th>' + t('usage.overview.th.cacheHit') + '</th><th>' + t('usage.overview.th.cacheHitRate') + '</th><th>' + t('usage.overview.th.costOfficial') + '</th><th>' + t('usage.overview.th.costGateway') + '</th></tr></thead>' +
1237
2517
  '<tbody>' + agents.map(function(a) {
1238
- var name = a.agent_aid ? a.agent_aid.split('.')[0] : '(unknown)';
2518
+ var name = a.agent_name || (a.agent_aid ? a.agent_aid.split('.')[0] : '(unknown)');
2519
+ // 计算缓存命中率
2520
+ var totalTokens = (a.cache_read_tokens || 0) + (a.cache_creation_tokens || 0) + (a.input_tokens || 0) + (a.output_tokens || 0);
2521
+ var hitRate = totalTokens > 0 ? ((a.cache_read_tokens || 0) / totalTokens * 100).toFixed(1) : '0.0';
2522
+
1239
2523
  return '<tr><td title="' + esc(a.agent_aid) + '">' + esc(name) + '</td>' +
1240
2524
  '<td>' + a.call_count + '</td>' +
1241
2525
  '<td>' + fmtTokens(a.input_tokens) + '</td>' +
1242
2526
  '<td>' + fmtTokens(a.output_tokens) + '</td>' +
1243
2527
  '<td>' + fmtTokens(a.cache_creation_tokens) + '</td>' +
1244
2528
  '<td>' + fmtTokens(a.cache_read_tokens) + '</td>' +
1245
- '<td>' + fmtCost(a.cost_usd, a.cost_cny) + '</td></tr>';
2529
+ '<td>' + hitRate + '%</td>' +
2530
+ '<td>' + fmtCostSplit(a.cost_official_usd, a.cost_official_cny) + '</td>' +
2531
+ '<td>' + fmtCostSplit(a.cost_usd, a.cost_cny) + '</td></tr>';
1246
2532
  }).join('') + '</tbody>';
1247
2533
  }
1248
2534
  }
2535
+
2536
+ // 保存总览数据供详细统计使用
2537
+ window._currentOverviewData = { ts, sessionCount, msgIn, msgOut, hitRate };
1249
2538
  }
1250
2539
 
1251
- function ovCard(value, label) {
1252
- return '<div class="usage-card"><div class="card-value">' + value + '</div><div class="card-label">' + label + '</div></div>';
2540
+ function initOverviewFilters() {
2541
+ // 范围按钮切换
2542
+ document.querySelectorAll('.ov-range-btn').forEach(function(btn) {
2543
+ btn.addEventListener('click', function() {
2544
+ document.querySelectorAll('.ov-range-btn').forEach(function(b) { b.classList.remove('active'); });
2545
+ btn.classList.add('active');
2546
+
2547
+ const range = btn.getAttribute('data-range');
2548
+ const customDateEl = $('#ov-custom-date');
2549
+
2550
+ if (range === 'custom') {
2551
+ if (customDateEl) customDateEl.style.display = 'flex';
2552
+ } else {
2553
+ if (customDateEl) customDateEl.style.display = 'none';
2554
+ loadUsageOverview(range);
2555
+ }
2556
+ });
2557
+ });
2558
+
2559
+ // 自定义日期查询按钮
2560
+ const queryBtn = $('#ov-query-btn');
2561
+ if (queryBtn) {
2562
+ queryBtn.addEventListener('click', function() {
2563
+ const fromEl = $('#ov-from');
2564
+ const toEl = $('#ov-to');
2565
+ if (fromEl && toEl && fromEl.value && toEl.value) {
2566
+ loadUsageOverview('custom', fromEl.value, toEl.value);
2567
+ }
2568
+ });
2569
+ }
2570
+
2571
+ // 设置默认日期为最近7天
2572
+ const now = new Date();
2573
+ const from = new Date(now.getTime() - 6 * 86400000);
2574
+ const fromEl = $('#ov-from');
2575
+ const toEl = $('#ov-to');
2576
+ if (fromEl) fromEl.value = formatDatetimeLocal(from);
2577
+ if (toEl) toEl.value = formatDatetimeLocal(now);
2578
+
2579
+ // 初始化明细查询
2580
+ initDetailQuery();
2581
+ }
2582
+
2583
+ // 格式化为 datetime-local 输入框的格式 (YYYY-MM-DDTHH:mm)
2584
+ function formatDatetimeLocal(date) {
2585
+ const year = date.getFullYear();
2586
+ const month = String(date.getMonth() + 1).padStart(2, '0');
2587
+ const day = String(date.getDate()).padStart(2, '0');
2588
+ const hours = String(date.getHours()).padStart(2, '0');
2589
+ const minutes = String(date.getMinutes()).padStart(2, '0');
2590
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
2591
+ }
2592
+
2593
+ // 模型访问明细查询
2594
+ function initDetailQuery() {
2595
+ // 填充Agent选择器
2596
+ loadDetailAgentList();
2597
+ // 填充Model选择器(按上面总览的时间范围)
2598
+ loadDetailModelList();
2599
+
2600
+ // 绑定分页大小变化
2601
+ const pageSizeEl = $('#detail-page-size');
2602
+ if (pageSizeEl) {
2603
+ pageSizeEl.addEventListener('change', function() {
2604
+ // 重置到第一页并查询
2605
+ const pageEl = $('#detail-page');
2606
+ if (pageEl) pageEl.value = '1';
2607
+ queryDetailUsage();
2608
+ });
2609
+ }
2610
+
2611
+ // 绑定上一页/下一页按钮
2612
+ const prevBtn = $('#detail-prev-page');
2613
+ const nextBtn = $('#detail-next-page');
2614
+ if (prevBtn) {
2615
+ prevBtn.addEventListener('click', function() {
2616
+ const pageEl = $('#detail-page');
2617
+ if (pageEl && Number(pageEl.value) > 1) {
2618
+ pageEl.value = String(Number(pageEl.value) - 1);
2619
+ queryDetailUsage();
2620
+ }
2621
+ });
2622
+ }
2623
+ if (nextBtn) {
2624
+ nextBtn.addEventListener('click', function() {
2625
+ const pageEl = $('#detail-page');
2626
+ if (pageEl) {
2627
+ pageEl.value = String(Number(pageEl.value) + 1);
2628
+ queryDetailUsage();
2629
+ }
2630
+ });
2631
+ }
2632
+
2633
+ // 绑定页码输入框回车事件
2634
+ const pageEl = $('#detail-page');
2635
+ if (pageEl) {
2636
+ pageEl.addEventListener('keypress', function(e) {
2637
+ if (e.key === 'Enter') {
2638
+ queryDetailUsage();
2639
+ }
2640
+ });
2641
+ }
2642
+
2643
+ // 绑定Agent选择器变化事件
2644
+ const agentEl = $('#detail-agent');
2645
+ if (agentEl) {
2646
+ agentEl.addEventListener('change', function() {
2647
+ const pageEl = $('#detail-page');
2648
+ if (pageEl) pageEl.value = '1';
2649
+ queryDetailUsage();
2650
+ });
2651
+ }
2652
+
2653
+ // 绑定Model选择器变化事件
2654
+ const modelEl = $('#detail-model');
2655
+ if (modelEl) {
2656
+ modelEl.addEventListener('change', function() {
2657
+ const pageEl = $('#detail-page');
2658
+ if (pageEl) pageEl.value = '1';
2659
+ queryDetailUsage();
2660
+ });
2661
+ }
2662
+ }
2663
+
2664
+ async function loadDetailAgentList() {
2665
+ try {
2666
+ const resp = await fetch(apiUrl('api/stats/agents'), {
2667
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
2668
+ });
2669
+ if (!resp.ok) return;
2670
+ const agents = await resp.json();
2671
+
2672
+ const selectEl = $('#detail-agent');
2673
+ if (selectEl && agents.length) {
2674
+ // 清空除第一个"全部"选项之外的所有选项
2675
+ while (selectEl.options.length > 1) {
2676
+ selectEl.remove(1);
2677
+ }
2678
+
2679
+ agents.forEach(function(a) {
2680
+ const option = document.createElement('option');
2681
+ option.value = a.agent_aid;
2682
+ // 优先显示agent_name,没有则显示aid前缀
2683
+ option.textContent = a.agent_name || a.agent_aid.split('.')[0];
2684
+ selectEl.appendChild(option);
2685
+ });
2686
+ // 默认选中第一个agent
2687
+ if (agents.length > 0) {
2688
+ selectEl.value = agents[0].agent_aid;
2689
+ }
2690
+ // 加载完成后自动查询一次
2691
+ queryDetailUsage();
2692
+ }
2693
+ } catch {}
2694
+ }
2695
+
2696
+ // 加载模型列表(按上面总览的时间范围)
2697
+ async function loadDetailModelList() {
2698
+ const selectEl = $('#detail-model');
2699
+ if (!selectEl) return;
2700
+ // 记住当前选中值,刷新后尽量保持
2701
+ const prev = selectEl.value;
2702
+ try {
2703
+ const timeRange = window._currentOverviewTimeRange || {};
2704
+ const params = new URLSearchParams();
2705
+ if (timeRange.fromTs) params.set('from', String(timeRange.fromTs));
2706
+ if (timeRange.toTs) params.set('to', String(timeRange.toTs));
2707
+
2708
+ const resp = await fetch(apiUrl('api/stats/models?' + params.toString()), {
2709
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
2710
+ });
2711
+ if (!resp.ok) return;
2712
+ const models = await resp.json();
2713
+
2714
+ // 清空除第一个"全部"选项之外的所有选项
2715
+ while (selectEl.options.length > 1) {
2716
+ selectEl.remove(1);
2717
+ }
2718
+ (models || []).forEach(function(m) {
2719
+ const option = document.createElement('option');
2720
+ option.value = m;
2721
+ option.textContent = m;
2722
+ selectEl.appendChild(option);
2723
+ });
2724
+ // 恢复之前的选择(若仍存在)
2725
+ if (prev && Array.prototype.some.call(selectEl.options, function(o) { return o.value === prev; })) {
2726
+ selectEl.value = prev;
2727
+ } else {
2728
+ selectEl.value = '';
2729
+ }
2730
+ } catch {}
2731
+ }
2732
+
2733
+ async function queryDetailUsage() {
2734
+ // 使用总览的时间范围
2735
+ const timeRange = window._currentOverviewTimeRange || {};
2736
+ const fromTs = timeRange.fromTs;
2737
+ const toTs = timeRange.toTs;
2738
+
2739
+ const agentEl = $('#detail-agent');
2740
+ const modelEl = $('#detail-model');
2741
+ const pageEl = $('#detail-page');
2742
+ const pageSizeEl = $('#detail-page-size');
2743
+
2744
+ const page = pageEl ? Number(pageEl.value) || 1 : 1;
2745
+ const pageSize = pageSizeEl ? Number(pageSizeEl.value) || 50 : 50;
2746
+ const offset = (page - 1) * pageSize;
2747
+
2748
+ const params = new URLSearchParams();
2749
+ if (fromTs) params.set('from', String(fromTs));
2750
+ if (toTs) params.set('to', String(toTs));
2751
+ if (agentEl && agentEl.value) params.set('agent', agentEl.value);
2752
+ if (modelEl && modelEl.value) params.set('model', modelEl.value);
2753
+ params.set('limit', String(pageSize));
2754
+ params.set('offset', String(offset));
2755
+
2756
+ try {
2757
+ const resp = await fetch(apiUrl('api/stats/detail?' + params.toString()), {
2758
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
2759
+ });
2760
+ if (!resp.ok) {
2761
+ showDetailError(t('usage.detail.error'));
2762
+ return;
2763
+ }
2764
+ const result = await resp.json();
2765
+ renderDetailTable(result.data, result.total, page, pageSize);
2766
+ } catch {
2767
+ showDetailError(t('usage.detail.error'));
2768
+ }
2769
+ }
2770
+
2771
+ function renderDetailTable(data, total, currentPage, pageSize) {
2772
+ const tableEl = $('#detail-table');
2773
+ if (!tableEl) return;
2774
+
2775
+ if (!data || !data.length) {
2776
+ tableEl.innerHTML = '<tbody><tr><td colspan="10" style="text-align:center;color:var(--dim)">' + t('usage.explorer.noData') + '</td></tr></tbody>';
2777
+ updatePaginationInfo(0, currentPage, pageSize);
2778
+ return;
2779
+ }
2780
+
2781
+ const html = '<thead><tr>' +
2782
+ '<th>' + t('usage.detail.th.time') + '</th>' +
2783
+ '<th>' + t('usage.detail.th.agent') + '</th>' +
2784
+ '<th>' + t('usage.detail.th.peer') + '</th>' +
2785
+ '<th>' + t('usage.detail.th.model') + '</th>' +
2786
+ '<th>' + t('usage.detail.th.input') + '</th>' +
2787
+ '<th>' + t('usage.detail.th.output') + '</th>' +
2788
+ '<th>' + t('usage.detail.th.cacheCreation') + '</th>' +
2789
+ '<th>' + t('usage.detail.th.cacheRead') + '</th>' +
2790
+ '<th>' + t('usage.detail.th.costOfficial') + '</th>' +
2791
+ '<th>' + t('usage.detail.th.costGateway') + '</th>' +
2792
+ '</tr></thead><tbody>' +
2793
+ data.map(function(row) {
2794
+ const time = new Date(row.ts).toLocaleString();
2795
+ const agentName = row.agent_name || (row.agent_aid || '').split('.')[0];
2796
+ const peerName = (row.peer_key || '').replace(/^aun#/, '').split('.')[0];
2797
+ return '<tr>' +
2798
+ '<td style="white-space:nowrap">' + time + '</td>' +
2799
+ '<td title="' + esc(row.agent_aid) + '">' + esc(agentName) + '</td>' +
2800
+ '<td title="' + esc(row.peer_key) + '">' + esc(peerName) + '</td>' +
2801
+ '<td>' + esc(row.model || '') + '</td>' +
2802
+ '<td>' + fmtTokens(row.input_tokens || 0) + '</td>' +
2803
+ '<td>' + fmtTokens(row.output_tokens || 0) + '</td>' +
2804
+ '<td>' + fmtTokens(row.cache_creation_tokens || 0) + '</td>' +
2805
+ '<td>' + fmtTokens(row.cache_read_tokens || 0) + '</td>' +
2806
+ '<td>' + fmtCostCompact(row.cost_official_usd, row.cost_official_cny) + '</td>' +
2807
+ '<td>' + fmtCostCompact(row.cost_gateway_usd, row.cost_gateway_cny) + '</td>' +
2808
+ '</tr>';
2809
+ }).join('') +
2810
+ '</tbody>';
2811
+
2812
+ tableEl.innerHTML = html;
2813
+ updatePaginationInfo(total, currentPage, pageSize);
2814
+ }
2815
+
2816
+ function updatePaginationInfo(total, currentPage, pageSize) {
2817
+ const infoEl = $('#detail-pagination-info');
2818
+ const prevBtn = $('#detail-prev-page');
2819
+ const nextBtn = $('#detail-next-page');
2820
+
2821
+ if (infoEl) {
2822
+ const start = total > 0 ? (currentPage - 1) * pageSize + 1 : 0;
2823
+ const end = Math.min(currentPage * pageSize, total);
2824
+ const totalPages = Math.ceil(total / pageSize) || 1;
2825
+ infoEl.textContent = t('usage.detail.pagination')
2826
+ .replace('{start}', start)
2827
+ .replace('{end}', end)
2828
+ .replace('{total}', total)
2829
+ .replace('{page}', currentPage)
2830
+ .replace('{totalPages}', totalPages);
2831
+ }
2832
+
2833
+ if (prevBtn) prevBtn.disabled = currentPage <= 1;
2834
+ if (nextBtn) nextBtn.disabled = currentPage >= Math.ceil(total / pageSize);
2835
+ }
2836
+
2837
+ function showDetailError(msg) {
2838
+ const tableEl = $('#detail-table');
2839
+ if (tableEl) {
2840
+ tableEl.innerHTML = '<tbody><tr><td colspan="10" style="text-align:center;color:var(--red)">' + esc(msg) + '</td></tr></tbody>';
2841
+ }
2842
+ }
2843
+
2844
+ function fmtCostCompact(usd, cny) {
2845
+ var parts = [];
2846
+ if (usd > 0) parts.push('$' + (usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)));
2847
+ if (cny > 0) parts.push('¥' + (cny < 0.01 ? cny.toFixed(4) : cny.toFixed(2)));
2848
+ if (parts.length === 0) return '-';
2849
+ return parts.join(' / ');
2850
+ }
2851
+
2852
+ function ovCard(value, label, groupClass) {
2853
+ var cls = 'usage-card' + (groupClass ? ' ' + groupClass : '');
2854
+ return '<div class="' + cls + '"><div class="card-value">' + value + '</div><div class="card-label">' + label + '</div></div>';
2855
+ }
2856
+
2857
+ // 创建多值卡片(合并多个指标到一个卡片中)
2858
+ function makeMultiValueCard(items, title, groupClass) {
2859
+ var cls = 'usage-card multi-value-card' + (groupClass ? ' ' + groupClass : '');
2860
+ var itemsHtml = items.map(function(item) {
2861
+ return '<div class="card-item"><div class="card-item-label">' + item.label + '</div><div class="card-item-value">' + item.value + '</div></div>';
2862
+ }).join('');
2863
+ return '<div class="' + cls + '"><div class="card-title">' + title + '</div><div class="card-items">' + itemsHtml + '</div></div>';
1253
2864
  }
1254
2865
 
1255
2866
  function fmtCost(usd, cny) {
@@ -1259,6 +2870,25 @@ function fmtCost(usd, cny) {
1259
2870
  return parts.length ? parts.join(' / ') : '$0';
1260
2871
  }
1261
2872
 
2873
+ // 分行显示美元和人民币
2874
+ function fmtCostSplit(usd, cny) {
2875
+ var parts = [];
2876
+ if (usd > 0) parts.push('$' + (usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)));
2877
+ if (cny > 0) parts.push('¥' + (cny < 0.01 ? cny.toFixed(4) : cny.toFixed(2)));
2878
+ if (parts.length === 0) return '<span style="color:var(--dim)">$0</span>';
2879
+ if (parts.length === 1) return parts[0];
2880
+ return parts[0] + '<br><span style="font-size:10px;color:var(--dim)">' + parts[1] + '</span>';
2881
+ }
2882
+
2883
+ // 带标签的价格显示(用于卡片)
2884
+ function fmtCostWithLabel(usd, cny, label) {
2885
+ var parts = [];
2886
+ if (usd > 0) parts.push('$' + (usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)));
2887
+ if (cny > 0) parts.push('¥' + (cny < 0.01 ? cny.toFixed(4) : cny.toFixed(2)));
2888
+ var value = parts.length ? parts.join(' / ') : '$0';
2889
+ return '<div class="card-label" style="margin-bottom:4px;margin-top:0">' + label + '</div><div class="card-value" style="font-size:18px">' + value + '</div>';
2890
+ }
2891
+
1262
2892
  // ── Usage subtab switching ──
1263
2893
  function initUsageSubtabs() {
1264
2894
  var btns = document.querySelectorAll('.usage-subtab');
@@ -1273,89 +2903,324 @@ function initUsageSubtabs() {
1273
2903
  });
1274
2904
  var panel = $('#usage-' + target);
1275
2905
  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();
2906
+ if (target === 'overview') {
2907
+ initOverviewFilters();
2908
+ loadUsageOverview();
2909
+ } else if (target === 'explorer') {
2910
+ initExplorer();
2911
+ // 自动加载模型列表和执行查询
2912
+ loadExplorerModels();
2913
+ setTimeout(() => runExplorerQuery(), 100);
2914
+ }
1279
2915
  });
1280
2916
  });
2917
+
2918
+ // 初始化总览页面的过滤器并加载默认数据(今日)
2919
+ initOverviewFilters();
2920
+ loadUsageOverview('today');
1281
2921
  }
1282
2922
 
1283
2923
  // ── Explorer ──
1284
2924
  var _explorerChart = null;
1285
2925
  var _explorerInited = false;
1286
2926
  var _expSelection = { type: null, key: null }; // { type: 'agent'|'peer', key: string } or null
2927
+ var _expCurrentRange = 'today'; // Explorer 当前选择的时间范围
2928
+ var _expTimeRange = { fromTs: null, toTs: null }; // Explorer 的时间范围
1287
2929
 
1288
2930
  function initExplorer() {
1289
2931
  if (_explorerInited) return;
1290
2932
  _explorerInited = true;
2933
+
2934
+ // 初始化时间范围选择
2935
+ initExplorerTimeFilters();
2936
+
2937
+ // 绑定查询按钮
1291
2938
  var btn = $('#exp-query-btn');
1292
2939
  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);
2940
+
1300
2941
  // Load sidebar lists
1301
2942
  loadExplorerSidebar();
1302
2943
  }
1303
2944
 
2945
+ // 初始化 Explorer 的时间范围选择
2946
+ function initExplorerTimeFilters() {
2947
+ // 范围按钮切换
2948
+ document.querySelectorAll('.exp-range-btn').forEach(function(btn) {
2949
+ btn.addEventListener('click', function() {
2950
+ document.querySelectorAll('.exp-range-btn').forEach(function(b) { b.classList.remove('active'); });
2951
+ btn.classList.add('active');
2952
+
2953
+ const range = btn.getAttribute('data-range');
2954
+ const customDateEl = $('#exp-custom-date');
2955
+
2956
+ if (range === 'custom') {
2957
+ if (customDateEl) customDateEl.style.display = 'flex';
2958
+ } else {
2959
+ if (customDateEl) customDateEl.style.display = 'none';
2960
+ _expCurrentRange = range;
2961
+ calculateExplorerTimeRange(range);
2962
+ loadExplorerModels(); // 加载可用模型
2963
+ runExplorerQuery();
2964
+ }
2965
+ });
2966
+ });
2967
+
2968
+ // 自定义时间查询按钮
2969
+ const timeQueryBtn = $('#exp-time-query-btn');
2970
+ if (timeQueryBtn) {
2971
+ timeQueryBtn.addEventListener('click', function() {
2972
+ const fromEl = $('#exp-from');
2973
+ const toEl = $('#exp-to');
2974
+ if (fromEl && toEl && fromEl.value && toEl.value) {
2975
+ _expCurrentRange = 'custom';
2976
+ _expTimeRange.fromTs = new Date(fromEl.value).getTime();
2977
+ _expTimeRange.toTs = new Date(toEl.value).getTime();
2978
+ loadExplorerModels(); // 加载可用模型
2979
+ runExplorerQuery();
2980
+ }
2981
+ });
2982
+ }
2983
+
2984
+ // 设置默认时间范围(今日)并初始化日期选择器
2985
+ const now = new Date();
2986
+ const fromEl = $('#exp-from');
2987
+ const toEl = $('#exp-to');
2988
+ if (fromEl) fromEl.value = formatDatetimeLocal(new Date(now.getFullYear(), now.getMonth(), now.getDate()));
2989
+ if (toEl) toEl.value = formatDatetimeLocal(now);
2990
+
2991
+ // 计算默认时间范围(今日)
2992
+ calculateExplorerTimeRange('today');
2993
+ }
2994
+
2995
+ // 计算 Explorer 的时间范围
2996
+ function calculateExplorerTimeRange(rangeType) {
2997
+ const now = new Date();
2998
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
2999
+ const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
3000
+
3001
+ switch (rangeType) {
3002
+ case 'today':
3003
+ _expTimeRange.fromTs = todayStart;
3004
+ _expTimeRange.toTs = todayEnd;
3005
+ break;
3006
+ case 'week':
3007
+ const dayOfWeek = now.getDay() || 7;
3008
+ const weekStart = new Date(todayStart - (dayOfWeek - 1) * 86400000);
3009
+ _expTimeRange.fromTs = weekStart.getTime();
3010
+ _expTimeRange.toTs = todayEnd;
3011
+ break;
3012
+ case 'lastWeek':
3013
+ const lastWeekEnd = new Date(todayStart - now.getDay() * 86400000);
3014
+ const lastWeekStart = new Date(lastWeekEnd.getTime() - 6 * 86400000);
3015
+ _expTimeRange.fromTs = lastWeekStart.getTime();
3016
+ _expTimeRange.toTs = new Date(lastWeekEnd.getTime() + 86400000 - 1).getTime();
3017
+ break;
3018
+ case 'month':
3019
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
3020
+ _expTimeRange.fromTs = monthStart.getTime();
3021
+ _expTimeRange.toTs = todayEnd;
3022
+ break;
3023
+ case 'last30':
3024
+ _expTimeRange.fromTs = todayStart - 29 * 86400000;
3025
+ _expTimeRange.toTs = todayEnd;
3026
+ break;
3027
+ }
3028
+ }
3029
+
3030
+ // 加载 Explorer 可用的模型列表(根据当前时间范围)
3031
+ async function loadExplorerModels() {
3032
+ const params = new URLSearchParams();
3033
+ if (_expTimeRange.fromTs) params.set('from', String(_expTimeRange.fromTs));
3034
+ if (_expTimeRange.toTs) params.set('to', String(_expTimeRange.toTs));
3035
+
3036
+ try {
3037
+ const resp = await fetch(apiUrl('api/stats/models?' + params.toString()), {
3038
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
3039
+ });
3040
+ if (!resp.ok) return;
3041
+ const models = await resp.json();
3042
+
3043
+ const selectEl = $('#exp-model');
3044
+ if (selectEl) {
3045
+ const currentValue = selectEl.value;
3046
+ selectEl.innerHTML = '<option value="">' + t('usage.explorer.all') + '</option>';
3047
+ models.forEach(function(model) {
3048
+ const option = document.createElement('option');
3049
+ option.value = model;
3050
+ option.textContent = model;
3051
+ selectEl.appendChild(option);
3052
+ });
3053
+ // 恢复之前的选择(如果还存在)
3054
+ if (currentValue && models.includes(currentValue)) {
3055
+ selectEl.value = currentValue;
3056
+ }
3057
+ }
3058
+ } catch {}
3059
+ }
3060
+
3061
+ // 获取 Explorer 时间范围的总览数据
3062
+ async function fetchExplorerOverviewData(filterParams) {
3063
+ try {
3064
+ const params = new URLSearchParams();
3065
+ if (_expTimeRange.fromTs) params.set('from', String(_expTimeRange.fromTs));
3066
+ if (_expTimeRange.toTs) params.set('to', String(_expTimeRange.toTs));
3067
+
3068
+ // 添加筛选参数
3069
+ if (filterParams) {
3070
+ if (filterParams.agent) params.set('agent', filterParams.agent);
3071
+ if (filterParams.peer) params.set('peer', filterParams.peer);
3072
+ }
3073
+
3074
+ const resp = await fetch(apiUrl('api/stats/overview?' + params.toString()), {
3075
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
3076
+ });
3077
+ const data = resp.ok ? await resp.json() : null;
3078
+
3079
+ if (data) {
3080
+ const ts = (data.token_stats && data.token_stats.all_time) ? data.token_stats.all_time
3081
+ : { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, call_count: 0, cost_official_usd: 0, cost_official_cny: 0, cost_usd: 0, cost_cny: 0 };
3082
+ const sessionCount = data.session_count || 0;
3083
+ const msgIn = data.msg_in || 0;
3084
+ const msgOut = data.msg_out || 0;
3085
+ const totalTokens = ts.cache_read_tokens + ts.cache_creation_tokens + ts.input_tokens + ts.output_tokens;
3086
+ const hitRate = totalTokens > 0 ? (ts.cache_read_tokens / totalTokens) * 100 : 0;
3087
+
3088
+ return { ts, sessionCount, msgIn, msgOut, hitRate };
3089
+ }
3090
+ } catch {}
3091
+
3092
+ // 返回空数据
3093
+ return {
3094
+ ts: { call_count: 0, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, cost_official_usd: 0, cost_official_cny: 0, cost_usd: 0, cost_cny: 0 },
3095
+ sessionCount: 0,
3096
+ msgIn: 0,
3097
+ msgOut: 0,
3098
+ hitRate: 0
3099
+ };
3100
+ }
3101
+
1304
3102
  async function loadExplorerSidebar() {
1305
3103
  var token = localStorage.getItem(TOKEN_KEY);
1306
3104
  var headers = { Authorization: 'Bearer ' + token };
1307
3105
  try {
1308
- var [agentsResp, peersResp] = await Promise.all([
1309
- fetch('/api/stats/agents', { headers }),
1310
- fetch('/api/stats/peers', { headers }),
1311
- ]);
3106
+ var agentsResp = await fetch(apiUrl('api/stats/agents'), { headers });
1312
3107
  var agents = agentsResp.ok ? await agentsResp.json() : [];
1313
- var peers = peersResp.ok ? await peersResp.json() : [];
1314
- renderExplorerSidebar(agents, peers);
3108
+ renderExplorerAgentList(agents);
3109
+
3110
+ // 初始加载时不加载 peers(等待用户选择 agent)
3111
+ renderExplorerPeerList([]);
1315
3112
  } catch {}
1316
3113
  }
1317
3114
 
1318
- function renderExplorerSidebar(agents, peers) {
3115
+ // 渲染 Agent 列表
3116
+ function renderExplorerAgentList(agents) {
1319
3117
  var agentList = $('#exp-agent-list');
1320
- var peerList = $('#exp-peer-list');
1321
- if (!agentList || !peerList) return;
3118
+ if (!agentList) return;
1322
3119
 
1323
3120
  // "All" item for agents
1324
3121
  var allHtml = '<div class="exp-sidebar-item active" data-type="all" data-key="">' +
1325
- '<span class="item-name">全部</span></div>';
3122
+ '<span class="item-name">' + t('usage.explorer.all') + '</span></div>';
1326
3123
 
1327
3124
  agentList.innerHTML = allHtml + agents.map(function(a) {
1328
- var name = a.agent_aid ? a.agent_aid.split('.')[0] : 'unknown';
3125
+ var name = a.agent_name || (a.agent_aid ? a.agent_aid.split('.')[0] : 'unknown');
1329
3126
  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>';
3127
+ '<span class="item-name" title="' + escHtml(a.agent_aid) + '">' + escHtml(name) + '</span></div>';
1332
3128
  }).join('');
1333
3129
 
3130
+ // 绑定点击事件
3131
+ agentList.querySelectorAll('.exp-sidebar-item').forEach(function(el) {
3132
+ el.addEventListener('click', async function() {
3133
+ // Clear active from all
3134
+ document.querySelectorAll('#exp-agent-list .exp-sidebar-item').forEach(function(x) { x.classList.remove('active'); });
3135
+ el.classList.add('active');
3136
+
3137
+ var type = el.getAttribute('data-type');
3138
+ var key = el.getAttribute('data-key');
3139
+
3140
+ if (type === 'all') {
3141
+ _expSelection = { type: null, key: null };
3142
+ $('#exp-selected-name').textContent = t('usage.explorer.all');
3143
+ // 选择"全部"时,清空 peers 列表
3144
+ renderExplorerPeerList([]);
3145
+ } else {
3146
+ _expSelection = { type: type, key: key };
3147
+ var name = el.querySelector('.item-name').textContent.trim();
3148
+ $('#exp-selected-name').textContent = name;
3149
+ // 选择特定 agent 时,加载该 agent 的 peers
3150
+ await loadPeersForAgent(key);
3151
+ }
3152
+
3153
+ runExplorerQuery();
3154
+ });
3155
+ });
3156
+ }
3157
+
3158
+ // 加载指定 agent 的 peers
3159
+ async function loadPeersForAgent(agentAid) {
3160
+ var token = localStorage.getItem(TOKEN_KEY);
3161
+ var headers = { Authorization: 'Bearer ' + token };
3162
+ try {
3163
+ const params = new URLSearchParams();
3164
+ params.set('agent', agentAid);
3165
+ // 不传递时间范围,获取该 agent 的所有 peers
3166
+
3167
+ var resp = await fetch(apiUrl('api/stats/peers?' + params.toString()), { headers });
3168
+ var peers = resp.ok ? await resp.json() : [];
3169
+ renderExplorerPeerList(peers);
3170
+ } catch {
3171
+ renderExplorerPeerList([]);
3172
+ }
3173
+ }
3174
+
3175
+ // 渲染 Peer 列表
3176
+ function renderExplorerPeerList(peers) {
3177
+ var peerList = $('#exp-peer-list');
3178
+ if (!peerList) return;
3179
+
3180
+ if (!peers || peers.length === 0) {
3181
+ peerList.innerHTML = '<div style="padding: 12px; color: var(--dim); font-size: 12px; text-align: center;">' + t('common.noData') + '</div>';
3182
+ return;
3183
+ }
3184
+
1334
3185
  peerList.innerHTML = peers.map(function(p) {
1335
3186
  var name = p.peer_key || 'unknown';
1336
- // 简化显示:去掉 channel# 前缀中的 aun#,保留核心部分
1337
- var display = name.replace(/^aun#/, '').split('.')[0];
3187
+ // 优先显示peer_name,否则简化显示peer_key
3188
+ var display = p.peer_name || name.replace(/^aun#/, '').split('#')[0].split('.')[0];
3189
+
3190
+ // 添加聊天类型标签
3191
+ var typeTag = '';
3192
+ if (p.peer_chat_type === 'group') {
3193
+ typeTag = '<span class="peer-tag peer-tag-group">' + t('usage.explorer.chatType.group') + '</span>';
3194
+ } else if (p.peer_chat_type === 'private') {
3195
+ typeTag = '<span class="peer-tag peer-tag-private">' + t('usage.explorer.chatType.private') + '</span>';
3196
+ }
3197
+
3198
+ // 群聊人数标签
3199
+ var memberTag = '';
3200
+ if (p.peer_chat_type === 'group' && p.peer_group_member_count) {
3201
+ memberTag = '<span class="peer-tag peer-tag-count">' + p.peer_group_member_count + t('usage.explorer.memberCount') + '</span>';
3202
+ }
3203
+
1338
3204
  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>' +
3205
+ '<span class="item-name" title="' + escHtml(name) + '">' +
3206
+ (typeTag ? typeTag + ' ' : '') + escHtml(display) + (memberTag ? ' ' + memberTag : '') +
3207
+ '</span>' +
1340
3208
  '<span class="item-meta">' + fmtTokens((p.input_tokens || 0) + (p.output_tokens || 0)) + '</span></div>';
1341
3209
  }).join('');
1342
3210
 
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) {
3211
+ // Bind click events for peers
3212
+ peerList.querySelectorAll('.exp-sidebar-item').forEach(function(el) {
1346
3213
  el.addEventListener('click', function() {
1347
- // Clear active from all
1348
- allItems.forEach(function(x) { x.classList.remove('active'); });
3214
+ // Clear active from all peers
3215
+ document.querySelectorAll('#exp-peer-list .exp-sidebar-item').forEach(function(x) { x.classList.remove('active'); });
1349
3216
  el.classList.add('active');
3217
+
1350
3218
  var type = el.getAttribute('data-type');
1351
3219
  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
- }
3220
+ _expSelection = { type: type, key: key };
3221
+ var name = el.querySelector('.item-name').textContent.trim();
3222
+ $('#exp-selected-name').textContent = name;
3223
+
1359
3224
  runExplorerQuery();
1360
3225
  });
1361
3226
  });
@@ -1364,11 +3229,13 @@ function renderExplorerSidebar(agents, peers) {
1364
3229
  function escHtml(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
1365
3230
 
1366
3231
  async function runExplorerQuery() {
3232
+ // 使用 Explorer 自己的时间范围
3233
+ const fromTs = _expTimeRange.fromTs;
3234
+ const toTs = _expTimeRange.toTs;
3235
+
1367
3236
  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()));
3237
+ if (fromTs) params.set('from', String(fromTs));
3238
+ if (toTs) params.set('to', String(toTs));
1372
3239
  // Inject selection from sidebar
1373
3240
  if (_expSelection.type === 'agent' && _expSelection.key) params.set('agent', _expSelection.key);
1374
3241
  if (_expSelection.type === 'peer' && _expSelection.key) params.set('peer', _expSelection.key);
@@ -1379,33 +3246,123 @@ async function runExplorerQuery() {
1379
3246
 
1380
3247
  var data;
1381
3248
  try {
1382
- var resp = await fetch('/api/stats/explorer?' + params.toString(), {
3249
+ var resp = await fetch(apiUrl('api/stats/explorer?' + params.toString()), {
1383
3250
  headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1384
3251
  });
1385
3252
  if (!resp.ok) return;
1386
3253
  data = await resp.json();
1387
3254
  } catch { return; }
1388
3255
 
1389
- // Show/hide detail cards
3256
+ // 根据查询结果计算卡片数据
1390
3257
  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>';
3258
+ if (cardsEl) {
3259
+ // 如果有筛选条件(agent/peer),使用查询结果计算;否则需要获取总览数据
3260
+ const hasFilter = _expSelection.type && _expSelection.key;
3261
+
3262
+ let cardData;
3263
+ let cardTitle = null; // 用于显示选中的 agent/peer 信息
3264
+
3265
+ if (hasFilter) {
3266
+ // 有筛选:根据查询结果计算 token 数据,并获取会话信息
3267
+ var totIn = 0, totOut = 0, totCacheCreation = 0, totCacheRead = 0, totCalls = 0;
3268
+ if (data && data.length) {
3269
+ data.forEach(function(r) {
3270
+ totIn += r.input_tokens || 0;
3271
+ totOut += r.output_tokens || 0;
3272
+ totCacheCreation += r.cache_creation_tokens || 0;
3273
+ totCacheRead += r.cache_read_tokens || 0;
3274
+ totCalls += r.call_count || 0;
3275
+ });
3276
+ }
3277
+ const totalTokens = totCacheRead + totCacheCreation + totIn + totOut;
3278
+ const hitRate = totalTokens > 0 ? (totCacheRead / totalTokens) * 100 : 0;
3279
+
3280
+ // 构建筛选参数
3281
+ const filterParams = {};
3282
+ if (_expSelection.type === 'agent') filterParams.agent = _expSelection.key;
3283
+ if (_expSelection.type === 'peer') filterParams.peer = _expSelection.key;
3284
+
3285
+ // 获取该筛选条件下的会话信息
3286
+ const overviewData = await fetchExplorerOverviewData(filterParams);
3287
+
3288
+ cardData = {
3289
+ ts: {
3290
+ call_count: totCalls,
3291
+ input_tokens: totIn,
3292
+ output_tokens: totOut,
3293
+ cache_creation_tokens: totCacheCreation,
3294
+ cache_read_tokens: totCacheRead,
3295
+ cost_official_usd: overviewData.ts.cost_official_usd || 0,
3296
+ cost_official_cny: overviewData.ts.cost_official_cny || 0,
3297
+ cost_usd: overviewData.ts.cost_usd || 0,
3298
+ cost_cny: overviewData.ts.cost_cny || 0
3299
+ },
3300
+ sessionCount: overviewData.sessionCount || 0,
3301
+ msgIn: overviewData.msgIn || 0,
3302
+ msgOut: overviewData.msgOut || 0,
3303
+ hitRate: hitRate
3304
+ };
3305
+
3306
+ // 构建卡片标题
3307
+ if (_expSelection.type === 'agent') {
3308
+ // 从侧边栏获取 agent 名称
3309
+ const selectedItem = document.querySelector('#exp-agent-list .exp-sidebar-item.active .item-name');
3310
+ const agentName = selectedItem ? selectedItem.textContent.trim() : '';
3311
+ const agentAid = _expSelection.key;
3312
+ cardTitle = agentName && agentName !== agentAid.split('.')[0]
3313
+ ? `${agentName} (AID: ${agentAid})`
3314
+ : `AID: ${agentAid}`;
3315
+ } else if (_expSelection.type === 'peer') {
3316
+ // 从侧边栏获取 peer 名称(去掉标签)
3317
+ const selectedItem = document.querySelector('#exp-peer-list .exp-sidebar-item.active .item-name');
3318
+ if (selectedItem) {
3319
+ // 克隆节点并移除所有标签元素
3320
+ const clone = selectedItem.cloneNode(true);
3321
+ const tags = clone.querySelectorAll('.peer-tag');
3322
+ tags.forEach(tag => tag.remove());
3323
+ const peerName = clone.textContent.trim();
3324
+ const peerKey = _expSelection.key;
3325
+ cardTitle = peerName ? `${peerName} (Peer: ${peerKey.split('#')[3] || peerKey.split('#')[0]})` : `Peer: ${peerKey}`;
3326
+ } else {
3327
+ cardTitle = `Peer: ${_expSelection.key}`;
3328
+ }
3329
+ }
3330
+ } else {
3331
+ // 无筛选:获取 Explorer 时间范围的总览数据
3332
+ cardData = await fetchExplorerOverviewData();
1401
3333
  }
1402
- } else {
1403
- if (cardsEl) cardsEl.style.display = 'none';
3334
+
3335
+ const { ts, sessionCount, msgIn, msgOut, hitRate } = cardData;
3336
+
3337
+ // 不显示标题行,直接显示卡片
3338
+ // 注意:会话信息是该时间范围的总数(不区分 agent/peer)
3339
+ const sessionCard = makeMultiValueCard([
3340
+ { label: t('usage.card.sessionCount'), value: sessionCount },
3341
+ { label: t('usage.card.msgIn'), value: msgIn },
3342
+ { label: t('usage.card.msgOut'), value: msgOut }
3343
+ ], t('usage.card.sessionInfo'), 'session-group');
3344
+
3345
+ const usageCard = makeMultiValueCard([
3346
+ { label: t('usage.card.modelCalls'), value: ts.call_count },
3347
+ { label: t('usage.card.inputTokens'), value: fmtTokens(ts.input_tokens) },
3348
+ { label: t('usage.card.outputTokens'), value: fmtTokens(ts.output_tokens) },
3349
+ { label: t('usage.card.cacheCreation'), value: fmtTokens(ts.cache_creation_tokens) },
3350
+ { label: t('usage.card.cacheHitTokens'), value: fmtTokens(ts.cache_read_tokens) },
3351
+ { label: t('usage.card.cacheHitRate'), value: hitRate.toFixed(1) + '%' }
3352
+ ], t('usage.card.usageInfo'), 'usage-group');
3353
+
3354
+ const costCard = makeMultiValueCard([
3355
+ { label: t('usage.card.costOfficial'), value: fmtCost(ts.cost_official_usd, ts.cost_official_cny) },
3356
+ { label: t('usage.card.costGateway'), value: fmtCost(ts.cost_usd, ts.cost_cny) }
3357
+ ], t('usage.card.costInfo'), 'cost-group');
3358
+
3359
+ cardsEl.innerHTML = sessionCard + usageCard + costCard;
3360
+ cardsEl.style.display = 'flex';
1404
3361
  }
1405
3362
 
1406
3363
  if (!data || !data.length) {
1407
3364
  var tbl = $('#usage-explorer-table');
1408
- if (tbl) tbl.innerHTML = '<tr><td>No data for selected range.</td></tr>';
3365
+ if (tbl) tbl.innerHTML = '<tr><td>' + t('usage.explorer.noData') + '</td></tr>';
1409
3366
  var chartEl = $('#usage-explorer-chart');
1410
3367
  if (chartEl && _explorerChart) { _explorerChart.dispose(); _explorerChart = null; }
1411
3368
  return;
@@ -1420,13 +3377,13 @@ async function runExplorerQuery() {
1420
3377
  var periods = data.map(function(r) { return r.period; });
1421
3378
  _explorerChart.setOption({
1422
3379
  tooltip: { trigger: 'axis' },
1423
- legend: { data: ['Input', 'Output'], top: 0, textStyle: { fontSize: 11 } },
3380
+ legend: { data: [t('usage.card.input'), t('usage.card.output')], top: 0, textStyle: { fontSize: 11 } },
1424
3381
  grid: { top: 30, bottom: 30, left: 60, right: 16 },
1425
3382
  xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 10, rotate: 30 } },
1426
3383
  yAxis: { type: 'value', axisLabel: { formatter: function(v) { return fmtTokens(v); } } },
1427
3384
  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' } },
3385
+ { name: t('usage.card.input'), type: 'line', data: data.map(function(r) { return r.input_tokens; }), smooth: true, areaStyle: { opacity: 0.15 }, itemStyle: { color: '#4f6ef7' } },
3386
+ { name: t('usage.card.output'), type: 'line', data: data.map(function(r) { return r.output_tokens; }), smooth: true, areaStyle: { opacity: 0.15 }, itemStyle: { color: '#38a169' } },
1430
3387
  ]
1431
3388
  });
1432
3389
  }
@@ -1435,7 +3392,7 @@ async function runExplorerQuery() {
1435
3392
  var tbl = $('#usage-explorer-table');
1436
3393
  if (tbl) {
1437
3394
  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>' +
3395
+ '<thead><tr><th>' + t('usage.explorer.th.period') + '</th><th>' + t('usage.explorer.th.input') + '</th><th>' + t('usage.explorer.th.output') + '</th><th>' + t('usage.explorer.th.cacheCreation') + '</th><th>' + t('usage.explorer.th.cacheHit') + '</th><th>' + t('usage.explorer.th.calls') + '</th></tr></thead>' +
1439
3396
  '<tbody>' + data.map(function(r) {
1440
3397
  return '<tr><td>' + r.period + '</td><td>' + fmtTokens(r.input_tokens) + '</td><td>' + fmtTokens(r.output_tokens) +
1441
3398
  '</td><td>' + fmtTokens(r.cache_creation_tokens) + '</td><td>' + fmtTokens(r.cache_read_tokens) +
@@ -1444,14 +3401,239 @@ async function runExplorerQuery() {
1444
3401
  }
1445
3402
  }
1446
3403
 
1447
- window.addEventListener('DOMContentLoaded', () => {
3404
+ // ── Monitor ──────────────────────────────────────
3405
+ // 绑定时间范围切换按钮(只绑一次)
3406
+ let _monRangeBound = false;
3407
+ function bindMonRangeTabs() {
3408
+ if (_monRangeBound) return;
3409
+ var tabs = document.querySelectorAll('#view-monitor .mon-range');
3410
+ if (!tabs.length) return;
3411
+ tabs.forEach(function (btn) {
3412
+ btn.onclick = function () {
3413
+ monRange = btn.dataset.range;
3414
+ document.querySelectorAll('#view-monitor .mon-range').forEach(function (b) {
3415
+ b.classList.toggle('active', b.dataset.range === monRange);
3416
+ });
3417
+ // 切范围 → 重新订阅(源按 range 返回不同分辨率的 history)
3418
+ subscribe('monitor', { range: monRange });
3419
+ };
3420
+ });
3421
+ _monRangeBound = true;
3422
+ }
3423
+
3424
+ function renderMonitor(data) {
3425
+ var wrap = $('#view-monitor .mon-layout');
3426
+ if (!wrap) return;
3427
+ bindMonRangeTabs();
3428
+ if (!data) { return; }
3429
+ if (!data.daemonRunning) {
3430
+ // 不清空骨架,仅在卡片区提示,避免破坏 toolbar
3431
+ var cardsEl0 = $('#mon-cards');
3432
+ if (cardsEl0) cardsEl0.innerHTML = '<div class="empty" style="grid-column:1/-1">daemon 未运行</div>';
3433
+ return;
3434
+ }
3435
+
3436
+ var s = data.snapshot;
3437
+ // history 是三档分辨率对象 { fine, mid, coarse };按当前范围选一档
3438
+ var rangeKey = { '2m': 'fine', '10m': 'mid', '1h': 'coarse' }[monRange] || 'fine';
3439
+ var hist = data.history || {};
3440
+ var h = Array.isArray(hist) ? hist : (hist[rangeKey] || []);
3441
+ var sys = s.system || {};
3442
+ var lh = (s.stats && s.stats.lastHour) || {};
3443
+ var recentErrs = (s.stats && s.stats.recentErrors) || [];
3444
+ var errRate = (lh.received > 0) ? ((lh.errors / lh.received) * 100).toFixed(1) + '%' : '0%';
3445
+ var agents = s.agents || [];
3446
+ var connected = agents.filter(function (a) { return a.status === 'connected'; }).length;
3447
+
3448
+ // ── Stat cards ──
3449
+ var sysMemPct = (sys.memTotal > 0) ? Math.round((sys.memUsed / sys.memTotal) * 100) : 0;
3450
+ var cards = [
3451
+ ['Uptime', fmtDur(s.uptimeMs / 1000)],
3452
+ ['消息 (1h)', lh.received || 0],
3453
+ ['在线 Agent', connected + '/' + agents.length],
3454
+ ['平均响应', Math.round(lh.avgResponseMs || 0) + 'ms'],
3455
+ ['错误率', errRate],
3456
+ ['进程 CPU', (s.cpuPercent != null ? s.cpuPercent : 0) + '%'],
3457
+ ['系统 CPU', (sys.cpuPercent != null ? sys.cpuPercent : 0) + '%'],
3458
+ ['进程内存', fmtBytes(s.memory ? s.memory.rss : 0)],
3459
+ ['系统内存', sysMemPct + '%'],
3460
+ ];
3461
+ $('#mon-cards').innerHTML = cards.map(function (c) {
3462
+ return '<div class="usage-card"><div class="card-value">' + c[1] + '</div><div class="card-label">' + c[0] + '</div></div>';
3463
+ }).join('');
3464
+
3465
+ var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
3466
+ var ts = h.map(function (p) { return new Date(p.ts).toLocaleTimeString(); });
3467
+ var css = function (v) { return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); };
3468
+ var cProc = css('--accent'), cSys = css('--orange');
3469
+
3470
+ // ── CPU dual-line:进程 vs 系统 ──
3471
+ monDualLine('mon-cpu-chart', '_monCpu', ts, isDark, 'CPU 占用',
3472
+ [
3473
+ { name: 'evolclaw 进程', data: h.map(function (p) { return p.procCpu; }), color: cProc },
3474
+ { name: '整机系统', data: h.map(function (p) { return p.sysCpu != null ? p.sysCpu : null; }), color: cSys },
3475
+ ],
3476
+ function (v) { return Number(v).toFixed(1) + '%'; }, [0, 100]);
3477
+
3478
+ // ── Memory dual-line:进程 RSS vs 系统已用 ──
3479
+ monDualLine('mon-mem-chart', '_monMem', ts, isDark, '内存占用',
3480
+ [
3481
+ { name: 'evolclaw RSS', data: h.map(function (p) { return p.procRss; }), color: cProc },
3482
+ { name: '系统已用', data: h.map(function (p) { return p.sysMemUsed != null ? p.sysMemUsed : null; }), color: cSys },
3483
+ ],
3484
+ function (v) { return fmtBytes(v); }, null);
3485
+
3486
+ // ── Message activity bar chart ──
3487
+ var msgEl = $('#mon-msg-chart');
3488
+ if (msgEl) {
3489
+ if (!window._monMsg) window._monMsg = echarts.init(msgEl, isDark ? 'dark' : null);
3490
+ window._monMsg.setOption({
3491
+ title: { text: '近一小时活动', left: 'center', top: 4, textStyle: { fontSize: 12, color: isDark ? '#e6edf3' : '#1a202c' } },
3492
+ tooltip: { trigger: 'axis' },
3493
+ grid: { top: 36, bottom: 24, left: 44, right: 12 },
3494
+ xAxis: { type: 'category', data: ['Received', 'Completed', 'Errors', 'Interrupts', 'ToolErr'], axisLabel: { fontSize: 9 } },
3495
+ yAxis: { type: 'value', minInterval: 1 },
3496
+ series: [{
3497
+ type: 'bar', barWidth: '45%',
3498
+ data: [
3499
+ { value: lh.received || 0, itemStyle: { color: css('--accent') } },
3500
+ { value: lh.completed || 0, itemStyle: { color: css('--green') } },
3501
+ { value: lh.errors || 0, itemStyle: { color: css('--red') } },
3502
+ { value: lh.interrupts || 0, itemStyle: { color: css('--orange') } },
3503
+ { value: lh.toolErrors || 0, itemStyle: { color: css('--blue') } },
3504
+ ],
3505
+ }],
3506
+ animation: false,
3507
+ });
3508
+ }
3509
+
3510
+ // ── Error breakdown donut ──
3511
+ var errEntries = Object.entries(lh.errorsByType || {});
3512
+ var errEl = $('#mon-err-chart');
3513
+ if (errEl) {
3514
+ if (errEntries.length) {
3515
+ if (!window._monErr) window._monErr = echarts.init(errEl, isDark ? 'dark' : null);
3516
+ window._monErr.setOption({
3517
+ title: { text: '错误分布', left: 'center', top: 4, textStyle: { fontSize: 12, color: isDark ? '#e6edf3' : '#1a202c' } },
3518
+ tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
3519
+ series: [{
3520
+ type: 'pie', radius: ['32%', '64%'], center: ['50%', '56%'],
3521
+ label: { fontSize: 10 },
3522
+ data: errEntries.map(function (e) { return { name: e[0], value: e[1] }; }),
3523
+ }],
3524
+ animation: false,
3525
+ });
3526
+ } else {
3527
+ if (window._monErr) { window._monErr.dispose(); window._monErr = null; }
3528
+ errEl.innerHTML = '<div class="empty" style="padding:24px;font-size:12px">近一小时无错误</div>';
3529
+ }
3530
+ }
3531
+
3532
+ // ── Per-agent table ──
3533
+ var dotMap = { connected: 'on', reconnecting: 'idle', aid_blocked: 'idle', kicked: 'off', kicked_no_retry: 'off', failed: 'off', disabled: 'off' };
3534
+ $('#mon-agent-table-wrap').innerHTML =
3535
+ '<div class="mon-section-title">各 Agent 运行状态</div>' +
3536
+ '<table class="usage-table"><thead><tr>' +
3537
+ '<th>Agent</th><th>状态</th><th>收</th><th>发</th><th>流入</th><th>流出</th><th>对端</th><th>队列</th><th>处理中</th>' +
3538
+ '</tr></thead><tbody>' +
3539
+ (agents.length ? agents.map(function (a) {
3540
+ var st = a.stats || {};
3541
+ var dot = dotMap[a.status] || 'off';
3542
+ return '<tr>' +
3543
+ '<td title="' + esc(a.aid) + '">' + esc(a.agentName || shortAid(a.aid)) + '</td>' +
3544
+ '<td><span class="dot ' + dot + '"></span>' + esc(a.status) + '</td>' +
3545
+ '<td>' + (st.messagesReceived || 0) + '</td>' +
3546
+ '<td>' + (st.messagesSent || 0) + '</td>' +
3547
+ '<td>' + fmtBytes(st.bytesReceived || 0) + '</td>' +
3548
+ '<td>' + fmtBytes(st.bytesSent || 0) + '</td>' +
3549
+ '<td>' + (st.uniquePeerCount || 0) + '</td>' +
3550
+ '<td>' + (st.queued || 0) + '</td>' +
3551
+ '<td>' + (st.processing ? '⚙ ' + st.processing : 0) + '</td>' +
3552
+ '</tr>';
3553
+ }).join('') : '<tr><td colspan="9" style="text-align:center;color:var(--dim)">暂无 Agent</td></tr>') +
3554
+ '</tbody></table>';
3555
+
3556
+ // ── Recent errors(替换原 Channels 位置)──
3557
+ $('#mon-err-list').innerHTML =
3558
+ '<div class="mon-section-title">最近错误 <span class="mon-section-sub">(最多 50 条)</span></div>' +
3559
+ (recentErrs.length
3560
+ ? '<div class="mon-err-rows">' + recentErrs.map(function (e) {
3561
+ var who = e.agentName ? shortAid(e.agentName) : '—';
3562
+ var tag = e.kind === 'tool'
3563
+ ? '<span class="mon-err-tag tag-tool">工具</span>'
3564
+ : '<span class="mon-err-tag tag-task">任务</span>';
3565
+ var label = e.kind === 'tool' ? (e.toolName || 'tool') : (e.errorType || 'error');
3566
+ var msg = e.message ? esc(e.message) : '';
3567
+ return '<div class="mon-err-row">' +
3568
+ '<span class="mon-err-time">' + fmtAgo(e.ts) + '</span>' +
3569
+ tag +
3570
+ '<span class="mon-err-aid" title="' + esc(e.agentName || '') + '">' + esc(who) + '</span>' +
3571
+ '<span class="mon-err-kind">' + esc(label) + '</span>' +
3572
+ '<span class="mon-err-msg" title="' + msg + '">' + msg + '</span>' +
3573
+ '</div>';
3574
+ }).join('') + '</div>'
3575
+ : '<div class="empty" style="padding:24px;font-size:12px">暂无错误记录</div>');
3576
+ }
3577
+
3578
+ // 双线时序图(进程 + 系统)。series: [{name,data,color}]
3579
+ function monDualLine(elId, varKey, times, isDark, title, series, fmtY, yRange) {
3580
+ var el = $('#' + elId);
3581
+ if (!el) return;
3582
+ if (!window[varKey]) window[varKey] = echarts.init(el, isDark ? 'dark' : null);
3583
+ window[varKey].setOption({
3584
+ title: { text: title, left: 'center', top: 4, textStyle: { fontSize: 12, color: isDark ? '#e6edf3' : '#1a202c' } },
3585
+ legend: { show: false },
3586
+ tooltip: {
3587
+ trigger: 'axis',
3588
+ formatter: function (params) {
3589
+ var lines = [params[0].axisValue];
3590
+ params.forEach(function (pt) {
3591
+ if (pt.value == null) return;
3592
+ lines.push(pt.marker + pt.seriesName + ': ' + (fmtY ? fmtY(pt.value) : pt.value));
3593
+ });
3594
+ return lines.join('<br/>');
3595
+ },
3596
+ },
3597
+ grid: { top: 36, bottom: 24, left: 56, right: 12 },
3598
+ xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 9 } },
3599
+ yAxis: {
3600
+ type: 'value',
3601
+ min: (yRange ? yRange[0] : 0),
3602
+ max: (yRange ? yRange[1] : undefined),
3603
+ axisLabel: { formatter: fmtY ? function (v) { return fmtY(v); } : '{value}' },
3604
+ },
3605
+ series: series.map(function (sr) {
3606
+ return {
3607
+ name: sr.name, type: 'line', data: sr.data, smooth: true, symbol: 'none',
3608
+ connectNulls: true,
3609
+ lineStyle: { width: 2, color: sr.color },
3610
+ areaStyle: { color: sr.color, opacity: 0.08 },
3611
+ itemStyle: { color: sr.color },
3612
+ };
3613
+ }),
3614
+ animation: false,
3615
+ });
3616
+ }
3617
+
3618
+ window.addEventListener('DOMContentLoaded', async () => {
1448
3619
  initTheme();
1449
3620
  initPairUI();
3621
+ initMsgTipFloat();
3622
+
3623
+ // 初始化语言切换
3624
+ const langBtn = $('#lang-btn');
3625
+ if (langBtn) {
3626
+ langBtn.addEventListener('click', toggleLang);
3627
+ }
3628
+ updateI18n(); // 应用当前语言
3629
+
3630
+ // 已有 token 直接进;否则先试本地直连免配对,失败再回落配对页。
3631
+ if (!localStorage.getItem(TOKEN_KEY)) {
3632
+ await tryLocalAutoPair();
3633
+ }
1450
3634
  if (localStorage.getItem(TOKEN_KEY)) {
1451
3635
  showApp();
1452
3636
  startApp();
1453
- loadUsageDashboard();
1454
- loadUsageOverview();
1455
3637
  initUsageSubtabs();
1456
3638
  } else {
1457
3639
  showPairPage();