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,770 @@
1
+ """
2
+ Subagent API 路由
3
+ """
4
+ from fastapi import APIRouter
5
+ from pydantic import BaseModel, Field
6
+ from typing import List, Optional, Dict, Any
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ sys.path.append(str(Path(__file__).parent.parent))
11
+
12
+ from data.subagent_reader import (
13
+ load_subagent_runs,
14
+ get_active_runs,
15
+ get_agent_runs,
16
+ get_agent_output_for_run,
17
+ get_agent_files_for_run
18
+ )
19
+ from data.task_history import merge_with_history
20
+ import time
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ class SubagentRun(BaseModel):
26
+ runId: str
27
+ agentId: str
28
+ task: str
29
+ startedAt: int
30
+ startedAtFormatted: str
31
+ endedAt: Optional[int] = None
32
+ endedAtFormatted: Optional[str] = None
33
+ outcome: Optional[str] = None
34
+ runtime: Optional[str] = None
35
+ totalTokens: Optional[int] = None
36
+
37
+
38
+ def format_timestamp(timestamp: int) -> str:
39
+ """格式化时间戳"""
40
+ if not timestamp:
41
+ return ''
42
+ dt = time.localtime(timestamp / 1000)
43
+ return time.strftime('%Y-%m-%d %H:%M:%S', dt)
44
+
45
+
46
+ def calculate_runtime(started_at: int, ended_at: Optional[int]) -> str:
47
+ """计算运行时长"""
48
+ end = ended_at if ended_at else int(time.time() * 1000)
49
+ diff_seconds = (end - started_at) / 1000
50
+
51
+ if diff_seconds < 60:
52
+ return f"{int(diff_seconds)}秒"
53
+ elif diff_seconds < 3600:
54
+ return f"{int(diff_seconds / 60)}分钟"
55
+ else:
56
+ return f"{int(diff_seconds / 3600)}小时"
57
+
58
+
59
+ def parse_agent_id(child_key: str) -> str:
60
+ """从 childSessionKey 解析 agentId"""
61
+ # 格式: agent:devops-agent:subagent:uuid
62
+ parts = child_key.split(':')
63
+ if len(parts) >= 2 and parts[0] == 'agent':
64
+ return parts[1]
65
+ return ''
66
+
67
+
68
+ def extract_outcome(outcome: Any) -> Optional[str]:
69
+ """提取 outcome 字符串"""
70
+ if isinstance(outcome, str):
71
+ return outcome
72
+ if isinstance(outcome, dict):
73
+ return outcome.get('status')
74
+ return None
75
+
76
+
77
+ @router.get("/subagents")
78
+ async def get_subagents():
79
+ """获取当前子代理运行(活跃 + 最近完成)"""
80
+ try:
81
+ all_runs = load_subagent_runs()
82
+
83
+ # 按开始时间倒序,取前20个
84
+ all_runs.sort(key=lambda x: x.get('startedAt', 0), reverse=True)
85
+ recent_runs = all_runs[:20]
86
+
87
+ result = []
88
+ for run in recent_runs:
89
+ agent_id = parse_agent_id(run.get('childSessionKey', ''))
90
+ outcome = run.get('outcome')
91
+
92
+ result.append({
93
+ 'runId': run.get('runId', ''),
94
+ 'agentId': agent_id,
95
+ 'task': run.get('task', ''),
96
+ 'startedAt': run.get('startedAt', 0),
97
+ 'startedAtFormatted': format_timestamp(run.get('startedAt', 0)),
98
+ 'endedAt': run.get('endedAt'),
99
+ 'endedAtFormatted': format_timestamp(run.get('endedAt')) if run.get('endedAt') else None,
100
+ 'outcome': extract_outcome(outcome),
101
+ 'runtime': calculate_runtime(
102
+ run.get('startedAt', 0),
103
+ run.get('endedAt')
104
+ ),
105
+ 'totalTokens': run.get('totalTokens')
106
+ })
107
+
108
+ return result
109
+ except Exception as e:
110
+ print(f"Error in get_subagents: {e}")
111
+ import traceback
112
+ traceback.print_exc()
113
+ return []
114
+
115
+
116
+ @router.get("/subagents/active")
117
+ async def get_active_subagents():
118
+ """获取活跃的子代理运行"""
119
+ try:
120
+ active_runs = get_active_runs()
121
+
122
+ result = []
123
+ for run in active_runs:
124
+ agent_id = parse_agent_id(run.get('childSessionKey', ''))
125
+
126
+ result.append({
127
+ 'runId': run.get('runId', ''),
128
+ 'agentId': agent_id,
129
+ 'task': run.get('task', ''),
130
+ 'startedAt': run.get('startedAt', 0),
131
+ 'startedAtFormatted': format_timestamp(run.get('startedAt', 0)),
132
+ 'endedAt': None,
133
+ 'endedAtFormatted': None,
134
+ 'outcome': None,
135
+ 'runtime': calculate_runtime(run.get('startedAt', 0), None),
136
+ 'totalTokens': run.get('totalTokens')
137
+ })
138
+
139
+ return result
140
+ except Exception as e:
141
+ print(f"Error in get_active_subagents: {e}")
142
+ return []
143
+
144
+
145
+ def _get_agent_name(agent_id: str) -> str:
146
+ """从配置获取 Agent 显示名称"""
147
+ try:
148
+ from data.config_reader import get_agent_config
149
+ config = get_agent_config(agent_id)
150
+ return config.get('name', agent_id) if config else agent_id
151
+ except Exception:
152
+ return agent_id
153
+
154
+
155
+ def _get_agent_workspace(agent_id: str) -> Optional[str]:
156
+ """从配置获取 Agent 工作区路径"""
157
+ if not agent_id:
158
+ return None
159
+ try:
160
+ from data.config_reader import get_agent_config
161
+ config = get_agent_config(agent_id)
162
+ return config.get('workspace') if config else None
163
+ except Exception:
164
+ return None
165
+
166
+
167
+ def _map_run_status(run: Dict[str, Any]) -> str:
168
+ """映射 run 状态为任务状态: pending/running/completed/failed"""
169
+ ended_at = run.get('endedAt')
170
+ if ended_at is None:
171
+ return 'running' # 执行中
172
+
173
+ outcome = run.get('outcome')
174
+ if isinstance(outcome, dict):
175
+ status = outcome.get('status', '')
176
+ if status == 'ok':
177
+ return 'completed'
178
+ if status in ('error', 'failed'):
179
+ return 'failed'
180
+ elif isinstance(outcome, str) and outcome.lower() in ('error', 'failed'):
181
+ return 'failed'
182
+
183
+ return 'completed'
184
+
185
+
186
+ def _extract_task_summary(task_raw: str) -> str:
187
+ """从完整任务文本提取首行摘要(不截断)"""
188
+ if not task_raw or not task_raw.strip():
189
+ return 'Unknown Task'
190
+ lines = [ln.strip() for ln in task_raw.split('\n') if ln.strip()]
191
+ first = lines[0] if lines else task_raw.strip()
192
+ # 去除 markdown 粗体
193
+ if first.startswith('**') and '**' in first[2:]:
194
+ first = first.split('**', 2)[-1].strip()
195
+ return first
196
+
197
+
198
+ def _extract_task_path(task_raw: str) -> str | None:
199
+ """从任务文本提取项目路径"""
200
+ if not task_raw:
201
+ return None
202
+ import re
203
+ # 匹配 **项目路径:** `path` 或 项目路径:path
204
+ m = re.search(r'\*\*项目路径[::]\*\*\s*`([^`]+)`', task_raw)
205
+ if m:
206
+ return m.group(1).strip()
207
+ m = re.search(r'项目路径[::]\s*`?([^`\n]+)`?', task_raw)
208
+ if m:
209
+ return m.group(1).strip()
210
+ return None
211
+
212
+
213
+ def _sanitize_task_display(text: str) -> str:
214
+ """去除任务展示中的 Markdown 符号:** 粗体、路径的 `` 反引号"""
215
+ if not text or not isinstance(text, str):
216
+ return text or ''
217
+ import re
218
+ # 去除 ** 粗体标记
219
+ s = re.sub(r'\*\*', '', text)
220
+ # 去除路径等外层的反引号:`path` -> path
221
+ s = re.sub(r'`([^`]+)`', r'\1', s)
222
+ return s
223
+
224
+
225
+ def _format_error_message(raw: str) -> str:
226
+ """将原始错误信息转为更明确的用户可读描述"""
227
+ if not raw or not isinstance(raw, str):
228
+ return '未知'
229
+ raw = raw.strip().lower()
230
+ mapping = {
231
+ 'terminated': '任务被终止(可能是超时或被用户取消)',
232
+ 'timeout': '任务执行超时',
233
+ 'cancelled': '任务已取消',
234
+ 'canceled': '任务已取消',
235
+ 'killed': '任务被终止',
236
+ 'subagent-error': '子任务执行异常',
237
+ 'error': '执行出错',
238
+ }
239
+ for key, desc in mapping.items():
240
+ if key in raw:
241
+ return desc
242
+ return raw.strip() or '未知'
243
+
244
+
245
+ def _get_session_message_count(child_session_key: str) -> int:
246
+ """
247
+ 从子 Agent 的 session 文件中统计消息数量。
248
+ 用于估算任务进度。
249
+
250
+ Args:
251
+ child_session_key: 格式 agent:<agentId>:subagent:<uuid>
252
+
253
+ Returns:
254
+ 消息数量,若无法获取则返回 0
255
+ """
256
+ if not child_session_key or ':' not in child_session_key:
257
+ return 0
258
+ parts = child_session_key.split(':')
259
+ if len(parts) < 2 or parts[0] != 'agent':
260
+ return 0
261
+ agent_id = parts[1]
262
+
263
+ try:
264
+ from data.config_reader import get_openclaw_root
265
+ openclaw_path = get_openclaw_root()
266
+ sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
267
+ if not sessions_index.exists():
268
+ return 0
269
+
270
+ with open(sessions_index, 'r', encoding='utf-8') as f:
271
+ index_data = json.load(f)
272
+ entry = index_data.get(child_session_key)
273
+ if not entry:
274
+ return 0
275
+ session_file = entry.get('sessionFile')
276
+ session_id = entry.get('sessionId')
277
+ if not session_file and not session_id:
278
+ return 0
279
+ if not session_file:
280
+ sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
281
+ session_file = str(sessions_dir / f"{session_id}.jsonl")
282
+
283
+ session_path = Path(session_file)
284
+ if not session_path.exists():
285
+ return 0
286
+
287
+ count = 0
288
+ with open(session_path, 'r', encoding='utf-8') as f:
289
+ for line in f:
290
+ try:
291
+ data = json.loads(line)
292
+ if data.get('type') == 'message':
293
+ count += 1
294
+ except json.JSONDecodeError:
295
+ continue
296
+ return count
297
+ except Exception as e:
298
+ print(f"_get_session_message_count 失败: {e}")
299
+ return 0
300
+
301
+
302
+ def _calculate_progress(run: Dict[str, Any]) -> int:
303
+ """
304
+ 计算任务的真实进度(0-100)。
305
+
306
+ 进度估算逻辑:
307
+ - 已完成任务: 100%
308
+ - 运行中任务: 基于会话消息数量估算
309
+ - 0-5 条消息: 20%
310
+ - 6-15 条消息: 40%
311
+ - 16-30 条消息: 60%
312
+ - 31+ 条消息: 80%
313
+ - 最大 80%(运行中的任务不超过 80%)
314
+
315
+ Args:
316
+ run: 运行记录
317
+
318
+ Returns:
319
+ 进度百分比 (0-100)
320
+ """
321
+ # 已完成任务直接返回 100
322
+ if run.get('endedAt'):
323
+ return 100
324
+
325
+ # 运行中任务,基于消息数量估算
326
+ child_key = run.get('childSessionKey', '')
327
+ if not child_key:
328
+ return 10 # 无法获取 session 时,给一个基础进度
329
+
330
+ msg_count = _get_session_message_count(child_key)
331
+
332
+ if msg_count <= 5:
333
+ return 20
334
+ elif msg_count <= 15:
335
+ return 40
336
+ elif msg_count <= 30:
337
+ return 60
338
+ else:
339
+ return 80
340
+
341
+
342
+ def _extract_subtasks_from_session(child_session_key: str) -> List[Dict[str, Any]]:
343
+ """
344
+ 从子 Agent 的 session 文件中提取嵌套的子任务(subagent 调用)。
345
+
346
+ Args:
347
+ child_session_key: 格式 agent:<agentId>:subagent:<uuid>
348
+
349
+ Returns:
350
+ 子任务列表,每个包含: {task, agentId, status}
351
+ """
352
+ if not child_session_key or ':' not in child_session_key:
353
+ return []
354
+ parts = child_session_key.split(':')
355
+ if len(parts) < 2 or parts[0] != 'agent':
356
+ return []
357
+ agent_id = parts[1]
358
+
359
+ try:
360
+ from data.config_reader import get_openclaw_root
361
+ openclaw_path = get_openclaw_root()
362
+ sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
363
+ if not sessions_index.exists():
364
+ return []
365
+
366
+ with open(sessions_index, 'r', encoding='utf-8') as f:
367
+ index_data = json.load(f)
368
+ entry = index_data.get(child_session_key)
369
+ if not entry:
370
+ return []
371
+ session_file = entry.get('sessionFile')
372
+ session_id = entry.get('sessionId')
373
+ if not session_file and not session_id:
374
+ return []
375
+ if not session_file:
376
+ sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
377
+ session_file = str(sessions_dir / f"{session_id}.jsonl")
378
+
379
+ session_path = Path(session_file)
380
+ if not session_path.exists():
381
+ return []
382
+
383
+ subtasks: List[Dict[str, Any]] = []
384
+ seen_tasks = set()
385
+
386
+ with open(session_path, 'r', encoding='utf-8') as f:
387
+ for line in f:
388
+ try:
389
+ data = json.loads(line)
390
+ if data.get('type') != 'message':
391
+ continue
392
+ msg = data.get('message', {})
393
+ if msg.get('role') != 'assistant':
394
+ continue
395
+ content = msg.get('content', [])
396
+ for c in content:
397
+ if not isinstance(c, dict) or c.get('type') != 'toolCall':
398
+ continue
399
+ name = c.get('name', '')
400
+ if name not in ('subagent', 'delegate', 'spawn'):
401
+ continue
402
+ args = c.get('arguments', {})
403
+ if isinstance(args, str):
404
+ try:
405
+ args = json.loads(args)
406
+ except json.JSONDecodeError:
407
+ continue
408
+ # 提取子任务信息
409
+ task_desc = args.get('task') or args.get('prompt') or args.get('instruction', '')
410
+ sub_agent_id = args.get('agentId') or args.get('agent') or args.get('agent_id', '')
411
+ if task_desc and task_desc not in seen_tasks:
412
+ seen_tasks.add(task_desc)
413
+ subtasks.append({
414
+ 'task': task_desc[:200] if len(task_desc) > 200 else task_desc,
415
+ 'agentId': sub_agent_id,
416
+ 'status': 'unknown' # 无法从 session 确定状态
417
+ })
418
+ except (json.JSONDecodeError, KeyError):
419
+ continue
420
+
421
+ return subtasks[:5] # 最多返回 5 个子任务
422
+ except Exception as e:
423
+ print(f"_extract_subtasks_from_session 失败: {e}")
424
+ return []
425
+
426
+
427
+ def _run_to_task(run: Dict[str, Any]) -> Dict[str, Any]:
428
+ """将 run 转为任务展示格式"""
429
+ agent_id = parse_agent_id(run.get('childSessionKey', ''))
430
+ outcome = run.get('outcome')
431
+ status = _map_run_status(run)
432
+ task_raw = run.get('task', 'Unknown Task')
433
+ task_name = _extract_task_summary(task_raw)
434
+ task_path = _extract_task_path(task_raw)
435
+ progress = _calculate_progress(run)
436
+ error_msg = None
437
+ if status == 'failed':
438
+ if isinstance(outcome, dict):
439
+ raw_err = outcome.get('error', outcome.get('message', outcome.get('reason', '任务失败')))
440
+ error_msg = _format_error_message(str(raw_err)) if raw_err else '任务失败'
441
+ elif isinstance(outcome, str):
442
+ error_msg = _format_error_message(outcome) if outcome.strip() else '任务失败'
443
+ task_display = task_raw if isinstance(task_raw, str) else str(task_raw)
444
+ task_display = _sanitize_task_display(task_display)
445
+ task_name = _sanitize_task_display(task_name)
446
+ result: Dict[str, Any] = {
447
+ 'id': run.get('runId', ''),
448
+ 'name': task_name,
449
+ 'task': task_display,
450
+ 'status': status,
451
+ 'progress': progress,
452
+ 'startTime': run.get('startedAt'),
453
+ 'endTime': run.get('endedAt'),
454
+ 'agentId': agent_id,
455
+ 'agentName': _get_agent_name(agent_id),
456
+ 'agentWorkspace': _get_agent_workspace(agent_id),
457
+ 'error': error_msg,
458
+ 'childSessionKey': run.get('childSessionKey')
459
+ }
460
+ if task_path:
461
+ result['taskPath'] = task_path
462
+ # 从 session 提取子任务(嵌套 subagent 调用)
463
+ child_key = run.get('childSessionKey', '')
464
+ if child_key:
465
+ subtasks = _extract_subtasks_from_session(child_key)
466
+ if subtasks:
467
+ result['subtasks'] = subtasks
468
+ # 任务成功时,从 session 提取 Agent 输出和生成的文件
469
+ if status == 'completed' and child_key:
470
+ output = get_agent_output_for_run(child_key)
471
+ if output:
472
+ result['output'] = output
473
+ files = get_agent_files_for_run(child_key)
474
+ if files:
475
+ result['generatedFiles'] = files
476
+ return result
477
+
478
+
479
+ @router.get("/tasks")
480
+ async def get_tasks():
481
+ """获取任务列表 - 合并 runs.json 与持久化历史,确保已完成任务不丢失"""
482
+ try:
483
+ all_runs = load_subagent_runs()
484
+ all_runs.sort(key=lambda x: x.get('startedAt', 0), reverse=True)
485
+
486
+ tasks = merge_with_history(all_runs, _run_to_task)
487
+ # 对历史任务补充缺失字段
488
+ for t in tasks:
489
+ if t.get('status') == 'completed' and t.get('childSessionKey'):
490
+ if not t.get('output'):
491
+ output = get_agent_output_for_run(t['childSessionKey'])
492
+ if output:
493
+ t['output'] = output
494
+ if not t.get('generatedFiles'):
495
+ files = get_agent_files_for_run(t['childSessionKey'])
496
+ if files:
497
+ t['generatedFiles'] = files
498
+ if not t.get('agentWorkspace') and t.get('agentId'):
499
+ t['agentWorkspace'] = _get_agent_workspace(t['agentId'])
500
+ return {'tasks': tasks}
501
+ except Exception as e:
502
+ print(f"Error in get_tasks: {e}")
503
+ import traceback
504
+ traceback.print_exc()
505
+ return {'tasks': []}
506
+
507
+
508
+ def _extract_timeline_from_session(child_session_key: str) -> List[Dict[str, Any]]:
509
+ """
510
+ 从 session 文件中提取任务执行时间线。
511
+
512
+ Args:
513
+ child_session_key: 格式 agent:<agentId>:subagent:<uuid>
514
+
515
+ Returns:
516
+ 时间线事件列表,每个包含: {time, type, description}
517
+ """
518
+ if not child_session_key or ':' not in child_session_key:
519
+ return []
520
+ parts = child_session_key.split(':')
521
+ if len(parts) < 2 or parts[0] != 'agent':
522
+ return []
523
+ agent_id = parts[1]
524
+
525
+ try:
526
+ from data.config_reader import get_openclaw_root
527
+ openclaw_path = get_openclaw_root()
528
+ sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
529
+ if not sessions_index.exists():
530
+ return []
531
+
532
+ with open(sessions_index, 'r', encoding='utf-8') as f:
533
+ index_data = json.load(f)
534
+ entry = index_data.get(child_session_key)
535
+ if not entry:
536
+ return []
537
+ session_file = entry.get('sessionFile')
538
+ session_id = entry.get('sessionId')
539
+ if not session_file and not session_id:
540
+ return []
541
+ if not session_file:
542
+ sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
543
+ session_file = str(sessions_dir / f"{session_id}.jsonl")
544
+
545
+ session_path = Path(session_file)
546
+ if not session_path.exists():
547
+ return []
548
+
549
+ timeline: List[Dict[str, Any]] = []
550
+ event_count = 0
551
+ max_events = 50 # 限制最大事件数
552
+
553
+ with open(session_path, 'r', encoding='utf-8') as f:
554
+ for line in f:
555
+ if event_count >= max_events:
556
+ break
557
+ try:
558
+ data = json.loads(line)
559
+ ts = data.get('timestamp')
560
+ # 确保 ts 是整数毫秒时间戳
561
+ if isinstance(ts, str):
562
+ # ISO 格式转毫秒时间戳
563
+ try:
564
+ from datetime import datetime
565
+ # 处理 ISO 格式:2026-03-07T04:07:25.262Z
566
+ if 'T' in ts:
567
+ dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
568
+ ts = int(dt.timestamp() * 1000)
569
+ else:
570
+ ts = int(ts)
571
+ except (ValueError, TypeError):
572
+ ts = 0
573
+ elif isinstance(ts, (int, float)):
574
+ ts = int(ts)
575
+ else:
576
+ ts = 0
577
+
578
+ if data.get('type') == 'message':
579
+ msg = data.get('message', {})
580
+ role = msg.get('role', '')
581
+ content = msg.get('content', [])
582
+
583
+ if role == 'user':
584
+ # 用户消息(任务开始)
585
+ for c in content:
586
+ if isinstance(c, dict) and c.get('type') == 'text':
587
+ text = c.get('text', '')[:100]
588
+ timeline.append({
589
+ 'time': ts,
590
+ 'type': 'start',
591
+ 'description': f'收到任务: {text}...' if len(c.get('text', '')) > 100 else f'收到任务: {text}'
592
+ })
593
+ event_count += 1
594
+ break
595
+
596
+ elif role == 'assistant':
597
+ # 助手响应中的工具调用
598
+ for c in content:
599
+ if not isinstance(c, dict):
600
+ continue
601
+ if c.get('type') == 'toolCall':
602
+ tool_name = c.get('name', 'unknown')
603
+ args = c.get('arguments', {})
604
+ if isinstance(args, str):
605
+ try:
606
+ args = json.loads(args)
607
+ except json.JSONDecodeError:
608
+ args = {}
609
+
610
+ # 生成描述
611
+ desc = _describe_tool_call(tool_name, args)
612
+ timeline.append({
613
+ 'time': ts,
614
+ 'type': 'tool',
615
+ 'tool': tool_name,
616
+ 'description': desc
617
+ })
618
+ event_count += 1
619
+
620
+ elif c.get('type') == 'text':
621
+ # 文本响应(可能是最终答案)
622
+ text = c.get('text', '')
623
+ if text.strip() and len(text) > 50:
624
+ # 简单判断是否是最终答案
625
+ keywords = ['完成', '成功', 'finished', 'done', 'result', '总结']
626
+ if any(kw in text.lower() for kw in keywords):
627
+ timeline.append({
628
+ 'time': ts,
629
+ 'type': 'response',
630
+ 'description': f'输出结果 ({len(text)} 字符)'
631
+ })
632
+ event_count += 1
633
+
634
+ except (json.JSONDecodeError, KeyError):
635
+ continue
636
+
637
+ return timeline
638
+ except Exception as e:
639
+ print(f"_extract_timeline_from_session 失败: {e}")
640
+ return []
641
+
642
+
643
+ def _describe_tool_call(tool_name: str, args: Dict[str, Any]) -> str:
644
+ """生成工具调用的可读描述"""
645
+ tool_descriptions = {
646
+ 'read': '读取文件',
647
+ 'write': '写入文件',
648
+ 'edit': '编辑文件',
649
+ 'bash': '执行命令',
650
+ 'grep': '搜索内容',
651
+ 'glob': '查找文件',
652
+ 'webfetch': '获取网页',
653
+ 'websearch': '网络搜索',
654
+ 'subagent': '派发子任务',
655
+ 'delegate': '委托任务',
656
+ }
657
+
658
+ base_desc = tool_descriptions.get(tool_name.lower(), f'调用 {tool_name}')
659
+
660
+ # 添加更多上下文
661
+ if tool_name.lower() in ('read', 'write', 'edit'):
662
+ path = args.get('path') or args.get('file_path', '')
663
+ if path:
664
+ filename = Path(path).name if isinstance(path, str) else str(path)
665
+ return f'{base_desc}: {filename}'
666
+ elif tool_name.lower() == 'bash':
667
+ cmd = args.get('command', '') or args.get('cmd', '')
668
+ if cmd:
669
+ cmd_short = cmd[:50] + '...' if len(cmd) > 50 else cmd
670
+ return f'{base_desc}: {cmd_short}'
671
+ elif tool_name.lower() in ('subagent', 'delegate'):
672
+ task = args.get('task', '') or args.get('prompt', '')
673
+ if task:
674
+ task_short = task[:50] + '...' if len(task) > 50 else task
675
+ return f'{base_desc}: {task_short}'
676
+
677
+ return base_desc
678
+
679
+
680
+ @router.get("/tasks/{run_id}/timeline")
681
+ async def get_task_timeline(run_id: str):
682
+ """
683
+ 获取任务执行时间线。
684
+
685
+ Args:
686
+ run_id: 任务运行 ID
687
+
688
+ Returns:
689
+ 时间线事件列表
690
+ """
691
+ try:
692
+ # 从 runs.json 查找对应的 session key
693
+ all_runs = load_subagent_runs()
694
+ target_run = None
695
+ for run in all_runs:
696
+ if run.get('runId') == run_id:
697
+ target_run = run
698
+ break
699
+
700
+ if not target_run:
701
+ # 尝试从历史记录查找
702
+ from data.task_history import load_task_history
703
+ history = load_task_history()
704
+ for record in history:
705
+ if record.get('id') == run_id:
706
+ target_run = record
707
+ break
708
+
709
+ if not target_run:
710
+ return {'timeline': [], 'error': 'Task not found'}
711
+
712
+ child_key = target_run.get('childSessionKey', '')
713
+ if not child_key:
714
+ return {'timeline': [], 'error': 'No session key'}
715
+
716
+ timeline = _extract_timeline_from_session(child_key)
717
+
718
+ # 添加任务开始和结束事件
719
+ # 兼容两种字段名:startedAt (runs.json) 和 startTime (task_history)
720
+ started_at = target_run.get('startedAt') or target_run.get('startTime')
721
+ ended_at = target_run.get('endedAt') or target_run.get('endTime')
722
+ outcome = target_run.get('outcome')
723
+
724
+ if started_at:
725
+ timeline.insert(0, {
726
+ 'time': started_at,
727
+ 'type': 'created',
728
+ 'description': '任务创建'
729
+ })
730
+
731
+ if ended_at:
732
+ if isinstance(outcome, dict) and outcome.get('status') == 'ok':
733
+ end_type = 'completed'
734
+ end_desc = '任务完成'
735
+ elif isinstance(outcome, dict) and outcome.get('status') in ('error', 'failed'):
736
+ end_type = 'failed'
737
+ err_msg = outcome.get('error') or outcome.get('message') or '任务失败'
738
+ end_desc = f'任务失败: {err_msg}'
739
+ elif isinstance(outcome, str) and outcome.lower() in ('error', 'failed'):
740
+ end_type = 'failed'
741
+ end_desc = f'任务失败: {outcome}'
742
+ else:
743
+ end_type = 'completed'
744
+ end_desc = '任务结束'
745
+
746
+ timeline.append({
747
+ 'time': ended_at,
748
+ 'type': end_type,
749
+ 'description': end_desc
750
+ })
751
+
752
+ # 按时间排序(确保 time 是数字)
753
+ def get_sort_time(x):
754
+ t = x.get('time', 0)
755
+ if isinstance(t, (int, float)):
756
+ return int(t)
757
+ elif isinstance(t, str):
758
+ try:
759
+ return int(t)
760
+ except ValueError:
761
+ return 0
762
+ return 0
763
+ timeline.sort(key=get_sort_time)
764
+
765
+ return {'timeline': timeline, 'runId': run_id}
766
+ except Exception as e:
767
+ print(f"Error in get_task_timeline: {e}")
768
+ import traceback
769
+ traceback.print_exc()
770
+ return {'timeline': [], 'error': str(e)}