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,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,4 @@
1
+ """文件监听与实时推送"""
2
+ from .file_watcher import start_file_watcher, stop_file_watcher
3
+
4
+ __all__ = ["start_file_watcher", "stop_file_watcher"]
@@ -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] 已停止")