evolclaw-web 1.2.0 → 1.2.2

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