openclaw-agent-dashboard 1.0.39 → 1.0.41

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 (58) hide show
  1. package/dashboard/api/agent_config_api.py +28 -7
  2. package/dashboard/api/agents.py +48 -10
  3. package/dashboard/api/agents_config.py +5 -1
  4. package/dashboard/api/chains.py +25 -5
  5. package/dashboard/api/collaboration.py +10 -9
  6. package/dashboard/api/debug_paths.py +5 -1
  7. package/dashboard/api/error_analysis.py +29 -11
  8. package/dashboard/api/errors.py +37 -11
  9. package/dashboard/api/fortify_routes.py +108 -0
  10. package/dashboard/api/input_safety.py +60 -0
  11. package/dashboard/api/performance.py +73 -53
  12. package/dashboard/api/subagents.py +95 -99
  13. package/dashboard/api/timeline.py +24 -3
  14. package/dashboard/api/version.py +2 -0
  15. package/dashboard/api/websocket.py +9 -7
  16. package/dashboard/core/__init__.py +1 -0
  17. package/dashboard/core/config_fortify.py +125 -0
  18. package/dashboard/core/error_handler.py +488 -0
  19. package/dashboard/core/fallback_manager.py +81 -0
  20. package/dashboard/core/logging_config.py +217 -0
  21. package/dashboard/core/safe_api_error.py +76 -0
  22. package/dashboard/core/schemas/__init__.py +16 -0
  23. package/dashboard/core/schemas/base.py +43 -0
  24. package/dashboard/core/schemas/session_schema.py +40 -0
  25. package/dashboard/core/schemas/subagent_schema.py +23 -0
  26. package/dashboard/data/agent_config_manager.py +6 -4
  27. package/dashboard/data/chain_reader.py +16 -12
  28. package/dashboard/data/error_analyzer.py +15 -11
  29. package/dashboard/data/session_reader.py +268 -46
  30. package/dashboard/data/subagent_reader.py +74 -49
  31. package/dashboard/data/timeline_reader.py +35 -49
  32. package/dashboard/main.py +24 -2
  33. package/dashboard/mechanism_reader.py +4 -5
  34. package/dashboard/mechanisms.py +2 -2
  35. package/dashboard/pytest.ini +3 -0
  36. package/dashboard/requirements.txt +5 -0
  37. package/dashboard/status/cache_fp_probe.py +40 -0
  38. package/dashboard/status/status_cache.py +199 -72
  39. package/dashboard/status/status_calculator.py +50 -30
  40. package/dashboard/tests/conftest.py +87 -0
  41. package/dashboard/tests/test_api_contracts.py +372 -0
  42. package/dashboard/tests/test_bench_fortify.py +176 -0
  43. package/dashboard/tests/test_fortify.py +952 -0
  44. package/dashboard/utils/__init__.py +1 -0
  45. package/dashboard/utils/data_repair.py +210 -0
  46. package/dashboard/watchers/file_watcher.py +380 -77
  47. package/frontend-dist/assets/{index-cYIOn3Wq.css → index-BIZ2xHfw.css} +1 -1
  48. package/frontend-dist/assets/{index-DyRXGevD.js → index-Cnr0b02R.js} +1 -1
  49. package/frontend-dist/index.html +2 -2
  50. package/openclaw.plugin.json +1 -1
  51. package/package.json +1 -1
  52. package/dashboard/agents.py +0 -74
  53. package/dashboard/collaboration.py +0 -407
  54. package/dashboard/errors.py +0 -63
  55. package/dashboard/performance.py +0 -474
  56. package/dashboard/session_reader.py +0 -240
  57. package/dashboard/status_calculator.py +0 -121
  58. package/dashboard/subagent_reader.py +0 -232
@@ -1,474 +0,0 @@
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) -> List[Dict]:
169
- """解析单个 session 文件,提取每条消息的 token 统计"""
170
- messages = []
171
-
172
- try:
173
- with open(session_path, 'r', encoding='utf-8') as f:
174
- for line in f:
175
- try:
176
- data = json.loads(line)
177
-
178
- # 只处理有 usage 和 timestamp 的消息
179
- if 'message' in data and 'usage' in data['message'] and 'timestamp' in data:
180
- usage = data['message']['usage']
181
- tokens = usage.get('totalTokens', 0) or 0
182
- is_request = data.get('message', {}).get('role') == 'assistant'
183
-
184
- try:
185
- timestamp = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
186
-
187
- # 只统计最近1小时的消息
188
- now = datetime.now(timezone.utc)
189
- one_hour_ago = now - timedelta(hours=1)
190
-
191
- if timestamp >= one_hour_ago:
192
- messages.append({
193
- 'timestamp': timestamp,
194
- 'tokens': tokens,
195
- 'is_request': is_request
196
- })
197
- except:
198
- pass
199
- except:
200
- continue
201
-
202
- return messages
203
- except Exception as e:
204
- print(f"解析 session 文件失败 {session_path}: {e}")
205
- return []
206
-
207
-
208
- @router.get("/performance")
209
- async def get_performance_stats(range: str = "20m"):
210
- """获取性能统计
211
-
212
- Args:
213
- range: 时间范围 (20m, 1h)
214
- """
215
- import time
216
- start = time.perf_counter()
217
- range_minutes = {
218
- "20m": 20,
219
- "1h": 60
220
- }.get(range, 20)
221
-
222
- stats = await get_real_stats(range_minutes)
223
- # 接口处理耗时作为延迟参考(毫秒)
224
- stats['current']['latency'] = int((time.perf_counter() - start) * 1000)
225
- return stats
226
-
227
-
228
- async def get_real_stats(range_minutes: int = 20) -> Dict:
229
- """获取真实的 TPM/RPM 统计
230
-
231
- Args:
232
- range_minutes: 时间范围(分钟)
233
- """
234
- stats = {
235
- 'current': {
236
- 'tpm': 0,
237
- 'rpm': 0,
238
- 'latency': 0,
239
- 'errorRate': 0.0
240
- },
241
- 'history': {
242
- 'tpm': [],
243
- 'rpm': [],
244
- 'timestamps': []
245
- },
246
- 'total': {
247
- 'tokens': 0,
248
- 'requests': 0
249
- }
250
- }
251
-
252
- # 读取所有 session 文件
253
- from data.config_reader import get_openclaw_root
254
- openclaw_path = get_openclaw_root()
255
- agents_path = openclaw_path / 'agents'
256
-
257
- if not agents_path.exists():
258
- return stats
259
-
260
- # 按分钟统计
261
- minutes_stats = {}
262
-
263
- # 扫描所有 agent 的 sessions
264
- for agent_dir in agents_path.iterdir():
265
- if not agent_dir.is_dir():
266
- continue
267
-
268
- sessions_path = agent_dir / 'sessions'
269
- if not sessions_path.exists():
270
- continue
271
-
272
- # 扫描所有 .jsonl 文件
273
- for session_file in sessions_path.glob('*.jsonl'):
274
- # 跳过 lock 和 deleted 文件
275
- if 'lock' in session_file.name or 'deleted' in session_file.name:
276
- continue
277
-
278
- # 解析 session 文件,获取所有消息
279
- messages = parse_session_file(session_file)
280
-
281
- # 按分钟逐条累加
282
- for msg in messages:
283
- minute_key = msg['timestamp'].strftime('%Y-%m-%d %H:%M')
284
-
285
- if minute_key not in minutes_stats:
286
- minutes_stats[minute_key] = {
287
- 'tokens': 0,
288
- 'requests': 0
289
- }
290
-
291
- minutes_stats[minute_key]['tokens'] += msg['tokens']
292
- if msg['is_request']:
293
- minutes_stats[minute_key]['requests'] += 1
294
-
295
- # 排序并转换为列表
296
- sorted_minutes = sorted(minutes_stats.items())
297
-
298
- # 填充最近 range_minutes 分钟的数据(缺失的分钟补0)
299
- timestamps = []
300
- tpm_data = []
301
- rpm_data = []
302
-
303
- now = datetime.now(timezone.utc)
304
- for i in range(range_minutes):
305
- minute_time = now - timedelta(minutes=(range_minutes - i - 1))
306
- minute_key = minute_time.strftime('%Y-%m-%d %H:%M')
307
-
308
- # 发送 Unix 时间戳(毫秒),前端可正确转换为本地时区
309
- timestamps.append(int(minute_time.timestamp() * 1000))
310
-
311
- if minute_key in minutes_stats:
312
- tpm_data.append(minutes_stats[minute_key]['tokens'])
313
- rpm_data.append(minutes_stats[minute_key]['requests'])
314
- else:
315
- tpm_data.append(0)
316
- rpm_data.append(0)
317
-
318
- stats['history']['tpm'] = tpm_data
319
- stats['history']['rpm'] = rpm_data
320
- stats['history']['timestamps'] = timestamps
321
-
322
- # 当前分钟的统计
323
- current_minute = now.strftime('%Y-%m-%d %H:%M')
324
- if current_minute in minutes_stats:
325
- stats['current']['tpm'] = minutes_stats[current_minute]['tokens']
326
- stats['current']['rpm'] = minutes_stats[current_minute]['requests']
327
- else:
328
- stats['current']['tpm'] = 0
329
- stats['current']['rpm'] = 0
330
-
331
- # 总计(基于历史数据)
332
- total_tokens = sum([s['tokens'] for s in minutes_stats.values()])
333
- total_requests = sum([s['requests'] for s in minutes_stats.values()])
334
-
335
- stats['total']['tokens'] = total_tokens
336
- stats['total']['requests'] = total_requests
337
-
338
- return stats
339
-
340
-
341
- async def get_minute_details(timestamp_ms: int) -> Dict[str, Any]:
342
- """获取指定分钟的调用详情,用于柱体点击钻取。时间展示使用 Asia/Shanghai 时区"""
343
- try:
344
- ts = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
345
- ts_local = ts.astimezone(TZ_DISPLAY)
346
- minute_key = ts_local.strftime('%Y-%m-%d %H:%M')
347
- minute_start = ts.replace(second=0, microsecond=0)
348
- minute_end = minute_start + timedelta(minutes=1)
349
-
350
- from data.config_reader import get_openclaw_root
351
- openclaw_path = get_openclaw_root()
352
- agents_path = openclaw_path / 'agents'
353
- if not agents_path.exists():
354
- return {'minute': minute_key, 'calls': [], 'totalTokens': 0}
355
-
356
- all_calls = []
357
- for agent_dir in agents_path.iterdir():
358
- if not agent_dir.is_dir():
359
- continue
360
- agent_id = agent_dir.name
361
- sessions_path = agent_dir / 'sessions'
362
- if not sessions_path.exists():
363
- continue
364
-
365
- for session_file in sessions_path.glob('*.jsonl'):
366
- if 'lock' in session_file.name or 'deleted' in session_file.name:
367
- continue
368
- records = parse_session_file_with_details(session_file, agent_id)
369
- for r in records:
370
- if minute_start <= r['timestamp'] < minute_end:
371
- # 转为 Asia/Shanghai 时区展示
372
- r_ts = r['timestamp']
373
- if r_ts.tzinfo is None:
374
- r_ts = r_ts.replace(tzinfo=timezone.utc)
375
- r_local = r_ts.astimezone(TZ_DISPLAY)
376
- all_calls.append({
377
- 'agentId': r['agentId'],
378
- 'sessionId': r['sessionId'],
379
- 'model': r['model'],
380
- 'tokens': r['tokens'],
381
- 'trigger': r['trigger'],
382
- 'inputTokens': r.get('inputTokens', 0),
383
- 'outputTokens': r.get('outputTokens', 0),
384
- 'time': r_local.strftime('%H:%M:%S')
385
- })
386
-
387
- all_calls.sort(key=lambda x: x['time'])
388
- total_tokens = sum(c['tokens'] for c in all_calls)
389
- return {
390
- 'minute': minute_key,
391
- 'calls': all_calls,
392
- 'totalCalls': len(all_calls),
393
- 'totalTokens': total_tokens
394
- }
395
- except Exception as e:
396
- print(f"获取分钟详情失败: {e}")
397
- import traceback
398
- traceback.print_exc()
399
- return {'minute': '', 'calls': [], 'totalTokens': 0}
400
-
401
-
402
- @router.get("/performance/details")
403
- async def get_performance_details(timestamp: int):
404
- """获取指定分钟的 TPM/RPM 调用详情(柱体点击钻取)
405
-
406
- Args:
407
- timestamp: 分钟起始的 Unix 毫秒时间戳
408
- """
409
- return await get_minute_details(timestamp)
410
-
411
-
412
- def _openclaw_path() -> Path:
413
- from data.config_reader import get_openclaw_root
414
- return get_openclaw_root()
415
-
416
-
417
- @router.get("/tokens/analysis")
418
- async def get_tokens_analysis():
419
- """
420
- Token 分析视图:按 agent、按 session 汇总 usage
421
- 数据来源:sessions.json (inputTokens, outputTokens, cacheRead, cacheWrite)
422
- """
423
- openclaw_path = _openclaw_path()
424
- agents_path = openclaw_path / 'agents'
425
-
426
- result = {"byAgent": {}, "total": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}
427
-
428
- if not agents_path.exists():
429
- return result
430
-
431
- for agent_dir in agents_path.iterdir():
432
- if not agent_dir.is_dir():
433
- continue
434
- agent_id = agent_dir.name
435
- sessions_index = agent_dir / 'sessions' / 'sessions.json'
436
- if not sessions_index.exists():
437
- continue
438
-
439
- try:
440
- with open(sessions_index, 'r', encoding='utf-8') as f:
441
- data = json.load(f)
442
- if not isinstance(data, dict):
443
- continue
444
-
445
- agent_total = {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "sessions": []}
446
- for session_key, entry in data.items():
447
- if not isinstance(entry, dict):
448
- continue
449
- inp = entry.get('inputTokens', 0) or 0
450
- out = entry.get('outputTokens', 0) or 0
451
- cr = entry.get('cacheRead', 0) or 0
452
- cw = entry.get('cacheWrite', 0) or 0
453
- agent_total["input"] += inp
454
- agent_total["output"] += out
455
- agent_total["cacheRead"] += cr
456
- agent_total["cacheWrite"] += cw
457
- agent_total["sessions"].append({
458
- "sessionKey": session_key,
459
- "input": inp,
460
- "output": out,
461
- "cacheRead": cr,
462
- "cacheWrite": cw,
463
- "total": inp + out,
464
- })
465
-
466
- result["byAgent"][agent_id] = agent_total
467
- result["total"]["input"] += agent_total["input"]
468
- result["total"]["output"] += agent_total["output"]
469
- result["total"]["cacheRead"] += agent_total["cacheRead"]
470
- result["total"]["cacheWrite"] += agent_total["cacheWrite"]
471
- except Exception:
472
- continue
473
-
474
- return result