htmlgen-mcp 0.3.3__py3-none-any.whl → 0.3.4__py3-none-any.whl

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.

Potentially problematic release.


This version of htmlgen-mcp might be problematic. Click here for more details.

@@ -0,0 +1,356 @@
1
+ """NAS 共享存储配置 - 解决集群环境文件一致性问题"""
2
+ import os
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ from typing import Dict, Optional, Any
8
+ import fcntl
9
+ import hashlib
10
+ import time
11
+
12
+
13
+ class NASStorage:
14
+ """NAS 存储管理器 - 确保所有节点访问同一份数据"""
15
+
16
+ def __init__(self, nas_base_path: str = "/app/mcp-servers/mcp-servers/html_agent"):
17
+ """
18
+ 初始化 NAS 存储
19
+
20
+ Args:
21
+ nas_base_path: NAS 挂载路径
22
+ """
23
+ self.nas_base = Path(nas_base_path)
24
+ self.projects_dir = self.nas_base / "projects"
25
+ self.cache_dir = self.nas_base / "cache"
26
+ self.locks_dir = self.nas_base / "locks"
27
+ self.metadata_dir = self.nas_base / "metadata"
28
+
29
+ # 创建必要的目录结构
30
+ self._init_directories()
31
+
32
+ def _init_directories(self):
33
+ """初始化目录结构"""
34
+ for dir_path in [
35
+ self.projects_dir,
36
+ self.cache_dir,
37
+ self.locks_dir,
38
+ self.metadata_dir,
39
+ self.cache_dir / "plans",
40
+ self.cache_dir / "contexts",
41
+ self.cache_dir / "jobs"
42
+ ]:
43
+ dir_path.mkdir(parents=True, exist_ok=True)
44
+
45
+ def get_project_path(self, project_name: str, create: bool = True) -> Path:
46
+ """
47
+ 获取项目路径
48
+
49
+ Args:
50
+ project_name: 项目名称
51
+ create: 是否创建目录
52
+
53
+ Returns:
54
+ 项目完整路径
55
+ """
56
+ # 生成唯一的项目目录名
57
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
58
+ safe_name = "".join(c for c in project_name if c.isalnum() or c in ('-', '_'))
59
+ project_dir = self.projects_dir / f"{safe_name}_{timestamp}"
60
+
61
+ if create:
62
+ project_dir.mkdir(parents=True, exist_ok=True)
63
+ # 创建项目元数据
64
+ self._save_project_metadata(project_dir, project_name)
65
+
66
+ return project_dir
67
+
68
+ def _save_project_metadata(self, project_dir: Path, project_name: str):
69
+ """保存项目元数据"""
70
+ metadata = {
71
+ "project_name": project_name,
72
+ "project_path": str(project_dir),
73
+ "created_at": datetime.now().isoformat(),
74
+ "node_id": os.environ.get("NODE_ID", "unknown"),
75
+ "server_instance": os.environ.get("HOSTNAME", "unknown")
76
+ }
77
+
78
+ metadata_file = self.metadata_dir / f"{project_dir.name}.json"
79
+ with open(metadata_file, 'w', encoding='utf-8') as f:
80
+ json.dump(metadata, f, ensure_ascii=False, indent=2)
81
+
82
+ def save_plan(self, plan_id: str, plan_data: Dict[str, Any]) -> Path:
83
+ """
84
+ 保存计划到 NAS
85
+
86
+ Args:
87
+ plan_id: 计划 ID
88
+ plan_data: 计划数据
89
+
90
+ Returns:
91
+ 保存的文件路径
92
+ """
93
+ plan_path = self.cache_dir / "plans" / f"{plan_id}.json"
94
+
95
+ # 添加时间戳和节点信息
96
+ plan_data["saved_at"] = datetime.now().isoformat()
97
+ plan_data["node_id"] = os.environ.get("NODE_ID", "unknown")
98
+
99
+ # 使用文件锁确保原子写入
100
+ with self._file_lock(plan_path):
101
+ with open(plan_path, 'w', encoding='utf-8') as f:
102
+ json.dump(plan_data, f, ensure_ascii=False, indent=2)
103
+
104
+ return plan_path
105
+
106
+ def load_plan(self, plan_id: str) -> Optional[Dict[str, Any]]:
107
+ """
108
+ 从 NAS 加载计划
109
+
110
+ Args:
111
+ plan_id: 计划 ID
112
+
113
+ Returns:
114
+ 计划数据,如果不存在则返回 None
115
+ """
116
+ plan_path = self.cache_dir / "plans" / f"{plan_id}.json"
117
+
118
+ if not plan_path.exists():
119
+ return None
120
+
121
+ with self._file_lock(plan_path, exclusive=False):
122
+ with open(plan_path, 'r', encoding='utf-8') as f:
123
+ return json.load(f)
124
+
125
+ def save_job_state(self, job_id: str, job_data: Dict[str, Any]) -> Path:
126
+ """
127
+ 保存任务状态到 NAS
128
+
129
+ Args:
130
+ job_id: 任务 ID
131
+ job_data: 任务数据
132
+
133
+ Returns:
134
+ 保存的文件路径
135
+ """
136
+ job_path = self.cache_dir / "jobs" / f"{job_id}.json"
137
+
138
+ # 添加更新时间
139
+ job_data["updated_at"] = datetime.now().isoformat()
140
+ job_data["node_id"] = os.environ.get("NODE_ID", "unknown")
141
+
142
+ with self._file_lock(job_path):
143
+ with open(job_path, 'w', encoding='utf-8') as f:
144
+ json.dump(job_data, f, ensure_ascii=False, indent=2)
145
+
146
+ return job_path
147
+
148
+ def load_job_state(self, job_id: str) -> Optional[Dict[str, Any]]:
149
+ """
150
+ 从 NAS 加载任务状态
151
+
152
+ Args:
153
+ job_id: 任务 ID
154
+
155
+ Returns:
156
+ 任务数据,如果不存在则返回 None
157
+ """
158
+ job_path = self.cache_dir / "jobs" / f"{job_id}.json"
159
+
160
+ if not job_path.exists():
161
+ return None
162
+
163
+ with self._file_lock(job_path, exclusive=False):
164
+ with open(job_path, 'r', encoding='utf-8') as f:
165
+ return json.load(f)
166
+
167
+ def list_projects(self, limit: int = 20) -> list:
168
+ """
169
+ 列出最近的项目
170
+
171
+ Args:
172
+ limit: 返回的项目数量
173
+
174
+ Returns:
175
+ 项目列表
176
+ """
177
+ projects = []
178
+
179
+ # 读取所有元数据文件
180
+ for metadata_file in self.metadata_dir.glob("*.json"):
181
+ try:
182
+ with open(metadata_file, 'r', encoding='utf-8') as f:
183
+ project_info = json.load(f)
184
+ projects.append(project_info)
185
+ except Exception:
186
+ continue
187
+
188
+ # 按创建时间排序
189
+ projects.sort(key=lambda x: x.get("created_at", ""), reverse=True)
190
+
191
+ return projects[:limit]
192
+
193
+ def _file_lock(self, file_path: Path, exclusive: bool = True):
194
+ """
195
+ 文件锁上下文管理器
196
+
197
+ Args:
198
+ file_path: 文件路径
199
+ exclusive: 是否独占锁
200
+
201
+ Returns:
202
+ 锁对象
203
+ """
204
+ # 使用单独的锁文件
205
+ lock_file = self.locks_dir / f"{file_path.name}.lock"
206
+
207
+ class FileLock:
208
+ def __init__(self, lock_path: Path, exclusive: bool):
209
+ self.lock_path = lock_path
210
+ self.exclusive = exclusive
211
+ self.lock_fd = None
212
+
213
+ def __enter__(self):
214
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
215
+ self.lock_fd = open(self.lock_path, 'w')
216
+
217
+ # 获取文件锁
218
+ flag = fcntl.LOCK_EX if self.exclusive else fcntl.LOCK_SH
219
+
220
+ # 重试机制
221
+ max_retries = 10
222
+ for i in range(max_retries):
223
+ try:
224
+ fcntl.flock(self.lock_fd, flag | fcntl.LOCK_NB)
225
+ break
226
+ except IOError:
227
+ if i == max_retries - 1:
228
+ raise
229
+ time.sleep(0.1 * (i + 1)) # 指数退避
230
+
231
+ return self
232
+
233
+ def __exit__(self, exc_type, exc_val, exc_tb):
234
+ if self.lock_fd:
235
+ fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
236
+ self.lock_fd.close()
237
+
238
+ # 清理过期的锁文件
239
+ try:
240
+ if self.lock_path.exists():
241
+ # 如果锁文件超过1小时,删除它
242
+ if time.time() - self.lock_path.stat().st_mtime > 3600:
243
+ self.lock_path.unlink(missing_ok=True)
244
+ except Exception:
245
+ pass
246
+
247
+ return FileLock(lock_file, exclusive)
248
+
249
+ def generate_unique_id(self, prefix: str = "") -> str:
250
+ """
251
+ 生成唯一 ID(结合时间戳、节点 ID 和随机数)
252
+
253
+ Args:
254
+ prefix: ID 前缀
255
+
256
+ Returns:
257
+ 唯一 ID
258
+ """
259
+ node_id = os.environ.get("NODE_ID", "default")
260
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
261
+ random_str = hashlib.md5(os.urandom(16)).hexdigest()[:8]
262
+
263
+ if prefix:
264
+ return f"{prefix}_{node_id}_{timestamp}_{random_str}"
265
+ return f"{node_id}_{timestamp}_{random_str}"
266
+
267
+ def cleanup_old_projects(self, days_to_keep: int = 7) -> int:
268
+ """
269
+ 清理旧项目
270
+
271
+ Args:
272
+ days_to_keep: 保留天数
273
+
274
+ Returns:
275
+ 删除的项目数量
276
+ """
277
+ deleted_count = 0
278
+ cutoff_time = time.time() - (days_to_keep * 24 * 3600)
279
+
280
+ for project_dir in self.projects_dir.iterdir():
281
+ if project_dir.is_dir():
282
+ try:
283
+ # 检查修改时间
284
+ if project_dir.stat().st_mtime < cutoff_time:
285
+ shutil.rmtree(project_dir)
286
+
287
+ # 删除对应的元数据
288
+ metadata_file = self.metadata_dir / f"{project_dir.name}.json"
289
+ metadata_file.unlink(missing_ok=True)
290
+
291
+ deleted_count += 1
292
+ except Exception:
293
+ continue
294
+
295
+ return deleted_count
296
+
297
+
298
+ # 全局 NAS 存储实例
299
+ _nas_storage: Optional[NASStorage] = None
300
+
301
+
302
+ def get_nas_storage() -> NASStorage:
303
+ """获取 NAS 存储实例(单例)"""
304
+ global _nas_storage
305
+ if _nas_storage is None:
306
+ nas_path = os.environ.get(
307
+ "NAS_STORAGE_PATH",
308
+ "/app/mcp-servers/mcp-servers/html_agent"
309
+ )
310
+ _nas_storage = NASStorage(nas_path)
311
+ return _nas_storage
312
+
313
+
314
+ # 导出的便捷函数
315
+ def save_project_file(project_name: str, relative_path: str, content: str) -> str:
316
+ """
317
+ 保存项目文件到 NAS
318
+
319
+ Args:
320
+ project_name: 项目名称
321
+ relative_path: 相对路径
322
+ content: 文件内容
323
+
324
+ Returns:
325
+ 保存的完整路径
326
+ """
327
+ storage = get_nas_storage()
328
+ project_dir = storage.get_project_path(project_name)
329
+
330
+ file_path = project_dir / relative_path
331
+ file_path.parent.mkdir(parents=True, exist_ok=True)
332
+
333
+ with open(file_path, 'w', encoding='utf-8') as f:
334
+ f.write(content)
335
+
336
+ return str(file_path)
337
+
338
+
339
+ def load_project_file(project_path: str, relative_path: str) -> Optional[str]:
340
+ """
341
+ 从 NAS 加载项目文件
342
+
343
+ Args:
344
+ project_path: 项目路径
345
+ relative_path: 相对路径
346
+
347
+ Returns:
348
+ 文件内容,如果不存在则返回 None
349
+ """
350
+ file_path = Path(project_path) / relative_path
351
+
352
+ if not file_path.exists():
353
+ return None
354
+
355
+ with open(file_path, 'r', encoding='utf-8') as f:
356
+ return f.read()
@@ -0,0 +1,194 @@
1
+ """进度查询工具 - MCP 接口"""
2
+ from typing import Dict, Optional, List, Any
3
+ from htmlgen_mcp.progress_tracker import get_progress_tracker
4
+
5
+
6
+ async def query_task_progress(task_id: str) -> Dict[str, Any]:
7
+ """
8
+ 查询任务进度
9
+
10
+ Args:
11
+ task_id: 任务 ID(执行任务时返回的 task_id)
12
+
13
+ Returns:
14
+ 包含任务进度信息的字典:
15
+ - task_id: 任务 ID
16
+ - status: 任务状态 (pending/running/completed/failed)
17
+ - progress: 进度百分比 (0-100)
18
+ - message: 最新进度消息
19
+ - current_step: 当前步骤
20
+ - total_steps: 总步骤数
21
+ - created_at: 创建时间
22
+ - updated_at: 最后更新时间
23
+ """
24
+ tracker = get_progress_tracker()
25
+ progress = tracker.get_realtime_progress(task_id)
26
+
27
+ if progress.get("status") == "not_found":
28
+ return {
29
+ "status": "error",
30
+ "message": f"未找到任务: {task_id}",
31
+ "task_id": task_id
32
+ }
33
+
34
+ return {
35
+ "status": "success",
36
+ "data": progress
37
+ }
38
+
39
+
40
+ async def list_tasks(
41
+ task_type: Optional[str] = None,
42
+ status: Optional[str] = None,
43
+ limit: int = 20
44
+ ) -> Dict[str, Any]:
45
+ """
46
+ 列出任务列表
47
+
48
+ Args:
49
+ task_type: 筛选任务类型 (plan_site/execute_plan/create_simple_site)
50
+ status: 筛选任务状态 (pending/running/completed/failed)
51
+ limit: 返回数量限制(默认20)
52
+
53
+ Returns:
54
+ 任务列表,包含每个任务的摘要信息
55
+ """
56
+ tracker = get_progress_tracker()
57
+ tasks = tracker.list_tasks(task_type=task_type, status=status, limit=limit)
58
+
59
+ return {
60
+ "status": "success",
61
+ "count": len(tasks),
62
+ "tasks": tasks
63
+ }
64
+
65
+
66
+ async def get_task_events(
67
+ task_id: str,
68
+ since_timestamp: Optional[str] = None
69
+ ) -> Dict[str, Any]:
70
+ """
71
+ 获取任务事件历史(支持增量查询)
72
+
73
+ Args:
74
+ task_id: 任务 ID
75
+ since_timestamp: 从此时间戳之后的事件(ISO格式)
76
+
77
+ Returns:
78
+ 任务的事件列表,包含详细的执行步骤
79
+ """
80
+ tracker = get_progress_tracker()
81
+
82
+ # 先检查任务是否存在
83
+ task_info = tracker.get_task_progress(task_id)
84
+ if not task_info:
85
+ return {
86
+ "status": "error",
87
+ "message": f"未找到任务: {task_id}",
88
+ "task_id": task_id
89
+ }
90
+
91
+ # 获取事件列表
92
+ events = tracker.get_task_events(task_id, since_timestamp)
93
+
94
+ return {
95
+ "status": "success",
96
+ "task_id": task_id,
97
+ "task_status": task_info.get("status"),
98
+ "task_progress": task_info.get("progress"),
99
+ "event_count": len(events),
100
+ "events": events
101
+ }
102
+
103
+
104
+ async def get_task_details(task_id: str) -> Dict[str, Any]:
105
+ """
106
+ 获取任务完整详情
107
+
108
+ Args:
109
+ task_id: 任务 ID
110
+
111
+ Returns:
112
+ 任务的完整信息,包含所有事件历史
113
+ """
114
+ tracker = get_progress_tracker()
115
+ task_info = tracker.get_task_progress(task_id)
116
+
117
+ if not task_info:
118
+ return {
119
+ "status": "error",
120
+ "message": f"未找到任务: {task_id}",
121
+ "task_id": task_id
122
+ }
123
+
124
+ return {
125
+ "status": "success",
126
+ "data": task_info
127
+ }
128
+
129
+
130
+ async def cleanup_old_tasks(days_to_keep: int = 7) -> Dict[str, Any]:
131
+ """
132
+ 清理旧任务记录
133
+
134
+ Args:
135
+ days_to_keep: 保留天数(默认7天)
136
+
137
+ Returns:
138
+ 清理结果
139
+ """
140
+ tracker = get_progress_tracker()
141
+ cleaned = tracker.cleanup_old_tasks(days_to_keep)
142
+
143
+ return {
144
+ "status": "success",
145
+ "message": f"已清理 {cleaned} 个旧任务",
146
+ "cleaned_count": cleaned,
147
+ "days_kept": days_to_keep
148
+ }
149
+
150
+
151
+ # SSE 流式进度查询(用于实时监控)
152
+ async def stream_task_progress(task_id: str):
153
+ """
154
+ 流式获取任务进度(生成器)
155
+
156
+ 用于 SSE 实时推送进度更新
157
+
158
+ Args:
159
+ task_id: 任务 ID
160
+
161
+ Yields:
162
+ 进度更新事件
163
+ """
164
+ import asyncio
165
+ tracker = get_progress_tracker()
166
+
167
+ last_timestamp = None
168
+ while True:
169
+ # 获取最新进度
170
+ progress = tracker.get_realtime_progress(task_id)
171
+
172
+ # 如果任务完成或失败,发送最终状态并退出
173
+ if progress.get("status") in ["completed", "failed", "not_found"]:
174
+ yield {
175
+ "event": "done",
176
+ "data": progress
177
+ }
178
+ break
179
+
180
+ # 获取增量事件
181
+ events = tracker.get_task_events(task_id, last_timestamp)
182
+ if events:
183
+ # 更新时间戳
184
+ last_timestamp = events[-1].get("timestamp")
185
+
186
+ # 发送事件
187
+ for event in events:
188
+ yield {
189
+ "event": "progress",
190
+ "data": event
191
+ }
192
+
193
+ # 等待一小段时间再查询
194
+ await asyncio.sleep(0.5)