evolclaw-web 1.2.0 → 1.2.3

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,607 @@
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.version': 'Version',
74
+ 'agents.stats.pid': 'PID',
75
+ 'agents.stats.uptime': 'Uptime',
76
+
77
+ // Agent table headers
78
+ 'agents.th.agent': 'Agent',
79
+ 'agents.th.aid': 'AID',
80
+ 'agents.th.work': '工作',
81
+ 'agents.th.queue': '队列',
82
+ 'agents.th.model': '模型',
83
+ 'agents.th.runtime': '运行',
84
+ 'agents.th.received': '收',
85
+ 'agents.th.sent': '发',
86
+ 'agents.th.completed': '完',
87
+ 'agents.th.errors': '错',
88
+ 'agents.th.interrupts': '断',
89
+ 'agents.th.lastActivity': '最后活动',
90
+ 'agents.th.operations': '操作',
91
+ 'agents.th.projectPath': '项目路径',
92
+
93
+ // Agent operations
94
+ 'agents.op.stopping': '停止中…',
95
+ 'agents.op.starting': '启动中…',
96
+ 'agents.op.reloading': '重载中…',
97
+ 'agents.op.disabling': '禁用中…',
98
+ 'agents.op.enabling': '启用中…',
99
+ 'agents.op.deleting': '删除中…',
100
+ 'agents.op.stopped': '✓ 已停止',
101
+ 'agents.op.started': '✓ 已启动',
102
+ 'agents.op.reloaded': '✓ 已重载',
103
+ 'agents.op.disabled': '✓ 已禁用',
104
+ 'agents.op.enabled': '✓ 已启用',
105
+ 'agents.op.deleted': '✓ 已删除',
106
+ 'agents.op.saved': '✓ 配置已保存,点「重载」生效',
107
+ 'agents.op.confirmReload': '确认强制重载?',
108
+ 'agents.op.confirmToggle': '确认强制',
109
+ 'agents.op.confirmDelete': '删除 Agent {aid}?\n此操作不可恢复。',
110
+ 'agents.op.confirmForceDelete': '确认强制删除?',
111
+ 'agents.op.confirmClearQueue': '清空 {aid} 的待处理消息队列?',
112
+ 'agents.op.clearQueueTitle': '清空 {count} 条待处理消息',
113
+ 'agents.op.viewAgentMd': '查看 agent.md ↗',
114
+
115
+ // Messages view
116
+ 'messages.colTitle.aid': 'Agent',
117
+ 'messages.colTitle.peers': 'Chats',
118
+ 'messages.colTitle.all': 'All',
119
+ 'messages.empty.selectAid': '← 选择一个 Agent',
120
+ 'messages.empty.selectToView': '选择 Agent 查看消息',
121
+ 'messages.empty.noMessages': '暂无消息',
122
+ 'messages.tag.group': '群聊',
123
+ 'messages.tag.encrypted': '🔒密文',
124
+ 'messages.tag.plain': '明文',
125
+ 'messages.tag.proactive': '自主',
126
+ 'messages.tag.inject': '注入',
127
+ 'messages.tag.responsive': '响应',
128
+ 'messages.msgKind.reply': '回复',
129
+ 'messages.msgKind.thought': '思考',
130
+ 'messages.msgKind.inject': '注入',
131
+ 'messages.msgKind.notify': '通知',
132
+ 'messages.msgType.thought': '思考',
133
+ 'messages.msgType.image': '图片',
134
+ 'messages.msgType.file': '文件',
135
+ 'messages.msgType.command': '命令',
136
+
137
+ // Sessions view
138
+ 'sessions.filter.normal': '🔍 仅有效',
139
+ 'sessions.filter.chat': '💬 对话',
140
+ 'sessions.search.placeholder': '🔎 搜索 peer / 内容',
141
+ 'sessions.empty.noMatch': '无匹配会话',
142
+ 'sessions.empty.noSessions': '该项目暂无会话',
143
+ 'sessions.empty.noContent': '该会话暂无内容',
144
+ 'sessions.header.project': '项目',
145
+ 'sessions.header.session': '会话',
146
+ 'sessions.stat.context': '📐 {tokens} ctx',
147
+ 'sessions.stat.cost': '💰 ${cost}',
148
+ 'sessions.turnType.modelOutput': '模型输出',
149
+ 'sessions.turnType.toolUse': '工具使用',
150
+ 'sessions.turnType.toolResult': '工具结果',
151
+ 'sessions.turnType.msgSend': '发送消息',
152
+
153
+ // Cache view
154
+ 'cache.daemonStopped': '⚠ EvolClaw 主进程未运行,无缓存统计可显示',
155
+ 'cache.notSupported': '⚠ 当前 EvolClaw 版本不支持 cache-stats(请升级 daemon)',
156
+ 'cache.card.hitRate': '命中率',
157
+ 'cache.card.reads': '读取总数',
158
+ 'cache.card.entries': '缓存条目',
159
+ 'cache.card.statChecks': 'stat 检查',
160
+ 'cache.card.reReads': '重读',
161
+ 'cache.card.evictions': '驱逐',
162
+ 'cache.card.invalidations': '失效',
163
+ 'cache.card.since': '统计起始',
164
+ 'cache.card.ago': '前',
165
+ 'cache.card.memory': '近似内存',
166
+ 'cache.card.hit': '命中',
167
+ 'cache.card.miss': '未命中',
168
+ 'cache.section.byGroup': '按缓存组',
169
+ 'cache.section.byPolicy': '按策略',
170
+ 'cache.th.group': '组',
171
+ 'cache.th.type': '类型',
172
+ 'cache.th.reads': '读取',
173
+ 'cache.th.hits': '命中',
174
+ 'cache.th.misses': '未命中',
175
+ 'cache.th.hitRate': '命中率',
176
+ 'cache.th.reReads': '重读',
177
+ 'cache.th.evictions': '驱逐',
178
+ 'cache.th.entries': '条目',
179
+ 'cache.th.memory': '内存',
180
+ 'cache.th.capacity': '容量',
181
+ 'cache.th.policy': '策略',
182
+ 'cache.th.statChecks': 'stat 检查',
183
+ 'cache.note': '注:config/defaults 与关系级 preferences 的读取也已并入本统计;渲染后结果(按 vars)不缓存,故不在此列。',
184
+ 'cache.policy.onReload': '靠 reload 刷新,平时零检查',
185
+ 'cache.policy.manual': '显式单刷',
186
+ 'cache.policy.mtime': '每读 statSync 门控',
187
+
188
+ // Monitor view
189
+ 'monitor.toolbar.timeRange': '时间范围',
190
+ 'monitor.range.2m': '2 分钟',
191
+ 'monitor.range.10m': '10 分钟',
192
+ 'monitor.range.1h': '1 小时',
193
+ 'monitor.legend.process': 'evolclaw 进程',
194
+ 'monitor.legend.system': '整机系统',
195
+
196
+ // Usage view
197
+ 'usage.subtab.overview': '总览',
198
+ 'usage.subtab.explorer': '详细统计',
199
+ 'usage.overview.range.today': '今日',
200
+ 'usage.overview.range.week': '本周',
201
+ 'usage.overview.range.lastWeek': '上周',
202
+ 'usage.overview.range.month': '本月',
203
+ 'usage.overview.range.last30': '最近30天',
204
+ 'usage.overview.range.custom': '自定义',
205
+ 'usage.card.input': '输入',
206
+ 'usage.card.output': '输出',
207
+ 'usage.card.cacheRead': '缓存读取',
208
+ 'usage.card.cacheHit': '缓存命中',
209
+ 'usage.card.calls': '调用',
210
+ 'usage.card.sessionCount': '会话数',
211
+ 'usage.card.msgIn': '收到消息',
212
+ 'usage.card.msgOut': '发出消息',
213
+ 'usage.card.modelCalls': '模型调用',
214
+ 'usage.card.inputTokens': '输入 Token',
215
+ 'usage.card.outputTokens': '输出 Token',
216
+ 'usage.card.cacheCreation': '缓存创建',
217
+ 'usage.card.cacheHitTokens': '缓存命中',
218
+ 'usage.card.cacheHitRate': '缓存命中率',
219
+ 'usage.card.totalCost': '总花费',
220
+ 'usage.card.costOfficial': '官方价格',
221
+ 'usage.card.costGateway': '网关价格',
222
+ 'usage.card.sessionInfo': '会话信息',
223
+ 'usage.card.usageInfo': '用量信息',
224
+ 'usage.card.costInfo': '花费信息',
225
+ 'usage.detail.title': '模型访问明细',
226
+ 'usage.detail.agent': '智能体',
227
+ 'usage.detail.model': '模型',
228
+ 'usage.detail.error': '查询失败',
229
+ 'usage.detail.th.time': '时间',
230
+ 'usage.detail.th.agent': '智能体',
231
+ 'usage.detail.th.peer': 'Peer',
232
+ 'usage.detail.th.model': '模型',
233
+ 'usage.detail.th.input': '输入',
234
+ 'usage.detail.th.output': '输出',
235
+ 'usage.detail.th.cacheCreation': '缓存创建',
236
+ 'usage.detail.th.cacheRead': '缓存读取',
237
+ 'usage.detail.th.costOfficial': '官方价格',
238
+ 'usage.detail.th.costGateway': '网关价格',
239
+ 'usage.detail.pageSize': '每页',
240
+ 'usage.detail.prevPage': '上一页',
241
+ 'usage.detail.nextPage': '下一页',
242
+ 'usage.detail.pagination': '显示 {start}-{end} / 共 {total} 条 (第 {page}/{totalPages} 页)',
243
+ 'usage.overview.title': '按 Agent 汇总(全时段)',
244
+ 'usage.overview.noData': '暂无数据',
245
+ 'usage.overview.th.agent': '智能体',
246
+ 'usage.overview.th.calls': '调用',
247
+ 'usage.overview.th.input': '输入',
248
+ 'usage.overview.th.output': '输出',
249
+ 'usage.overview.th.cacheCreation': '缓存创建',
250
+ 'usage.overview.th.cacheHit': '缓存命中',
251
+ 'usage.overview.th.cacheHitRate': '命中率',
252
+ 'usage.overview.th.costOfficial': '官方价格',
253
+ 'usage.overview.th.costGateway': '网关价格',
254
+ 'usage.overview.th.cost': '花费',
255
+ 'usage.dashboard.title.topPeers': 'Top Peers (Today)',
256
+ 'usage.dashboard.th.rank': '#',
257
+ 'usage.dashboard.th.peer': 'Peer',
258
+ 'usage.dashboard.th.tokens': 'Tokens',
259
+ 'usage.dashboard.th.calls': 'Calls',
260
+ 'usage.explorer.sidebar.agents': '智能体',
261
+ 'usage.explorer.sidebar.peers': '对端智能体',
262
+ 'usage.explorer.chatType.group': '群聊',
263
+ 'usage.explorer.chatType.private': '单聊',
264
+ 'usage.explorer.memberCount': '人',
265
+ 'usage.explorer.selectHint': '请从左侧选择 Agent 或 Peer',
266
+ 'usage.explorer.all': '全部',
267
+ 'usage.explorer.filter.from': 'From',
268
+ 'usage.explorer.filter.to': 'To',
269
+ 'usage.explorer.filter.model': 'Model',
270
+ 'usage.explorer.filter.granularity': '粒度',
271
+ 'usage.explorer.filter.granularity.hour': 'Hour',
272
+ 'usage.explorer.filter.granularity.day': 'Day',
273
+ 'usage.explorer.filter.granularity.week': 'Week',
274
+ 'usage.explorer.filter.granularity.month': 'Month',
275
+ 'usage.explorer.results': 'Results',
276
+ 'usage.explorer.noData': 'No data for selected range.',
277
+ 'usage.explorer.th.period': 'Period',
278
+ 'usage.explorer.th.input': 'Input',
279
+ 'usage.explorer.th.output': 'Output',
280
+ 'usage.explorer.th.cacheCreation': 'Cache↑',
281
+ 'usage.explorer.th.cacheHit': 'CacheHit',
282
+ 'usage.explorer.th.calls': 'Calls',
283
+ },
284
+ 'en-US': {
285
+ // Tabs
286
+ 'tab.agents': 'Agents',
287
+ 'tab.messages': 'Messages',
288
+ 'tab.sessions': 'Sessions',
289
+ 'tab.triggers': 'Triggers',
290
+ 'tab.cache': 'Cache',
291
+ 'tab.system': 'System',
292
+ 'tab.gateway': 'Agent Gateway',
293
+ 'tab.usage': 'Usage',
294
+ 'tab.monitor': 'Monitor',
295
+
296
+ // Status
297
+ 'status.connecting': 'Connecting…',
298
+ 'status.connected': 'Connected',
299
+ 'status.disconnected': 'Disconnected',
300
+ 'status.reconnecting': 'Reconnecting',
301
+ 'status.stopped': 'Stopped',
302
+ 'status.idle': 'Idle',
303
+ 'status.working': 'Working',
304
+
305
+ // Actions
306
+ 'action.logout': 'Logout',
307
+ 'action.pair': 'Pair',
308
+ 'action.stop': 'Stop',
309
+ 'action.start': 'Start',
310
+ 'action.enable': 'Enable',
311
+ 'action.disable': 'Disable',
312
+ 'action.reload': 'Reload Config',
313
+ 'action.edit': 'Edit Config',
314
+ 'action.delete': 'Delete Agent',
315
+ 'action.clearQueue': 'Clear Queue',
316
+ 'action.new': '+ New',
317
+ 'action.query': 'Query',
318
+
319
+ // Pair page
320
+ 'pair.title': '🔭 EvolClaw Watch',
321
+ 'pair.hint': 'Enter the 6-digit pairing code shown in terminal',
322
+ 'pair.placeholder': '000000',
323
+ 'pair.error.length': 'Please enter 6-digit pairing code',
324
+ 'pair.error.failed': 'Pairing failed',
325
+ 'pair.error.network': 'Network error',
326
+ 'pair.error.tokenInvalid': 'Token expired, please pair again',
327
+
328
+ // Common
329
+ 'common.loading': 'Loading…',
330
+ 'common.empty': 'No data',
331
+ 'common.noData': 'N/A',
332
+ 'common.operating': 'Operating…',
333
+ 'common.buildTime': 'Build Time',
334
+
335
+ // Agents view
336
+ 'agents.subtitle.enabled': 'Enabled',
337
+ 'agents.subtitle.disabled': 'Disabled',
338
+ 'agents.daemonStopped': '⚠ EvolClaw daemon not running, showing recent activity only',
339
+ 'agents.empty.disabled': 'No disabled agents',
340
+ 'agents.empty.enabled': 'No enabled agents',
341
+ 'agents.stats.gateway': 'Gateway',
342
+ 'agents.stats.aids': 'AIDs',
343
+ 'agents.stats.total': 'total',
344
+ 'agents.stats.online': 'online',
345
+ 'agents.stats.offline': 'offline',
346
+ 'agents.stats.messages': 'Messages',
347
+ 'agents.stats.version': 'Version',
348
+ 'agents.stats.pid': 'PID',
349
+ 'agents.stats.uptime': 'Uptime',
350
+
351
+ // Agent table headers
352
+ 'agents.th.agent': 'Agent',
353
+ 'agents.th.aid': 'AID',
354
+ 'agents.th.work': 'Work',
355
+ 'agents.th.queue': 'Queue',
356
+ 'agents.th.model': 'Model',
357
+ 'agents.th.runtime': 'Runtime',
358
+ 'agents.th.received': 'Recv',
359
+ 'agents.th.sent': 'Sent',
360
+ 'agents.th.completed': 'Done',
361
+ 'agents.th.errors': 'Err',
362
+ 'agents.th.interrupts': 'Int',
363
+ 'agents.th.lastActivity': 'Last Activity',
364
+ 'agents.th.operations': 'Operations',
365
+ 'agents.th.projectPath': 'Project Path',
366
+
367
+ // Agent operations
368
+ 'agents.op.stopping': 'Stopping…',
369
+ 'agents.op.starting': 'Starting…',
370
+ 'agents.op.reloading': 'Reloading…',
371
+ 'agents.op.disabling': 'Disabling…',
372
+ 'agents.op.enabling': 'Enabling…',
373
+ 'agents.op.deleting': 'Deleting…',
374
+ 'agents.op.stopped': '✓ Stopped',
375
+ 'agents.op.started': '✓ Started',
376
+ 'agents.op.reloaded': '✓ Reloaded',
377
+ 'agents.op.disabled': '✓ Disabled',
378
+ 'agents.op.enabled': '✓ Enabled',
379
+ 'agents.op.deleted': '✓ Deleted',
380
+ 'agents.op.saved': '✓ Config saved, click "Reload" to apply',
381
+ 'agents.op.confirmReload': 'Force reload?',
382
+ 'agents.op.confirmToggle': 'Force',
383
+ 'agents.op.confirmDelete': 'Delete agent {aid}?\nThis cannot be undone.',
384
+ 'agents.op.confirmForceDelete': 'Force delete?',
385
+ 'agents.op.confirmClearQueue': 'Clear pending message queue for {aid}?',
386
+ 'agents.op.clearQueueTitle': 'Clear {count} pending messages',
387
+ 'agents.op.viewAgentMd': 'View agent.md ↗',
388
+
389
+ // Messages view
390
+ 'messages.colTitle.aid': 'Agent',
391
+ 'messages.colTitle.peers': 'Chats',
392
+ 'messages.colTitle.all': 'All',
393
+ 'messages.empty.selectAid': '← Select Agent',
394
+ 'messages.empty.selectToView': 'Select Agent to view messages',
395
+ 'messages.empty.noMessages': 'No messages',
396
+ 'messages.tag.group': 'Group',
397
+ 'messages.tag.encrypted': '🔒Encrypted',
398
+ 'messages.tag.plain': 'Plain',
399
+ 'messages.tag.proactive': 'Proactive',
400
+ 'messages.tag.inject': 'Inject',
401
+ 'messages.tag.responsive': 'Responsive',
402
+ 'messages.msgKind.reply': 'Reply',
403
+ 'messages.msgKind.thought': 'Thought',
404
+ 'messages.msgKind.inject': 'Inject',
405
+ 'messages.msgKind.notify': 'Notify',
406
+ 'messages.msgType.thought': 'Thought',
407
+ 'messages.msgType.image': 'Image',
408
+ 'messages.msgType.file': 'File',
409
+ 'messages.msgType.command': 'Command',
410
+
411
+ // Sessions view
412
+ 'sessions.filter.normal': '🔍 Valid Only',
413
+ 'sessions.filter.chat': '💬 Chat',
414
+ 'sessions.search.placeholder': '🔎 Search peer / content',
415
+ 'sessions.empty.noMatch': 'No matching sessions',
416
+ 'sessions.empty.noSessions': 'No sessions in this project',
417
+ 'sessions.empty.noContent': 'No content in this session',
418
+ 'sessions.header.project': 'Project',
419
+ 'sessions.header.session': 'Session',
420
+ 'sessions.stat.context': '📐 {tokens} ctx',
421
+ 'sessions.stat.cost': '💰 ${cost}',
422
+ 'sessions.turnType.modelOutput': 'Model Output',
423
+ 'sessions.turnType.toolUse': 'Tool Use',
424
+ 'sessions.turnType.toolResult': 'Tool Result',
425
+ 'sessions.turnType.msgSend': 'Send Message',
426
+
427
+ // Cache view
428
+ 'cache.daemonStopped': '⚠ EvolClaw daemon not running, no cache stats available',
429
+ 'cache.notSupported': '⚠ Current EvolClaw version does not support cache-stats (please upgrade daemon)',
430
+ 'cache.card.hitRate': 'Hit Rate',
431
+ 'cache.card.reads': 'Total Reads',
432
+ 'cache.card.entries': 'Cache Entries',
433
+ 'cache.card.statChecks': 'Stat Checks',
434
+ 'cache.card.reReads': 'Re-reads',
435
+ 'cache.card.evictions': 'Evictions',
436
+ 'cache.card.invalidations': 'Invalidations',
437
+ 'cache.card.since': 'Stats Since',
438
+ 'cache.card.ago': 'ago',
439
+ 'cache.card.memory': 'approx memory',
440
+ 'cache.card.hit': 'hit',
441
+ 'cache.card.miss': 'miss',
442
+ 'cache.section.byGroup': 'By Cache Group',
443
+ 'cache.section.byPolicy': 'By Policy',
444
+ 'cache.th.group': 'Group',
445
+ 'cache.th.type': 'Type',
446
+ 'cache.th.reads': 'Reads',
447
+ 'cache.th.hits': 'Hits',
448
+ 'cache.th.misses': 'Misses',
449
+ 'cache.th.hitRate': 'Hit Rate',
450
+ 'cache.th.reReads': 'Re-reads',
451
+ 'cache.th.evictions': 'Evictions',
452
+ 'cache.th.entries': 'Entries',
453
+ 'cache.th.memory': 'Memory',
454
+ 'cache.th.capacity': 'Capacity',
455
+ 'cache.th.policy': 'Policy',
456
+ 'cache.th.statChecks': 'Stat Checks',
457
+ 'cache.note': 'Note: Reads of config/defaults and relation-level preferences are included; rendered results (by vars) are not cached and not shown here.',
458
+ 'cache.policy.onReload': 'Refresh on reload, zero checks normally',
459
+ 'cache.policy.manual': 'Explicit single refresh',
460
+ 'cache.policy.mtime': 'statSync gate on each read',
461
+
462
+ // Monitor view
463
+ 'monitor.toolbar.timeRange': 'Time Range',
464
+ 'monitor.range.2m': '2 minutes',
465
+ 'monitor.range.10m': '10 minutes',
466
+ 'monitor.range.1h': '1 hour',
467
+ 'monitor.legend.process': 'evolclaw process',
468
+ 'monitor.legend.system': 'system',
469
+
470
+ // Usage view
471
+ 'usage.subtab.overview': 'Overview',
472
+ 'usage.subtab.explorer': 'Detailed Statistics',
473
+ 'usage.overview.range.today': 'Today',
474
+ 'usage.overview.range.week': 'This Week',
475
+ 'usage.overview.range.lastWeek': 'Last Week',
476
+ 'usage.overview.range.month': 'This Month',
477
+ 'usage.overview.range.last30': 'Last 30 Days',
478
+ 'usage.overview.range.custom': 'Custom',
479
+ 'usage.card.input': 'Input',
480
+ 'usage.card.output': 'Output',
481
+ 'usage.card.cacheRead': 'Cache Read',
482
+ 'usage.card.cacheHit': 'Cache Hit',
483
+ 'usage.card.calls': 'Calls',
484
+ 'usage.card.sessionCount': 'Sessions',
485
+ 'usage.card.msgIn': 'Received Messages',
486
+ 'usage.card.msgOut': 'Sent Messages',
487
+ 'usage.card.modelCalls': 'Model Calls',
488
+ 'usage.card.inputTokens': 'Input Tokens',
489
+ 'usage.card.outputTokens': 'Output Tokens',
490
+ 'usage.card.cacheCreation': 'Cache Creation',
491
+ 'usage.card.cacheHitTokens': 'Cache Hit',
492
+ 'usage.card.cacheHitRate': 'Cache Hit Rate',
493
+ 'usage.card.totalCost': 'Total Cost',
494
+ 'usage.card.costOfficial': 'Official Price',
495
+ 'usage.card.costGateway': 'Gateway Price',
496
+ 'usage.card.sessionInfo': 'Session Info',
497
+ 'usage.card.usageInfo': 'Usage Info',
498
+ 'usage.card.costInfo': 'Cost Info',
499
+ 'usage.detail.title': 'Model Access Details',
500
+ 'usage.detail.agent': 'Agent',
501
+ 'usage.detail.model': 'Model',
502
+ 'usage.detail.error': 'Query failed',
503
+ 'usage.detail.th.time': 'Time',
504
+ 'usage.detail.th.agent': 'Agent',
505
+ 'usage.detail.th.peer': 'Peer',
506
+ 'usage.detail.th.model': 'Model',
507
+ 'usage.detail.th.input': 'Input',
508
+ 'usage.detail.th.output': 'Output',
509
+ 'usage.detail.th.cacheCreation': 'Cache Creation',
510
+ 'usage.detail.th.cacheRead': 'Cache Read',
511
+ 'usage.detail.th.costOfficial': 'Official',
512
+ 'usage.detail.th.costGateway': 'Gateway',
513
+ 'usage.detail.pageSize': 'Per page',
514
+ 'usage.detail.prevPage': 'Previous',
515
+ 'usage.detail.nextPage': 'Next',
516
+ 'usage.detail.pagination': 'Showing {start}-{end} of {total} (Page {page}/{totalPages})',
517
+ 'usage.overview.title': 'Summary by Agent (All Time)',
518
+ 'usage.overview.noData': 'No data',
519
+ 'usage.overview.th.agent': 'Agent',
520
+ 'usage.overview.th.calls': 'Calls',
521
+ 'usage.overview.th.input': 'Input',
522
+ 'usage.overview.th.output': 'Output',
523
+ 'usage.overview.th.cacheCreation': 'Cache Creation',
524
+ 'usage.overview.th.cacheHit': 'Cache Hit',
525
+ 'usage.overview.th.cacheHitRate': 'Hit Rate',
526
+ 'usage.overview.th.costOfficial': 'Official Price',
527
+ 'usage.overview.th.costGateway': 'Gateway Price',
528
+ 'usage.overview.th.cost': 'Cost',
529
+ 'usage.dashboard.title.topPeers': 'Top Peers (Today)',
530
+ 'usage.dashboard.th.rank': '#',
531
+ 'usage.dashboard.th.peer': 'Peer',
532
+ 'usage.dashboard.th.tokens': 'Tokens',
533
+ 'usage.dashboard.th.calls': 'Calls',
534
+ 'usage.explorer.sidebar.agents': 'Agents',
535
+ 'usage.explorer.sidebar.peers': 'Peers',
536
+ 'usage.explorer.chatType.group': 'Group',
537
+ 'usage.explorer.chatType.private': 'Private',
538
+ 'usage.explorer.memberCount': ' members',
539
+ 'usage.explorer.selectHint': 'Select an Agent or Peer from the left',
540
+ 'usage.explorer.all': 'All',
541
+ 'usage.explorer.filter.from': 'From',
542
+ 'usage.explorer.filter.to': 'To',
543
+ 'usage.explorer.filter.model': 'Model',
544
+ 'usage.explorer.filter.granularity': 'Granularity',
545
+ 'usage.explorer.filter.granularity.hour': 'Hour',
546
+ 'usage.explorer.filter.granularity.day': 'Day',
547
+ 'usage.explorer.filter.granularity.week': 'Week',
548
+ 'usage.explorer.filter.granularity.month': 'Month',
549
+ 'usage.explorer.results': 'Results',
550
+ 'usage.explorer.noData': 'No data for selected range.',
551
+ 'usage.explorer.th.period': 'Period',
552
+ 'usage.explorer.th.input': 'Input',
553
+ 'usage.explorer.th.output': 'Output',
554
+ 'usage.explorer.th.cacheCreation': 'Cache↑',
555
+ 'usage.explorer.th.cacheHit': 'CacheHit',
556
+ 'usage.explorer.th.calls': 'Calls',
557
+ }
558
+ };
559
+
560
+ let currentLang = localStorage.getItem(LANG_KEY) || 'zh-CN';
561
+
562
+ function t(key) {
563
+ return translations[currentLang]?.[key] || key;
564
+ }
565
+
566
+ function updateI18n() {
567
+ // 处理元素文本内容
568
+ document.querySelectorAll('[data-i18n]').forEach(el => {
569
+ const key = el.getAttribute('data-i18n');
570
+ const text = t(key);
571
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
572
+ el.placeholder = text;
573
+ } else if (el.tagName === 'OPTION') {
574
+ el.textContent = text;
575
+ } else {
576
+ el.textContent = text;
577
+ }
578
+ });
579
+ // 处理 title 属性(单独的 data-i18n-title)
580
+ document.querySelectorAll('[data-i18n-title]').forEach(el => {
581
+ const key = el.getAttribute('data-i18n-title');
582
+ el.title = t(key);
583
+ });
584
+ // 更新 html lang 属性
585
+ document.documentElement.lang = currentLang;
586
+ }
587
+
588
+ function toggleLang() {
589
+ currentLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN';
590
+ localStorage.setItem(LANG_KEY, currentLang);
591
+ updateI18n();
592
+ // 强制重新渲染当前视图
593
+ if (state[currentView]) renderView(currentView);
594
+ }
595
+
596
+ // ── 基础路径 ──
597
+ // 本地直连时页面在 "/",经 AUN Service Proxy 时页面在 "/ecweb/"。
598
+ // 取当前页面所在目录(含尾斜杠)作为所有 API/WS 的前缀,使绝对路径在两种
599
+ // 部署下都正确(proxy-server 用首段路径选服务,前缀不能丢)。
600
+ const BASE = location.pathname.replace(/[^/]*$/, '');
601
+ const apiUrl = (p) => BASE + p.replace(/^\/+/, '');
5
602
 
6
603
  // ── 配对 ──
7
604
  async function pair(code) {
8
- const resp = await fetch('/api/pair', {
605
+ const resp = await fetch(apiUrl('api/pair'), {
9
606
  method: 'POST',
10
607
  headers: { 'Content-Type': 'application/json' },
11
608
  body: JSON.stringify({ code }),
@@ -13,6 +610,19 @@ async function pair(code) {
13
610
  return resp.json();
14
611
  }
15
612
 
613
+ // 本地直连免配对:用空码探测,服务端若判定本地直连会直接发 token。
614
+ // 远程(隧道/真远程)会返回配对码错误,此时回落到配对页。
615
+ async function tryLocalAutoPair() {
616
+ try {
617
+ const res = await pair('');
618
+ if (res && res.ok && res.token) {
619
+ localStorage.setItem(TOKEN_KEY, res.token);
620
+ return true;
621
+ }
622
+ } catch {}
623
+ return false;
624
+ }
625
+
16
626
  function showPairPage(hint) {
17
627
  if (ws) { try { ws.close(); } catch {} ws = null; }
18
628
  $('#pair-page').style.display = 'flex';
@@ -31,7 +641,7 @@ function initPairUI() {
31
641
  const err = $('#pair-error');
32
642
  const submit = async () => {
33
643
  const code = input.value.trim();
34
- if (code.length !== 6) { err.textContent = '请输入 6 位配对码'; return; }
644
+ if (code.length !== 6) { err.textContent = t('pair.error.length'); return; }
35
645
  btn.disabled = true; err.textContent = '';
36
646
  try {
37
647
  const res = await pair(code);
@@ -40,10 +650,10 @@ function initPairUI() {
40
650
  showApp();
41
651
  startApp();
42
652
  } else {
43
- err.textContent = res.reason || '配对失败';
653
+ err.textContent = res.reason || t('pair.error.failed');
44
654
  }
45
655
  } catch {
46
- err.textContent = '网络错误';
656
+ err.textContent = t('pair.error.network');
47
657
  } finally {
48
658
  btn.disabled = false;
49
659
  }
@@ -56,9 +666,9 @@ function initPairUI() {
56
666
  // ── WebSocket 客户端(自动重连)──
57
667
  let ws = null;
58
668
  let reconnectDelay = 1000;
59
- let currentView = 'agents';
669
+ let currentView = localStorage.getItem(VIEW_KEY) || 'agents';
60
670
  let pendingSub = null; // 重连后要恢复的订阅
61
- const state = { agents: null, msg: null, session: null, cache: null, system: null, triggers: null, monitor: null };
671
+ const state = { agents: null, msg: null, session: null, cache: null, system: null, triggers: null, monitor: null, gateway: null };
62
672
 
63
673
  function setConnStatus(text, cls) {
64
674
  const el = $('#conn-status');
@@ -70,25 +680,49 @@ function connect() {
70
680
  const token = localStorage.getItem(TOKEN_KEY);
71
681
  if (!token) { showPairPage(); return; }
72
682
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
73
- ws = new WebSocket(`${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`);
683
+ ws = new WebSocket(`${proto}://${location.host}${BASE}ws?token=${encodeURIComponent(token)}`);
74
684
 
75
685
  ws.onopen = () => {
76
- setConnStatus('● 已连接', 'ok');
686
+ setConnStatus('● ' + t('status.connected'), 'ok');
77
687
  reconnectDelay = 1000;
78
- subscribe(currentView, pendingSub || {});
688
+ // 获取可用的 baseagent
689
+ fetch(`${BASE}api/available-baseagents`)
690
+ .then(r => r.json())
691
+ .then(data => {
692
+ availableBaseagents = data;
693
+ // 如果当前没有选中 baseagent,默认选第一个可用的
694
+ if (!sessSel.baseagent) {
695
+ sessSel.baseagent = data.claude ? 'claude' : (data.codex ? 'codex' : null);
696
+ }
697
+ console.log('[ecweb] Available baseagents:', availableBaseagents, 'Selected:', sessSel.baseagent);
698
+ // 重新订阅当前视图(带上正确的参数)
699
+ if (currentView === 'session') {
700
+ subscribe('session', { sessionId: sessSel.sessionId, project: sessSel.project, baseagent: sessSel.baseagent });
701
+ } else {
702
+ subscribe(currentView, pendingSub || {});
703
+ }
704
+ })
705
+ .catch(err => {
706
+ console.warn('[ecweb] Failed to fetch available-baseagents:', err);
707
+ // 失败时默认使用 claude
708
+ availableBaseagents = { claude: true, codex: false };
709
+ if (!sessSel.baseagent) sessSel.baseagent = 'claude';
710
+ subscribe(currentView, pendingSub || {});
711
+ });
79
712
  };
80
713
 
81
714
  ws.onmessage = (ev) => {
82
715
  let msg;
83
716
  try { msg = JSON.parse(ev.data); } catch { return; }
84
717
  if (msg.type === 'pong') return;
85
- if (msg.type === 'error') { console.warn('server error:', msg.message); return; }
718
+ if (msg.type === 'error') { console.warn('[ecweb] Server error:', msg.message); return; }
86
719
  if (msg.type === 'menu.response') {
87
720
  const pend = _menuPending[msg.requestId];
88
721
  if (pend) { delete _menuPending[msg.requestId]; pend.resolve(msg.data); }
89
722
  return;
90
723
  }
91
724
  if (msg.type === 'snapshot' || msg.type === 'delta') {
725
+ console.log('[ecweb] Received', msg.type, 'for view:', msg.view, 'currentView:', currentView);
92
726
  // system 视图保留客户端写入的 check/upgrade,防止 3s 轮询覆盖
93
727
  if (msg.view === 'system' && state.system) {
94
728
  state.system = {
@@ -99,17 +733,20 @@ function connect() {
99
733
  } else {
100
734
  state[msg.view] = msg.data;
101
735
  }
102
- if (msg.view === currentView) renderView(currentView);
736
+ if (msg.view === currentView) {
737
+ console.log('[ecweb] Rendering view:', currentView, 'with data:', msg.data);
738
+ renderView(currentView);
739
+ }
103
740
  }
104
741
  };
105
742
 
106
743
  ws.onclose = (ev) => {
107
744
  if (ev.code === 4001) {
108
745
  localStorage.removeItem(TOKEN_KEY);
109
- showPairPage('token 已失效,请重新配对');
746
+ showPairPage(t('pair.error.tokenInvalid'));
110
747
  return;
111
748
  }
112
- setConnStatus('○ 重连中…', 'err');
749
+ setConnStatus('○ ' + t('status.reconnecting') + '…', 'err');
113
750
  setTimeout(connect, reconnectDelay);
114
751
  reconnectDelay = Math.min(reconnectDelay * 1.5, 15000);
115
752
  };
@@ -120,7 +757,14 @@ function connect() {
120
757
  function subscribe(view, params) {
121
758
  pendingSub = params;
122
759
  if (ws && ws.readyState === WebSocket.OPEN) {
760
+ // session 视图添加 baseagent 参数
761
+ if (view === 'session' && sessSel.baseagent) {
762
+ params = { ...params, baseagent: sessSel.baseagent };
763
+ }
764
+ console.log('[ecweb] Subscribing:', view, params);
123
765
  ws.send(JSON.stringify({ type: 'subscribe', view, ...params }));
766
+ } else {
767
+ console.warn('[ecweb] WebSocket not ready, subscription pending');
124
768
  }
125
769
  }
126
770
 
@@ -147,24 +791,27 @@ setInterval(() => {
147
791
 
148
792
  // ── Tab 切换 ──
149
793
  let msgSel = { aid: null, peer: null };
150
- let sessSel = { sessionId: null, project: null };
794
+ let sessSel = { sessionId: null, project: null, baseagent: null };
151
795
  let trigSel = { agent: null };
152
796
  let sessSearch = '';
153
797
  let sessFilterNormal = false; // true=只显示有效会话(userMsgs >= 2)
154
798
  let sessChatMode = false; // false=完整视图,true=对话视图(折叠处理过程)
155
799
  let monRange = '2m'; // Monitor 时间窗口:2m / 10m / 1h
800
+ let availableBaseagents = { claude: false, codex: false }; // 可用的 baseagent
156
801
 
157
802
  function switchView(view) {
158
803
  currentView = view;
804
+ localStorage.setItem(VIEW_KEY, view);
159
805
  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.view === view));
160
806
  document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + view));
161
807
  // 切换时按当前选择恢复订阅
162
808
  if (view === 'msg') subscribe('msg', { aid: msgSel.aid, peer: msgSel.peer });
163
- else if (view === 'session') subscribe('session', { sessionId: sessSel.sessionId, project: sessSel.project });
809
+ else if (view === 'session') subscribe('session', { sessionId: sessSel.sessionId, project: sessSel.project, baseagent: sessSel.baseagent });
164
810
  else if (view === 'cache') subscribe('cache', {});
165
811
  else if (view === 'system') subscribe('system', {});
166
812
  else if (view === 'triggers') subscribe('triggers', { agent: trigSel.agent });
167
813
  else if (view === 'monitor') subscribe('monitor', { range: monRange });
814
+ else if (view === 'gateway') subscribe('gateway', {});
168
815
  else subscribe('agents', {});
169
816
  if (state[view]) renderView(view);
170
817
  }
@@ -183,6 +830,7 @@ function renderView(view) {
183
830
  else if (view === 'system') renderSystem(state.system);
184
831
  else if (view === 'triggers') renderTriggers(state.triggers);
185
832
  else if (view === 'monitor') renderMonitor(state.monitor);
833
+ else if (view === 'gateway') renderGateway(state.gateway);
186
834
  }
187
835
 
188
836
  // ── 工具 ──
@@ -190,6 +838,11 @@ function esc(s) {
190
838
  return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
191
839
  }
192
840
  function shortAid(aid) { return String(aid || '').split('.')[0]; }
841
+ function shortId(id) {
842
+ const s = String(id || '');
843
+ if (!s) return 'unknown';
844
+ return s.includes('.') ? shortAid(s) : (s.length > 18 ? s.slice(0, 10) + '…' + s.slice(-5) : s);
845
+ }
193
846
  function fmtBytes(b) {
194
847
  if (!b) return '0';
195
848
  const u = ['B', 'KB', 'MB', 'GB']; let i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), 3);
@@ -246,25 +899,39 @@ let _agSubtab = 'enabled'; // 'enabled' | 'disabled'
246
899
  // stopped → connected(仅首次连接无消息时) → idle(收到第一条后) → working → idle ...
247
900
  function agentStateBadge(s, agStatus, connStatus) {
248
901
  if (agStatus === 'stopped' || connStatus === 'disconnected' || connStatus === 'failed')
249
- return '<span class="state-badge stopped">停止</span>';
902
+ return `<span class="state-badge stopped">${t('status.stopped')}</span>`;
250
903
  if (connStatus === 'reconnecting')
251
- return '<span class="state-badge stopped">重连中</span>';
904
+ return `<span class="state-badge stopped">${t('status.reconnecting')}</span>`;
252
905
  if ((s.processing || 0) > 0)
253
- return '<span class="state-badge working">working</span>';
906
+ return `<span class="state-badge working">${t('status.working')}</span>`;
254
907
  // 收到过消息 → 永远是 idle,不再回到 connected
255
- if ((s.messagesReceived || 0) > 0 || (s.messagesSent || 0) > 0)
256
- return '<span class="state-badge idle">idle</span>';
257
- return '<span class="state-badge connected">connected</span>';
908
+ if ((s.received || 0) > 0 || (s.sent || 0) > 0 || (s.completed || 0) > 0 || (s.errors || 0) > 0 || (s.interrupts || 0) > 0 ||
909
+ (s.messagesReceived || 0) > 0 || (s.messagesSent || 0) > 0)
910
+ return `<span class="state-badge idle">${t('status.idle')}</span>`;
911
+ return `<span class="state-badge connected">${t('status.connected')}</span>`;
258
912
  }
259
913
 
260
914
  // 发送方式图标标记
261
- const MSG_KIND_META = { send: { icon: '💬', label: '回复' }, thought: { icon: '💭', label: '思考' }, inject: { icon: '📥', label: '注入' }, notify: { icon: '🔔', label: '通知' } };
262
- function msgTagsHtml(kind, encrypt, chatmode) {
915
+ const MSG_KIND_META = {
916
+ send: { icon: '💬', label: () => t('messages.msgKind.reply') },
917
+ thought: { icon: '💭', label: () => t('messages.msgKind.thought') },
918
+ inject: { icon: '📥', label: () => t('messages.msgKind.inject') },
919
+ notify: { icon: '🔔', label: () => t('messages.msgKind.notify') }
920
+ };
921
+ // 消息详情流用:jsonl 持久化的 msgType 词汇(text 为普通回复,不另标)
922
+ const MSG_TYPE_META = {
923
+ thought: { icon: '💭', label: () => t('messages.msgType.thought') },
924
+ image: { icon: '🖼️', label: () => t('messages.msgType.image') },
925
+ file: { icon: '📎', label: () => t('messages.msgType.file') },
926
+ command: { icon: '⌘', label: () => t('messages.msgType.command') }
927
+ };
928
+ function msgTagsHtml(kind, encrypt, chatmode, dir) {
263
929
  let h = '';
264
- const km = MSG_KIND_META[kind];
265
- if (km) h += `<span class="mtag">${km.icon}${km.label}</span>`;
266
- if (encrypt != null) h += `<span class="mtag">${encrypt ? '🔒密文' : '明文'}</span>`;
267
- if (chatmode) h += `<span class="mtag">${chatmode === 'proactive' ? '自主' : (chatmode === 'inject' ? '注入' : '响应')}</span>`;
930
+ // 'send' 仅出向才是「回复」;入向是用户输入,不打回复标记
931
+ const km = (kind === 'send' && dir === 'in') ? null : MSG_KIND_META[kind];
932
+ if (km) h += `<span class="mtag${kind === 'send' ? ' mtag-reply' : ''}">${km.icon}${km.label()}</span>`;
933
+ if (encrypt != null) h += `<span class="mtag">${encrypt ? t('messages.tag.encrypted') : t('messages.tag.plain')}</span>`;
934
+ if (chatmode) h += `<span class="mtag">${chatmode === 'proactive' ? t('messages.tag.proactive') : (chatmode === 'inject' ? t('messages.tag.inject') : t('messages.tag.responsive'))}</span>`;
268
935
  return h;
269
936
  }
270
937
 
@@ -273,7 +940,7 @@ function agentPreviewHtml(s) {
273
940
  const clip = (t) => esc(String(t).replace(/\n/g, ' ').slice(0, 80));
274
941
  const line = (dir, peer, text, kind, encrypt, chatmode) => {
275
942
  const arrow = dir === 'in' ? '<span class="arrow-in">↓</span>' : '<span class="arrow-out">↑</span>';
276
- const tags = msgTagsHtml(kind, encrypt, chatmode);
943
+ const tags = msgTagsHtml(kind, encrypt, chatmode, dir);
277
944
  const peerHtml = peer ? `<span class="peer">${esc(shortAid(peer))}</span>: ` : '';
278
945
  const textCls = dir === 'in' ? 'text-in' : 'text-out';
279
946
  return `${arrow}${tags ? ' ' + tags + ' ' : ' '}${peerHtml}<span class="${textCls}">${clip(text)}</span>`;
@@ -289,32 +956,105 @@ function agentPreviewHtml(s) {
289
956
  return '';
290
957
  }
291
958
 
292
- // HTML tooltip(最近 N 轮):彩色箭头 + 方式 + 对端 + 文字
959
+ // HTML tooltip(最近 N 轮):时间 + 彩色箭头 + 方式 + 对端 + 文字
960
+ // 渲染为隐藏的内容持有节点(.msg-tip-src);实际展示由 initMsgTipFloat 的浮层负责
293
961
  function recentMsgTooltipHtml(recent) {
294
962
  if (!recent || !recent.length) return '';
295
- let h = '<div class="msg-tip">';
963
+ let h = '<div class="msg-tip-src">';
296
964
  for (const m of recent) {
297
965
  const rcls = m.dir === 'in' ? 'tip-row-in' : 'tip-row-out';
298
966
  const arrow = m.dir === 'in' ? '↓' : '↑';
299
- const km = MSG_KIND_META[m.kind];
300
- const kh = km ? `<span class="tip-kind">${km.icon}${km.label}</span>` : '';
301
- const enc = m.encrypt != null ? `<span class="tip-flag">${m.encrypt ? '🔒密文' : '明文'}</span>` : '';
302
- const mode = m.chatmode ? `<span class="tip-flag">${m.chatmode === 'proactive' ? '自主' : (m.chatmode === 'inject' ? '注入' : '响应')}</span>` : '';
967
+ // 'send' 仅出向才是「回复」;入向是用户输入,不打回复标记
968
+ const km = (m.kind === 'send' && m.dir === 'in') ? null : MSG_KIND_META[m.kind];
969
+ const kh = km ? `<span class="tip-kind${m.kind === 'send' ? ' tip-kind-reply' : ''}">${km.icon}${km.label()}</span>` : '';
970
+ const enc = m.encrypt != null ? `<span class="tip-flag">${m.encrypt ? t('messages.tag.encrypted') : t('messages.tag.plain')}</span>` : '';
971
+ 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>` : '';
303
972
  const peer = m.peer ? esc(shortAid(m.peer)) : '';
304
- const text = esc(String(m.text).replace(/\n/g, ' ').slice(0, 60));
305
- h += `<div class="tip-row ${rcls}">${arrow}${kh}${enc}${mode} <b>${peer}</b> ${text}</div>`;
973
+ const text = esc(String(m.text).replace(/\n/g, ' ').slice(0, 140));
974
+ const time = m.ts ? `<span class="tip-time">${fmtTime(m.ts)}</span>` : '';
975
+ h += `<div class="tip-row ${rcls}">${time}${arrow}${kh}${enc}${mode} <b>${peer}</b> ${text}</div>`;
306
976
  }
307
977
  return h + '</div>';
308
978
  }
309
979
 
310
- // 顶部统计条:Gateway / AIDs total·connected·offline / Messages ↓↑ / Traffic ↓↑ / Version·PID·Uptime
311
- function agentsStatsBar(data, aids, stats) {
980
+ // 单例浮层 tooltip:固定定位、自动翻转上下、横向夹取,确保始终在可视区域内;
981
+ // 鼠标可移动到 tooltip 上而不消失(延迟隐藏 + 进入取消)。
982
+ function initMsgTipFloat() {
983
+ if (initMsgTipFloat._done) return;
984
+ initMsgTipFloat._done = true;
985
+
986
+ let floatEl = null, hideTimer = null, curWrap = null;
987
+ const GAP = 8, MARGIN = 8;
988
+
989
+ function ensureFloat() {
990
+ if (floatEl) return floatEl;
991
+ floatEl = document.createElement('div');
992
+ floatEl.id = 'msg-tip-float';
993
+ floatEl.className = 'msg-tip';
994
+ document.body.appendChild(floatEl);
995
+ floatEl.addEventListener('mouseenter', cancelHide);
996
+ floatEl.addEventListener('mouseleave', scheduleHide);
997
+ return floatEl;
998
+ }
999
+ function cancelHide() { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }
1000
+ function scheduleHide() { cancelHide(); hideTimer = setTimeout(hideNow, 180); }
1001
+ function hideNow() { cancelHide(); curWrap = null; if (floatEl) floatEl.classList.remove('show'); }
1002
+
1003
+ function position(wrap) {
1004
+ const f = floatEl;
1005
+ const r = wrap.getBoundingClientRect();
1006
+ const vw = document.documentElement.clientWidth;
1007
+ const vh = document.documentElement.clientHeight;
1008
+ const fw = f.offsetWidth, fh = f.offsetHeight;
1009
+ // 纵向:优先放上方;上方放不下则放下方;都放不下则在视口内夹取
1010
+ let top;
1011
+ if (r.top - GAP - fh >= MARGIN) top = r.top - GAP - fh;
1012
+ else if (r.bottom + GAP + fh <= vh - MARGIN) top = r.bottom + GAP;
1013
+ else top = Math.max(MARGIN, Math.min(vh - MARGIN - fh, r.top - GAP - fh));
1014
+ // 横向:对齐左缘,超出右界则左移,再夹取左界
1015
+ let left = r.left;
1016
+ if (left + fw > vw - MARGIN) left = vw - MARGIN - fw;
1017
+ if (left < MARGIN) left = MARGIN;
1018
+ f.style.top = Math.round(top) + 'px';
1019
+ f.style.left = Math.round(left) + 'px';
1020
+ }
1021
+
1022
+ function show(wrap) {
1023
+ const src = wrap.querySelector('.msg-tip-src');
1024
+ if (!src || !src.innerHTML.trim()) return;
1025
+ const f = ensureFloat();
1026
+ cancelHide();
1027
+ if (curWrap !== wrap) { f.innerHTML = src.innerHTML; curWrap = wrap; }
1028
+ f.classList.add('show');
1029
+ position(wrap);
1030
+ }
1031
+
1032
+ document.addEventListener('mouseover', (e) => {
1033
+ const wrap = e.target.closest && e.target.closest('.ag-msg-wrap');
1034
+ if (wrap) show(wrap);
1035
+ });
1036
+ document.addEventListener('mouseout', (e) => {
1037
+ const wrap = e.target.closest && e.target.closest('.ag-msg-wrap');
1038
+ if (!wrap) return;
1039
+ const to = e.relatedTarget;
1040
+ if (to && (wrap.contains(to) || (floatEl && floatEl.contains(to)))) return;
1041
+ scheduleHide();
1042
+ });
1043
+ // 滚动时隐藏,避免浮层与行脱节
1044
+ window.addEventListener('scroll', hideNow, true);
1045
+ }
1046
+
1047
+ // 顶部统计条:Gateway / AIDs total·connected·offline / Messages / Version·PID·Uptime
1048
+ function agentsStatsBar(data, aids, agentStats) {
312
1049
  const connected = aids.filter(a => (a.status || 'connected') === 'connected').length;
313
1050
  const offline = aids.length - connected;
314
- let recv = 0, sent = 0, bin = 0, bout = 0;
315
- for (const s of stats) {
316
- recv += s.messagesReceived || 0; sent += s.messagesSent || 0;
317
- bin += s.bytesReceived || 0; bout += s.bytesSent || 0;
1051
+ let recv = 0, sent = 0, done = 0, errors = 0, interrupts = 0;
1052
+ for (const s of agentStats) {
1053
+ recv += s.received || 0;
1054
+ sent += s.sent || 0;
1055
+ done += s.completed || 0;
1056
+ errors += s.errors || 0;
1057
+ interrupts += s.interrupts || 0;
318
1058
  }
319
1059
  const gws = [...new Set(aids.filter(a => a.gatewayUrl).map(a => a.gatewayUrl))];
320
1060
  const gw = gws.length ? gws.map(esc).join(', ') : '—';
@@ -324,34 +1064,40 @@ function agentsStatsBar(data, aids, stats) {
324
1064
  const ver = data.version || '—';
325
1065
 
326
1066
  let h = '<div class="agents-stats">';
327
- h += `<span class="sg"><span class="sg-k">Gateway</span><span class="sg-gw">${gw}</span></span>`;
328
- h += `<span class="sg"><span class="sg-k">AIDs</span>${aids.length} total · <span class="num-on">${connected} 在线</span>` +
329
- `${offline ? ` · <span class="num-off">${offline} 离线</span>` : ''}</span>`;
330
- h += `<span class="sg"><span class="sg-k">Messages</span><span class="in">↓${recv}</span> <span class="out">↑${sent}</span></span>`;
331
- h += `<span class="sg"><span class="sg-k">Traffic</span><span class="in">↓${fmtBytes(bin)}</span> <span class="out">↑${fmtBytes(bout)}</span></span>`;
332
- h += `<span class="sg"><span class="sg-k">Version</span>${esc(ver)} · <span class="sg-k">PID</span>${pid} · <span class="sg-k">Uptime</span>${uptime}</span>`;
1067
+ h += `<span class="sg"><span class="sg-k">${t('agents.stats.gateway')}</span><span class="sg-gw">${gw}</span></span>`;
1068
+ 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>` +
1069
+ `${offline ? ` · <span class="num-off">${offline} ${t('agents.stats.offline')}</span>` : ''}</span>`;
1070
+ h += `<span class="sg"><span class="sg-k">${t('agents.stats.messages')}</span>收 ${recv} · 发 ${sent} · 错 ${errors} · 断 ${interrupts} · 完 ${done}</span>`;
1071
+ 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>`;
333
1072
  h += '</div>';
334
1073
  return h;
335
1074
  }
336
1075
 
1076
+ function agentQueueHtml(s) {
1077
+ const processing = s.processing || 0;
1078
+ const queued = s.queued || 0;
1079
+ if (processing === 0 && queued === 0) return '<span class="ag-queue-empty">-</span>';
1080
+ return `<span class="ag-queue-num">${processing}/${queued}</span>`;
1081
+ }
1082
+
337
1083
  // 操作列 HTML(启用页):停止/启动 + 清空队列(conditional) + ···(禁用/重载/编辑/md/删除)
338
1084
  function agentOpsHtml(aid, ag, s) {
339
1085
  if (_agentOps.has(aid)) {
340
- return `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(aid) || '操作中…')}</span></div>`;
1086
+ return `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(aid) || t('common.operating'))}</span></div>`;
341
1087
  }
342
1088
  const queued = s.queued || 0;
343
1089
  const running = ag.status === 'running';
344
1090
  let h = `<div class="agent-ops" data-aid="${esc(aid)}" data-status="${esc(ag.status)}">`;
345
- if (running) h += `<button class="ctrl-btn ops-stop" data-op="stop">停止</button>`;
346
- else h += `<button class="ctrl-btn ops-start" data-op="start">启动</button>`;
347
- if (queued > 0) h += `<button class="ctrl-btn ops-clear-queue" data-op="clear-queue" title="清空 ${queued} 条待处理消息">清空队列</button>`;
1091
+ if (running) h += `<button class="ctrl-btn ops-stop" data-op="stop">${t('action.stop')}</button>`;
1092
+ else h += `<button class="ctrl-btn ops-start" data-op="start">${t('action.start')}</button>`;
1093
+ 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>`;
348
1094
  h += `<div class="ops-more"><button class="ctrl-btn ops-more-btn" data-op="more">···</button>` +
349
1095
  `<div class="ops-dropdown">` +
350
- `<button class="ops-dd-item" data-op="toggle">禁用</button>` +
351
- `<button class="ops-dd-item" data-op="reload">重载配置</button>` +
352
- `<button class="ops-dd-item" data-op="edit">编辑配置</button>` +
353
- `<a class="ops-dd-item" href="https://${esc(aid)}/agent.md" target="_blank" rel="noopener">查看 agent.md ↗</a>` +
354
- `<button class="ops-dd-item danger" data-op="delete">删除 Agent</button>` +
1096
+ `<button class="ops-dd-item" data-op="toggle">${t('action.disable')}</button>` +
1097
+ `<button class="ops-dd-item" data-op="reload">${t('action.reload')}</button>` +
1098
+ `<button class="ops-dd-item" data-op="edit">${t('action.edit')}</button>` +
1099
+ `<a class="ops-dd-item" href="https://${esc(aid)}/agent.md" target="_blank" rel="noopener">${t('agents.op.viewAgentMd')}</a>` +
1100
+ `<button class="ops-dd-item danger" data-op="delete">${t('action.delete')}</button>` +
355
1101
  `</div></div>`;
356
1102
  h += '</div>';
357
1103
  return h;
@@ -359,13 +1105,15 @@ function agentOpsHtml(aid, ag, s) {
359
1105
 
360
1106
  function renderAgents(data) {
361
1107
  const el = $('#view-agents');
362
- if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
1108
+ if (!data) { el.innerHTML = `<div class="empty">${t('common.loading')}</div>`; return; }
363
1109
  if (el.querySelector('.ops-more.open')) return;
364
1110
 
365
1111
  const allAgents = data.agents || [];
366
1112
  const aids = data.aids || [];
367
1113
  const statsByAid = {};
368
1114
  for (const s of (data.stats || [])) statsByAid[s.aid] = s;
1115
+ const agentStatsByAid = {};
1116
+ for (const s of (data.agentStats || [])) agentStatsByAid[s.aid] = s;
369
1117
  const aidConnByAid = {};
370
1118
  for (const a of aids) aidConnByAid[a.aid] = a;
371
1119
 
@@ -375,27 +1123,27 @@ function renderAgents(data) {
375
1123
  // 子标签栏
376
1124
  let html = '<div class="agents-toolbar">' +
377
1125
  `<div class="ag-subtabs">` +
378
- `<button class="ag-subtab${_agSubtab === 'enabled' ? ' active' : ''}" data-subtab="enabled">启用 (${enabledCount})</button>` +
379
- `<button class="ag-subtab${_agSubtab === 'disabled' ? ' active' : ''}" data-subtab="disabled">禁用 (${disabledCount})</button>` +
1126
+ `<button class="ag-subtab${_agSubtab === 'enabled' ? ' active' : ''}" data-subtab="enabled">${t('agents.subtitle.enabled')} (${enabledCount})</button>` +
1127
+ `<button class="ag-subtab${_agSubtab === 'disabled' ? ' active' : ''}" data-subtab="disabled">${t('agents.subtitle.disabled')} (${disabledCount})</button>` +
380
1128
  `</div>` +
381
- `<button class="ctrl-btn" id="agent-new-btn">+ 新建</button>` +
1129
+ `<button class="ctrl-btn" id="agent-new-btn">${t('action.new')}</button>` +
382
1130
  '</div>';
383
1131
 
384
1132
  if (!data.daemonRunning) {
385
- html += '<div class="banner">⚠ EvolClaw 主进程未运行,仅显示最近活动记录</div>';
1133
+ html += `<div class="banner">${t('agents.daemonStopped')}</div>`;
386
1134
  }
387
1135
 
388
1136
  if (_agSubtab === 'disabled') {
389
1137
  const disabledAgents = allAgents.filter(ag => ag.status === 'disabled');
390
1138
  if (!disabledAgents.length) {
391
- html += '<div class="empty">暂无禁用 Agent</div>';
1139
+ html += `<div class="empty">${t('agents.empty.disabled')}</div>`;
392
1140
  } else {
393
- html += '<table><thead><tr><th>Agent</th><th>项目路径</th><th>操作</th></tr></thead><tbody>';
1141
+ 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>`;
394
1142
  for (const ag of disabledAgents) {
395
1143
  const busy = _agentOps.has(ag.aid);
396
1144
  const ops = busy
397
- ? `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(ag.aid) || '操作中…')}</span></div>`
398
- : `<div class="agent-ops" data-aid="${esc(ag.aid)}" data-status="disabled"><button class="ctrl-btn ops-enable" data-op="toggle">启用</button></div>`;
1145
+ ? `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(ag.aid) || t('common.operating'))}</span></div>`
1146
+ : `<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>`;
399
1147
  html += `<tr class="ag-main">` +
400
1148
  `<td><div class="ag-id"><span class="dot off"></span><span class="ag-id-text"><span class="ag-name">${esc(ag.displayName || shortAid(ag.aid))}</span><span class="ag-aid">${esc(ag.aid)}</span></span></div></td>` +
401
1149
  `<td style="font-size:11px;font-family:monospace">${esc(ag.projectPath || '—')}</td>` +
@@ -409,27 +1157,29 @@ function renderAgents(data) {
409
1157
  }
410
1158
 
411
1159
  // ── 启用页 ──
412
- // 按收发消息总数降序排序(活跃的排前面)
413
- const totalMsgs = (ag) => {
414
- const s = statsByAid[ag.aid] || {};
415
- return (s.messagesReceived || 0) + (s.messagesSent || 0);
1160
+ // 按全渠道任务活动降序排序(活跃的排前面)
1161
+ const totalActivity = (ag) => {
1162
+ const s = agentStatsByAid[ag.aid] || {};
1163
+ return (s.received || 0) + (s.sent || 0) + (s.completed || 0) + (s.errors || 0) + (s.interrupts || 0);
416
1164
  };
417
1165
  const enabledAgents = allAgents.filter(ag => ag.status !== 'disabled')
418
- .sort((a, b) => totalMsgs(b) - totalMsgs(a));
1166
+ .sort((a, b) => totalActivity(b) - totalActivity(a));
419
1167
  if (!enabledAgents.length) {
420
- html += '<div class="empty">暂无启用 Agent</div>';
1168
+ html += `<div class="empty">${t('agents.empty.enabled')}</div>`;
421
1169
  el.innerHTML = html;
422
1170
  bindAgentsEvents(el);
423
1171
  return;
424
1172
  }
425
1173
 
426
1174
  html += '<table><thead><tr>' +
427
- '<th>AID</th><th>工作</th><th>队列</th><th>模型</th><th>运行</th><th>收</th><th>发</th>' +
428
- '<th>入字节</th><th>出字节</th><th>对端数量</th><th>最后活动</th><th>操作</th>' +
1175
+ `<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>` +
1176
+ `<th>${t('agents.th.received')}</th><th>${t('agents.th.sent')}</th><th>${t('agents.th.errors')}</th><th>${t('agents.th.interrupts')}</th><th>${t('agents.th.completed')}</th>` +
1177
+ `<th>${t('agents.th.lastActivity')}</th><th>${t('agents.th.operations')}</th>` +
429
1178
  '</tr></thead><tbody>';
430
1179
 
431
1180
  for (const ag of enabledAgents) {
432
1181
  const s = statsByAid[ag.aid] || {};
1182
+ const runStats = agentStatsByAid[ag.aid] || {};
433
1183
  const conn = aidConnByAid[ag.aid] || {};
434
1184
  const connStatus = conn.status || (ag.status === 'running' ? 'connected' : 'disconnected');
435
1185
  const dotCls = connStatus === 'connected' ? 'on' : (connStatus === 'reconnecting' ? 'idle' : 'off');
@@ -437,10 +1187,7 @@ function renderAgents(data) {
437
1187
  const uptime = (connStatus === 'connected' && conn.lastConnectedAt) ? fmtDur((Date.now() - conn.lastConnectedAt) / 1000) : '—';
438
1188
  const lastTs = Math.max(s.lastReceivedAt || 0, s.lastSentAt || 0, ag.lastActivity || 0);
439
1189
  const preview = agentPreviewHtml(s);
440
- // 队列数:不含正在处理的那条
441
- const rawQueued = s.queued || 0;
442
- const queued = rawQueued;
443
- const queueCell = queued > 0 ? `<span class="ag-queue-num">${queued}</span>` : '<span style="color:var(--dim)">0</span>';
1190
+ const queueCell = agentQueueHtml(runStats);
444
1191
  const model = ag.model || ag.baseagent || '—';
445
1192
 
446
1193
  const idCell = `<div class="ag-id"><span class="dot ${dotCls}" title="${esc(connStatus)}"></span>` +
@@ -449,15 +1196,17 @@ function renderAgents(data) {
449
1196
 
450
1197
  html += `<tr class="ag-main">` +
451
1198
  `<td>${idCell}</td>` +
452
- `<td>${agentStateBadge(s, ag.status, connStatus)}</td>` +
1199
+ `<td>${agentStateBadge({ ...s, ...runStats }, ag.status, connStatus)}</td>` +
453
1200
  `<td>${queueCell}</td>` +
454
1201
  `<td style="font-size:11px;color:var(--dim)">${esc(model)}</td>` +
455
1202
  `<td>${uptime}</td>` +
456
- `<td>${s.messagesReceived ?? 0}</td><td>${s.messagesSent ?? 0}</td>` +
457
- `<td>${fmtBytes(s.bytesReceived)}</td><td>${fmtBytes(s.bytesSent)}</td>` +
458
- `<td>${s.uniquePeerCount ?? conn.peerCount ?? 0}</td>` +
1203
+ `<td>${runStats.received || 0}</td>` +
1204
+ `<td>${runStats.sent || 0}</td>` +
1205
+ `<td>${runStats.errors || 0}</td>` +
1206
+ `<td>${runStats.interrupts || 0}</td>` +
1207
+ `<td>${runStats.completed || 0}</td>` +
459
1208
  `<td>${fmtAgo(lastTs)}</td>` +
460
- `<td class="agent-ops-cell">${agentOpsHtml(ag.aid, ag, s)}</td>` +
1209
+ `<td class="agent-ops-cell">${agentOpsHtml(ag.aid, ag, runStats)}</td>` +
461
1210
  '</tr>';
462
1211
  // 自定义 tooltip(HTML,hover 显示)
463
1212
  const recent = (s.recentMessages || []);
@@ -470,7 +1219,7 @@ function renderAgents(data) {
470
1219
  }
471
1220
  html += '</tbody></table>';
472
1221
  if (data.daemonRunning) {
473
- html += agentsStatsBar(data, aids, data.stats || []);
1222
+ html += agentsStatsBar(data, aids, data.agentStats || []);
474
1223
  }
475
1224
  el.innerHTML = html;
476
1225
  bindAgentsEvents(el);
@@ -504,17 +1253,17 @@ function groupLabel(g) {
504
1253
 
505
1254
  function renderCache(data) {
506
1255
  const el = $('#view-cache');
507
- if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
1256
+ if (!data) { el.innerHTML = `<div class="empty">${t('common.loading')}</div>`; return; }
508
1257
  if (!data.daemonRunning) {
509
- el.innerHTML = '<div class="banner">⚠ EvolClaw 主进程未运行,无缓存统计可显示</div>';
1258
+ el.innerHTML = `<div class="banner">${t('cache.daemonStopped')}</div>`;
510
1259
  return;
511
1260
  }
512
1261
  if (!data.supported || !data.stats) {
513
- el.innerHTML = '<div class="banner">⚠ 当前 EvolClaw 版本不支持 cache-stats(请升级 daemon)</div>';
1262
+ el.innerHTML = `<div class="banner">${t('cache.notSupported')}</div>`;
514
1263
  return;
515
1264
  }
516
1265
  const s = data.stats;
517
- const t = s.totals;
1266
+ const tot = s.totals;
518
1267
  const occ = s.occupancy || {};
519
1268
  // 全部组占用合计
520
1269
  let totalBytes = 0;
@@ -523,23 +1272,23 @@ function renderCache(data) {
523
1272
  let html = '';
524
1273
 
525
1274
  // ① 总览卡片
526
- const rate = hitRate(t);
1275
+ const rate = hitRate(tot);
527
1276
  html += '<div class="cache-cards">';
528
- html += card('命中率', fmtPct(rate), rateCls(rate), `${fmtNum(t.hits)} 命中 / ${fmtNum(t.misses)} 未命中`);
529
- html += card('读取总数', fmtNum(t.gets), '', `${fmtNum(t.hits)} hit · ${fmtNum(t.misses)} miss`);
530
- html += card('缓存条目', fmtNum(s.size), '', fmtBytes(totalBytes) + ' 近似内存');
531
- html += card('stat 检查', fmtNum(t.statChecks), '', 'mtime 策略每读一次');
532
- html += card('重读', fmtNum(t.reReads), '', '带外改后自动重读');
533
- html += card('驱逐', fmtNum(t.evictions), t.evictions ? 'idle' : '', 'LRU 超限');
534
- html += card('失效', fmtNum(t.invalidations), '', 'reload/单刷清除');
535
- html += card('统计起始', fmtAgo(s.since) + ' ', '', fmtTime(s.since));
1277
+ html += card(t('cache.card.hitRate'), fmtPct(rate), rateCls(rate), `${fmtNum(tot.hits)} ${t('cache.card.hit')} / ${fmtNum(tot.misses)} ${t('cache.card.miss')}`);
1278
+ html += card(t('cache.card.reads'), fmtNum(tot.gets), '', `${fmtNum(tot.hits)} ${t('cache.card.hit')} · ${fmtNum(tot.misses)} ${t('cache.card.miss')}`);
1279
+ html += card(t('cache.card.entries'), fmtNum(s.size), '', fmtBytes(totalBytes) + ' ' + t('cache.card.memory'));
1280
+ html += card(t('cache.card.statChecks'), fmtNum(tot.statChecks), '', 'mtime ' + t('cache.policy.mtime'));
1281
+ html += card(t('cache.card.reReads'), fmtNum(tot.reReads), '', t('cache.policy.manual'));
1282
+ html += card(t('cache.card.evictions'), fmtNum(tot.evictions), tot.evictions ? 'idle' : '', 'LRU');
1283
+ html += card(t('cache.card.invalidations'), fmtNum(tot.invalidations), '', 'reload');
1284
+ html += card(t('cache.card.since'), fmtAgo(s.since) + ' ' + t('cache.card.ago'), '', fmtTime(s.since));
536
1285
  html += '</div>';
537
1286
 
538
1287
  // ② 按 group 表(每组命中率 + 占用 + 容量水位)
539
- html += '<h3 class="cache-h">按缓存组</h3>';
1288
+ html += `<h3 class="cache-h">${t('cache.section.byGroup')}</h3>`;
540
1289
  html += '<table><thead><tr>' +
541
- '<th>组</th><th>类型</th><th>读取</th><th>命中</th><th>未命中</th><th>命中率</th>' +
542
- '<th>重读</th><th>驱逐</th><th>条目</th><th>内存</th><th>容量</th>' +
1290
+ `<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>` +
1291
+ `<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>` +
543
1292
  '</tr></thead><tbody>';
544
1293
  const groups = Object.keys(s.byGroup).sort((a, b) => (s.byGroup[b].gets || 0) - (s.byGroup[a].gets || 0));
545
1294
  for (const g of groups) {
@@ -565,11 +1314,15 @@ function renderCache(data) {
565
1314
  html += '</tbody></table>';
566
1315
 
567
1316
  // ③ 按 policy 表
568
- html += '<h3 class="cache-h">按策略</h3>';
1317
+ html += `<h3 class="cache-h">${t('cache.section.byPolicy')}</h3>`;
569
1318
  html += '<table><thead><tr>' +
570
- '<th>策略</th><th>读取</th><th>命中</th><th>未命中</th><th>命中率</th><th>stat 检查</th><th>重读</th>' +
1319
+ `<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>` +
571
1320
  '</tr></thead><tbody>';
572
- const POLICY_DESC = { 'on-reload': '靠 reload 刷新,平时零检查', 'manual': '显式单刷', 'mtime': '每读 statSync 门控' };
1321
+ const POLICY_DESC = {
1322
+ 'on-reload': t('cache.policy.onReload'),
1323
+ 'manual': t('cache.policy.manual'),
1324
+ 'mtime': t('cache.policy.mtime')
1325
+ };
573
1326
  for (const pol of ['on-reload', 'mtime', 'manual']) {
574
1327
  const c = s.byPolicy[pol];
575
1328
  if (!c || !c.gets) continue;
@@ -583,8 +1336,7 @@ function renderCache(data) {
583
1336
  }
584
1337
  html += '</tbody></table>';
585
1338
 
586
- html += '<div class="cache-note">注:config/defaults 与关系级 preferences 的读取也已并入本统计;' +
587
- '渲染后结果(按 vars)不缓存,故不在此列。</div>';
1339
+ html += `<div class="cache-note">${t('cache.note')}</div>`;
588
1340
 
589
1341
  el.innerHTML = html;
590
1342
  }
@@ -600,17 +1352,20 @@ function card(label, value, valCls, sub) {
600
1352
  // ── Messages 视图 ──
601
1353
  function renderMsg(data) {
602
1354
  if (!data) return;
603
- const aids = data.aids || [];
1355
+ const aids = data.scopes || data.aids || [];
604
1356
  const peers = data.peers || [];
605
1357
  const messages = data.messages || [];
1358
+ if (data.scope && data.scope !== msgSel.aid) msgSel.aid = data.scope;
606
1359
 
607
1360
  // 左:AID 列表
608
- let aidsHtml = '<div class="col-title">AID</div>';
1361
+ let aidsHtml = `<div class="col-title">${t('messages.colTitle.aid')}</div>`;
609
1362
  for (const a of aids) {
610
1363
  const sel = a.aid === msgSel.aid ? ' sel' : '';
1364
+ const name = a.selfAID && a.selfAID !== 'unknown' ? shortAid(a.selfAID) : 'unknown';
1365
+ const groupBit = a.groupCount ? ` · 群 ${a.groupCount}` : '';
611
1366
  aidsHtml += `<div class="list-item${sel}" data-aid="${esc(a.aid)}">` +
612
- `<div class="name">${esc(shortAid(a.aid))}</div>` +
613
- `<div class="sub">↓${a.totalIn} ↑${a.totalOut} · ${a.peerCount} peers</div></div>`;
1367
+ `<div class="name">${esc(name)}</div>` +
1368
+ `<div class="sub">↓${a.totalIn} ↑${a.totalOut} · ${a.peerCount} chats${groupBit} · ${fmtAgo(a.lastAt)}</div></div>`;
614
1369
  }
615
1370
  $('#msg-aids').innerHTML = aidsHtml;
616
1371
  $('#msg-aids').querySelectorAll('.list-item').forEach(item => {
@@ -618,19 +1373,24 @@ function renderMsg(data) {
618
1373
  });
619
1374
 
620
1375
  // 中:对端列表
621
- let peersHtml = '<div class="col-title">Peers</div>';
1376
+ let peersHtml = `<div class="col-title">${t('messages.colTitle.peers')}</div>`;
622
1377
  if (msgSel.aid) {
623
1378
  const allSel = msgSel.peer === null ? ' sel' : '';
624
- peersHtml += `<div class="list-item${allSel}" data-peer=""><div class="name">All</div>` +
625
- `<div class="sub">${peers.length} peers</div></div>`;
1379
+ peersHtml += `<div class="list-item${allSel}" data-peer=""><div class="name">${t('messages.colTitle.all')}</div>` +
1380
+ `<div class="sub">${peers.length} chats</div></div>`;
626
1381
  for (const p of peers) {
627
1382
  const sel = p.peerId === msgSel.peer ? ' sel' : '';
1383
+ const displayName = p.chatType === 'group'
1384
+ ? (p.groupName || p.peerName || p.groupId || p.peerId)
1385
+ : (p.peerName || p.peerId);
1386
+ const channelLabel = p.channelName && p.channelName !== 'main' ? `${p.channelType}/${p.channelName}` : (p.channelType || '');
1387
+ const typeLabel = p.chatType === 'group' ? `${channelLabel} · ${t('messages.tag.group')}` : channelLabel;
628
1388
  peersHtml += `<div class="list-item${sel}" data-peer="${esc(p.peerId)}">` +
629
- `<div class="name">${esc(p.peerName || shortAid(p.peerId))}</div>` +
1389
+ `<div class="name">${esc(shortId(displayName))} <span class="tag">${esc(typeLabel)}</span></div>` +
630
1390
  `<div class="sub">↓${p.inbound} ↑${p.outbound} · ${fmtAgo(p.lastAt)}</div></div>`;
631
1391
  }
632
1392
  } else {
633
- peersHtml += '<div class="empty">← 选择一个 AID</div>';
1393
+ peersHtml += `<div class="empty">${t('messages.empty.selectAid')}</div>`;
634
1394
  }
635
1395
  $('#msg-peers').innerHTML = peersHtml;
636
1396
  $('#msg-peers').querySelectorAll('.list-item').forEach(item => {
@@ -639,23 +1399,28 @@ function renderMsg(data) {
639
1399
 
640
1400
  // 右:消息流
641
1401
  const stream = $('#msg-stream');
642
- if (!msgSel.aid) { stream.innerHTML = '<div class="empty">选择 AID 查看消息</div>'; return; }
1402
+ if (!msgSel.aid) { stream.innerHTML = `<div class="empty">${t('messages.empty.selectToView')}</div>`; return; }
643
1403
  const atBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 60;
644
1404
  let msgHtml = '';
645
1405
  for (const m of messages) {
646
1406
  const cls = m.dir === 'in' ? 'in' : 'out';
647
1407
  const arrow = m.dir === 'in' ? '↓' : '↑';
648
- const from = shortAid(m.from), to = shortAid(m.to);
1408
+ const from = shortId(m.from), to = shortId(m.to);
649
1409
  const tags = [];
650
- if (m.chatType === 'group') tags.push('群聊');
651
- if (m.encrypt != null) tags.push(m.encrypt ? '密文' : '明文');
652
- if (m.chatmode) tags.push(m.chatmode === 'proactive' ? '自主' : '响应');
653
- const tagHtml = tags.map(t => `<span class="tag">${esc(t)}</span>`).join('');
1410
+ if (m.channelType) tags.push(m.channelType);
1411
+ if (m.chatType === 'group') tags.push(t('messages.tag.group'));
1412
+ // 消息详情流的 kind 来自 jsonl msgType(text/thought/image/file/command),
1413
+ // agents 页内存态的 MsgKind(send/thought/inject/notify)不是同一套词汇。
1414
+ const mt = MSG_TYPE_META[m.msgType];
1415
+ if (mt) tags.push(`${mt.icon}${mt.label()}`);
1416
+ if (m.encrypt != null) tags.push(m.encrypt ? t('messages.tag.encrypted') : t('messages.tag.plain'));
1417
+ if (m.chatmode) tags.push(m.chatmode === 'proactive' ? t('messages.tag.proactive') : (m.chatmode === 'inject' ? t('messages.tag.inject') : t('messages.tag.responsive')));
1418
+ const tagHtml = tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('');
654
1419
  msgHtml += `<div class="bubble ${cls}">` +
655
1420
  `<div class="meta">${fmtTime(m.ts)} ${arrow} ${esc(from)}→${esc(to)}${tagHtml}</div>` +
656
1421
  `<div class="body">${esc(m.content)}</div></div>`;
657
1422
  }
658
- stream.innerHTML = msgHtml || '<div class="empty">暂无消息</div>';
1423
+ stream.innerHTML = msgHtml || `<div class="empty">${t('messages.empty.noMessages')}</div>`;
659
1424
  if (atBottom) stream.scrollTop = stream.scrollHeight;
660
1425
  }
661
1426
 
@@ -683,8 +1448,19 @@ function renderSession(data) {
683
1448
  const projOpts = projects.map(p =>
684
1449
  `<option value="${esc(p.encoded)}"${p.encoded === sessSel.project ? ' selected' : ''}>${esc(p.label)} (${p.count})</option>`
685
1450
  ).join('');
1451
+
1452
+ // baseagent 下拉选择器(只显示可用的)
1453
+ let baseagentOpts = '';
1454
+ if (availableBaseagents.claude) {
1455
+ baseagentOpts += `<option value="claude"${sessSel.baseagent === 'claude' ? ' selected' : ''}>Claude</option>`;
1456
+ }
1457
+ if (availableBaseagents.codex) {
1458
+ baseagentOpts += `<option value="codex"${sessSel.baseagent === 'codex' ? ' selected' : ''}>Codex</option>`;
1459
+ }
1460
+
686
1461
  const normalCount = transcripts.filter(t => (t.userMsgs || 0) >= 2).length;
687
1462
  let listHtml = '<div class="sess-filter">' +
1463
+ `<select id="sess-baseagent" title="Base Agent">${baseagentOpts}</select>` +
688
1464
  `<select id="sess-project">${projOpts}</select>` +
689
1465
  `<input id="sess-search" type="text" placeholder="搜索标题/首条消息…" value="${esc(sessSearch)}">` +
690
1466
  `<button id="sess-filter-btn" class="ctrl-btn${sessFilterNormal ? ' active' : ''}" title="只显示有效会话(≥2 条用户消息)">有效 ${normalCount}</button>` +
@@ -712,11 +1488,19 @@ function renderSession(data) {
712
1488
  $('#sess-list').innerHTML = listHtml;
713
1489
 
714
1490
  // 绑定交互(注意保持搜索框焦点)
1491
+ const baseagentSel = $('#sess-baseagent');
1492
+ if (baseagentSel) baseagentSel.onchange = () => {
1493
+ console.log('[ecweb] Baseagent changed to:', baseagentSel.value);
1494
+ sessSel = { sessionId: null, project: null, baseagent: baseagentSel.value };
1495
+ sessSearch = '';
1496
+ console.log('[ecweb] Subscribing to session with baseagent:', sessSel.baseagent);
1497
+ subscribe('session', { baseagent: sessSel.baseagent });
1498
+ };
715
1499
  const projSel = $('#sess-project');
716
1500
  if (projSel) projSel.onchange = () => {
717
- sessSel = { sessionId: null, project: projSel.value };
1501
+ sessSel = { sessionId: null, project: projSel.value, baseagent: sessSel.baseagent };
718
1502
  sessSearch = '';
719
- subscribe('session', { project: sessSel.project });
1503
+ subscribe('session', { project: sessSel.project, baseagent: sessSel.baseagent });
720
1504
  };
721
1505
  const filterBtn = $('#sess-filter-btn');
722
1506
  if (filterBtn) filterBtn.onclick = () => { sessFilterNormal = !sessFilterNormal; renderSession(state.session); };
@@ -726,7 +1510,7 @@ function renderSession(data) {
726
1510
  if (q) { searchEl.focus(); searchEl.setSelectionRange(searchEl.value.length, searchEl.value.length); }
727
1511
  }
728
1512
  $('#sess-list').querySelectorAll('.list-item').forEach(item => {
729
- item.onclick = () => { sessSel = { sessionId: item.dataset.sid, project: sessSel.project }; subscribe('session', sessSel); };
1513
+ item.onclick = () => { sessSel = { sessionId: item.dataset.sid, project: sessSel.project, baseagent: sessSel.baseagent }; subscribe('session', sessSel); };
730
1514
  });
731
1515
 
732
1516
  // 右:transcript 详情
@@ -947,8 +1731,8 @@ function setAgentOp(aid, label) {
947
1731
  if (ag.status === 'disabled') {
948
1732
  // 禁用页:只有启用按钮 / 操作中态
949
1733
  cell.innerHTML = _agentOps.has(aid)
950
- ? `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(aid) || '操作中…')}</span></div>`
951
- : `<div class="agent-ops" data-aid="${esc(aid)}" data-status="disabled"><button class="ctrl-btn ops-enable" data-op="toggle">启用</button></div>`;
1734
+ ? `<div class="agent-ops agent-ops-busy"><span class="ops-busy-label">${esc(_agentOps.get(aid) || t('common.operating'))}</span></div>`
1735
+ : `<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>`;
952
1736
  } else {
953
1737
  const statsByAid = {};
954
1738
  for (const s of (state.agents.stats || [])) statsByAid[s.aid] = s;
@@ -1018,29 +1802,29 @@ async function withAgentOp(aid, label, fn) {
1018
1802
  }
1019
1803
 
1020
1804
  async function agentOpReload(aid, force = false) {
1021
- await withAgentOp(aid, '重载中…', async () => {
1805
+ await withAgentOp(aid, t('agents.op.reloading'), async () => {
1022
1806
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'reload', args: { aid, force } }));
1023
1807
  if (r.error?.code === 'BUSY') {
1024
- if (confirm(r.error.message + '\n确认强制重载?')) { setAgentOp(aid, null); return agentOpReload(aid, true); }
1808
+ if (confirm(r.error.message + '\n' + t('agents.op.confirmReload'))) { setAgentOp(aid, null); return agentOpReload(aid, true); }
1025
1809
  return;
1026
1810
  }
1027
1811
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
1028
- toast('✓ 已重载');
1812
+ toast(t('agents.op.reloaded'));
1029
1813
  subscribe('agents', {});
1030
1814
  });
1031
1815
  }
1032
1816
 
1033
1817
  async function agentOpToggle(aid, status) {
1034
1818
  const action = status === 'disabled' ? 'enable' : 'disable';
1035
- const label = action === 'disable' ? '禁用中…' : '启用中…';
1819
+ const label = action === 'disable' ? t('agents.op.disabling') : t('agents.op.enabling');
1036
1820
  await withAgentOp(aid, label, async () => {
1037
1821
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid } }));
1038
1822
  if (r.error?.code === 'BUSY') {
1039
- if (confirm(r.error.message + `\n确认强制${action === 'disable' ? '禁用' : '启用'}?`)) {
1823
+ if (confirm(r.error.message + `\n${t('agents.op.confirmToggle')}${action === 'disable' ? t('action.disable') : t('action.enable')}?`)) {
1040
1824
  const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action, args: { aid, force: true } }));
1041
1825
  if (r2.error) toast(r2.error.message || r2.error.code, true);
1042
1826
  else {
1043
- toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`);
1827
+ toast(action === 'disable' ? t('agents.op.disabled') : t('agents.op.enabled'));
1044
1828
  // 禁用后立即切到禁用页;启用后等数据刷新(agent 需先完成启动才移到启用页)
1045
1829
  if (action === 'disable') _agSubtab = 'disabled';
1046
1830
  subscribe('agents', {});
@@ -1049,55 +1833,55 @@ async function agentOpToggle(aid, status) {
1049
1833
  return;
1050
1834
  }
1051
1835
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
1052
- toast(`✓ 已${action === 'disable' ? '禁用' : '启用'}`);
1836
+ toast(action === 'disable' ? t('agents.op.disabled') : t('agents.op.enabled'));
1053
1837
  if (action === 'disable') _agSubtab = 'disabled';
1054
1838
  subscribe('agents', {});
1055
1839
  });
1056
1840
  }
1057
1841
 
1058
1842
  async function agentOpDelete(aid) {
1059
- if (!confirm(`删除 Agent ${aid}?\n此操作不可恢复。`)) return;
1843
+ if (!confirm(t('agents.op.confirmDelete').replace('{aid}', aid))) return;
1060
1844
  const purge = confirm('同时清除 agent 数据目录?');
1061
- await withAgentOp(aid, '删除中…', async () => {
1845
+ await withAgentOp(aid, t('agents.op.deleting'), async () => {
1062
1846
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge } }));
1063
1847
  if (r.error?.code === 'BUSY') {
1064
- if (confirm(r.error.message + '\n确认强制删除?')) {
1848
+ if (confirm(r.error.message + '\n' + t('agents.op.confirmForceDelete'))) {
1065
1849
  const r2 = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'delete', args: { aid, purge, force: true } }));
1066
1850
  if (r2.error) toast(r2.error.message || r2.error.code, true);
1067
- else { toast('✓ 已删除'); subscribe('agents', {}); }
1851
+ else { toast(t('agents.op.deleted')); subscribe('agents', {}); }
1068
1852
  }
1069
1853
  return;
1070
1854
  }
1071
1855
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
1072
- toast('✓ 已删除');
1856
+ toast(t('agents.op.deleted'));
1073
1857
  subscribe('agents', {});
1074
1858
  });
1075
1859
  }
1076
1860
 
1077
1861
  async function agentOpClearQueue(aid) {
1078
- if (!confirm(`清空 ${aid} 的待处理消息队列?`)) return;
1079
- await withAgentOp(aid, '清空中…', async () => {
1862
+ if (!confirm(t('agents.op.confirmClearQueue').replace('{aid}', aid))) return;
1863
+ await withAgentOp(aid, t('common.operating'), async () => {
1080
1864
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'queue-clear', args: { aid } }));
1081
1865
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
1082
- toast(`✓ 已清空 ${r.data?.cleared ?? 0} 条待处理消息`);
1866
+ toast(`✓ ${r.data?.cleared ?? 0} messages cleared`);
1083
1867
  subscribe('agents', {});
1084
1868
  });
1085
1869
  }
1086
1870
 
1087
1871
  async function agentOpStop(aid) {
1088
- await withAgentOp(aid, '停止中…', async () => {
1872
+ await withAgentOp(aid, t('agents.op.stopping'), async () => {
1089
1873
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'stop', args: { aid } }));
1090
1874
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
1091
- toast('✓ 已停止');
1875
+ toast(t('agents.op.stopped'));
1092
1876
  subscribe('agents', {});
1093
1877
  });
1094
1878
  }
1095
1879
 
1096
1880
  async function agentOpStart(aid) {
1097
- await withAgentOp(aid, '启动中…', async () => {
1881
+ await withAgentOp(aid, t('agents.op.starting'), async () => {
1098
1882
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'start', args: { aid } }));
1099
1883
  if (r.error) { toast(r.error.message || r.error.code, true); return; }
1100
- toast('✓ 已启动');
1884
+ toast(t('agents.op.started'));
1101
1885
  subscribe('agents', {});
1102
1886
  });
1103
1887
  }
@@ -1136,7 +1920,7 @@ async function agentOpNew() {
1136
1920
  }
1137
1921
 
1138
1922
  async function agentOpEdit(aid) {
1139
- await withAgentOp(aid, '查询中…', async () => {
1923
+ await withAgentOp(aid, t('common.operating'), async () => {
1140
1924
  const qr = await menuSend({ type: 'menu.query', name: 'agent', args: { aid } });
1141
1925
  const q = mResp(qr);
1142
1926
  if (q.error) { toast(q.error.message || q.error.code, true); return; }
@@ -1148,10 +1932,10 @@ async function agentOpEdit(aid) {
1148
1932
  if (projectRaw !== null) patch.projects = { defaultPath: projectRaw };
1149
1933
  if (ownersRaw !== null) patch.owners = ownersRaw.split(',').map(s => s.trim()).filter(Boolean);
1150
1934
  if (!Object.keys(patch).length) return;
1151
- setAgentOp(aid, '保存中…');
1935
+ setAgentOp(aid, t('common.operating'));
1152
1936
  const r = mResp(await menuSend({ type: 'menu.action', name: 'agent', action: 'update', args: { aid, patch } }));
1153
1937
  if (r.error) toast(r.error.message || r.error.code, true);
1154
- else toast('✓ 配置已保存,点「重载」生效');
1938
+ else toast(t('agents.op.saved'));
1155
1939
  });
1156
1940
  }
1157
1941
 
@@ -1164,7 +1948,7 @@ function channelHealthRow(c) {
1164
1948
  if (c.flapCount > 0) meta += ` <span style="color:var(--red)">抖动 ${c.flapCount}</span>`;
1165
1949
  const reason = c.kickReason || c.lastError;
1166
1950
  if (reason && !c.connected) meta += ` <span style="color:var(--red)" title="${esc(reason)}">"${esc(reason)}"</span>`;
1167
- return `<div class="ch-row"><span class="dot ${dot}"></span>${esc(c.type)}${c.instName && c.instName !== c.type ? ' ' + esc(c.instName) : ''}${meta}</div>`;
1951
+ return `<div class="ch-row"><span class="dot ${dot}"></span>${esc(c.type)}${c.instName ? ' ' + esc(c.instName) : ''}${meta}</div>`;
1168
1952
  }
1169
1953
 
1170
1954
  function agentHealthCard(ag) {
@@ -1181,7 +1965,7 @@ function agentHealthCard(ag) {
1181
1965
  for (const c of (ag.channels || [])) chans += channelHealthRow(c);
1182
1966
  h += `<div class="ahc-row"><span class="ahc-k">渠道</span><span class="ahc-v">${chans || '<span style="color:var(--dim)">无</span>'}</span></div>`;
1183
1967
  // 负载
1184
- const load = `${ag.processing ?? 0} 处理中 · ${ag.pending ?? 0} 待处理 · ${ag.activeSessions ?? 0} 会话`;
1968
+ const load = `${ag.processing ?? 0} 处理中 · ${ag.pending ?? 0} 待处理`;
1185
1969
  h += `<div class="ahc-row"><span class="ahc-k">负载</span><span class="ahc-v">${load}</span></div>`;
1186
1970
  // 活动
1187
1971
  if (ag.lastActivity) h += `<div class="ahc-row"><span class="ahc-k">活动</span><span class="ahc-v">${fmtAgo(ag.lastActivity)} 前</span></div>`;
@@ -1191,6 +1975,16 @@ function agentHealthCard(ag) {
1191
1975
  return h;
1192
1976
  }
1193
1977
 
1978
+ function systemBaseagentCards(baseagents) {
1979
+ const list = Array.isArray(baseagents) ? baseagents : [];
1980
+ return list.map(ba => {
1981
+ const ver = ba.version ? `・${ba.version}` : '';
1982
+ const title = `Baseagent・${ba.active ? '✓ ' : ''}${ba.name || 'unknown'}${ver}`;
1983
+ const detail = [ba.model, ba.effort].filter(Boolean).map(esc).join(' · ') || '未指定模型/强度';
1984
+ return `<div class="cache-card"><div class="card-label">${esc(title)}</div><div class="card-val">${detail}</div></div>`;
1985
+ }).join('');
1986
+ }
1987
+
1194
1988
  function renderSystem(data) {
1195
1989
  const el = $('#view-system');
1196
1990
  if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
@@ -1231,27 +2025,36 @@ function renderSystem(data) {
1231
2025
 
1232
2026
  // ③ 健康快照
1233
2027
  if (chk) {
2028
+ // 从 chk.structured 读取数据(后端返回的数据结构)
2029
+ const s = chk.structured || chk; // 兼容旧版本(如果 chk 本身就是 structured)
1234
2030
  html += '<div class="sys-health">';
1235
2031
  // 队列 + 近 1 小时(数字卡片同一行)
1236
2032
  html += '<div class="cache-cards" style="margin-bottom:8px">';
1237
- html += `<div class="cache-card"><div class="card-label">队列</div><div class="card-val">${chk.queue?.pending ?? 0} 待 · ${chk.queue?.processing ?? 0} 处理中</div></div>`;
1238
- const h = chk.lastHour;
2033
+ html += `<div class="cache-card"><div class="card-label">队列</div><div class="card-val">${s.queue?.pending ?? 0} 待 · ${s.queue?.processing ?? 0} 处理中</div></div>`;
2034
+ const h = s.lastHour;
1239
2035
  if (h) {
1240
2036
  const errDetail = h.errors > 0 ? ` (${Object.entries(h.errorsByType || {}).map(([t, c]) => `${t}:${c}`).join(', ')})` : '';
1241
2037
  const avg = h.completed > 0 ? ` · 均 ${(h.avgResponseMs / 1000).toFixed(1)}s` : '';
1242
2038
  html += `<div class="cache-card"><div class="card-label">近 1 小时</div><div class="card-val">收 ${h.received} · 完 ${h.completed} · 错 ${h.errors}${errDetail} · 断 ${h.interrupts}${avg}</div></div>`;
1243
2039
  }
2040
+ html += systemBaseagentCards(sys.baseagents);
1244
2041
  html += '</div>';
1245
2042
  // 每个 EvolAgent 一张卡片:后端 + 渠道健康 + 负载
1246
- if (chk.evolagents?.length) {
2043
+ // 排序:启用的(非 disabled)在前,停用的(disabled)在后
2044
+ if (s.evolagents?.length) {
2045
+ const sortedAgents = s.evolagents.slice().sort((a, b) => {
2046
+ const aDisabled = a.status === 'disabled' ? 1 : 0;
2047
+ const bDisabled = b.status === 'disabled' ? 1 : 0;
2048
+ return aDisabled - bDisabled;
2049
+ });
1247
2050
  html += '<div class="agent-health-grid">';
1248
- for (const ag of chk.evolagents) html += agentHealthCard(ag);
2051
+ for (const ag of sortedAgents) html += agentHealthCard(ag);
1249
2052
  html += '</div>';
1250
2053
  }
1251
2054
  // 未归属任何 EvolAgent 的渠道(系统级 / DefaultAgent)
1252
- if (chk.unownedChannels?.length) {
2055
+ if (s.unownedChannels?.length) {
1253
2056
  html += '<div class="cache-card" style="margin-top:8px"><div class="card-label">未归属渠道</div>';
1254
- for (const c of chk.unownedChannels) html += channelHealthRow(c);
2057
+ for (const c of s.unownedChannels) html += channelHealthRow(c);
1255
2058
  html += '</div>';
1256
2059
  }
1257
2060
  html += '</div>';
@@ -1288,6 +2091,276 @@ function bindSystemEvents(el, data) {
1288
2091
  });
1289
2092
  }
1290
2093
 
2094
+ // ── Gateway 视图(网关 = baseagent 后端接入配置) ──
2095
+ // 数据来自 daemon menu.query name=gateway(apiKey 已掩码)。
2096
+ // 写操作走 menuSend({name:'gateway', ...}):update/test/delete。
2097
+
2098
+ // 各 baseagent 类型的可编辑字段定义(驱动编辑表单与展示)
2099
+ const GATEWAY_FIELDS = {
2100
+ claude: [
2101
+ { key: 'baseUrl', label: 'Base URL', placeholder: 'https://gateway.example.com(留空=官方)' },
2102
+ { key: 'model', label: '默认模型', placeholder: 'opus / sonnet / claude-...' },
2103
+ { key: 'effort', label: 'Effort', placeholder: 'low / medium / high / xhigh / max' },
2104
+ ],
2105
+ codex: [
2106
+ { key: 'baseUrl', label: 'Base URL', placeholder: 'https://gateway.example.com(留空=官方)' },
2107
+ { key: 'model', label: '默认模型', placeholder: 'gpt-5.2-codex / ...' },
2108
+ { key: 'effort', label: 'Effort', placeholder: 'low / medium / high' },
2109
+ { key: 'reasoning', label: 'Reasoning', placeholder: '(可选)' },
2110
+ ],
2111
+ gemini: [
2112
+ { key: 'model', label: '默认模型', placeholder: 'gemini-2.5-flash / ...' },
2113
+ { key: 'mode', label: '模式', placeholder: 'cli / sdk' },
2114
+ { key: 'cliPath', label: 'CLI 路径', placeholder: 'gemini' },
2115
+ { key: 'project', label: 'GCP Project', placeholder: '(Vertex 用)' },
2116
+ { key: 'location', label: 'Location', placeholder: 'us-central1' },
2117
+ ],
2118
+ };
2119
+
2120
+ const GATEWAY_TYPE_ICON = { claude: '🟣', codex: '🟢', gemini: '🔵' };
2121
+
2122
+ // 标记每条网关的运行时测试结果:`${scope}#${type}` → { ok, latency, modelCount, error }
2123
+ const _gwTest = new Map();
2124
+ let _gwEditing = null; // 当前编辑中的网关 key(`${scope}#${type}`)或 'new'
2125
+
2126
+ function gwKey(scope, type) { return scope + '#' + type; }
2127
+
2128
+ function renderGateway(data) {
2129
+ const el = $('#view-gateway');
2130
+
2131
+ if (!data) { el.innerHTML = '<div class="empty">加载中…</div>'; return; }
2132
+ if (data.error) {
2133
+ el.innerHTML = `<div class="empty">⚠ ${esc(data.error)}</div>`;
2134
+ return;
2135
+ }
2136
+ const gateways = data.gateways || [];
2137
+ const scopes = data.scopes || ['defaults'];
2138
+
2139
+ // 按 scope 分组
2140
+ const byScope = new Map();
2141
+ for (const s of scopes) byScope.set(s, []);
2142
+ for (const g of gateways) {
2143
+ if (!byScope.has(g.scope)) byScope.set(g.scope, []);
2144
+ byScope.get(g.scope).push(g);
2145
+ }
2146
+
2147
+ let html = '<div class="gw-wrap">';
2148
+
2149
+ html += '<div class="gw-intro">网关 = 各 AI 后端(baseagent)的接入配置。Base URL 即网关地址,留空走官方端点。' +
2150
+ '此处为只读展示,配置请通过配置文件(defaults.json / agents/&lt;aid&gt;/config.json)管理。</div>';
2151
+
2152
+ // 只展示全局默认配置块(用于编辑)
2153
+ for (const [scope, list] of byScope) {
2154
+ if (scope !== 'defaults') continue; // 跳过 per-agent 原始配置块(已在下方 effective 展示)
2155
+ const scopeLabel = '🌐 全局默认 (defaults)';
2156
+ html += `<div class="gw-scope">`;
2157
+ html += `<div class="gw-scope-head"><span class="gw-scope-title">${scopeLabel}</span></div>`;
2158
+ html += '<div class="gw-cards">';
2159
+ if (!list.length) {
2160
+ html += '<div class="empty" style="padding:12px">该作用域暂无网关配置</div>';
2161
+ } else {
2162
+ for (const g of list) html += gatewayCard(g);
2163
+ }
2164
+ html += '</div></div>';
2165
+ }
2166
+
2167
+ // ── Agent 使用配置(effective):紧凑表格 + 编辑按钮 ──
2168
+ const effective = data.effective || [];
2169
+ if (effective.length > 0) {
2170
+ html += '<div class="gw-effective-section">';
2171
+ html += '<div class="gw-scope-head"><span class="gw-scope-title">📋 Agent 网关配置</span></div>';
2172
+ html += '<table class="gw-eff-table"><thead><tr>' +
2173
+ '<th>Agent</th><th>Base Agent</th><th>Base URL</th><th>模型</th><th>API Key</th><th>Effort</th><th>来源</th>' +
2174
+ '</tr></thead><tbody>';
2175
+ for (const eff of effective) {
2176
+ const f = eff.fields || {};
2177
+ const blockSrc = eff.blockSource || 'defaults';
2178
+ const srcCls = blockSrc === 'agent' ? 'gw-src-agent' : 'gw-src-defaults';
2179
+ const srcLabel = blockSrc === 'agent' ? '⚡ agent' : '🔗 默认';
2180
+ const baseUrlVal = f.baseUrl?.value || '';
2181
+ const modelVal = f.model?.value || '';
2182
+ const keyVal = f.apiKey?.value || '';
2183
+ const effortVal = f.effort?.value || '';
2184
+
2185
+ html += `<tr class="gw-eff-tr${blockSrc === 'defaults' ? ' gw-eff-tr-inherited' : ''}">` +
2186
+ `<td class="gw-eff-td-aid" title="${esc(eff.aid)}">${esc(shortAid(eff.aid))}</td>` +
2187
+ `<td>${GATEWAY_TYPE_ICON[eff.type] || ''} ${esc(eff.type)}</td>` +
2188
+ `<td class="gw-eff-td-url" title="${esc(baseUrlVal)}">${baseUrlVal ? esc(baseUrlVal) : '<span class="gw-dim">官方</span>'}</td>` +
2189
+ `<td>${modelVal ? esc(modelVal) : '<span class="gw-dim">—</span>'}</td>` +
2190
+ `<td>${keyVal ? esc(keyVal) : '<span class="gw-dim">—</span>'}</td>` +
2191
+ `<td>${effortVal ? esc(effortVal) : '<span class="gw-dim">—</span>'}</td>` +
2192
+ `<td><span class="gw-eff-src-tag ${srcCls}">${srcLabel}</span></td>` +
2193
+ `</tr>`;
2194
+ }
2195
+ html += '</tbody></table></div>';
2196
+ }
2197
+
2198
+ html += '</div>';
2199
+ el.innerHTML = html;
2200
+ bindGatewayEvents(el, data);
2201
+ }
2202
+
2203
+ function gatewayCard(g) {
2204
+ const key = gwKey(g.scope, g.type);
2205
+ const icon = GATEWAY_TYPE_ICON[g.type] || '⚙';
2206
+ const test = _gwTest.get(key);
2207
+
2208
+ // 连通性测试状态点
2209
+ let dot = '<span class="gw-dot gw-dot-unknown" title="未测试"></span>';
2210
+ if (test) {
2211
+ if (test.ok) dot = `<span class="gw-dot gw-dot-ok" title="${test.latency}ms · ${test.modelCount} 模型"></span>`;
2212
+ else dot = `<span class="gw-dot gw-dot-err" title="${esc(test.error || '失败')}"></span>`;
2213
+ }
2214
+
2215
+ // API Key 展示
2216
+ let keyHtml;
2217
+ if (!g.apiKeyMask) keyHtml = '<span class="gw-dim">未配置</span>';
2218
+ else if (g.apiKeyIsEnvRef) keyHtml = `<code class="gw-env">${esc(g.apiKeyMask)}</code>`;
2219
+ else keyHtml = '<span class="gw-dim" title="明文密钥已隐藏,建议改用 $ENV 引用">*** (明文)</span>';
2220
+
2221
+ const rows = [];
2222
+ rows.push(['Base URL', g.baseUrl ? esc(g.baseUrl) : '<span class="gw-dim">官方端点</span>']);
2223
+ rows.push(['默认模型', g.model ? esc(g.model) : '<span class="gw-dim">—</span>']);
2224
+ rows.push(['API Key', keyHtml]);
2225
+ if (g.effort) rows.push(['Effort', esc(g.effort)]);
2226
+ if (g.reasoning) rows.push(['Reasoning', esc(g.reasoning)]);
2227
+ if (g.mode) rows.push(['模式', esc(g.mode)]);
2228
+ if (g.cliPath) rows.push(['CLI 路径', esc(g.cliPath)]);
2229
+ if (g.project) rows.push(['Project', esc(g.project)]);
2230
+ if (g.location) rows.push(['Location', esc(g.location)]);
2231
+
2232
+ let html = `<div class="gw-card" data-key="${esc(key)}">`;
2233
+ html += `<div class="gw-card-head">${dot}<span class="gw-card-icon">${icon}</span>` +
2234
+ `<span class="gw-card-title">${esc(g.name)}</span>` +
2235
+ `<span class="gw-card-type">${esc(g.type)}</span></div>`;
2236
+ html += '<div class="gw-card-body">';
2237
+ for (const [label, val] of rows) {
2238
+ html += `<div class="gw-row"><span class="gw-row-label">${esc(label)}</span><span class="gw-row-val">${val}</span></div>`;
2239
+ }
2240
+ html += '</div>';
2241
+ // 卡片操作按钮已移除(只读模式)
2242
+ html += '</div>';
2243
+ return html;
2244
+ }
2245
+
2246
+ // ── Gateway 编辑/操作弹窗函数(已禁用,网关配置现为只读展示)──
2247
+ // 如需恢复网关编辑功能,取消下方注释即可。
2248
+
2249
+ /*
2250
+ // 编辑/新增弹窗
2251
+ function openGatewayEditor(scope, type, existing, scopes) {
2252
+ const isNew = !existing;
2253
+ const fields = GATEWAY_FIELDS[type] || GATEWAY_FIELDS.claude;
2254
+
2255
+ let html = '<div class="gw-modal-backdrop" id="gw-modal-backdrop"><div class="gw-modal">';
2256
+ html += `<div class="gw-modal-head">${isNew ? '添加网关' : '编辑网关'}</div>`;
2257
+ html += '<div class="gw-modal-body">';
2258
+
2259
+ // scope 选择(新增时可选,编辑时锁定)
2260
+ html += '<label class="gw-field"><span class="gw-field-label">作用域</span>';
2261
+ if (isNew) {
2262
+ html += '<select id="gw-f-scope">';
2263
+ for (const s of (scopes || ['defaults'])) {
2264
+ const lbl = s === 'defaults' ? '全局默认' : shortAid(s);
2265
+ html += `<option value="${esc(s)}"${s === scope ? ' selected' : ''}>${esc(lbl)}</option>`;
2266
+ }
2267
+ html += '</select>';
2268
+ } else {
2269
+ html += `<input id="gw-f-scope" type="text" value="${esc(scope)}" disabled>`;
2270
+ }
2271
+ html += '</label>';
2272
+
2273
+ // type 选择(新增时可选,编辑时锁定)
2274
+ html += '<label class="gw-field"><span class="gw-field-label">后端类型</span>';
2275
+ if (isNew) {
2276
+ html += '<select id="gw-f-type">';
2277
+ for (const t of ['claude', 'codex', 'gemini']) {
2278
+ html += `<option value="${t}"${t === type ? ' selected' : ''}>${t}</option>`;
2279
+ }
2280
+ html += '</select>';
2281
+ } else {
2282
+ html += `<input id="gw-f-type" type="text" value="${esc(type)}" disabled>`;
2283
+ }
2284
+ html += '</label>';
2285
+
2286
+ // 动态字段
2287
+ html += '<div id="gw-dyn-fields">';
2288
+ for (const f of fields) {
2289
+ const val = existing ? (existing[f.key] || '') : '';
2290
+ html += `<label class="gw-field"><span class="gw-field-label">${esc(f.label)}</span>` +
2291
+ `<input class="gw-dyn" data-key="${esc(f.key)}" type="text" value="${esc(val)}" placeholder="${esc(f.placeholder || '')}"></label>`;
2292
+ }
2293
+ html += '</div>';
2294
+
2295
+ // API Key(仅 $ENV 引用)
2296
+ const curKey = existing && existing.apiKeyIsEnvRef ? existing.apiKeyMask : '';
2297
+ html += '<label class="gw-field"><span class="gw-field-label">API Key 引用</span>' +
2298
+ `<input id="gw-f-apikey" type="text" value="${esc(curKey)}" placeholder="$ENV:ANTHROPIC_AUTH_TOKEN(留空不改)"></label>`;
2299
+ html += '<div class="gw-hint">仅支持环境变量引用,格式 <code>$ENV:变量名</code>。明文密钥请写入环境变量后引用。</div>';
2300
+
2301
+ html += '</div>'; // body
2302
+ html += '<div class="gw-modal-actions">' +
2303
+ '<button class="ctrl-btn" id="gw-cancel">取消</button> ' +
2304
+ '<button class="ctrl-btn primary" id="gw-save">保存</button>' +
2305
+ '</div>';
2306
+ html += '</div></div>';
2307
+
2308
+ const wrap = document.createElement('div');
2309
+ wrap.innerHTML = html;
2310
+ document.body.appendChild(wrap.firstChild);
2311
+
2312
+ const backdrop = $('#gw-modal-backdrop');
2313
+ const close = () => { try { backdrop.remove(); } catch {} };
2314
+ backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
2315
+ $('#gw-cancel').onclick = close;
2316
+
2317
+ // 新增时切换 type 重建动态字段
2318
+ if (isNew) {
2319
+ $('#gw-f-type').onchange = (e) => {
2320
+ const newType = e.target.value;
2321
+ const dyn = $('#gw-dyn-fields');
2322
+ const fs2 = GATEWAY_FIELDS[newType] || GATEWAY_FIELDS.claude;
2323
+ dyn.innerHTML = fs2.map(f =>
2324
+ `<label class="gw-field"><span class="gw-field-label">${esc(f.label)}</span>` +
2325
+ `<input class="gw-dyn" data-key="${esc(f.key)}" type="text" value="" placeholder="${esc(f.placeholder || '')}"></label>`
2326
+ ).join('');
2327
+ };
2328
+ }
2329
+
2330
+ $('#gw-save').onclick = async () => {
2331
+ const fScope = $('#gw-f-scope').value;
2332
+ const fType = $('#gw-f-type').value;
2333
+ const patch = {};
2334
+ document.querySelectorAll('#gw-dyn-fields .gw-dyn').forEach(inp => {
2335
+ patch[inp.dataset.key] = inp.value.trim();
2336
+ });
2337
+ const apiKey = $('#gw-f-apikey').value.trim();
2338
+ if (apiKey) {
2339
+ if (!apiKey.startsWith('$ENV:')) { toast('API Key 必须是 $ENV:变量名 引用', true); return; }
2340
+ patch.apiKey = apiKey;
2341
+ }
2342
+ try {
2343
+ const r = mResp(await menuSend({
2344
+ type: 'menu.update', name: 'gateway',
2345
+ value: JSON.stringify({ scope: fScope, type: fType, patch }),
2346
+ }));
2347
+ if (r.error) { toast(r.error.message || r.error.code, true); return; }
2348
+ toast(r.data && r.data.reloaded ? '已保存并重载' : '已保存(未重载)');
2349
+ close();
2350
+ subscribe('gateway', {}); // 刷新
2351
+ } catch (e) { toast(e.message, true); }
2352
+ };
2353
+ }
2354
+ */
2355
+
2356
+ // openAgentSelectModal、showGatewayConfigModal、showPriceEditModal 等函数已移除(只读模式)
2357
+ // 如需恢复,取消上方注释块即可。
2358
+
2359
+ function bindGatewayEvents(el, data) {
2360
+ // 已移除所有编辑操作事件绑定(网关配置现为只读展示)
2361
+ void el; void data;
2362
+ }
2363
+
1291
2364
  // ── Triggers 视图 ──
1292
2365
  function trigStatusBadge(status) {
1293
2366
  const map = {
@@ -1380,6 +2453,8 @@ function renderTriggers(data) {
1380
2453
 
1381
2454
  function startApp() {
1382
2455
  initTabs();
2456
+ // 恢复保存的 tab 视图
2457
+ switchView(currentView);
1383
2458
  connect();
1384
2459
  $('#logout-btn').onclick = () => {
1385
2460
  localStorage.removeItem(TOKEN_KEY);
@@ -1405,149 +2480,485 @@ function initTheme() {
1405
2480
  ['_monCpu', '_monMem', '_monMsg', '_monErr'].forEach(function (k) {
1406
2481
  if (window[k]) { window[k].dispose(); window[k] = null; }
1407
2482
  });
1408
- loadUsageDashboard();
1409
2483
  if (currentView === 'monitor') renderMonitor(state.monitor);
1410
2484
  };
1411
2485
  }
1412
2486
  }
1413
2487
 
1414
- // ── Usage Dashboard ──
1415
- let _hourlyChart = null;
1416
- let _modelChart = null;
1417
-
2488
+ // ── Usage 相关函数 ──
1418
2489
  function fmtTokens(n) {
1419
2490
  if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
1420
2491
  if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
1421
2492
  return String(n);
1422
2493
  }
1423
2494
 
1424
- async function loadUsageDashboard() {
1425
- let data;
1426
- try {
1427
- const resp = await fetch('/api/stats/dashboard', {
1428
- headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1429
- });
1430
- if (!resp.ok) data = null;
1431
- else data = await resp.json();
1432
- } catch { data = null; }
1433
-
1434
- // 无数据时渲染默认空状态
1435
- const t = (data && data.today) ? data.today : { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_hit_rate: 0, call_count: 0 };
1436
- var cards = $('#usage-cards');
1437
- if (cards) {
1438
- cards.innerHTML =
1439
- '<div class="usage-card"><div class="card-value">' + fmtTokens(t.input_tokens) + '</div><div class="card-label">Input</div></div>' +
1440
- '<div class="usage-card"><div class="card-value">' + fmtTokens(t.output_tokens) + '</div><div class="card-label">Output</div></div>' +
1441
- '<div class="usage-card"><div class="card-value">' + fmtTokens(t.cache_read_tokens) + '</div><div class="card-label">Cache Read</div></div>' +
1442
- '<div class="usage-card"><div class="card-value">' + (t.cache_hit_rate * 100).toFixed(1) + '%</div><div class="card-label">Cache Hit</div></div>' +
1443
- '<div class="usage-card"><div class="card-value">' + t.call_count + '</div><div class="card-label">Calls</div></div>';
1444
- }
1445
-
1446
- // Hourly stacked bar
1447
- var hourlyEl = $('#usage-hourly-chart');
1448
- if (hourlyEl && data.hourly && data.hourly.length) {
1449
- var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1450
- if (!_hourlyChart) _hourlyChart = echarts.init(hourlyEl, isDark ? 'dark' : null);
1451
- var hours = data.hourly.map(function(h) { return (h.hour.split(' ')[1] || h.hour); });
1452
- _hourlyChart.setOption({
1453
- tooltip: { trigger: 'axis' },
1454
- legend: { data: ['Input', 'Output', 'Cache'], top: 0, textStyle: { fontSize: 11 } },
1455
- grid: { top: 30, bottom: 24, left: 50, right: 16 },
1456
- xAxis: { type: 'category', data: hours, axisLabel: { fontSize: 10 } },
1457
- yAxis: { type: 'value', axisLabel: { formatter: function(v) { return fmtTokens(v); } } },
1458
- series: [
1459
- { name: 'Input', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.input_tokens; }), itemStyle: { color: '#4f6ef7' } },
1460
- { name: 'Output', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.output_tokens; }), itemStyle: { color: '#38a169' } },
1461
- { name: 'Cache', type: 'bar', stack: 'tokens', data: data.hourly.map(function(h) { return h.cache_read_tokens; }), itemStyle: { color: '#dd6b20', opacity: 0.6 } },
1462
- ]
1463
- });
2495
+ // ── Usage Overview(总览,支持日期范围筛选)──
2496
+ let _ovCurrentRange = 'today'; // 当前选择的范围
2497
+
2498
+ async function loadUsageOverview(rangeType, customFrom, customTo) {
2499
+ rangeType = rangeType || _ovCurrentRange;
2500
+ _ovCurrentRange = rangeType;
2501
+
2502
+ // 计算日期范围
2503
+ let fromTs, toTs;
2504
+ const now = new Date();
2505
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
2506
+ const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
2507
+
2508
+ switch (rangeType) {
2509
+ case 'today':
2510
+ fromTs = todayStart;
2511
+ toTs = todayEnd;
2512
+ break;
2513
+ case 'week': // 本周(周一到今天)
2514
+ const dayOfWeek = now.getDay() || 7; // 周日=7
2515
+ const weekStart = new Date(todayStart - (dayOfWeek - 1) * 86400000);
2516
+ fromTs = weekStart.getTime();
2517
+ toTs = todayEnd;
2518
+ break;
2519
+ case 'lastWeek': // 上周(上周一到上周日)
2520
+ const lastWeekEnd = new Date(todayStart - now.getDay() * 86400000);
2521
+ const lastWeekStart = new Date(lastWeekEnd.getTime() - 6 * 86400000);
2522
+ fromTs = lastWeekStart.getTime();
2523
+ toTs = new Date(lastWeekEnd.getTime() + 86400000 - 1).getTime();
2524
+ break;
2525
+ case 'month': // 本月
2526
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
2527
+ fromTs = monthStart.getTime();
2528
+ toTs = todayEnd;
2529
+ break;
2530
+ case 'last30': // 最近30天
2531
+ fromTs = todayStart - 29 * 86400000;
2532
+ toTs = todayEnd;
2533
+ break;
2534
+ case 'custom': // 自定义
2535
+ if (customFrom && customTo) {
2536
+ // 支持 datetime-local 输入,直接解析时间戳
2537
+ fromTs = new Date(customFrom).getTime();
2538
+ toTs = new Date(customTo).getTime();
2539
+ } else {
2540
+ fromTs = todayStart;
2541
+ toTs = todayEnd;
2542
+ }
2543
+ break;
2544
+ default:
2545
+ fromTs = null;
2546
+ toTs = null;
1464
2547
  }
1465
2548
 
1466
- // Model pie
1467
- var modelEl = $('#usage-model-chart');
1468
- if (modelEl && data.top_models && data.top_models.length) {
1469
- var isDark2 = document.documentElement.getAttribute('data-theme') === 'dark';
1470
- if (!_modelChart) _modelChart = echarts.init(modelEl, isDark2 ? 'dark' : null);
1471
- _modelChart.setOption({
1472
- tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
1473
- series: [{
1474
- type: 'pie', radius: ['35%', '70%'], center: ['50%', '55%'],
1475
- label: { fontSize: 10 },
1476
- data: data.top_models.map(function(m) { return { name: m.model.split('/').pop(), value: m.total_tokens }; }),
1477
- }]
1478
- });
1479
- }
2549
+ // 保存当前时间范围到全局变量,供详细统计使用
2550
+ window._currentOverviewTimeRange = { fromTs, toTs, rangeType, customFrom, customTo };
1480
2551
 
1481
- // Top peers table
1482
- var peersEl = $('#usage-top-peers');
1483
- if (peersEl && data.top_peers && data.top_peers.length) {
1484
- peersEl.innerHTML =
1485
- '<thead><tr><th>#</th><th>Peer</th><th>Tokens</th><th>Calls</th></tr></thead>' +
1486
- '<tbody>' + data.top_peers.map(function(p, i) {
1487
- return '<tr><td>' + (i + 1) + '</td><td>' + p.peer_key + '</td><td>' + fmtTokens(p.total_tokens) + '</td><td>' + p.call_count + '</td></tr>';
1488
- }).join('') + '</tbody>';
2552
+ // 时间范围变化后,刷新明细的模型列表与查询结果(重置到第一页)
2553
+ if ($('#detail-model')) {
2554
+ const detailPageEl = $('#detail-page');
2555
+ if (detailPageEl) detailPageEl.value = '1';
2556
+ loadDetailModelList();
2557
+ queryDetailUsage();
1489
2558
  }
1490
2559
 
1491
- }
1492
-
1493
- // ── Usage Overview(全时段总览)──
1494
- async function loadUsageOverview() {
1495
2560
  let data;
1496
2561
  try {
1497
- const resp = await fetch('/api/stats/overview', {
2562
+ const params = new URLSearchParams();
2563
+ if (fromTs) params.set('from', String(fromTs));
2564
+ if (toTs) params.set('to', String(toTs));
2565
+
2566
+ const resp = await fetch(apiUrl('api/stats/overview?' + params.toString()), {
1498
2567
  headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1499
2568
  });
1500
2569
  data = resp.ok ? await resp.json() : null;
1501
2570
  } catch { data = null; }
1502
2571
 
1503
2572
  const ts = (data && data.token_stats && data.token_stats.all_time) ? data.token_stats.all_time
1504
- : { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, call_count: 0, cost_usd: 0, cost_cny: 0 };
2573
+ : { 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 };
1505
2574
  const sessionCount = (data && data.session_count) || 0;
1506
2575
  const msgIn = (data && data.msg_in) || 0;
1507
2576
  const msgOut = (data && data.msg_out) || 0;
1508
- const totalIn = ts.input_tokens + ts.cache_read_tokens;
1509
- const hitRate = totalIn > 0 ? (ts.cache_read_tokens / totalIn) * 100 : 0;
2577
+ // 新的缓存命中率计算规则:缓存命中 / (缓存命中 + 缓存写入 + 输入token + 输出token)
2578
+ const totalTokens = ts.cache_read_tokens + ts.cache_creation_tokens + ts.input_tokens + ts.output_tokens;
2579
+ const hitRate = totalTokens > 0 ? (ts.cache_read_tokens / totalTokens) * 100 : 0;
1510
2580
 
1511
2581
  const cardsEl = $('#ov-cards');
1512
2582
  if (cardsEl) {
1513
- cardsEl.innerHTML = [
1514
- ovCard(sessionCount, '会话数'),
1515
- ovCard(msgIn, '收到消息'),
1516
- ovCard(msgOut, '发出消息'),
1517
- ovCard(ts.call_count, '模型调用'),
1518
- ovCard(fmtTokens(ts.input_tokens), '输入 Token'),
1519
- ovCard(fmtTokens(ts.output_tokens), '输出 Token'),
1520
- ovCard(fmtTokens(ts.cache_creation_tokens), '缓存创建'),
1521
- ovCard(fmtTokens(ts.cache_read_tokens), '缓存命中'),
1522
- ovCard(hitRate.toFixed(1) + '%', '缓存命中率'),
1523
- ovCard(fmtCost(ts.cost_usd, ts.cost_cny), '总花费'),
1524
- ].join('');
2583
+ // 合并相关信息到大卡片中
2584
+ const sessionCard = makeMultiValueCard([
2585
+ { label: t('usage.card.sessionCount'), value: sessionCount },
2586
+ { label: t('usage.card.msgIn'), value: msgIn },
2587
+ { label: t('usage.card.msgOut'), value: msgOut }
2588
+ ], t('usage.card.sessionInfo'), 'session-group');
2589
+
2590
+ const usageCard = makeMultiValueCard([
2591
+ { label: t('usage.card.modelCalls'), value: ts.call_count },
2592
+ { label: t('usage.card.inputTokens'), value: fmtTokens(ts.input_tokens) },
2593
+ { label: t('usage.card.outputTokens'), value: fmtTokens(ts.output_tokens) },
2594
+ { label: t('usage.card.cacheCreation'), value: fmtTokens(ts.cache_creation_tokens) },
2595
+ { label: t('usage.card.cacheHitTokens'), value: fmtTokens(ts.cache_read_tokens) },
2596
+ { label: t('usage.card.cacheHitRate'), value: hitRate.toFixed(1) + '%' }
2597
+ ], t('usage.card.usageInfo'), 'usage-group');
2598
+
2599
+ const costCard = makeMultiValueCard([
2600
+ { label: t('usage.card.costOfficial'), value: fmtCost(ts.cost_official_usd, ts.cost_official_cny) },
2601
+ { label: t('usage.card.costGateway'), value: fmtCost(ts.cost_usd, ts.cost_cny) }
2602
+ ], t('usage.card.costInfo'), 'cost-group');
2603
+
2604
+ cardsEl.innerHTML = sessionCard + usageCard + costCard;
1525
2605
  }
1526
2606
 
1527
2607
  const agentTbl = $('#ov-agent-table');
1528
2608
  const agents = (data && data.token_stats && data.token_stats.by_agent) || [];
1529
2609
  if (agentTbl) {
1530
2610
  if (!agents.length) {
1531
- agentTbl.innerHTML = '<tbody><tr><td>暂无数据</td></tr></tbody>';
2611
+ agentTbl.innerHTML = '<tbody><tr><td>' + t('usage.overview.noData') + '</td></tr></tbody>';
1532
2612
  } else {
1533
2613
  agentTbl.innerHTML =
1534
- '<thead><tr><th>Agent</th><th>调用</th><th>输入</th><th>输出</th><th>缓存创建</th><th>缓存命中</th><th>花费</th></tr></thead>' +
2614
+ '<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>' +
1535
2615
  '<tbody>' + agents.map(function(a) {
1536
- var name = a.agent_aid ? a.agent_aid.split('.')[0] : '(unknown)';
2616
+ var name = a.agent_name || (a.agent_aid ? a.agent_aid.split('.')[0] : '(unknown)');
2617
+ // 计算缓存命中率
2618
+ var totalTokens = (a.cache_read_tokens || 0) + (a.cache_creation_tokens || 0) + (a.input_tokens || 0) + (a.output_tokens || 0);
2619
+ var hitRate = totalTokens > 0 ? ((a.cache_read_tokens || 0) / totalTokens * 100).toFixed(1) : '0.0';
2620
+
1537
2621
  return '<tr><td title="' + esc(a.agent_aid) + '">' + esc(name) + '</td>' +
1538
2622
  '<td>' + a.call_count + '</td>' +
1539
2623
  '<td>' + fmtTokens(a.input_tokens) + '</td>' +
1540
2624
  '<td>' + fmtTokens(a.output_tokens) + '</td>' +
1541
2625
  '<td>' + fmtTokens(a.cache_creation_tokens) + '</td>' +
1542
2626
  '<td>' + fmtTokens(a.cache_read_tokens) + '</td>' +
1543
- '<td>' + fmtCost(a.cost_usd, a.cost_cny) + '</td></tr>';
2627
+ '<td>' + hitRate + '%</td>' +
2628
+ '<td>' + fmtCostSplit(a.cost_official_usd, a.cost_official_cny) + '</td>' +
2629
+ '<td>' + fmtCostSplit(a.cost_usd, a.cost_cny) + '</td></tr>';
1544
2630
  }).join('') + '</tbody>';
1545
2631
  }
1546
2632
  }
2633
+
2634
+ // 保存总览数据供详细统计使用
2635
+ window._currentOverviewData = { ts, sessionCount, msgIn, msgOut, hitRate };
1547
2636
  }
1548
2637
 
1549
- function ovCard(value, label) {
1550
- return '<div class="usage-card"><div class="card-value">' + value + '</div><div class="card-label">' + label + '</div></div>';
2638
+ function initOverviewFilters() {
2639
+ // 范围按钮切换
2640
+ document.querySelectorAll('.ov-range-btn').forEach(function(btn) {
2641
+ btn.addEventListener('click', function() {
2642
+ document.querySelectorAll('.ov-range-btn').forEach(function(b) { b.classList.remove('active'); });
2643
+ btn.classList.add('active');
2644
+
2645
+ const range = btn.getAttribute('data-range');
2646
+ const customDateEl = $('#ov-custom-date');
2647
+
2648
+ if (range === 'custom') {
2649
+ if (customDateEl) customDateEl.style.display = 'flex';
2650
+ } else {
2651
+ if (customDateEl) customDateEl.style.display = 'none';
2652
+ loadUsageOverview(range);
2653
+ }
2654
+ });
2655
+ });
2656
+
2657
+ // 自定义日期查询按钮
2658
+ const queryBtn = $('#ov-query-btn');
2659
+ if (queryBtn) {
2660
+ queryBtn.addEventListener('click', function() {
2661
+ const fromEl = $('#ov-from');
2662
+ const toEl = $('#ov-to');
2663
+ if (fromEl && toEl && fromEl.value && toEl.value) {
2664
+ loadUsageOverview('custom', fromEl.value, toEl.value);
2665
+ }
2666
+ });
2667
+ }
2668
+
2669
+ // 设置默认日期为最近7天
2670
+ const now = new Date();
2671
+ const from = new Date(now.getTime() - 6 * 86400000);
2672
+ const fromEl = $('#ov-from');
2673
+ const toEl = $('#ov-to');
2674
+ if (fromEl) fromEl.value = formatDatetimeLocal(from);
2675
+ if (toEl) toEl.value = formatDatetimeLocal(now);
2676
+
2677
+ // 初始化明细查询
2678
+ initDetailQuery();
2679
+ }
2680
+
2681
+ // 格式化为 datetime-local 输入框的格式 (YYYY-MM-DDTHH:mm)
2682
+ function formatDatetimeLocal(date) {
2683
+ const year = date.getFullYear();
2684
+ const month = String(date.getMonth() + 1).padStart(2, '0');
2685
+ const day = String(date.getDate()).padStart(2, '0');
2686
+ const hours = String(date.getHours()).padStart(2, '0');
2687
+ const minutes = String(date.getMinutes()).padStart(2, '0');
2688
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
2689
+ }
2690
+
2691
+ // 模型访问明细查询
2692
+ function initDetailQuery() {
2693
+ // 填充Agent选择器
2694
+ loadDetailAgentList();
2695
+ // 填充Model选择器(按上面总览的时间范围)
2696
+ loadDetailModelList();
2697
+
2698
+ // 绑定分页大小变化
2699
+ const pageSizeEl = $('#detail-page-size');
2700
+ if (pageSizeEl) {
2701
+ pageSizeEl.addEventListener('change', function() {
2702
+ // 重置到第一页并查询
2703
+ const pageEl = $('#detail-page');
2704
+ if (pageEl) pageEl.value = '1';
2705
+ queryDetailUsage();
2706
+ });
2707
+ }
2708
+
2709
+ // 绑定上一页/下一页按钮
2710
+ const prevBtn = $('#detail-prev-page');
2711
+ const nextBtn = $('#detail-next-page');
2712
+ if (prevBtn) {
2713
+ prevBtn.addEventListener('click', function() {
2714
+ const pageEl = $('#detail-page');
2715
+ if (pageEl && Number(pageEl.value) > 1) {
2716
+ pageEl.value = String(Number(pageEl.value) - 1);
2717
+ queryDetailUsage();
2718
+ }
2719
+ });
2720
+ }
2721
+ if (nextBtn) {
2722
+ nextBtn.addEventListener('click', function() {
2723
+ const pageEl = $('#detail-page');
2724
+ if (pageEl) {
2725
+ pageEl.value = String(Number(pageEl.value) + 1);
2726
+ queryDetailUsage();
2727
+ }
2728
+ });
2729
+ }
2730
+
2731
+ // 绑定页码输入框回车事件
2732
+ const pageEl = $('#detail-page');
2733
+ if (pageEl) {
2734
+ pageEl.addEventListener('keypress', function(e) {
2735
+ if (e.key === 'Enter') {
2736
+ queryDetailUsage();
2737
+ }
2738
+ });
2739
+ }
2740
+
2741
+ // 绑定Agent选择器变化事件
2742
+ const agentEl = $('#detail-agent');
2743
+ if (agentEl) {
2744
+ agentEl.addEventListener('change', function() {
2745
+ const pageEl = $('#detail-page');
2746
+ if (pageEl) pageEl.value = '1';
2747
+ queryDetailUsage();
2748
+ });
2749
+ }
2750
+
2751
+ // 绑定Model选择器变化事件
2752
+ const modelEl = $('#detail-model');
2753
+ if (modelEl) {
2754
+ modelEl.addEventListener('change', function() {
2755
+ const pageEl = $('#detail-page');
2756
+ if (pageEl) pageEl.value = '1';
2757
+ queryDetailUsage();
2758
+ });
2759
+ }
2760
+ }
2761
+
2762
+ async function loadDetailAgentList() {
2763
+ try {
2764
+ const resp = await fetch(apiUrl('api/stats/agents'), {
2765
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
2766
+ });
2767
+ if (!resp.ok) return;
2768
+ const agents = await resp.json();
2769
+
2770
+ const selectEl = $('#detail-agent');
2771
+ if (selectEl && agents.length) {
2772
+ // 清空除第一个"全部"选项之外的所有选项
2773
+ while (selectEl.options.length > 1) {
2774
+ selectEl.remove(1);
2775
+ }
2776
+
2777
+ agents.forEach(function(a) {
2778
+ const option = document.createElement('option');
2779
+ option.value = a.agent_aid;
2780
+ // 优先显示agent_name,没有则显示aid前缀
2781
+ option.textContent = a.agent_name || a.agent_aid.split('.')[0];
2782
+ selectEl.appendChild(option);
2783
+ });
2784
+ // 默认选中第一个agent
2785
+ if (agents.length > 0) {
2786
+ selectEl.value = agents[0].agent_aid;
2787
+ }
2788
+ // 加载完成后自动查询一次
2789
+ queryDetailUsage();
2790
+ }
2791
+ } catch {}
2792
+ }
2793
+
2794
+ // 加载模型列表(按上面总览的时间范围)
2795
+ async function loadDetailModelList() {
2796
+ const selectEl = $('#detail-model');
2797
+ if (!selectEl) return;
2798
+ // 记住当前选中值,刷新后尽量保持
2799
+ const prev = selectEl.value;
2800
+ try {
2801
+ const timeRange = window._currentOverviewTimeRange || {};
2802
+ const params = new URLSearchParams();
2803
+ if (timeRange.fromTs) params.set('from', String(timeRange.fromTs));
2804
+ if (timeRange.toTs) params.set('to', String(timeRange.toTs));
2805
+
2806
+ const resp = await fetch(apiUrl('api/stats/models?' + params.toString()), {
2807
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
2808
+ });
2809
+ if (!resp.ok) return;
2810
+ const models = await resp.json();
2811
+
2812
+ // 清空除第一个"全部"选项之外的所有选项
2813
+ while (selectEl.options.length > 1) {
2814
+ selectEl.remove(1);
2815
+ }
2816
+ (models || []).forEach(function(m) {
2817
+ const option = document.createElement('option');
2818
+ option.value = m;
2819
+ option.textContent = m;
2820
+ selectEl.appendChild(option);
2821
+ });
2822
+ // 恢复之前的选择(若仍存在)
2823
+ if (prev && Array.prototype.some.call(selectEl.options, function(o) { return o.value === prev; })) {
2824
+ selectEl.value = prev;
2825
+ } else {
2826
+ selectEl.value = '';
2827
+ }
2828
+ } catch {}
2829
+ }
2830
+
2831
+ async function queryDetailUsage() {
2832
+ // 使用总览的时间范围
2833
+ const timeRange = window._currentOverviewTimeRange || {};
2834
+ const fromTs = timeRange.fromTs;
2835
+ const toTs = timeRange.toTs;
2836
+
2837
+ const agentEl = $('#detail-agent');
2838
+ const modelEl = $('#detail-model');
2839
+ const pageEl = $('#detail-page');
2840
+ const pageSizeEl = $('#detail-page-size');
2841
+
2842
+ const page = pageEl ? Number(pageEl.value) || 1 : 1;
2843
+ const pageSize = pageSizeEl ? Number(pageSizeEl.value) || 50 : 50;
2844
+ const offset = (page - 1) * pageSize;
2845
+
2846
+ const params = new URLSearchParams();
2847
+ if (fromTs) params.set('from', String(fromTs));
2848
+ if (toTs) params.set('to', String(toTs));
2849
+ if (agentEl && agentEl.value) params.set('agent', agentEl.value);
2850
+ if (modelEl && modelEl.value) params.set('model', modelEl.value);
2851
+ params.set('limit', String(pageSize));
2852
+ params.set('offset', String(offset));
2853
+
2854
+ try {
2855
+ const resp = await fetch(apiUrl('api/stats/detail?' + params.toString()), {
2856
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
2857
+ });
2858
+ if (!resp.ok) {
2859
+ showDetailError(t('usage.detail.error'));
2860
+ return;
2861
+ }
2862
+ const result = await resp.json();
2863
+ renderDetailTable(result.data, result.total, page, pageSize);
2864
+ } catch {
2865
+ showDetailError(t('usage.detail.error'));
2866
+ }
2867
+ }
2868
+
2869
+ function renderDetailTable(data, total, currentPage, pageSize) {
2870
+ const tableEl = $('#detail-table');
2871
+ if (!tableEl) return;
2872
+
2873
+ if (!data || !data.length) {
2874
+ tableEl.innerHTML = '<tbody><tr><td colspan="10" style="text-align:center;color:var(--dim)">' + t('usage.explorer.noData') + '</td></tr></tbody>';
2875
+ updatePaginationInfo(0, currentPage, pageSize);
2876
+ return;
2877
+ }
2878
+
2879
+ const html = '<thead><tr>' +
2880
+ '<th>' + t('usage.detail.th.time') + '</th>' +
2881
+ '<th>' + t('usage.detail.th.agent') + '</th>' +
2882
+ '<th>' + t('usage.detail.th.peer') + '</th>' +
2883
+ '<th>' + t('usage.detail.th.model') + '</th>' +
2884
+ '<th>' + t('usage.detail.th.input') + '</th>' +
2885
+ '<th>' + t('usage.detail.th.output') + '</th>' +
2886
+ '<th>' + t('usage.detail.th.cacheCreation') + '</th>' +
2887
+ '<th>' + t('usage.detail.th.cacheRead') + '</th>' +
2888
+ '<th>' + t('usage.detail.th.costOfficial') + '</th>' +
2889
+ '<th>' + t('usage.detail.th.costGateway') + '</th>' +
2890
+ '</tr></thead><tbody>' +
2891
+ data.map(function(row) {
2892
+ const time = new Date(row.ts).toLocaleString();
2893
+ const agentName = row.agent_name || (row.agent_aid || '').split('.')[0];
2894
+ const peerName = (row.peer_key || '').replace(/^aun#/, '').split('.')[0];
2895
+ return '<tr>' +
2896
+ '<td style="white-space:nowrap">' + time + '</td>' +
2897
+ '<td title="' + esc(row.agent_aid) + '">' + esc(agentName) + '</td>' +
2898
+ '<td title="' + esc(row.peer_key) + '">' + esc(peerName) + '</td>' +
2899
+ '<td>' + esc(row.model || '') + '</td>' +
2900
+ '<td>' + fmtTokens(row.input_tokens || 0) + '</td>' +
2901
+ '<td>' + fmtTokens(row.output_tokens || 0) + '</td>' +
2902
+ '<td>' + fmtTokens(row.cache_creation_tokens || 0) + '</td>' +
2903
+ '<td>' + fmtTokens(row.cache_read_tokens || 0) + '</td>' +
2904
+ '<td>' + fmtCostCompact(row.cost_official_usd, row.cost_official_cny) + '</td>' +
2905
+ '<td>' + fmtCostCompact(row.cost_gateway_usd, row.cost_gateway_cny) + '</td>' +
2906
+ '</tr>';
2907
+ }).join('') +
2908
+ '</tbody>';
2909
+
2910
+ tableEl.innerHTML = html;
2911
+ updatePaginationInfo(total, currentPage, pageSize);
2912
+ }
2913
+
2914
+ function updatePaginationInfo(total, currentPage, pageSize) {
2915
+ const infoEl = $('#detail-pagination-info');
2916
+ const prevBtn = $('#detail-prev-page');
2917
+ const nextBtn = $('#detail-next-page');
2918
+
2919
+ if (infoEl) {
2920
+ const start = total > 0 ? (currentPage - 1) * pageSize + 1 : 0;
2921
+ const end = Math.min(currentPage * pageSize, total);
2922
+ const totalPages = Math.ceil(total / pageSize) || 1;
2923
+ infoEl.textContent = t('usage.detail.pagination')
2924
+ .replace('{start}', start)
2925
+ .replace('{end}', end)
2926
+ .replace('{total}', total)
2927
+ .replace('{page}', currentPage)
2928
+ .replace('{totalPages}', totalPages);
2929
+ }
2930
+
2931
+ if (prevBtn) prevBtn.disabled = currentPage <= 1;
2932
+ if (nextBtn) nextBtn.disabled = currentPage >= Math.ceil(total / pageSize);
2933
+ }
2934
+
2935
+ function showDetailError(msg) {
2936
+ const tableEl = $('#detail-table');
2937
+ if (tableEl) {
2938
+ tableEl.innerHTML = '<tbody><tr><td colspan="10" style="text-align:center;color:var(--red)">' + esc(msg) + '</td></tr></tbody>';
2939
+ }
2940
+ }
2941
+
2942
+ function fmtCostCompact(usd, cny) {
2943
+ var parts = [];
2944
+ if (usd > 0) parts.push('$' + (usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)));
2945
+ if (cny > 0) parts.push('¥' + (cny < 0.01 ? cny.toFixed(4) : cny.toFixed(2)));
2946
+ if (parts.length === 0) return '-';
2947
+ return parts.join(' / ');
2948
+ }
2949
+
2950
+ function ovCard(value, label, groupClass) {
2951
+ var cls = 'usage-card' + (groupClass ? ' ' + groupClass : '');
2952
+ return '<div class="' + cls + '"><div class="card-value">' + value + '</div><div class="card-label">' + label + '</div></div>';
2953
+ }
2954
+
2955
+ // 创建多值卡片(合并多个指标到一个卡片中)
2956
+ function makeMultiValueCard(items, title, groupClass) {
2957
+ var cls = 'usage-card multi-value-card' + (groupClass ? ' ' + groupClass : '');
2958
+ var itemsHtml = items.map(function(item) {
2959
+ return '<div class="card-item"><div class="card-item-label">' + item.label + '</div><div class="card-item-value">' + item.value + '</div></div>';
2960
+ }).join('');
2961
+ return '<div class="' + cls + '"><div class="card-title">' + title + '</div><div class="card-items">' + itemsHtml + '</div></div>';
1551
2962
  }
1552
2963
 
1553
2964
  function fmtCost(usd, cny) {
@@ -1557,6 +2968,25 @@ function fmtCost(usd, cny) {
1557
2968
  return parts.length ? parts.join(' / ') : '$0';
1558
2969
  }
1559
2970
 
2971
+ // 分行显示美元和人民币
2972
+ function fmtCostSplit(usd, cny) {
2973
+ var parts = [];
2974
+ if (usd > 0) parts.push('$' + (usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)));
2975
+ if (cny > 0) parts.push('¥' + (cny < 0.01 ? cny.toFixed(4) : cny.toFixed(2)));
2976
+ if (parts.length === 0) return '<span style="color:var(--dim)">$0</span>';
2977
+ if (parts.length === 1) return parts[0];
2978
+ return parts[0] + '<br><span style="font-size:10px;color:var(--dim)">' + parts[1] + '</span>';
2979
+ }
2980
+
2981
+ // 带标签的价格显示(用于卡片)
2982
+ function fmtCostWithLabel(usd, cny, label) {
2983
+ var parts = [];
2984
+ if (usd > 0) parts.push('$' + (usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)));
2985
+ if (cny > 0) parts.push('¥' + (cny < 0.01 ? cny.toFixed(4) : cny.toFixed(2)));
2986
+ var value = parts.length ? parts.join(' / ') : '$0';
2987
+ return '<div class="card-label" style="margin-bottom:4px;margin-top:0">' + label + '</div><div class="card-value" style="font-size:18px">' + value + '</div>';
2988
+ }
2989
+
1560
2990
  // ── Usage subtab switching ──
1561
2991
  function initUsageSubtabs() {
1562
2992
  var btns = document.querySelectorAll('.usage-subtab');
@@ -1571,89 +3001,324 @@ function initUsageSubtabs() {
1571
3001
  });
1572
3002
  var panel = $('#usage-' + target);
1573
3003
  if (panel) { panel.classList.add('active'); panel.style.display = ''; }
1574
- if (target === 'overview') loadUsageOverview();
1575
- else if (target === 'dashboard') loadUsageDashboard();
1576
- else if (target === 'explorer') initExplorer();
3004
+ if (target === 'overview') {
3005
+ initOverviewFilters();
3006
+ loadUsageOverview();
3007
+ } else if (target === 'explorer') {
3008
+ initExplorer();
3009
+ // 自动加载模型列表和执行查询
3010
+ loadExplorerModels();
3011
+ setTimeout(() => runExplorerQuery(), 100);
3012
+ }
1577
3013
  });
1578
3014
  });
3015
+
3016
+ // 初始化总览页面的过滤器并加载默认数据(今日)
3017
+ initOverviewFilters();
3018
+ loadUsageOverview('today');
1579
3019
  }
1580
3020
 
1581
3021
  // ── Explorer ──
1582
3022
  var _explorerChart = null;
1583
3023
  var _explorerInited = false;
1584
3024
  var _expSelection = { type: null, key: null }; // { type: 'agent'|'peer', key: string } or null
3025
+ var _expCurrentRange = 'today'; // Explorer 当前选择的时间范围
3026
+ var _expTimeRange = { fromTs: null, toTs: null }; // Explorer 的时间范围
1585
3027
 
1586
3028
  function initExplorer() {
1587
3029
  if (_explorerInited) return;
1588
3030
  _explorerInited = true;
3031
+
3032
+ // 初始化时间范围选择
3033
+ initExplorerTimeFilters();
3034
+
3035
+ // 绑定查询按钮
1589
3036
  var btn = $('#exp-query-btn');
1590
3037
  if (btn) btn.onclick = runExplorerQuery;
1591
- // Default date range: last 7 days
1592
- var now = new Date();
1593
- var from = new Date(now.getTime() - 7 * 86400000);
1594
- var fromEl = $('#exp-from');
1595
- var toEl = $('#exp-to');
1596
- if (fromEl) fromEl.value = from.toISOString().slice(0, 10);
1597
- if (toEl) toEl.value = now.toISOString().slice(0, 10);
3038
+
1598
3039
  // Load sidebar lists
1599
3040
  loadExplorerSidebar();
1600
3041
  }
1601
3042
 
3043
+ // 初始化 Explorer 的时间范围选择
3044
+ function initExplorerTimeFilters() {
3045
+ // 范围按钮切换
3046
+ document.querySelectorAll('.exp-range-btn').forEach(function(btn) {
3047
+ btn.addEventListener('click', function() {
3048
+ document.querySelectorAll('.exp-range-btn').forEach(function(b) { b.classList.remove('active'); });
3049
+ btn.classList.add('active');
3050
+
3051
+ const range = btn.getAttribute('data-range');
3052
+ const customDateEl = $('#exp-custom-date');
3053
+
3054
+ if (range === 'custom') {
3055
+ if (customDateEl) customDateEl.style.display = 'flex';
3056
+ } else {
3057
+ if (customDateEl) customDateEl.style.display = 'none';
3058
+ _expCurrentRange = range;
3059
+ calculateExplorerTimeRange(range);
3060
+ loadExplorerModels(); // 加载可用模型
3061
+ runExplorerQuery();
3062
+ }
3063
+ });
3064
+ });
3065
+
3066
+ // 自定义时间查询按钮
3067
+ const timeQueryBtn = $('#exp-time-query-btn');
3068
+ if (timeQueryBtn) {
3069
+ timeQueryBtn.addEventListener('click', function() {
3070
+ const fromEl = $('#exp-from');
3071
+ const toEl = $('#exp-to');
3072
+ if (fromEl && toEl && fromEl.value && toEl.value) {
3073
+ _expCurrentRange = 'custom';
3074
+ _expTimeRange.fromTs = new Date(fromEl.value).getTime();
3075
+ _expTimeRange.toTs = new Date(toEl.value).getTime();
3076
+ loadExplorerModels(); // 加载可用模型
3077
+ runExplorerQuery();
3078
+ }
3079
+ });
3080
+ }
3081
+
3082
+ // 设置默认时间范围(今日)并初始化日期选择器
3083
+ const now = new Date();
3084
+ const fromEl = $('#exp-from');
3085
+ const toEl = $('#exp-to');
3086
+ if (fromEl) fromEl.value = formatDatetimeLocal(new Date(now.getFullYear(), now.getMonth(), now.getDate()));
3087
+ if (toEl) toEl.value = formatDatetimeLocal(now);
3088
+
3089
+ // 计算默认时间范围(今日)
3090
+ calculateExplorerTimeRange('today');
3091
+ }
3092
+
3093
+ // 计算 Explorer 的时间范围
3094
+ function calculateExplorerTimeRange(rangeType) {
3095
+ const now = new Date();
3096
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
3097
+ const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
3098
+
3099
+ switch (rangeType) {
3100
+ case 'today':
3101
+ _expTimeRange.fromTs = todayStart;
3102
+ _expTimeRange.toTs = todayEnd;
3103
+ break;
3104
+ case 'week':
3105
+ const dayOfWeek = now.getDay() || 7;
3106
+ const weekStart = new Date(todayStart - (dayOfWeek - 1) * 86400000);
3107
+ _expTimeRange.fromTs = weekStart.getTime();
3108
+ _expTimeRange.toTs = todayEnd;
3109
+ break;
3110
+ case 'lastWeek':
3111
+ const lastWeekEnd = new Date(todayStart - now.getDay() * 86400000);
3112
+ const lastWeekStart = new Date(lastWeekEnd.getTime() - 6 * 86400000);
3113
+ _expTimeRange.fromTs = lastWeekStart.getTime();
3114
+ _expTimeRange.toTs = new Date(lastWeekEnd.getTime() + 86400000 - 1).getTime();
3115
+ break;
3116
+ case 'month':
3117
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
3118
+ _expTimeRange.fromTs = monthStart.getTime();
3119
+ _expTimeRange.toTs = todayEnd;
3120
+ break;
3121
+ case 'last30':
3122
+ _expTimeRange.fromTs = todayStart - 29 * 86400000;
3123
+ _expTimeRange.toTs = todayEnd;
3124
+ break;
3125
+ }
3126
+ }
3127
+
3128
+ // 加载 Explorer 可用的模型列表(根据当前时间范围)
3129
+ async function loadExplorerModels() {
3130
+ const params = new URLSearchParams();
3131
+ if (_expTimeRange.fromTs) params.set('from', String(_expTimeRange.fromTs));
3132
+ if (_expTimeRange.toTs) params.set('to', String(_expTimeRange.toTs));
3133
+
3134
+ try {
3135
+ const resp = await fetch(apiUrl('api/stats/models?' + params.toString()), {
3136
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
3137
+ });
3138
+ if (!resp.ok) return;
3139
+ const models = await resp.json();
3140
+
3141
+ const selectEl = $('#exp-model');
3142
+ if (selectEl) {
3143
+ const currentValue = selectEl.value;
3144
+ selectEl.innerHTML = '<option value="">' + t('usage.explorer.all') + '</option>';
3145
+ models.forEach(function(model) {
3146
+ const option = document.createElement('option');
3147
+ option.value = model;
3148
+ option.textContent = model;
3149
+ selectEl.appendChild(option);
3150
+ });
3151
+ // 恢复之前的选择(如果还存在)
3152
+ if (currentValue && models.includes(currentValue)) {
3153
+ selectEl.value = currentValue;
3154
+ }
3155
+ }
3156
+ } catch {}
3157
+ }
3158
+
3159
+ // 获取 Explorer 时间范围的总览数据
3160
+ async function fetchExplorerOverviewData(filterParams) {
3161
+ try {
3162
+ const params = new URLSearchParams();
3163
+ if (_expTimeRange.fromTs) params.set('from', String(_expTimeRange.fromTs));
3164
+ if (_expTimeRange.toTs) params.set('to', String(_expTimeRange.toTs));
3165
+
3166
+ // 添加筛选参数
3167
+ if (filterParams) {
3168
+ if (filterParams.agent) params.set('agent', filterParams.agent);
3169
+ if (filterParams.peer) params.set('peer', filterParams.peer);
3170
+ }
3171
+
3172
+ const resp = await fetch(apiUrl('api/stats/overview?' + params.toString()), {
3173
+ headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
3174
+ });
3175
+ const data = resp.ok ? await resp.json() : null;
3176
+
3177
+ if (data) {
3178
+ const ts = (data.token_stats && data.token_stats.all_time) ? data.token_stats.all_time
3179
+ : { 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 };
3180
+ const sessionCount = data.session_count || 0;
3181
+ const msgIn = data.msg_in || 0;
3182
+ const msgOut = data.msg_out || 0;
3183
+ const totalTokens = ts.cache_read_tokens + ts.cache_creation_tokens + ts.input_tokens + ts.output_tokens;
3184
+ const hitRate = totalTokens > 0 ? (ts.cache_read_tokens / totalTokens) * 100 : 0;
3185
+
3186
+ return { ts, sessionCount, msgIn, msgOut, hitRate };
3187
+ }
3188
+ } catch {}
3189
+
3190
+ // 返回空数据
3191
+ return {
3192
+ 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 },
3193
+ sessionCount: 0,
3194
+ msgIn: 0,
3195
+ msgOut: 0,
3196
+ hitRate: 0
3197
+ };
3198
+ }
3199
+
1602
3200
  async function loadExplorerSidebar() {
1603
3201
  var token = localStorage.getItem(TOKEN_KEY);
1604
3202
  var headers = { Authorization: 'Bearer ' + token };
1605
3203
  try {
1606
- var [agentsResp, peersResp] = await Promise.all([
1607
- fetch('/api/stats/agents', { headers }),
1608
- fetch('/api/stats/peers', { headers }),
1609
- ]);
3204
+ var agentsResp = await fetch(apiUrl('api/stats/agents'), { headers });
1610
3205
  var agents = agentsResp.ok ? await agentsResp.json() : [];
1611
- var peers = peersResp.ok ? await peersResp.json() : [];
1612
- renderExplorerSidebar(agents, peers);
3206
+ renderExplorerAgentList(agents);
3207
+
3208
+ // 初始加载时不加载 peers(等待用户选择 agent)
3209
+ renderExplorerPeerList([]);
1613
3210
  } catch {}
1614
3211
  }
1615
3212
 
1616
- function renderExplorerSidebar(agents, peers) {
3213
+ // 渲染 Agent 列表
3214
+ function renderExplorerAgentList(agents) {
1617
3215
  var agentList = $('#exp-agent-list');
1618
- var peerList = $('#exp-peer-list');
1619
- if (!agentList || !peerList) return;
3216
+ if (!agentList) return;
1620
3217
 
1621
3218
  // "All" item for agents
1622
3219
  var allHtml = '<div class="exp-sidebar-item active" data-type="all" data-key="">' +
1623
- '<span class="item-name">全部</span></div>';
3220
+ '<span class="item-name">' + t('usage.explorer.all') + '</span></div>';
1624
3221
 
1625
3222
  agentList.innerHTML = allHtml + agents.map(function(a) {
1626
- var name = a.agent_aid ? a.agent_aid.split('.')[0] : 'unknown';
3223
+ var name = a.agent_name || (a.agent_aid ? a.agent_aid.split('.')[0] : 'unknown');
1627
3224
  return '<div class="exp-sidebar-item" data-type="agent" data-key="' + escHtml(a.agent_aid) + '">' +
1628
- '<span class="item-name" title="' + escHtml(a.agent_aid) + '">' + escHtml(name) + '</span>' +
1629
- '<span class="item-meta">' + fmtTokens(a.input_tokens + a.output_tokens) + '</span></div>';
3225
+ '<span class="item-name" title="' + escHtml(a.agent_aid) + '">' + escHtml(name) + '</span></div>';
1630
3226
  }).join('');
1631
3227
 
3228
+ // 绑定点击事件
3229
+ agentList.querySelectorAll('.exp-sidebar-item').forEach(function(el) {
3230
+ el.addEventListener('click', async function() {
3231
+ // Clear active from all
3232
+ document.querySelectorAll('#exp-agent-list .exp-sidebar-item').forEach(function(x) { x.classList.remove('active'); });
3233
+ el.classList.add('active');
3234
+
3235
+ var type = el.getAttribute('data-type');
3236
+ var key = el.getAttribute('data-key');
3237
+
3238
+ if (type === 'all') {
3239
+ _expSelection = { type: null, key: null };
3240
+ $('#exp-selected-name').textContent = t('usage.explorer.all');
3241
+ // 选择"全部"时,清空 peers 列表
3242
+ renderExplorerPeerList([]);
3243
+ } else {
3244
+ _expSelection = { type: type, key: key };
3245
+ var name = el.querySelector('.item-name').textContent.trim();
3246
+ $('#exp-selected-name').textContent = name;
3247
+ // 选择特定 agent 时,加载该 agent 的 peers
3248
+ await loadPeersForAgent(key);
3249
+ }
3250
+
3251
+ runExplorerQuery();
3252
+ });
3253
+ });
3254
+ }
3255
+
3256
+ // 加载指定 agent 的 peers
3257
+ async function loadPeersForAgent(agentAid) {
3258
+ var token = localStorage.getItem(TOKEN_KEY);
3259
+ var headers = { Authorization: 'Bearer ' + token };
3260
+ try {
3261
+ const params = new URLSearchParams();
3262
+ params.set('agent', agentAid);
3263
+ // 不传递时间范围,获取该 agent 的所有 peers
3264
+
3265
+ var resp = await fetch(apiUrl('api/stats/peers?' + params.toString()), { headers });
3266
+ var peers = resp.ok ? await resp.json() : [];
3267
+ renderExplorerPeerList(peers);
3268
+ } catch {
3269
+ renderExplorerPeerList([]);
3270
+ }
3271
+ }
3272
+
3273
+ // 渲染 Peer 列表
3274
+ function renderExplorerPeerList(peers) {
3275
+ var peerList = $('#exp-peer-list');
3276
+ if (!peerList) return;
3277
+
3278
+ if (!peers || peers.length === 0) {
3279
+ peerList.innerHTML = '<div style="padding: 12px; color: var(--dim); font-size: 12px; text-align: center;">' + t('common.noData') + '</div>';
3280
+ return;
3281
+ }
3282
+
1632
3283
  peerList.innerHTML = peers.map(function(p) {
1633
3284
  var name = p.peer_key || 'unknown';
1634
- // 简化显示:去掉 channel# 前缀中的 aun#,保留核心部分
1635
- var display = name.replace(/^aun#/, '').split('.')[0];
3285
+ // 优先显示peer_name,否则简化显示peer_key
3286
+ var display = p.peer_name || name.replace(/^aun#/, '').split('#')[0].split('.')[0];
3287
+
3288
+ // 添加聊天类型标签
3289
+ var typeTag = '';
3290
+ if (p.peer_chat_type === 'group') {
3291
+ typeTag = '<span class="peer-tag peer-tag-group">' + t('usage.explorer.chatType.group') + '</span>';
3292
+ } else if (p.peer_chat_type === 'private') {
3293
+ typeTag = '<span class="peer-tag peer-tag-private">' + t('usage.explorer.chatType.private') + '</span>';
3294
+ }
3295
+
3296
+ // 群聊人数标签
3297
+ var memberTag = '';
3298
+ if (p.peer_chat_type === 'group' && p.peer_group_member_count) {
3299
+ memberTag = '<span class="peer-tag peer-tag-count">' + p.peer_group_member_count + t('usage.explorer.memberCount') + '</span>';
3300
+ }
3301
+
1636
3302
  return '<div class="exp-sidebar-item" data-type="peer" data-key="' + escHtml(p.peer_key) + '">' +
1637
- '<span class="item-name" title="' + escHtml(name) + '">' + escHtml(display) + '</span>' +
3303
+ '<span class="item-name" title="' + escHtml(name) + '">' +
3304
+ (typeTag ? typeTag + ' ' : '') + escHtml(display) + (memberTag ? ' ' + memberTag : '') +
3305
+ '</span>' +
1638
3306
  '<span class="item-meta">' + fmtTokens((p.input_tokens || 0) + (p.output_tokens || 0)) + '</span></div>';
1639
3307
  }).join('');
1640
3308
 
1641
- // Bind click events
1642
- var allItems = document.querySelectorAll('#exp-agent-list .exp-sidebar-item, #exp-peer-list .exp-sidebar-item');
1643
- allItems.forEach(function(el) {
3309
+ // Bind click events for peers
3310
+ peerList.querySelectorAll('.exp-sidebar-item').forEach(function(el) {
1644
3311
  el.addEventListener('click', function() {
1645
- // Clear active from all
1646
- allItems.forEach(function(x) { x.classList.remove('active'); });
3312
+ // Clear active from all peers
3313
+ document.querySelectorAll('#exp-peer-list .exp-sidebar-item').forEach(function(x) { x.classList.remove('active'); });
1647
3314
  el.classList.add('active');
3315
+
1648
3316
  var type = el.getAttribute('data-type');
1649
3317
  var key = el.getAttribute('data-key');
1650
- if (type === 'all') {
1651
- _expSelection = { type: null, key: null };
1652
- $('#exp-selected-name').textContent = '全部';
1653
- } else {
1654
- _expSelection = { type: type, key: key };
1655
- $('#exp-selected-name').textContent = key;
1656
- }
3318
+ _expSelection = { type: type, key: key };
3319
+ var name = el.querySelector('.item-name').textContent.trim();
3320
+ $('#exp-selected-name').textContent = name;
3321
+
1657
3322
  runExplorerQuery();
1658
3323
  });
1659
3324
  });
@@ -1662,11 +3327,13 @@ function renderExplorerSidebar(agents, peers) {
1662
3327
  function escHtml(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
1663
3328
 
1664
3329
  async function runExplorerQuery() {
3330
+ // 使用 Explorer 自己的时间范围
3331
+ const fromTs = _expTimeRange.fromTs;
3332
+ const toTs = _expTimeRange.toTs;
3333
+
1665
3334
  var params = new URLSearchParams();
1666
- var fromEl = $('#exp-from');
1667
- var toEl = $('#exp-to');
1668
- if (fromEl && fromEl.value) params.set('from', String(new Date(fromEl.value + 'T00:00:00').getTime()));
1669
- if (toEl && toEl.value) params.set('to', String(new Date(toEl.value + 'T23:59:59').getTime()));
3335
+ if (fromTs) params.set('from', String(fromTs));
3336
+ if (toTs) params.set('to', String(toTs));
1670
3337
  // Inject selection from sidebar
1671
3338
  if (_expSelection.type === 'agent' && _expSelection.key) params.set('agent', _expSelection.key);
1672
3339
  if (_expSelection.type === 'peer' && _expSelection.key) params.set('peer', _expSelection.key);
@@ -1677,33 +3344,123 @@ async function runExplorerQuery() {
1677
3344
 
1678
3345
  var data;
1679
3346
  try {
1680
- var resp = await fetch('/api/stats/explorer?' + params.toString(), {
3347
+ var resp = await fetch(apiUrl('api/stats/explorer?' + params.toString()), {
1681
3348
  headers: { Authorization: 'Bearer ' + localStorage.getItem(TOKEN_KEY) }
1682
3349
  });
1683
3350
  if (!resp.ok) return;
1684
3351
  data = await resp.json();
1685
3352
  } catch { return; }
1686
3353
 
1687
- // Show/hide detail cards
3354
+ // 根据查询结果计算卡片数据
1688
3355
  var cardsEl = $('#exp-detail-cards');
1689
- if (data && data.length) {
1690
- var totIn = 0, totOut = 0, totCache = 0, totCalls = 0;
1691
- data.forEach(function(r) { totIn += r.input_tokens; totOut += r.output_tokens; totCache += r.cache_read_tokens; totCalls += r.call_count; });
1692
- if (cardsEl) {
1693
- cardsEl.style.display = 'flex';
1694
- cardsEl.innerHTML =
1695
- '<div class="usage-card"><div class="card-value">' + fmtTokens(totIn) + '</div><div class="card-label">Input</div></div>' +
1696
- '<div class="usage-card"><div class="card-value">' + fmtTokens(totOut) + '</div><div class="card-label">Output</div></div>' +
1697
- '<div class="usage-card"><div class="card-value">' + fmtTokens(totCache) + '</div><div class="card-label">Cache Read</div></div>' +
1698
- '<div class="usage-card"><div class="card-value">' + totCalls + '</div><div class="card-label">Calls</div></div>';
3356
+ if (cardsEl) {
3357
+ // 如果有筛选条件(agent/peer),使用查询结果计算;否则需要获取总览数据
3358
+ const hasFilter = _expSelection.type && _expSelection.key;
3359
+
3360
+ let cardData;
3361
+ let cardTitle = null; // 用于显示选中的 agent/peer 信息
3362
+
3363
+ if (hasFilter) {
3364
+ // 有筛选:根据查询结果计算 token 数据,并获取会话信息
3365
+ var totIn = 0, totOut = 0, totCacheCreation = 0, totCacheRead = 0, totCalls = 0;
3366
+ if (data && data.length) {
3367
+ data.forEach(function(r) {
3368
+ totIn += r.input_tokens || 0;
3369
+ totOut += r.output_tokens || 0;
3370
+ totCacheCreation += r.cache_creation_tokens || 0;
3371
+ totCacheRead += r.cache_read_tokens || 0;
3372
+ totCalls += r.call_count || 0;
3373
+ });
3374
+ }
3375
+ const totalTokens = totCacheRead + totCacheCreation + totIn + totOut;
3376
+ const hitRate = totalTokens > 0 ? (totCacheRead / totalTokens) * 100 : 0;
3377
+
3378
+ // 构建筛选参数
3379
+ const filterParams = {};
3380
+ if (_expSelection.type === 'agent') filterParams.agent = _expSelection.key;
3381
+ if (_expSelection.type === 'peer') filterParams.peer = _expSelection.key;
3382
+
3383
+ // 获取该筛选条件下的会话信息
3384
+ const overviewData = await fetchExplorerOverviewData(filterParams);
3385
+
3386
+ cardData = {
3387
+ ts: {
3388
+ call_count: totCalls,
3389
+ input_tokens: totIn,
3390
+ output_tokens: totOut,
3391
+ cache_creation_tokens: totCacheCreation,
3392
+ cache_read_tokens: totCacheRead,
3393
+ cost_official_usd: overviewData.ts.cost_official_usd || 0,
3394
+ cost_official_cny: overviewData.ts.cost_official_cny || 0,
3395
+ cost_usd: overviewData.ts.cost_usd || 0,
3396
+ cost_cny: overviewData.ts.cost_cny || 0
3397
+ },
3398
+ sessionCount: overviewData.sessionCount || 0,
3399
+ msgIn: overviewData.msgIn || 0,
3400
+ msgOut: overviewData.msgOut || 0,
3401
+ hitRate: hitRate
3402
+ };
3403
+
3404
+ // 构建卡片标题
3405
+ if (_expSelection.type === 'agent') {
3406
+ // 从侧边栏获取 agent 名称
3407
+ const selectedItem = document.querySelector('#exp-agent-list .exp-sidebar-item.active .item-name');
3408
+ const agentName = selectedItem ? selectedItem.textContent.trim() : '';
3409
+ const agentAid = _expSelection.key;
3410
+ cardTitle = agentName && agentName !== agentAid.split('.')[0]
3411
+ ? `${agentName} (AID: ${agentAid})`
3412
+ : `AID: ${agentAid}`;
3413
+ } else if (_expSelection.type === 'peer') {
3414
+ // 从侧边栏获取 peer 名称(去掉标签)
3415
+ const selectedItem = document.querySelector('#exp-peer-list .exp-sidebar-item.active .item-name');
3416
+ if (selectedItem) {
3417
+ // 克隆节点并移除所有标签元素
3418
+ const clone = selectedItem.cloneNode(true);
3419
+ const tags = clone.querySelectorAll('.peer-tag');
3420
+ tags.forEach(tag => tag.remove());
3421
+ const peerName = clone.textContent.trim();
3422
+ const peerKey = _expSelection.key;
3423
+ cardTitle = peerName ? `${peerName} (Peer: ${peerKey.split('#')[3] || peerKey.split('#')[0]})` : `Peer: ${peerKey}`;
3424
+ } else {
3425
+ cardTitle = `Peer: ${_expSelection.key}`;
3426
+ }
3427
+ }
3428
+ } else {
3429
+ // 无筛选:获取 Explorer 时间范围的总览数据
3430
+ cardData = await fetchExplorerOverviewData();
1699
3431
  }
1700
- } else {
1701
- if (cardsEl) cardsEl.style.display = 'none';
3432
+
3433
+ const { ts, sessionCount, msgIn, msgOut, hitRate } = cardData;
3434
+
3435
+ // 不显示标题行,直接显示卡片
3436
+ // 注意:会话信息是该时间范围的总数(不区分 agent/peer)
3437
+ const sessionCard = makeMultiValueCard([
3438
+ { label: t('usage.card.sessionCount'), value: sessionCount },
3439
+ { label: t('usage.card.msgIn'), value: msgIn },
3440
+ { label: t('usage.card.msgOut'), value: msgOut }
3441
+ ], t('usage.card.sessionInfo'), 'session-group');
3442
+
3443
+ const usageCard = makeMultiValueCard([
3444
+ { label: t('usage.card.modelCalls'), value: ts.call_count },
3445
+ { label: t('usage.card.inputTokens'), value: fmtTokens(ts.input_tokens) },
3446
+ { label: t('usage.card.outputTokens'), value: fmtTokens(ts.output_tokens) },
3447
+ { label: t('usage.card.cacheCreation'), value: fmtTokens(ts.cache_creation_tokens) },
3448
+ { label: t('usage.card.cacheHitTokens'), value: fmtTokens(ts.cache_read_tokens) },
3449
+ { label: t('usage.card.cacheHitRate'), value: hitRate.toFixed(1) + '%' }
3450
+ ], t('usage.card.usageInfo'), 'usage-group');
3451
+
3452
+ const costCard = makeMultiValueCard([
3453
+ { label: t('usage.card.costOfficial'), value: fmtCost(ts.cost_official_usd, ts.cost_official_cny) },
3454
+ { label: t('usage.card.costGateway'), value: fmtCost(ts.cost_usd, ts.cost_cny) }
3455
+ ], t('usage.card.costInfo'), 'cost-group');
3456
+
3457
+ cardsEl.innerHTML = sessionCard + usageCard + costCard;
3458
+ cardsEl.style.display = 'flex';
1702
3459
  }
1703
3460
 
1704
3461
  if (!data || !data.length) {
1705
3462
  var tbl = $('#usage-explorer-table');
1706
- if (tbl) tbl.innerHTML = '<tr><td>No data for selected range.</td></tr>';
3463
+ if (tbl) tbl.innerHTML = '<tr><td>' + t('usage.explorer.noData') + '</td></tr>';
1707
3464
  var chartEl = $('#usage-explorer-chart');
1708
3465
  if (chartEl && _explorerChart) { _explorerChart.dispose(); _explorerChart = null; }
1709
3466
  return;
@@ -1718,13 +3475,13 @@ async function runExplorerQuery() {
1718
3475
  var periods = data.map(function(r) { return r.period; });
1719
3476
  _explorerChart.setOption({
1720
3477
  tooltip: { trigger: 'axis' },
1721
- legend: { data: ['Input', 'Output'], top: 0, textStyle: { fontSize: 11 } },
3478
+ legend: { data: [t('usage.card.input'), t('usage.card.output')], top: 0, textStyle: { fontSize: 11 } },
1722
3479
  grid: { top: 30, bottom: 30, left: 60, right: 16 },
1723
3480
  xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 10, rotate: 30 } },
1724
3481
  yAxis: { type: 'value', axisLabel: { formatter: function(v) { return fmtTokens(v); } } },
1725
3482
  series: [
1726
- { name: 'Input', type: 'line', data: data.map(function(r) { return r.input_tokens; }), smooth: true, areaStyle: { opacity: 0.15 }, itemStyle: { color: '#4f6ef7' } },
1727
- { name: 'Output', type: 'line', data: data.map(function(r) { return r.output_tokens; }), smooth: true, areaStyle: { opacity: 0.15 }, itemStyle: { color: '#38a169' } },
3483
+ { 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' } },
3484
+ { 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' } },
1728
3485
  ]
1729
3486
  });
1730
3487
  }
@@ -1733,7 +3490,7 @@ async function runExplorerQuery() {
1733
3490
  var tbl = $('#usage-explorer-table');
1734
3491
  if (tbl) {
1735
3492
  tbl.innerHTML =
1736
- '<thead><tr><th>Period</th><th>Input</th><th>Output</th><th>Cache↑</th><th>CacheHit</th><th>Calls</th></tr></thead>' +
3493
+ '<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>' +
1737
3494
  '<tbody>' + data.map(function(r) {
1738
3495
  return '<tr><td>' + r.period + '</td><td>' + fmtTokens(r.input_tokens) + '</td><td>' + fmtTokens(r.output_tokens) +
1739
3496
  '</td><td>' + fmtTokens(r.cache_creation_tokens) + '</td><td>' + fmtTokens(r.cache_read_tokens) +
@@ -1875,21 +3632,21 @@ function renderMonitor(data) {
1875
3632
  $('#mon-agent-table-wrap').innerHTML =
1876
3633
  '<div class="mon-section-title">各 Agent 运行状态</div>' +
1877
3634
  '<table class="usage-table"><thead><tr>' +
1878
- '<th>Agent</th><th>状态</th><th>收</th><th>发</th><th>流入</th><th>流出</th><th>对端</th><th>队列</th><th>处理中</th>' +
3635
+ '<th>Agent</th><th>状态</th><th>收</th><th>发</th><th>错</th><th>断</th><th>完</th><th>队列</th><th>处理中</th>' +
1879
3636
  '</tr></thead><tbody>' +
1880
3637
  (agents.length ? agents.map(function (a) {
1881
- var st = a.stats || {};
3638
+ var st = a.runtimeStats || {};
1882
3639
  var dot = dotMap[a.status] || 'off';
1883
3640
  return '<tr>' +
1884
3641
  '<td title="' + esc(a.aid) + '">' + esc(a.agentName || shortAid(a.aid)) + '</td>' +
1885
3642
  '<td><span class="dot ' + dot + '"></span>' + esc(a.status) + '</td>' +
1886
- '<td>' + (st.messagesReceived || 0) + '</td>' +
1887
- '<td>' + (st.messagesSent || 0) + '</td>' +
1888
- '<td>' + fmtBytes(st.bytesReceived || 0) + '</td>' +
1889
- '<td>' + fmtBytes(st.bytesSent || 0) + '</td>' +
1890
- '<td>' + (st.uniquePeerCount || 0) + '</td>' +
3643
+ '<td>' + (st.received || 0) + '</td>' +
3644
+ '<td>' + (st.sent || 0) + '</td>' +
3645
+ '<td>' + (st.errors || 0) + '</td>' +
3646
+ '<td>' + (st.interrupts || 0) + '</td>' +
3647
+ '<td>' + (st.completed || 0) + '</td>' +
1891
3648
  '<td>' + (st.queued || 0) + '</td>' +
1892
- '<td>' + (st.processing ? '⚙ ' + st.processing : 0) + '</td>' +
3649
+ '<td>' + (st.processing || 0) + '</td>' +
1893
3650
  '</tr>';
1894
3651
  }).join('') : '<tr><td colspan="9" style="text-align:center;color:var(--dim)">暂无 Agent</td></tr>') +
1895
3652
  '</tbody></table>';
@@ -1956,14 +3713,25 @@ function monDualLine(elId, varKey, times, isDark, title, series, fmtY, yRange) {
1956
3713
  });
1957
3714
  }
1958
3715
 
1959
- window.addEventListener('DOMContentLoaded', () => {
3716
+ window.addEventListener('DOMContentLoaded', async () => {
1960
3717
  initTheme();
1961
3718
  initPairUI();
3719
+ initMsgTipFloat();
3720
+
3721
+ // 初始化语言切换
3722
+ const langBtn = $('#lang-btn');
3723
+ if (langBtn) {
3724
+ langBtn.addEventListener('click', toggleLang);
3725
+ }
3726
+ updateI18n(); // 应用当前语言
3727
+
3728
+ // 已有 token 直接进;否则先试本地直连免配对,失败再回落配对页。
3729
+ if (!localStorage.getItem(TOKEN_KEY)) {
3730
+ await tryLocalAutoPair();
3731
+ }
1962
3732
  if (localStorage.getItem(TOKEN_KEY)) {
1963
3733
  showApp();
1964
3734
  startApp();
1965
- loadUsageDashboard();
1966
- loadUsageOverview();
1967
3735
  initUsageSubtabs();
1968
3736
  } else {
1969
3737
  showPairPage();