openclaw-agent-dashboard 1.0.4

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.
Files changed (111) hide show
  1. package/.github/workflows/release.yml +56 -0
  2. package/README.md +302 -0
  3. package/docs/CHANGELOG_AGENT_MODIFICATIONS.md +132 -0
  4. package/docs/RELEASE-LATEST.md +189 -0
  5. package/docs/RELEASE-MODEL-CONFIG.md +95 -0
  6. package/docs/release-guide.md +259 -0
  7. package/docs/release-operations-manual.md +167 -0
  8. package/docs/specs/tr3-install-system.md +580 -0
  9. package/docs/windows-collaboration-model-paths-troubleshooting.md +0 -0
  10. package/frontend/index.html +12 -0
  11. package/frontend/package-lock.json +1240 -0
  12. package/frontend/package.json +19 -0
  13. package/frontend/src/App.vue +331 -0
  14. package/frontend/src/components/AgentCard.vue +796 -0
  15. package/frontend/src/components/AgentConfigPanel.vue +539 -0
  16. package/frontend/src/components/AgentDetailPanel.vue +738 -0
  17. package/frontend/src/components/ErrorAnalysisView.vue +546 -0
  18. package/frontend/src/components/ErrorCenterPanel.vue +844 -0
  19. package/frontend/src/components/PerformanceMonitor.vue +515 -0
  20. package/frontend/src/components/SettingsPanel.vue +236 -0
  21. package/frontend/src/components/TokenAnalysisPanel.vue +683 -0
  22. package/frontend/src/components/chain/ChainEdge.vue +85 -0
  23. package/frontend/src/components/chain/ChainNode.vue +166 -0
  24. package/frontend/src/components/chain/TaskChainView.vue +425 -0
  25. package/frontend/src/components/chain/index.ts +3 -0
  26. package/frontend/src/components/chain/types.ts +70 -0
  27. package/frontend/src/components/collaboration/CollaborationFlowSection.vue +1032 -0
  28. package/frontend/src/components/collaboration/CollaborationFlowWrapper.vue +113 -0
  29. package/frontend/src/components/performance/PerformancePanel.vue +119 -0
  30. package/frontend/src/components/performance/PerformanceSection.vue +1137 -0
  31. package/frontend/src/components/tasks/TaskStatusSection.vue +973 -0
  32. package/frontend/src/components/timeline/TimelineConnector.vue +31 -0
  33. package/frontend/src/components/timeline/TimelineRound.vue +135 -0
  34. package/frontend/src/components/timeline/TimelineStep.vue +691 -0
  35. package/frontend/src/components/timeline/TimelineToolLink.vue +109 -0
  36. package/frontend/src/components/timeline/TimelineView.vue +540 -0
  37. package/frontend/src/components/timeline/index.ts +5 -0
  38. package/frontend/src/components/timeline/types.ts +120 -0
  39. package/frontend/src/composables/index.ts +7 -0
  40. package/frontend/src/composables/useDebounce.ts +48 -0
  41. package/frontend/src/composables/useRealtime.ts +52 -0
  42. package/frontend/src/composables/useState.ts +52 -0
  43. package/frontend/src/composables/useThrottle.ts +46 -0
  44. package/frontend/src/composables/useVirtualScroll.ts +106 -0
  45. package/frontend/src/main.ts +4 -0
  46. package/frontend/src/managers/EventDispatcher.ts +127 -0
  47. package/frontend/src/managers/RealtimeDataManager.ts +293 -0
  48. package/frontend/src/managers/StateManager.ts +128 -0
  49. package/frontend/src/managers/index.ts +5 -0
  50. package/frontend/src/types/collaboration.ts +135 -0
  51. package/frontend/src/types/index.ts +20 -0
  52. package/frontend/src/types/performance.ts +105 -0
  53. package/frontend/src/types/task.ts +38 -0
  54. package/frontend/vite.config.ts +18 -0
  55. package/package.json +22 -0
  56. package/plugin/README.md +99 -0
  57. package/plugin/config.json.example +1 -0
  58. package/plugin/index.js +250 -0
  59. package/plugin/openclaw.plugin.json +17 -0
  60. package/plugin/package.json +21 -0
  61. package/scripts/build-plugin.js +67 -0
  62. package/scripts/bundle.sh +62 -0
  63. package/scripts/install-plugin.sh +162 -0
  64. package/scripts/install-python-deps.js +346 -0
  65. package/scripts/install-python-deps.sh +226 -0
  66. package/scripts/install.js +512 -0
  67. package/scripts/install.sh +367 -0
  68. package/scripts/lib/common.js +490 -0
  69. package/scripts/lib/common.sh +137 -0
  70. package/scripts/release-pack.sh +110 -0
  71. package/scripts/start.js +50 -0
  72. package/scripts/test_available_models.py +284 -0
  73. package/scripts/test_websocket_ping.py +44 -0
  74. package/src/backend/agents.py +73 -0
  75. package/src/backend/api/__init__.py +1 -0
  76. package/src/backend/api/agent_config_api.py +90 -0
  77. package/src/backend/api/agents.py +73 -0
  78. package/src/backend/api/agents_config.py +75 -0
  79. package/src/backend/api/chains.py +126 -0
  80. package/src/backend/api/collaboration.py +902 -0
  81. package/src/backend/api/debug_paths.py +39 -0
  82. package/src/backend/api/error_analysis.py +146 -0
  83. package/src/backend/api/errors.py +281 -0
  84. package/src/backend/api/performance.py +784 -0
  85. package/src/backend/api/subagents.py +770 -0
  86. package/src/backend/api/timeline.py +144 -0
  87. package/src/backend/api/websocket.py +251 -0
  88. package/src/backend/collaboration.py +405 -0
  89. package/src/backend/data/__init__.py +1 -0
  90. package/src/backend/data/agent_config_manager.py +270 -0
  91. package/src/backend/data/chain_reader.py +299 -0
  92. package/src/backend/data/config_reader.py +153 -0
  93. package/src/backend/data/error_analyzer.py +430 -0
  94. package/src/backend/data/session_reader.py +445 -0
  95. package/src/backend/data/subagent_reader.py +244 -0
  96. package/src/backend/data/task_history.py +118 -0
  97. package/src/backend/data/timeline_reader.py +981 -0
  98. package/src/backend/errors.py +63 -0
  99. package/src/backend/main.py +89 -0
  100. package/src/backend/mechanism_reader.py +131 -0
  101. package/src/backend/mechanisms.py +32 -0
  102. package/src/backend/performance.py +474 -0
  103. package/src/backend/requirements.txt +5 -0
  104. package/src/backend/session_reader.py +238 -0
  105. package/src/backend/status/__init__.py +1 -0
  106. package/src/backend/status/error_detector.py +122 -0
  107. package/src/backend/status/status_calculator.py +301 -0
  108. package/src/backend/status_calculator.py +121 -0
  109. package/src/backend/subagent_reader.py +229 -0
  110. package/src/backend/watchers/__init__.py +4 -0
  111. package/src/backend/watchers/file_watcher.py +159 -0
@@ -0,0 +1,784 @@
1
+ """
2
+ 性能监控 - 真实 TPM/RPM 统计(逐条消息解析)
3
+ 支持按分钟查看调用详情,便于分析调用瓶颈
4
+ """
5
+ from fastapi import APIRouter
6
+ from typing import List, Dict, Any, Optional
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+ from datetime import datetime, timedelta, timezone
11
+ from zoneinfo import ZoneInfo
12
+
13
+ # 详情展示使用 Asia/Shanghai 时区
14
+ TZ_DISPLAY = ZoneInfo('Asia/Shanghai')
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ def _extract_trigger_text(msg: Dict) -> str:
20
+ """从消息中提取触发内容(完整展示)"""
21
+ content = msg.get('content') or []
22
+ if isinstance(content, str):
23
+ return content.replace('\n', ' ')
24
+ if not isinstance(content, list):
25
+ return ''
26
+ for item in content:
27
+ if isinstance(item, dict):
28
+ if item.get('type') == 'text' and item.get('text'):
29
+ text = str(item['text'])
30
+ if '[Subagent Task]' in text:
31
+ m = re.search(r'\*\*任务[::]\s*(.+?)\*\*', text)
32
+ if m:
33
+ return f"子任务: {m.group(1).strip()}"
34
+ return text.replace('\n', ' ')
35
+ if item.get('type') == 'toolCall':
36
+ return f"工具调用: {item.get('name', '?')}"
37
+ return ''
38
+
39
+
40
+ def _extract_tool_call_detail(msg: Dict, tool_call_id: str) -> str:
41
+ """从 assistant 消息的 content 中提取 toolCall 的 arguments 详情"""
42
+ content = msg.get('content') or []
43
+ if not isinstance(content, list):
44
+ return ''
45
+ for item in content:
46
+ if not isinstance(item, dict):
47
+ continue
48
+ if item.get('type') == 'toolCall' and item.get('id') == tool_call_id:
49
+ name = item.get('name', '')
50
+ args = item.get('arguments') or {}
51
+ if isinstance(args, str):
52
+ try:
53
+ args = json.loads(args)
54
+ except Exception:
55
+ args = {}
56
+ if not isinstance(args, dict):
57
+ args = {}
58
+ if name == 'exec' and args:
59
+ cmd = args.get('command', '')
60
+ if cmd:
61
+ return f"exec: {cmd}"
62
+ if name == 'read' and args:
63
+ path = args.get('path', '')
64
+ if path:
65
+ return f"read: {path}"
66
+ if name == 'write' and args:
67
+ path = args.get('path', '')
68
+ if path:
69
+ return f"write: {path}"
70
+ if name == 'process' and args:
71
+ action = args.get('action', '')
72
+ sid = args.get('sessionId', '')
73
+ if action and sid:
74
+ return f"process: {action} ({sid})"
75
+ if action:
76
+ return f"process: {action}"
77
+ if name == 'sessions_spawn' and args:
78
+ task = (args.get('task') or '').replace(chr(10), ' ')
79
+ agent = args.get('agentId', '')
80
+ if task and agent:
81
+ return f"sessions_spawn: {agent} - {task}"
82
+ if agent:
83
+ return f"sessions_spawn: {agent}"
84
+ # 其他工具:显示完整 arguments
85
+ if args:
86
+ try:
87
+ s = json.dumps(args, ensure_ascii=False)
88
+ return f"{name}: {s}"
89
+ except Exception:
90
+ pass
91
+ return f"工具: {name}"
92
+ return ''
93
+
94
+
95
+ def parse_session_file_with_details(session_path: Path, agent_id: str) -> List[Dict]:
96
+ """解析 session,返回带详情的 API 调用记录(assistant 消息)"""
97
+ records = []
98
+ id_to_msg = {}
99
+
100
+ try:
101
+ with open(session_path, 'r', encoding='utf-8') as f:
102
+ for line in f:
103
+ try:
104
+ data = json.loads(line)
105
+ if data.get('type') != 'message':
106
+ continue
107
+ msg = data.get('message', {})
108
+ if not msg:
109
+ continue
110
+
111
+ msg_id = data.get('id', '')
112
+ id_to_msg[msg_id] = {'data': data, 'msg': msg}
113
+
114
+ if msg.get('role') != 'assistant':
115
+ continue
116
+ if 'usage' not in msg:
117
+ continue
118
+
119
+ try:
120
+ ts = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
121
+ except Exception:
122
+ continue
123
+
124
+ usage = msg.get('usage', {})
125
+ tokens = usage.get('totalTokens', 0) or 0
126
+ model = msg.get('model', '')
127
+
128
+ trigger = ''
129
+ parent_id = data.get('parentId')
130
+ if parent_id and parent_id in id_to_msg:
131
+ parent = id_to_msg[parent_id]['msg']
132
+ parent_role = parent.get('role', '')
133
+ if parent_role == 'user':
134
+ trigger = _extract_trigger_text(parent)
135
+ elif parent_role == 'toolResult':
136
+ tool = parent.get('toolName', '') or (parent.get('details') or {}).get('tool', '?')
137
+ tool_call_id = parent.get('toolCallId', '')
138
+ # 从 toolResult 的 parent(发起调用的 assistant)获取 toolCall 详情
139
+ parent_data = id_to_msg.get(parent_id, {})
140
+ parent_of_tr = parent_data.get('data', {})
141
+ tr_parent_id = parent_of_tr.get('parentId', '')
142
+ if tool_call_id and tr_parent_id and tr_parent_id in id_to_msg:
143
+ detail = _extract_tool_call_detail(id_to_msg[tr_parent_id]['msg'], tool_call_id)
144
+ # 重要:这是 toolResult 触发的消息,即工具执行完成后的回传,不是工具调用本身
145
+ # 【完成回传】前缀醒目,因果顺序:派发 → 子Agent执行 → 完成回传
146
+ trigger = f"【完成回传】{detail}" if detail else f"【完成回传】工具: {tool}"
147
+ else:
148
+ trigger = f"【完成回传】工具: {tool}"
149
+
150
+ records.append({
151
+ 'timestamp': ts,
152
+ 'tokens': tokens,
153
+ 'agentId': agent_id,
154
+ 'sessionId': session_path.stem,
155
+ 'model': model,
156
+ 'trigger': trigger or '(用户输入)',
157
+ 'inputTokens': usage.get('input', 0),
158
+ 'outputTokens': usage.get('output', 0)
159
+ })
160
+ except Exception:
161
+ continue
162
+ return records
163
+ except Exception as e:
164
+ print(f"解析 session 详情失败 {session_path}: {e}")
165
+ return []
166
+
167
+
168
+ def parse_session_file(session_path: Path, range_hours: int = 1) -> List[Dict]:
169
+ """解析单个 session 文件,提取每条消息的 token 统计
170
+
171
+ Args:
172
+ session_path: session 文件路径
173
+ range_hours: 时间范围(小时),0 表示不限制
174
+ """
175
+ messages = []
176
+
177
+ try:
178
+ with open(session_path, 'r', encoding='utf-8') as f:
179
+ for line in f:
180
+ try:
181
+ data = json.loads(line)
182
+
183
+ # 只处理有 usage 和 timestamp 的消息
184
+ if 'message' in data and 'usage' in data['message'] and 'timestamp' in data:
185
+ usage = data['message']['usage']
186
+ tokens = usage.get('totalTokens', 0) or 0
187
+ is_request = data.get('message', {}).get('role') == 'assistant'
188
+
189
+ try:
190
+ timestamp = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
191
+
192
+ # 根据 range_hours 过滤时间范围,0 表示不过滤
193
+ if range_hours > 0:
194
+ now = datetime.now(timezone.utc)
195
+ time_ago = now - timedelta(hours=range_hours)
196
+ if timestamp < time_ago:
197
+ continue
198
+
199
+ messages.append({
200
+ 'timestamp': timestamp,
201
+ 'tokens': tokens,
202
+ 'is_request': is_request
203
+ })
204
+ except:
205
+ pass
206
+ except:
207
+ continue
208
+
209
+ return messages
210
+ except Exception as e:
211
+ print(f"解析 session 文件失败 {session_path}: {e}")
212
+ return []
213
+
214
+
215
+ @router.get("/performance")
216
+ async def get_performance_stats(range: str = "20m"):
217
+ """获取性能统计
218
+
219
+ Args:
220
+ range: 时间范围 (20m, 1h, 24h)
221
+ """
222
+ range_config = {
223
+ "20m": {"minutes": 20, "hours": 1, "granularity": "minute"},
224
+ "1h": {"minutes": 60, "hours": 1, "granularity": "minute"},
225
+ "24h": {"minutes": 1440, "hours": 24, "granularity": "hour"}
226
+ }
227
+
228
+ config = range_config.get(range, range_config["20m"])
229
+ stats = await get_real_stats(config["minutes"], config["hours"], config["granularity"])
230
+ return stats
231
+
232
+
233
+ async def get_real_stats(range_minutes: int = 20, range_hours: int = 1, granularity: str = "minute") -> Dict:
234
+ """获取真实的 TPM/RPM 统计
235
+
236
+ Args:
237
+ range_minutes: 时间范围(分钟)
238
+ range_hours: 用于解析 session 的时间范围(小时)
239
+ granularity: 聚合粒度 (minute, hour)
240
+ """
241
+ stats = {
242
+ 'current': {
243
+ 'tpm': 0,
244
+ 'rpm': 0,
245
+ 'windowTotal': {
246
+ 'tokens': 0,
247
+ 'requests': 0
248
+ }
249
+ },
250
+ 'history': {
251
+ 'tpm': [],
252
+ 'rpm': [],
253
+ 'timestamps': []
254
+ },
255
+ 'statistics': {
256
+ 'avgTpm': 0,
257
+ 'peakTpm': 0,
258
+ 'peakTime': ''
259
+ }
260
+ }
261
+
262
+ # 使用环境变量或默认路径
263
+ openclaw_path = _openclaw_path()
264
+ agents_path = openclaw_path / 'agents'
265
+
266
+ if not agents_path.exists():
267
+ return stats
268
+
269
+ # 按时间槽统计(分钟或小时)
270
+ time_slot_stats = {}
271
+
272
+ # 扫描所有 agent 的 sessions
273
+ for agent_dir in agents_path.iterdir():
274
+ if not agent_dir.is_dir():
275
+ continue
276
+
277
+ sessions_path = agent_dir / 'sessions'
278
+ if not sessions_path.exists():
279
+ continue
280
+
281
+ # 扫描所有 .jsonl 文件
282
+ for session_file in sessions_path.glob('*.jsonl'):
283
+ # 跳过 lock 和 deleted 文件
284
+ if 'lock' in session_file.name or 'deleted' in session_file.name:
285
+ continue
286
+
287
+ # 解析 session 文件,获取所有消息
288
+ messages = parse_session_file(session_file, range_hours)
289
+
290
+ # 按时间槽逐条累加
291
+ for msg in messages:
292
+ if granularity == "hour":
293
+ slot_key = msg['timestamp'].strftime('%Y-%m-%d %H:00')
294
+ else:
295
+ slot_key = msg['timestamp'].strftime('%Y-%m-%d %H:%M')
296
+
297
+ if slot_key not in time_slot_stats:
298
+ time_slot_stats[slot_key] = {
299
+ 'tokens': 0,
300
+ 'requests': 0
301
+ }
302
+
303
+ time_slot_stats[slot_key]['tokens'] += msg['tokens']
304
+ if msg['is_request']:
305
+ time_slot_stats[slot_key]['requests'] += 1
306
+
307
+ # 填充时间槽数据
308
+ timestamps = []
309
+ tpm_data = []
310
+ rpm_data = []
311
+ now = datetime.now(timezone.utc)
312
+
313
+ if granularity == "hour":
314
+ # 24h 模式:24 个小时槽
315
+ for i in range(24):
316
+ hour_time = now - timedelta(hours=(23 - i))
317
+ slot_key = hour_time.strftime('%Y-%m-%d %H:00')
318
+ timestamps.append(int(hour_time.timestamp() * 1000))
319
+
320
+ if slot_key in time_slot_stats:
321
+ tpm_data.append(time_slot_stats[slot_key]['tokens'])
322
+ rpm_data.append(time_slot_stats[slot_key]['requests'])
323
+ else:
324
+ tpm_data.append(0)
325
+ rpm_data.append(0)
326
+ else:
327
+ # 20m / 1h 模式:分钟槽
328
+ for i in range(range_minutes):
329
+ minute_time = now - timedelta(minutes=(range_minutes - i - 1))
330
+ slot_key = minute_time.strftime('%Y-%m-%d %H:%M')
331
+ timestamps.append(int(minute_time.timestamp() * 1000))
332
+
333
+ if slot_key in time_slot_stats:
334
+ tpm_data.append(time_slot_stats[slot_key]['tokens'])
335
+ rpm_data.append(time_slot_stats[slot_key]['requests'])
336
+ else:
337
+ tpm_data.append(0)
338
+ rpm_data.append(0)
339
+
340
+ stats['history']['tpm'] = tpm_data
341
+ stats['history']['rpm'] = rpm_data
342
+ stats['history']['timestamps'] = timestamps
343
+
344
+ # 当前时间槽的统计
345
+ if granularity == "hour":
346
+ current_slot = now.strftime('%Y-%m-%d %H:00')
347
+ else:
348
+ current_slot = now.strftime('%Y-%m-%d %H:%M')
349
+
350
+ if current_slot in time_slot_stats:
351
+ stats['current']['tpm'] = time_slot_stats[current_slot]['tokens']
352
+ stats['current']['rpm'] = time_slot_stats[current_slot]['requests']
353
+
354
+ # 时间窗口总计
355
+ stats['current']['windowTotal']['tokens'] = sum(tpm_data)
356
+ stats['current']['windowTotal']['requests'] = sum(rpm_data)
357
+
358
+ # 统计摘要
359
+ non_zero_tpm = [t for t in tpm_data if t > 0]
360
+ if non_zero_tpm:
361
+ stats['statistics']['avgTpm'] = int(sum(non_zero_tpm) / len(non_zero_tpm))
362
+ stats['statistics']['peakTpm'] = max(non_zero_tpm)
363
+ peak_idx = tpm_data.index(max(non_zero_tpm))
364
+ # 格式化峰值时间
365
+ peak_ts = datetime.fromtimestamp(timestamps[peak_idx] / 1000, tz=TZ_DISPLAY)
366
+ if granularity == "hour":
367
+ stats['statistics']['peakTime'] = peak_ts.strftime('%H:00')
368
+ else:
369
+ stats['statistics']['peakTime'] = peak_ts.strftime('%H:%M')
370
+
371
+ return stats
372
+
373
+
374
+ async def get_minute_details(
375
+ timestamp_ms: int,
376
+ granularity: str = "minute",
377
+ agent: Optional[str] = None,
378
+ search: Optional[str] = None,
379
+ sort: str = "tokens_desc",
380
+ limit: int = 50
381
+ ) -> Dict[str, Any]:
382
+ """获取指定时间窗口的调用详情,用于柱体点击钻取。时间展示使用 Asia/Shanghai 时区
383
+
384
+ Args:
385
+ timestamp_ms: Unix 毫秒时间戳
386
+ granularity: 粒度 (minute, hour)
387
+ agent: 筛选指定 Agent
388
+ search: 搜索触发内容
389
+ sort: 排序方式 (tokens_desc, tokens_asc, time_asc, time_desc)
390
+ limit: 返回数量限制
391
+ """
392
+ try:
393
+ ts = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
394
+ ts_local = ts.astimezone(TZ_DISPLAY)
395
+
396
+ if granularity == "hour":
397
+ time_key = ts_local.strftime('%Y-%m-%d %H:00')
398
+ time_start = ts.replace(minute=0, second=0, microsecond=0)
399
+ time_end = time_start + timedelta(hours=1)
400
+ else:
401
+ time_key = ts_local.strftime('%Y-%m-%d %H:%M')
402
+ time_start = ts.replace(second=0, microsecond=0)
403
+ time_end = time_start + timedelta(minutes=1)
404
+
405
+ openclaw_path = _openclaw_path()
406
+ agents_path = openclaw_path / 'agents'
407
+ if not agents_path.exists():
408
+ return {'timeWindow': time_key, 'calls': [], 'totalCalls': 0, 'totalTokens': 0, 'summary': {'avgTokens': 0}, 'agents': []}
409
+
410
+ all_calls = []
411
+ agent_set = set()
412
+
413
+ for agent_dir in agents_path.iterdir():
414
+ if not agent_dir.is_dir():
415
+ continue
416
+ agent_id = agent_dir.name
417
+ agent_set.add(agent_id)
418
+
419
+ # 如果指定了 agent 筛选,跳过不匹配的
420
+ if agent and agent_id != agent:
421
+ continue
422
+
423
+ sessions_path = agent_dir / 'sessions'
424
+ if not sessions_path.exists():
425
+ continue
426
+
427
+ for session_file in sessions_path.glob('*.jsonl'):
428
+ if 'lock' in session_file.name or 'deleted' in session_file.name:
429
+ continue
430
+ records = parse_session_file_with_details(session_file, agent_id)
431
+ for r in records:
432
+ if time_start <= r['timestamp'] < time_end:
433
+ # 转为 Asia/Shanghai 时区展示
434
+ r_ts = r['timestamp']
435
+ if r_ts.tzinfo is None:
436
+ r_ts = r_ts.replace(tzinfo=timezone.utc)
437
+ r_local = r_ts.astimezone(TZ_DISPLAY)
438
+
439
+ call_item = {
440
+ 'agentId': r['agentId'],
441
+ 'sessionId': r['sessionId'],
442
+ 'model': r['model'],
443
+ 'tokens': r['tokens'],
444
+ 'trigger': r['trigger'],
445
+ 'inputTokens': r.get('inputTokens', 0),
446
+ 'outputTokens': r.get('outputTokens', 0),
447
+ 'time': r_local.strftime('%H:%M:%S'),
448
+ 'timestamp': int(r_ts.timestamp() * 1000)
449
+ }
450
+
451
+ # 如果指定了搜索关键词,过滤触发内容
452
+ if search:
453
+ if search.lower() not in call_item['trigger'].lower():
454
+ continue
455
+
456
+ all_calls.append(call_item)
457
+
458
+ # 排序
459
+ if sort == "tokens_desc":
460
+ all_calls.sort(key=lambda x: x['tokens'], reverse=True)
461
+ elif sort == "tokens_asc":
462
+ all_calls.sort(key=lambda x: x['tokens'])
463
+ elif sort == "time_asc":
464
+ all_calls.sort(key=lambda x: x['timestamp'])
465
+ elif sort == "time_desc":
466
+ all_calls.sort(key=lambda x: x['timestamp'], reverse=True)
467
+
468
+ # 计算统计信息
469
+ total_tokens = sum(c['tokens'] for c in all_calls)
470
+ avg_tokens = int(total_tokens / len(all_calls)) if all_calls else 0
471
+
472
+ # 分页
473
+ total_count = len(all_calls)
474
+ paginated_calls = all_calls[:limit]
475
+
476
+ return {
477
+ 'timeWindow': time_key,
478
+ 'calls': paginated_calls,
479
+ 'totalCalls': total_count,
480
+ 'totalTokens': total_tokens,
481
+ 'summary': {
482
+ 'avgTokens': avg_tokens
483
+ },
484
+ 'agents': sorted(list(agent_set)),
485
+ 'pagination': {
486
+ 'total': total_count,
487
+ 'limit': limit,
488
+ 'hasMore': total_count > limit
489
+ }
490
+ }
491
+ except Exception as e:
492
+ print(f"获取调用详情失败: {e}")
493
+ import traceback
494
+ traceback.print_exc()
495
+ return {'timeWindow': '', 'calls': [], 'totalCalls': 0, 'totalTokens': 0, 'summary': {'avgTokens': 0}, 'agents': [], 'pagination': {'total': 0, 'limit': limit, 'hasMore': False}}
496
+
497
+
498
+ @router.get("/performance/details")
499
+ async def get_performance_details(
500
+ timestamp: int,
501
+ granularity: str = "minute",
502
+ agent: Optional[str] = None,
503
+ search: Optional[str] = None,
504
+ sort: str = "tokens_desc",
505
+ limit: int = 50
506
+ ):
507
+ """获取指定时间窗口的调用详情(柱体点击钻取)
508
+
509
+ Args:
510
+ timestamp: 时间窗口起始的 Unix 毫秒时间戳
511
+ granularity: 粒度 (minute, hour)
512
+ agent: 筛选指定 Agent
513
+ search: 搜索触发内容
514
+ sort: 排序方式 (tokens_desc, tokens_asc, time_asc, time_desc)
515
+ limit: 返回数量限制
516
+ """
517
+ return await get_minute_details(timestamp, granularity, agent, search, sort, limit)
518
+
519
+
520
+ def _openclaw_path() -> Path:
521
+ from data.config_reader import get_openclaw_root
522
+ return get_openclaw_root()
523
+
524
+
525
+ @router.get("/tokens/analysis")
526
+ async def get_tokens_analysis(range: str = "all"):
527
+ """
528
+ Token 分析视图:按 agent、按 session 汇总 usage
529
+
530
+ Args:
531
+ range: 时间范围 (20m, 1h, 24h, all)
532
+
533
+ 数据来源:sessions.json (inputTokens, outputTokens, cacheRead, cacheWrite)
534
+ """
535
+ # 保存参数避免与 Python 内置 range() 冲突
536
+ time_range = range
537
+ openclaw_path = _openclaw_path()
538
+ agents_path = openclaw_path / 'agents'
539
+
540
+ # 默认定价 (Claude 3.5 Sonnet)
541
+ PRICING = {
542
+ 'inputPrice': 3.00, # 每 1M Token
543
+ 'outputPrice': 15.00,
544
+ 'cacheReadPrice': 0.30,
545
+ 'cacheWritePrice': 3.75
546
+ }
547
+
548
+ result = {
549
+ "summary": {
550
+ "input": 0,
551
+ "output": 0,
552
+ "cacheRead": 0,
553
+ "cacheWrite": 0,
554
+ "total": 0,
555
+ "cacheHitRate": 0.0
556
+ },
557
+ "cost": {
558
+ "input": 0.0,
559
+ "output": 0.0,
560
+ "cacheRead": 0.0,
561
+ "cacheWrite": 0.0,
562
+ "total": 0.0,
563
+ "saved": 0.0,
564
+ "savedPercent": 0.0
565
+ },
566
+ "byAgent": [],
567
+ "trend": None
568
+ }
569
+
570
+ if not agents_path.exists():
571
+ return result
572
+
573
+ # 确定是否需要趋势数据
574
+ need_trend = time_range in ('20m', '1h', '24h')
575
+ trend_data = {"timestamps": [], "input": [], "output": []} if need_trend else None
576
+
577
+ if need_trend:
578
+ # 从 jsonl 文件计算带时间范围的统计
579
+ now = datetime.now(timezone.utc)
580
+ if time_range == '20m':
581
+ time_ago = now - timedelta(minutes=20)
582
+ granularity = 'minute'
583
+ num_slots = 20
584
+ elif time_range == '1h':
585
+ time_ago = now - timedelta(hours=1)
586
+ granularity = 'minute'
587
+ num_slots = 60
588
+ else: # 24h
589
+ time_ago = now - timedelta(hours=24)
590
+ granularity = 'hour'
591
+ num_slots = 24
592
+
593
+ # 初始化时间槽数据
594
+ slot_stats = {}
595
+ for i in range(num_slots):
596
+ if granularity == 'hour':
597
+ slot_time = now - timedelta(hours=(num_slots - i - 1))
598
+ slot_key = slot_time.strftime('%Y-%m-%d %H:00')
599
+ else:
600
+ slot_time = now - timedelta(minutes=(num_slots - i - 1))
601
+ slot_key = slot_time.strftime('%Y-%m-%d %H:%M')
602
+ slot_stats[slot_key] = {
603
+ 'timestamp': int(slot_time.timestamp() * 1000),
604
+ 'input': 0,
605
+ 'output': 0,
606
+ 'cacheRead': 0,
607
+ 'cacheWrite': 0
608
+ }
609
+
610
+ agent_totals = {}
611
+
612
+ for agent_dir in agents_path.iterdir():
613
+ if not agent_dir.is_dir():
614
+ continue
615
+ agent_id = agent_dir.name
616
+ sessions_path = agent_dir / 'sessions'
617
+ if not sessions_path.exists():
618
+ continue
619
+
620
+ agent_totals[agent_id] = {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}
621
+
622
+ for session_file in sessions_path.glob('*.jsonl'):
623
+ if 'lock' in session_file.name or 'deleted' in session_file.name:
624
+ continue
625
+
626
+ try:
627
+ with open(session_file, 'r', encoding='utf-8') as f:
628
+ for line in f:
629
+ try:
630
+ data = json.loads(line)
631
+ if data.get('type') != 'message':
632
+ continue
633
+ msg = data.get('message', {})
634
+ if msg.get('role') != 'assistant' or 'usage' not in msg:
635
+ continue
636
+
637
+ try:
638
+ ts = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
639
+ except:
640
+ continue
641
+
642
+ if ts < time_ago:
643
+ continue
644
+
645
+ usage = msg['usage']
646
+ inp = usage.get('input', 0) or 0
647
+ out = usage.get('output', 0) or 0
648
+ cr = usage.get('cacheRead', 0) or 0
649
+ cw = usage.get('cacheWrite', 0) or 0
650
+
651
+ # 确定时间槽
652
+ if granularity == 'hour':
653
+ slot_key = ts.strftime('%Y-%m-%d %H:00')
654
+ else:
655
+ slot_key = ts.strftime('%Y-%m-%d %H:%M')
656
+
657
+ if slot_key in slot_stats:
658
+ slot_stats[slot_key]['input'] += inp
659
+ slot_stats[slot_key]['output'] += out
660
+ slot_stats[slot_key]['cacheRead'] += cr
661
+ slot_stats[slot_key]['cacheWrite'] += cw
662
+
663
+ agent_totals[agent_id]["input"] += inp
664
+ agent_totals[agent_id]["output"] += out
665
+ agent_totals[agent_id]["cacheRead"] += cr
666
+ agent_totals[agent_id]["cacheWrite"] += cw
667
+ except:
668
+ continue
669
+ except:
670
+ continue
671
+
672
+ # 汇总趋势数据
673
+ sorted_slots = sorted(slot_stats.items())
674
+ trend_data = {
675
+ "timestamps": [s[1]['timestamp'] for s in sorted_slots],
676
+ "input": [s[1]['input'] for s in sorted_slots],
677
+ "output": [s[1]['output'] for s in sorted_slots]
678
+ }
679
+
680
+ # 汇总 agent 数据
681
+ for agent_id, totals in agent_totals.items():
682
+ total_tokens = totals["input"] + totals["output"]
683
+ if total_tokens > 0:
684
+ result["byAgent"].append({
685
+ "agent": agent_id,
686
+ "input": totals["input"],
687
+ "output": totals["output"],
688
+ "cacheRead": totals["cacheRead"],
689
+ "cacheWrite": totals["cacheWrite"],
690
+ "total": total_tokens
691
+ })
692
+ result["summary"]["input"] += totals["input"]
693
+ result["summary"]["output"] += totals["output"]
694
+ result["summary"]["cacheRead"] += totals["cacheRead"]
695
+ result["summary"]["cacheWrite"] += totals["cacheWrite"]
696
+ else:
697
+ # 从 sessions.json 读取全部数据
698
+ for agent_dir in agents_path.iterdir():
699
+ if not agent_dir.is_dir():
700
+ continue
701
+ agent_id = agent_dir.name
702
+ sessions_index = agent_dir / 'sessions' / 'sessions.json'
703
+ if not sessions_index.exists():
704
+ continue
705
+
706
+ try:
707
+ with open(sessions_index, 'r', encoding='utf-8') as f:
708
+ data = json.load(f)
709
+ if not isinstance(data, dict):
710
+ continue
711
+
712
+ agent_total = {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}
713
+ for session_key, entry in data.items():
714
+ if not isinstance(entry, dict):
715
+ continue
716
+ inp = entry.get('inputTokens', 0) or 0
717
+ out = entry.get('outputTokens', 0) or 0
718
+ cr = entry.get('cacheRead', 0) or 0
719
+ cw = entry.get('cacheWrite', 0) or 0
720
+ agent_total["input"] += inp
721
+ agent_total["output"] += out
722
+ agent_total["cacheRead"] += cr
723
+ agent_total["cacheWrite"] += cw
724
+
725
+ agent_total_tokens = agent_total["input"] + agent_total["output"]
726
+ result["byAgent"].append({
727
+ "agent": agent_id,
728
+ "input": agent_total["input"],
729
+ "output": agent_total["output"],
730
+ "cacheRead": agent_total["cacheRead"],
731
+ "cacheWrite": agent_total["cacheWrite"],
732
+ "total": agent_total_tokens
733
+ })
734
+
735
+ result["summary"]["input"] += agent_total["input"]
736
+ result["summary"]["output"] += agent_total["output"]
737
+ result["summary"]["cacheRead"] += agent_total["cacheRead"]
738
+ result["summary"]["cacheWrite"] += agent_total["cacheWrite"]
739
+ except Exception:
740
+ continue
741
+
742
+ # 计算汇总
743
+ result["summary"]["total"] = result["summary"]["input"] + result["summary"]["output"]
744
+
745
+ # 计算缓存命中率
746
+ total_input = result["summary"]["input"] + result["summary"]["cacheRead"]
747
+ if total_input > 0:
748
+ result["summary"]["cacheHitRate"] = round(result["summary"]["cacheRead"] / total_input, 4)
749
+
750
+ # 计算成本
751
+ def calc_cost(tokens: int, price_per_m: float) -> float:
752
+ return round((tokens / 1_000_000) * price_per_m, 4)
753
+
754
+ result["cost"]["input"] = calc_cost(result["summary"]["input"], PRICING['inputPrice'])
755
+ result["cost"]["output"] = calc_cost(result["summary"]["output"], PRICING['outputPrice'])
756
+ result["cost"]["cacheRead"] = calc_cost(result["summary"]["cacheRead"], PRICING['cacheReadPrice'])
757
+ result["cost"]["cacheWrite"] = calc_cost(result["summary"]["cacheWrite"], PRICING['cacheWritePrice'])
758
+ result["cost"]["total"] = round(
759
+ result["cost"]["input"] + result["cost"]["output"] +
760
+ result["cost"]["cacheRead"] + result["cost"]["cacheWrite"], 4
761
+ )
762
+
763
+ # 计算节省金额(如果不用缓存,这些 input 要按原价付费)
764
+ saved_by_cache = calc_cost(result["summary"]["cacheRead"], PRICING['inputPrice']) - result["cost"]["cacheRead"]
765
+ result["cost"]["saved"] = round(saved_by_cache, 4)
766
+
767
+ # 节省百分比
768
+ if result["cost"]["total"] > 0:
769
+ result["cost"]["savedPercent"] = round(saved_by_cache / (result["cost"]["total"] + saved_by_cache), 4)
770
+
771
+ # 按 total 降序排序 byAgent
772
+ result["byAgent"].sort(key=lambda x: x["total"], reverse=True)
773
+
774
+ # 计算占比
775
+ grand_total = result["summary"]["total"]
776
+ if grand_total > 0:
777
+ for agent in result["byAgent"]:
778
+ agent["percent"] = round(agent["total"] / grand_total, 4)
779
+
780
+ # 添加趋势数据
781
+ if trend_data:
782
+ result["trend"] = trend_data
783
+
784
+ return result