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,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
状态计算器 - 计算 Agent 状态
|
|
3
|
+
"""
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
sys.path.append(str(Path(__file__).parent.parent))
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import Literal
|
|
10
|
+
from data.config_reader import get_agents_list, get_agent_config
|
|
11
|
+
from data.subagent_reader import is_agent_working, get_agent_runs
|
|
12
|
+
from data.session_reader import (
|
|
13
|
+
has_recent_errors,
|
|
14
|
+
get_last_error,
|
|
15
|
+
has_recent_session_activity,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
AgentStatus = Literal['idle', 'working', 'down']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def calculate_agent_status(agent_id: str) -> AgentStatus:
|
|
23
|
+
"""
|
|
24
|
+
计算 Agent 状态(基于 runs.json + sessions.json)
|
|
25
|
+
|
|
26
|
+
优先级:
|
|
27
|
+
1. 异常 (down) - 最近5分钟有 stopReason=error
|
|
28
|
+
2. 工作中 (working) - 有活跃 subagent run 或 session 正在处理(最近5分钟有活动)
|
|
29
|
+
3. 空闲 (idle) - 无活跃 run 且最近5分钟无 session 活动
|
|
30
|
+
"""
|
|
31
|
+
# 检查异常
|
|
32
|
+
if has_recent_errors(agent_id, minutes=5):
|
|
33
|
+
return 'down'
|
|
34
|
+
|
|
35
|
+
# 检查工作中:subagent run 未结束,或 session 最近有活动
|
|
36
|
+
if is_agent_working(agent_id):
|
|
37
|
+
return 'working'
|
|
38
|
+
if has_recent_session_activity(agent_id, minutes=2):
|
|
39
|
+
return 'working'
|
|
40
|
+
|
|
41
|
+
# 默认空闲
|
|
42
|
+
return 'idle'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_agents_with_status() -> list:
|
|
46
|
+
"""获取所有 Agent 及其状态"""
|
|
47
|
+
agents = get_agents_list()
|
|
48
|
+
result = []
|
|
49
|
+
|
|
50
|
+
for agent in agents:
|
|
51
|
+
agent_id = agent.get('id')
|
|
52
|
+
status = calculate_agent_status(agent_id)
|
|
53
|
+
|
|
54
|
+
# 获取当前任务
|
|
55
|
+
current_task = get_current_task(agent_id)
|
|
56
|
+
|
|
57
|
+
# 获取最后活跃时间
|
|
58
|
+
last_active = get_last_active_time(agent_id)
|
|
59
|
+
|
|
60
|
+
# 获取错误信息
|
|
61
|
+
last_error = get_last_error(agent_id) if status == 'down' else None
|
|
62
|
+
|
|
63
|
+
result.append({
|
|
64
|
+
'id': agent_id,
|
|
65
|
+
'name': agent.get('name'),
|
|
66
|
+
'role': agent.get('name'),
|
|
67
|
+
'status': status,
|
|
68
|
+
'currentTask': current_task,
|
|
69
|
+
'lastActiveAt': last_active,
|
|
70
|
+
'error': last_error
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_current_task(agent_id: str) -> str:
|
|
77
|
+
"""获取 Agent 当前任务"""
|
|
78
|
+
runs = get_agent_runs(agent_id, limit=1)
|
|
79
|
+
if not runs:
|
|
80
|
+
return ''
|
|
81
|
+
|
|
82
|
+
run = runs[0]
|
|
83
|
+
task = run.get('task', '')
|
|
84
|
+
|
|
85
|
+
# 截取前60个字符(统一长度)
|
|
86
|
+
if len(task) > 60:
|
|
87
|
+
task = task[:57] + '...'
|
|
88
|
+
|
|
89
|
+
return task
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_last_active_time(agent_id: str) -> int:
|
|
93
|
+
"""获取 Agent 最后活跃时间(runs 或 sessions.json updatedAt)"""
|
|
94
|
+
from data.session_reader import get_session_updated_at
|
|
95
|
+
|
|
96
|
+
runs = get_agent_runs(agent_id, limit=1)
|
|
97
|
+
run_ts = 0
|
|
98
|
+
if runs:
|
|
99
|
+
run = runs[0]
|
|
100
|
+
run_ts = run.get('endedAt') or run.get('startedAt', 0)
|
|
101
|
+
|
|
102
|
+
session_ts = get_session_updated_at(agent_id)
|
|
103
|
+
return max(run_ts, session_ts)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def format_last_active(timestamp: int) -> str:
|
|
107
|
+
"""格式化最后活跃时间为相对时间"""
|
|
108
|
+
if not timestamp:
|
|
109
|
+
return '未知'
|
|
110
|
+
|
|
111
|
+
now = int(time.time() * 1000)
|
|
112
|
+
diff_seconds = (now - timestamp) / 1000
|
|
113
|
+
|
|
114
|
+
if diff_seconds < 60:
|
|
115
|
+
return f"{int(diff_seconds)}秒前"
|
|
116
|
+
elif diff_seconds < 3600:
|
|
117
|
+
return f"{int(diff_seconds / 60)}分钟前"
|
|
118
|
+
elif diff_seconds < 86400:
|
|
119
|
+
return f"{int(diff_seconds / 3600)}小时前"
|
|
120
|
+
else:
|
|
121
|
+
return f"{int(diff_seconds / 86400)}天前"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
子代理运行读取器 - 读取 subagents/runs.json
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Dict, Any, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _openclaw_home() -> Path:
|
|
11
|
+
"""OpenClaw 根目录,优先使用 OPENCLAW_HOME 环境变量"""
|
|
12
|
+
env = os.environ.get("OPENCLAW_HOME")
|
|
13
|
+
if env:
|
|
14
|
+
p = Path(env).expanduser()
|
|
15
|
+
if p.exists():
|
|
16
|
+
return p
|
|
17
|
+
return Path.home() / ".openclaw"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SUBAGENTS_RUNS_PATH = _openclaw_home() / "subagents" / "runs.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_subagent_runs() -> List[Dict[str, Any]]:
|
|
24
|
+
"""加载子代理运行记录
|
|
25
|
+
|
|
26
|
+
OpenClaw runs.json 格式: {"version": 2, "runs": { runId: record }}
|
|
27
|
+
"""
|
|
28
|
+
if not SUBAGENTS_RUNS_PATH.exists():
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
with open(SUBAGENTS_RUNS_PATH, 'r', encoding='utf-8') as f:
|
|
32
|
+
data = json.load(f)
|
|
33
|
+
|
|
34
|
+
runs = data.get('runs', {})
|
|
35
|
+
if isinstance(runs, dict):
|
|
36
|
+
return list(runs.values())
|
|
37
|
+
return runs if isinstance(runs, list) else []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_active_runs() -> List[Dict[str, Any]]:
|
|
41
|
+
"""获取活跃的运行(未结束)"""
|
|
42
|
+
runs = load_subagent_runs()
|
|
43
|
+
return [run for run in runs if run.get('endedAt') is None]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_agent_runs(agent_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
47
|
+
"""获取指定 Agent 的运行记录"""
|
|
48
|
+
runs = load_subagent_runs()
|
|
49
|
+
agent_runs = []
|
|
50
|
+
|
|
51
|
+
for run in runs:
|
|
52
|
+
child_key = run.get('childSessionKey', '')
|
|
53
|
+
if f'agent:{agent_id}:' in child_key:
|
|
54
|
+
agent_runs.append(run)
|
|
55
|
+
|
|
56
|
+
# 按开始时间倒序
|
|
57
|
+
agent_runs.sort(key=lambda x: x.get('startedAt', 0), reverse=True)
|
|
58
|
+
return agent_runs[:limit]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_agent_working(agent_id: str) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
判断 Agent 是否在工作中
|
|
64
|
+
- 作为执行者:childSessionKey 包含 agent:{agent_id}:
|
|
65
|
+
- 作为派发者:requesterSessionKey 包含 agent:{agent_id}:(主 Agent 等待子 Agent 完成)
|
|
66
|
+
"""
|
|
67
|
+
active_runs = get_active_runs()
|
|
68
|
+
for run in active_runs:
|
|
69
|
+
child_key = run.get('childSessionKey', '')
|
|
70
|
+
requester_key = run.get('requesterSessionKey', '')
|
|
71
|
+
if f'agent:{agent_id}:' in child_key:
|
|
72
|
+
return True
|
|
73
|
+
if f'agent:{agent_id}:' in requester_key:
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_agent_output_for_run(child_session_key: str, max_chars: int = 10000) -> Optional[str]:
|
|
79
|
+
"""
|
|
80
|
+
从子 Agent 的 session 文件中提取最后一次 assistant 消息的文本输出。
|
|
81
|
+
用于任务成功时展示 Agent 的执行结果。
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
child_session_key: 格式 agent:<agentId>:subagent:<uuid>
|
|
85
|
+
max_chars: 最大返回字符数,超出则截断
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Agent 的文本输出,若无法获取则返回 None
|
|
89
|
+
"""
|
|
90
|
+
if not child_session_key or ':' not in child_session_key:
|
|
91
|
+
return None
|
|
92
|
+
parts = child_session_key.split(':')
|
|
93
|
+
if len(parts) < 2 or parts[0] != 'agent':
|
|
94
|
+
return None
|
|
95
|
+
agent_id = parts[1]
|
|
96
|
+
|
|
97
|
+
openclaw_path = _openclaw_home()
|
|
98
|
+
sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
|
|
99
|
+
if not sessions_index.exists():
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
with open(sessions_index, 'r', encoding='utf-8') as f:
|
|
104
|
+
index_data = json.load(f)
|
|
105
|
+
entry = index_data.get(child_session_key)
|
|
106
|
+
if not entry:
|
|
107
|
+
return None
|
|
108
|
+
session_file = entry.get('sessionFile')
|
|
109
|
+
session_id = entry.get('sessionId')
|
|
110
|
+
if not session_file and not session_id:
|
|
111
|
+
return None
|
|
112
|
+
if not session_file:
|
|
113
|
+
sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
|
|
114
|
+
session_file = str(sessions_dir / f"{session_id}.jsonl")
|
|
115
|
+
|
|
116
|
+
session_path = Path(session_file)
|
|
117
|
+
if not session_path.exists():
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
last_text = None
|
|
121
|
+
with open(session_path, 'r', encoding='utf-8') as f:
|
|
122
|
+
for line in f:
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(line)
|
|
125
|
+
if data.get('type') != 'message':
|
|
126
|
+
continue
|
|
127
|
+
msg = data.get('message', {})
|
|
128
|
+
if msg.get('role') != 'assistant':
|
|
129
|
+
continue
|
|
130
|
+
content = msg.get('content', [])
|
|
131
|
+
for c in content:
|
|
132
|
+
if isinstance(c, dict) and c.get('type') == 'text':
|
|
133
|
+
text = c.get('text', '')
|
|
134
|
+
if text and text.strip():
|
|
135
|
+
last_text = text
|
|
136
|
+
break
|
|
137
|
+
except (json.JSONDecodeError, KeyError):
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if not last_text or not last_text.strip():
|
|
141
|
+
return None
|
|
142
|
+
if len(last_text) > max_chars:
|
|
143
|
+
return last_text[:max_chars] + '\n\n...(输出已截断)'
|
|
144
|
+
return last_text
|
|
145
|
+
except Exception as e:
|
|
146
|
+
print(f"get_agent_output_for_run 失败: {e}")
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_agent_files_for_run(child_session_key: str) -> List[str]:
|
|
151
|
+
"""
|
|
152
|
+
从子 Agent 的 session 中提取本次任务生成/修改的文件路径。
|
|
153
|
+
解析 write、edit 等工具调用中的 path 参数。
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
去重后的文件路径列表
|
|
157
|
+
"""
|
|
158
|
+
if not child_session_key or ':' not in child_session_key:
|
|
159
|
+
return []
|
|
160
|
+
parts = child_session_key.split(':')
|
|
161
|
+
if len(parts) < 2 or parts[0] != 'agent':
|
|
162
|
+
return []
|
|
163
|
+
agent_id = parts[1]
|
|
164
|
+
|
|
165
|
+
openclaw_path = _openclaw_home()
|
|
166
|
+
sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
|
|
167
|
+
if not sessions_index.exists():
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
with open(sessions_index, 'r', encoding='utf-8') as f:
|
|
172
|
+
index_data = json.load(f)
|
|
173
|
+
entry = index_data.get(child_session_key)
|
|
174
|
+
if not entry:
|
|
175
|
+
return []
|
|
176
|
+
session_file = entry.get('sessionFile')
|
|
177
|
+
session_id = entry.get('sessionId')
|
|
178
|
+
if not session_file and not session_id:
|
|
179
|
+
return []
|
|
180
|
+
if not session_file:
|
|
181
|
+
sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
|
|
182
|
+
session_file = str(sessions_dir / f"{session_id}.jsonl")
|
|
183
|
+
|
|
184
|
+
session_path = Path(session_file)
|
|
185
|
+
if not session_path.exists():
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
file_paths: List[str] = []
|
|
189
|
+
file_tools = ('write', 'edit')
|
|
190
|
+
|
|
191
|
+
with open(session_path, 'r', encoding='utf-8') as f:
|
|
192
|
+
for line in f:
|
|
193
|
+
try:
|
|
194
|
+
data = json.loads(line)
|
|
195
|
+
if data.get('type') != 'message':
|
|
196
|
+
continue
|
|
197
|
+
msg = data.get('message', {})
|
|
198
|
+
if msg.get('role') != 'assistant':
|
|
199
|
+
continue
|
|
200
|
+
content = msg.get('content', [])
|
|
201
|
+
for c in content:
|
|
202
|
+
if not isinstance(c, dict) or c.get('type') != 'toolCall':
|
|
203
|
+
continue
|
|
204
|
+
name = c.get('name', '')
|
|
205
|
+
if name not in file_tools:
|
|
206
|
+
continue
|
|
207
|
+
args = c.get('arguments', {})
|
|
208
|
+
if isinstance(args, str):
|
|
209
|
+
try:
|
|
210
|
+
args = json.loads(args)
|
|
211
|
+
except json.JSONDecodeError:
|
|
212
|
+
continue
|
|
213
|
+
path = args.get('path') or args.get('file_path')
|
|
214
|
+
if path and isinstance(path, str) and path.strip():
|
|
215
|
+
file_paths.append(path.strip())
|
|
216
|
+
except (json.JSONDecodeError, KeyError):
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# 去重并保持顺序
|
|
220
|
+
seen = set()
|
|
221
|
+
result = []
|
|
222
|
+
for p in file_paths:
|
|
223
|
+
if p not in seen:
|
|
224
|
+
seen.add(p)
|
|
225
|
+
result.append(p)
|
|
226
|
+
return result
|
|
227
|
+
except Exception as e:
|
|
228
|
+
print(f"get_agent_files_for_run 失败: {e}")
|
|
229
|
+
return []
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
文件变更监听 - 关键文件变更时触发 WebSocket 推送
|
|
3
|
+
使用 watchdog 监听 runs.json、sessions/*.jsonl、task_history.json、model-failures.log
|
|
4
|
+
"""
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_openclaw_dir() -> Path:
|
|
12
|
+
from data.config_reader import get_openclaw_root
|
|
13
|
+
return get_openclaw_root()
|
|
14
|
+
DEBOUNCE_SECONDS = 0.3 # 同一文件短时间多次变更只触发一次
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_watch_dirs() -> list[tuple[Path, bool]]:
|
|
18
|
+
"""获取需要监听的目录列表 (path, recursive)"""
|
|
19
|
+
dirs: list[tuple[Path, bool]] = []
|
|
20
|
+
openclaw_dir = _get_openclaw_dir()
|
|
21
|
+
subagents = openclaw_dir / "subagents"
|
|
22
|
+
if subagents.exists():
|
|
23
|
+
dirs.append((subagents, False))
|
|
24
|
+
try:
|
|
25
|
+
from data.task_history import get_dashboard_data_dir
|
|
26
|
+
dashboard_data = get_dashboard_data_dir()
|
|
27
|
+
if dashboard_data.exists():
|
|
28
|
+
dirs.append((dashboard_data, False))
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
# workspace/*/memory: 从配置读取,或回退到 workspace-main
|
|
32
|
+
try:
|
|
33
|
+
from data.config_reader import get_workspace_paths
|
|
34
|
+
for ws in get_workspace_paths():
|
|
35
|
+
memory = ws / "memory"
|
|
36
|
+
if memory.exists():
|
|
37
|
+
dirs.append((memory, False))
|
|
38
|
+
except Exception:
|
|
39
|
+
memory = openclaw_dir / "workspace-main" / "memory"
|
|
40
|
+
if memory.exists():
|
|
41
|
+
dirs.append((memory, False))
|
|
42
|
+
agents_dir = openclaw_dir / "agents"
|
|
43
|
+
if agents_dir.exists():
|
|
44
|
+
for agent_dir in agents_dir.iterdir():
|
|
45
|
+
if agent_dir.is_dir():
|
|
46
|
+
sessions_dir = agent_dir / "sessions"
|
|
47
|
+
if sessions_dir.exists():
|
|
48
|
+
dirs.append((sessions_dir, True))
|
|
49
|
+
return dirs
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DebouncedHandler:
|
|
53
|
+
"""防抖:短时间多次变更只触发一次回调"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, callback: Callable[[], None], debounce_sec: float = DEBOUNCE_SECONDS):
|
|
56
|
+
self.callback = callback
|
|
57
|
+
self.debounce_sec = debounce_sec
|
|
58
|
+
self._last_trigger: float = 0
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
self._timer: Optional[threading.Timer] = None
|
|
61
|
+
|
|
62
|
+
def trigger(self) -> None:
|
|
63
|
+
with self._lock:
|
|
64
|
+
now = time.monotonic()
|
|
65
|
+
if self._timer:
|
|
66
|
+
self._timer.cancel()
|
|
67
|
+
self._timer = None
|
|
68
|
+
|
|
69
|
+
def do_callback() -> None:
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._last_trigger = time.monotonic()
|
|
72
|
+
self._timer = None
|
|
73
|
+
try:
|
|
74
|
+
self.callback()
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print(f"[FileWatcher] 回调异常: {e}")
|
|
77
|
+
|
|
78
|
+
if now - self._last_trigger < self.debounce_sec:
|
|
79
|
+
self._timer = threading.Timer(self.debounce_sec - (now - self._last_trigger), do_callback)
|
|
80
|
+
self._timer.daemon = True
|
|
81
|
+
self._timer.start()
|
|
82
|
+
else:
|
|
83
|
+
do_callback()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_observer = None
|
|
87
|
+
_handler = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _on_file_changed() -> None:
|
|
91
|
+
"""文件变更时触发 WebSocket 推送"""
|
|
92
|
+
try:
|
|
93
|
+
from api.websocket import broadcast_full_state
|
|
94
|
+
import asyncio
|
|
95
|
+
|
|
96
|
+
loop = _event_loop
|
|
97
|
+
if loop and broadcast_full_state:
|
|
98
|
+
future = asyncio.run_coroutine_threadsafe(broadcast_full_state(), loop)
|
|
99
|
+
future.result(timeout=10)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
print(f"[FileWatcher] 推送失败: {e}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_event_loop = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def start_file_watcher(loop) -> None:
|
|
108
|
+
"""启动文件监听(在 FastAPI 启动时调用)"""
|
|
109
|
+
global _observer, _handler, _event_loop
|
|
110
|
+
_event_loop = loop
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
from watchdog.observers import Observer
|
|
114
|
+
from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent
|
|
115
|
+
except ImportError:
|
|
116
|
+
print("[FileWatcher] watchdog 未安装,跳过文件监听。请执行: pip install watchdog")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
RELEVANT_SUFFIXES = (".json", ".jsonl", ".log")
|
|
120
|
+
|
|
121
|
+
class Handler(FileSystemEventHandler):
|
|
122
|
+
def _should_trigger(self, src_path: str) -> bool:
|
|
123
|
+
return any(src_path.endswith(s) for s in RELEVANT_SUFFIXES)
|
|
124
|
+
|
|
125
|
+
def on_modified(self, event):
|
|
126
|
+
if event.is_directory:
|
|
127
|
+
return
|
|
128
|
+
if self._should_trigger(event.src_path):
|
|
129
|
+
_handler.trigger()
|
|
130
|
+
|
|
131
|
+
def on_created(self, event):
|
|
132
|
+
if event.is_directory:
|
|
133
|
+
return
|
|
134
|
+
if self._should_trigger(event.src_path):
|
|
135
|
+
_handler.trigger()
|
|
136
|
+
|
|
137
|
+
watch_dirs = _get_watch_dirs()
|
|
138
|
+
if not watch_dirs:
|
|
139
|
+
print("[FileWatcher] 无有效监听路径,跳过")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
_handler = DebouncedHandler(_on_file_changed)
|
|
143
|
+
_observer = Observer()
|
|
144
|
+
|
|
145
|
+
for watch_dir, recursive in watch_dirs:
|
|
146
|
+
_observer.schedule(Handler(), str(watch_dir), recursive=recursive)
|
|
147
|
+
|
|
148
|
+
_observer.start()
|
|
149
|
+
print(f"[FileWatcher] 已启动,监听 {len(watch_dirs)} 个目录")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def stop_file_watcher() -> None:
|
|
153
|
+
"""停止文件监听"""
|
|
154
|
+
global _observer
|
|
155
|
+
if _observer:
|
|
156
|
+
_observer.stop()
|
|
157
|
+
_observer.join(timeout=2)
|
|
158
|
+
_observer = None
|
|
159
|
+
print("[FileWatcher] 已停止")
|