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.
- package/.github/workflows/release.yml +56 -0
- package/README.md +302 -0
- package/docs/CHANGELOG_AGENT_MODIFICATIONS.md +132 -0
- package/docs/RELEASE-LATEST.md +189 -0
- package/docs/RELEASE-MODEL-CONFIG.md +95 -0
- package/docs/release-guide.md +259 -0
- package/docs/release-operations-manual.md +167 -0
- package/docs/specs/tr3-install-system.md +580 -0
- package/docs/windows-collaboration-model-paths-troubleshooting.md +0 -0
- package/frontend/index.html +12 -0
- package/frontend/package-lock.json +1240 -0
- package/frontend/package.json +19 -0
- package/frontend/src/App.vue +331 -0
- package/frontend/src/components/AgentCard.vue +796 -0
- package/frontend/src/components/AgentConfigPanel.vue +539 -0
- package/frontend/src/components/AgentDetailPanel.vue +738 -0
- package/frontend/src/components/ErrorAnalysisView.vue +546 -0
- package/frontend/src/components/ErrorCenterPanel.vue +844 -0
- package/frontend/src/components/PerformanceMonitor.vue +515 -0
- package/frontend/src/components/SettingsPanel.vue +236 -0
- package/frontend/src/components/TokenAnalysisPanel.vue +683 -0
- package/frontend/src/components/chain/ChainEdge.vue +85 -0
- package/frontend/src/components/chain/ChainNode.vue +166 -0
- package/frontend/src/components/chain/TaskChainView.vue +425 -0
- package/frontend/src/components/chain/index.ts +3 -0
- package/frontend/src/components/chain/types.ts +70 -0
- package/frontend/src/components/collaboration/CollaborationFlowSection.vue +1032 -0
- package/frontend/src/components/collaboration/CollaborationFlowWrapper.vue +113 -0
- package/frontend/src/components/performance/PerformancePanel.vue +119 -0
- package/frontend/src/components/performance/PerformanceSection.vue +1137 -0
- package/frontend/src/components/tasks/TaskStatusSection.vue +973 -0
- package/frontend/src/components/timeline/TimelineConnector.vue +31 -0
- package/frontend/src/components/timeline/TimelineRound.vue +135 -0
- package/frontend/src/components/timeline/TimelineStep.vue +691 -0
- package/frontend/src/components/timeline/TimelineToolLink.vue +109 -0
- package/frontend/src/components/timeline/TimelineView.vue +540 -0
- package/frontend/src/components/timeline/index.ts +5 -0
- package/frontend/src/components/timeline/types.ts +120 -0
- package/frontend/src/composables/index.ts +7 -0
- package/frontend/src/composables/useDebounce.ts +48 -0
- package/frontend/src/composables/useRealtime.ts +52 -0
- package/frontend/src/composables/useState.ts +52 -0
- package/frontend/src/composables/useThrottle.ts +46 -0
- package/frontend/src/composables/useVirtualScroll.ts +106 -0
- package/frontend/src/main.ts +4 -0
- package/frontend/src/managers/EventDispatcher.ts +127 -0
- package/frontend/src/managers/RealtimeDataManager.ts +293 -0
- package/frontend/src/managers/StateManager.ts +128 -0
- package/frontend/src/managers/index.ts +5 -0
- package/frontend/src/types/collaboration.ts +135 -0
- package/frontend/src/types/index.ts +20 -0
- package/frontend/src/types/performance.ts +105 -0
- package/frontend/src/types/task.ts +38 -0
- package/frontend/vite.config.ts +18 -0
- package/package.json +22 -0
- package/plugin/README.md +99 -0
- package/plugin/config.json.example +1 -0
- package/plugin/index.js +250 -0
- package/plugin/openclaw.plugin.json +17 -0
- package/plugin/package.json +21 -0
- package/scripts/build-plugin.js +67 -0
- package/scripts/bundle.sh +62 -0
- package/scripts/install-plugin.sh +162 -0
- package/scripts/install-python-deps.js +346 -0
- package/scripts/install-python-deps.sh +226 -0
- package/scripts/install.js +512 -0
- package/scripts/install.sh +367 -0
- package/scripts/lib/common.js +490 -0
- package/scripts/lib/common.sh +137 -0
- package/scripts/release-pack.sh +110 -0
- package/scripts/start.js +50 -0
- package/scripts/test_available_models.py +284 -0
- package/scripts/test_websocket_ping.py +44 -0
- package/src/backend/agents.py +73 -0
- package/src/backend/api/__init__.py +1 -0
- package/src/backend/api/agent_config_api.py +90 -0
- package/src/backend/api/agents.py +73 -0
- package/src/backend/api/agents_config.py +75 -0
- package/src/backend/api/chains.py +126 -0
- package/src/backend/api/collaboration.py +902 -0
- package/src/backend/api/debug_paths.py +39 -0
- package/src/backend/api/error_analysis.py +146 -0
- package/src/backend/api/errors.py +281 -0
- package/src/backend/api/performance.py +784 -0
- package/src/backend/api/subagents.py +770 -0
- package/src/backend/api/timeline.py +144 -0
- package/src/backend/api/websocket.py +251 -0
- package/src/backend/collaboration.py +405 -0
- package/src/backend/data/__init__.py +1 -0
- package/src/backend/data/agent_config_manager.py +270 -0
- package/src/backend/data/chain_reader.py +299 -0
- package/src/backend/data/config_reader.py +153 -0
- package/src/backend/data/error_analyzer.py +430 -0
- package/src/backend/data/session_reader.py +445 -0
- package/src/backend/data/subagent_reader.py +244 -0
- package/src/backend/data/task_history.py +118 -0
- package/src/backend/data/timeline_reader.py +981 -0
- package/src/backend/errors.py +63 -0
- package/src/backend/main.py +89 -0
- package/src/backend/mechanism_reader.py +131 -0
- package/src/backend/mechanisms.py +32 -0
- package/src/backend/performance.py +474 -0
- package/src/backend/requirements.txt +5 -0
- package/src/backend/session_reader.py +238 -0
- package/src/backend/status/__init__.py +1 -0
- package/src/backend/status/error_detector.py +122 -0
- package/src/backend/status/status_calculator.py +301 -0
- package/src/backend/status_calculator.py +121 -0
- package/src/backend/subagent_reader.py +229 -0
- package/src/backend/watchers/__init__.py +4 -0
- 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
|