htmlgen-mcp 0.3.1__py3-none-any.whl → 0.3.2__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.
- htmlgen_mcp/agents/cluster_state.py +414 -0
- htmlgen_mcp/agents/cluster_storage.py +341 -0
- htmlgen_mcp/config.py +5 -5
- htmlgen_mcp/web_agent_server.py +26 -5
- {htmlgen_mcp-0.3.1.dist-info → htmlgen_mcp-0.3.2.dist-info}/METADATA +1 -1
- {htmlgen_mcp-0.3.1.dist-info → htmlgen_mcp-0.3.2.dist-info}/RECORD +9 -7
- {htmlgen_mcp-0.3.1.dist-info → htmlgen_mcp-0.3.2.dist-info}/WHEEL +0 -0
- {htmlgen_mcp-0.3.1.dist-info → htmlgen_mcp-0.3.2.dist-info}/entry_points.txt +0 -0
- {htmlgen_mcp-0.3.1.dist-info → htmlgen_mcp-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
集群状态管理模块 - 支持分布式部署
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional, List, Dict, Any
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskStatus(Enum):
|
|
15
|
+
"""任务状态枚举"""
|
|
16
|
+
PENDING = "pending"
|
|
17
|
+
RUNNING = "running"
|
|
18
|
+
SUCCESS = "success"
|
|
19
|
+
FAILED = "failed"
|
|
20
|
+
CANCELLED = "cancelled"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StateManager:
|
|
24
|
+
"""
|
|
25
|
+
抽象状态管理器基类
|
|
26
|
+
可以有多种实现:RedisStateManager, PostgreSQLStateManager, MongoDBStateManager
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def create_task(self, task_id: str, user_input: str, project_directory: str, metadata: dict = None) -> bool:
|
|
30
|
+
"""创建新任务"""
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def update_task_status(self, task_id: str, status: TaskStatus, message: str = None) -> bool:
|
|
34
|
+
"""更新任务状态"""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def get_task_status(self, task_id: str) -> Optional[dict]:
|
|
38
|
+
"""获取任务状态"""
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def add_created_file(self, task_id: str, file_path: str, size: int = 0) -> bool:
|
|
42
|
+
"""记录创建的文件"""
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def get_created_files(self, task_id: str) -> List[str]:
|
|
46
|
+
"""获取任务创建的所有文件"""
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
def add_execution_step(self, task_id: str, step: dict) -> bool:
|
|
50
|
+
"""添加执行步骤记录"""
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def get_execution_history(self, task_id: str) -> List[dict]:
|
|
54
|
+
"""获取执行历史"""
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def acquire_lock(self, resource_id: str, timeout: int = 10) -> bool:
|
|
58
|
+
"""获取分布式锁"""
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
|
|
61
|
+
def release_lock(self, resource_id: str) -> bool:
|
|
62
|
+
"""释放分布式锁"""
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RedisStateManager(StateManager):
|
|
67
|
+
"""基于 Redis 的状态管理器"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, redis_url: str = "redis://localhost:6379/0"):
|
|
70
|
+
try:
|
|
71
|
+
import redis
|
|
72
|
+
from redis.lock import Lock
|
|
73
|
+
except ImportError:
|
|
74
|
+
raise ImportError("请安装 redis: pip install redis")
|
|
75
|
+
|
|
76
|
+
self.redis = redis.from_url(redis_url, decode_responses=True)
|
|
77
|
+
self.Lock = Lock
|
|
78
|
+
|
|
79
|
+
def create_task(self, task_id: str, user_input: str, project_directory: str, metadata: dict = None) -> bool:
|
|
80
|
+
"""创建新任务"""
|
|
81
|
+
task_data = {
|
|
82
|
+
"task_id": task_id,
|
|
83
|
+
"user_input": user_input,
|
|
84
|
+
"project_directory": project_directory,
|
|
85
|
+
"status": TaskStatus.PENDING.value,
|
|
86
|
+
"created_at": datetime.now().isoformat(),
|
|
87
|
+
"updated_at": datetime.now().isoformat(),
|
|
88
|
+
"metadata": metadata or {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# 使用 Hash 存储任务基本信息
|
|
92
|
+
self.redis.hset(f"task:{task_id}", mapping={
|
|
93
|
+
k: json.dumps(v) if isinstance(v, (dict, list)) else str(v)
|
|
94
|
+
for k, v in task_data.items()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
# 设置过期时间(7天)
|
|
98
|
+
self.redis.expire(f"task:{task_id}", 7 * 24 * 3600)
|
|
99
|
+
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def update_task_status(self, task_id: str, status: TaskStatus, message: str = None) -> bool:
|
|
103
|
+
"""更新任务状态"""
|
|
104
|
+
updates = {
|
|
105
|
+
"status": status.value,
|
|
106
|
+
"updated_at": datetime.now().isoformat()
|
|
107
|
+
}
|
|
108
|
+
if message:
|
|
109
|
+
updates["message"] = message
|
|
110
|
+
|
|
111
|
+
self.redis.hset(f"task:{task_id}", mapping=updates)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def get_task_status(self, task_id: str) -> Optional[dict]:
|
|
115
|
+
"""获取任务状态"""
|
|
116
|
+
data = self.redis.hgetall(f"task:{task_id}")
|
|
117
|
+
if not data:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# 反序列化 JSON 字段
|
|
121
|
+
for key in ["metadata"]:
|
|
122
|
+
if key in data:
|
|
123
|
+
try:
|
|
124
|
+
data[key] = json.loads(data[key])
|
|
125
|
+
except (json.JSONDecodeError, TypeError):
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
def add_created_file(self, task_id: str, file_path: str, size: int = 0) -> bool:
|
|
131
|
+
"""记录创建的文件"""
|
|
132
|
+
file_info = {
|
|
133
|
+
"path": file_path,
|
|
134
|
+
"size": size,
|
|
135
|
+
"created_at": datetime.now().isoformat()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# 使用 List 存储文件列表
|
|
139
|
+
self.redis.rpush(f"task:{task_id}:files", json.dumps(file_info))
|
|
140
|
+
self.redis.expire(f"task:{task_id}:files", 7 * 24 * 3600)
|
|
141
|
+
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
def get_created_files(self, task_id: str) -> List[str]:
|
|
145
|
+
"""获取任务创建的所有文件"""
|
|
146
|
+
files_json = self.redis.lrange(f"task:{task_id}:files", 0, -1)
|
|
147
|
+
files = []
|
|
148
|
+
for file_json in files_json:
|
|
149
|
+
try:
|
|
150
|
+
file_info = json.loads(file_json)
|
|
151
|
+
files.append(file_info["path"])
|
|
152
|
+
except (json.JSONDecodeError, KeyError):
|
|
153
|
+
continue
|
|
154
|
+
return files
|
|
155
|
+
|
|
156
|
+
def add_execution_step(self, task_id: str, step: dict) -> bool:
|
|
157
|
+
"""添加执行步骤记录"""
|
|
158
|
+
step_data = {
|
|
159
|
+
**step,
|
|
160
|
+
"timestamp": datetime.now().isoformat()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# 使用 List 存储执行历史
|
|
164
|
+
self.redis.rpush(f"task:{task_id}:history", json.dumps(step_data))
|
|
165
|
+
self.redis.expire(f"task:{task_id}:history", 7 * 24 * 3600)
|
|
166
|
+
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def get_execution_history(self, task_id: str) -> List[dict]:
|
|
170
|
+
"""获取执行历史"""
|
|
171
|
+
history_json = self.redis.lrange(f"task:{task_id}:history", 0, -1)
|
|
172
|
+
history = []
|
|
173
|
+
for step_json in history_json:
|
|
174
|
+
try:
|
|
175
|
+
history.append(json.loads(step_json))
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
continue
|
|
178
|
+
return history
|
|
179
|
+
|
|
180
|
+
def acquire_lock(self, resource_id: str, timeout: int = 10) -> bool:
|
|
181
|
+
"""获取分布式锁"""
|
|
182
|
+
lock = self.redis.lock(f"lock:{resource_id}", timeout=timeout)
|
|
183
|
+
return lock.acquire(blocking=True, blocking_timeout=timeout)
|
|
184
|
+
|
|
185
|
+
def release_lock(self, resource_id: str) -> bool:
|
|
186
|
+
"""释放分布式锁"""
|
|
187
|
+
try:
|
|
188
|
+
lock = self.redis.lock(f"lock:{resource_id}")
|
|
189
|
+
lock.release()
|
|
190
|
+
return True
|
|
191
|
+
except Exception:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DatabaseStateManager(StateManager):
|
|
196
|
+
"""基于 PostgreSQL/MySQL 的状态管理器"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, db_url: str):
|
|
199
|
+
try:
|
|
200
|
+
from sqlalchemy import create_engine, Column, String, Integer, Text, DateTime, JSON
|
|
201
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
202
|
+
from sqlalchemy.orm import sessionmaker
|
|
203
|
+
except ImportError:
|
|
204
|
+
raise ImportError("请安装 sqlalchemy: pip install sqlalchemy psycopg2-binary")
|
|
205
|
+
|
|
206
|
+
self.engine = create_engine(db_url)
|
|
207
|
+
Base = declarative_base()
|
|
208
|
+
|
|
209
|
+
# 定义数据模型
|
|
210
|
+
class Task(Base):
|
|
211
|
+
__tablename__ = 'agent_tasks'
|
|
212
|
+
|
|
213
|
+
task_id = Column(String(64), primary_key=True)
|
|
214
|
+
user_input = Column(Text)
|
|
215
|
+
project_directory = Column(String(512))
|
|
216
|
+
status = Column(String(20))
|
|
217
|
+
message = Column(Text, nullable=True)
|
|
218
|
+
metadata = Column(JSON, nullable=True)
|
|
219
|
+
created_at = Column(DateTime, default=datetime.now)
|
|
220
|
+
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
|
221
|
+
|
|
222
|
+
class CreatedFile(Base):
|
|
223
|
+
__tablename__ = 'agent_files'
|
|
224
|
+
|
|
225
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
226
|
+
task_id = Column(String(64), index=True)
|
|
227
|
+
file_path = Column(String(1024))
|
|
228
|
+
size = Column(Integer, default=0)
|
|
229
|
+
created_at = Column(DateTime, default=datetime.now)
|
|
230
|
+
|
|
231
|
+
class ExecutionStep(Base):
|
|
232
|
+
__tablename__ = 'agent_execution_history'
|
|
233
|
+
|
|
234
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
235
|
+
task_id = Column(String(64), index=True)
|
|
236
|
+
step = Column(Integer)
|
|
237
|
+
tool = Column(String(100))
|
|
238
|
+
status = Column(String(20))
|
|
239
|
+
result = Column(Text)
|
|
240
|
+
duration = Column(Integer, default=0)
|
|
241
|
+
created_at = Column(DateTime, default=datetime.now)
|
|
242
|
+
|
|
243
|
+
Base.metadata.create_all(self.engine)
|
|
244
|
+
|
|
245
|
+
self.Session = sessionmaker(bind=self.engine)
|
|
246
|
+
self.Task = Task
|
|
247
|
+
self.CreatedFile = CreatedFile
|
|
248
|
+
self.ExecutionStep = ExecutionStep
|
|
249
|
+
|
|
250
|
+
def create_task(self, task_id: str, user_input: str, project_directory: str, metadata: dict = None) -> bool:
|
|
251
|
+
"""创建新任务"""
|
|
252
|
+
session = self.Session()
|
|
253
|
+
try:
|
|
254
|
+
task = self.Task(
|
|
255
|
+
task_id=task_id,
|
|
256
|
+
user_input=user_input,
|
|
257
|
+
project_directory=project_directory,
|
|
258
|
+
status=TaskStatus.PENDING.value,
|
|
259
|
+
metadata=metadata
|
|
260
|
+
)
|
|
261
|
+
session.add(task)
|
|
262
|
+
session.commit()
|
|
263
|
+
return True
|
|
264
|
+
except Exception as e:
|
|
265
|
+
session.rollback()
|
|
266
|
+
print(f"创建任务失败: {e}")
|
|
267
|
+
return False
|
|
268
|
+
finally:
|
|
269
|
+
session.close()
|
|
270
|
+
|
|
271
|
+
def update_task_status(self, task_id: str, status: TaskStatus, message: str = None) -> bool:
|
|
272
|
+
"""更新任务状态"""
|
|
273
|
+
session = self.Session()
|
|
274
|
+
try:
|
|
275
|
+
task = session.query(self.Task).filter_by(task_id=task_id).first()
|
|
276
|
+
if task:
|
|
277
|
+
task.status = status.value
|
|
278
|
+
task.updated_at = datetime.now()
|
|
279
|
+
if message:
|
|
280
|
+
task.message = message
|
|
281
|
+
session.commit()
|
|
282
|
+
return True
|
|
283
|
+
return False
|
|
284
|
+
except Exception as e:
|
|
285
|
+
session.rollback()
|
|
286
|
+
print(f"更新任务状态失败: {e}")
|
|
287
|
+
return False
|
|
288
|
+
finally:
|
|
289
|
+
session.close()
|
|
290
|
+
|
|
291
|
+
def get_task_status(self, task_id: str) -> Optional[dict]:
|
|
292
|
+
"""获取任务状态"""
|
|
293
|
+
session = self.Session()
|
|
294
|
+
try:
|
|
295
|
+
task = session.query(self.Task).filter_by(task_id=task_id).first()
|
|
296
|
+
if not task:
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
"task_id": task.task_id,
|
|
301
|
+
"user_input": task.user_input,
|
|
302
|
+
"project_directory": task.project_directory,
|
|
303
|
+
"status": task.status,
|
|
304
|
+
"message": task.message,
|
|
305
|
+
"metadata": task.metadata,
|
|
306
|
+
"created_at": task.created_at.isoformat() if task.created_at else None,
|
|
307
|
+
"updated_at": task.updated_at.isoformat() if task.updated_at else None
|
|
308
|
+
}
|
|
309
|
+
finally:
|
|
310
|
+
session.close()
|
|
311
|
+
|
|
312
|
+
def add_created_file(self, task_id: str, file_path: str, size: int = 0) -> bool:
|
|
313
|
+
"""记录创建的文件"""
|
|
314
|
+
session = self.Session()
|
|
315
|
+
try:
|
|
316
|
+
file_record = self.CreatedFile(
|
|
317
|
+
task_id=task_id,
|
|
318
|
+
file_path=file_path,
|
|
319
|
+
size=size
|
|
320
|
+
)
|
|
321
|
+
session.add(file_record)
|
|
322
|
+
session.commit()
|
|
323
|
+
return True
|
|
324
|
+
except Exception as e:
|
|
325
|
+
session.rollback()
|
|
326
|
+
print(f"记录文件失败: {e}")
|
|
327
|
+
return False
|
|
328
|
+
finally:
|
|
329
|
+
session.close()
|
|
330
|
+
|
|
331
|
+
def get_created_files(self, task_id: str) -> List[str]:
|
|
332
|
+
"""获取任务创建的所有文件"""
|
|
333
|
+
session = self.Session()
|
|
334
|
+
try:
|
|
335
|
+
files = session.query(self.CreatedFile).filter_by(task_id=task_id).all()
|
|
336
|
+
return [f.file_path for f in files]
|
|
337
|
+
finally:
|
|
338
|
+
session.close()
|
|
339
|
+
|
|
340
|
+
def add_execution_step(self, task_id: str, step: dict) -> bool:
|
|
341
|
+
"""添加执行步骤记录"""
|
|
342
|
+
session = self.Session()
|
|
343
|
+
try:
|
|
344
|
+
step_record = self.ExecutionStep(
|
|
345
|
+
task_id=task_id,
|
|
346
|
+
step=step.get("step", 0),
|
|
347
|
+
tool=step.get("tool", ""),
|
|
348
|
+
status=step.get("status", ""),
|
|
349
|
+
result=json.dumps(step.get("result", "")),
|
|
350
|
+
duration=step.get("duration", 0)
|
|
351
|
+
)
|
|
352
|
+
session.add(step_record)
|
|
353
|
+
session.commit()
|
|
354
|
+
return True
|
|
355
|
+
except Exception as e:
|
|
356
|
+
session.rollback()
|
|
357
|
+
print(f"记录执行步骤失败: {e}")
|
|
358
|
+
return False
|
|
359
|
+
finally:
|
|
360
|
+
session.close()
|
|
361
|
+
|
|
362
|
+
def get_execution_history(self, task_id: str) -> List[dict]:
|
|
363
|
+
"""获取执行历史"""
|
|
364
|
+
session = self.Session()
|
|
365
|
+
try:
|
|
366
|
+
steps = session.query(self.ExecutionStep).filter_by(task_id=task_id).order_by(self.ExecutionStep.step).all()
|
|
367
|
+
return [
|
|
368
|
+
{
|
|
369
|
+
"step": s.step,
|
|
370
|
+
"tool": s.tool,
|
|
371
|
+
"status": s.status,
|
|
372
|
+
"result": s.result,
|
|
373
|
+
"duration": s.duration,
|
|
374
|
+
"created_at": s.created_at.isoformat() if s.created_at else None
|
|
375
|
+
}
|
|
376
|
+
for s in steps
|
|
377
|
+
]
|
|
378
|
+
finally:
|
|
379
|
+
session.close()
|
|
380
|
+
|
|
381
|
+
def acquire_lock(self, resource_id: str, timeout: int = 10) -> bool:
|
|
382
|
+
"""获取分布式锁(需要配合数据库锁实现)"""
|
|
383
|
+
# PostgreSQL 可以使用 advisory lock
|
|
384
|
+
# MySQL 可以使用 GET_LOCK
|
|
385
|
+
# 这里简化实现,实际应使用数据库特定的锁机制
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
def release_lock(self, resource_id: str) -> bool:
|
|
389
|
+
"""释放分布式锁"""
|
|
390
|
+
return True
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def create_state_manager(backend: str = "redis", connection_string: str = None) -> StateManager:
|
|
394
|
+
"""
|
|
395
|
+
工厂函数:根据配置创建状态管理器
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
backend: 后端类型,支持 'redis', 'postgresql', 'mysql'
|
|
399
|
+
connection_string: 连接字符串
|
|
400
|
+
- Redis: redis://localhost:6379/0
|
|
401
|
+
- PostgreSQL: postgresql://user:pass@localhost/dbname
|
|
402
|
+
- MySQL: mysql://user:pass@localhost/dbname
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
StateManager 实例
|
|
406
|
+
"""
|
|
407
|
+
if backend == "redis":
|
|
408
|
+
return RedisStateManager(connection_string or "redis://localhost:6379/0")
|
|
409
|
+
elif backend in ["postgresql", "mysql"]:
|
|
410
|
+
if not connection_string:
|
|
411
|
+
raise ValueError(f"{backend} 需要提供 connection_string")
|
|
412
|
+
return DatabaseStateManager(connection_string)
|
|
413
|
+
else:
|
|
414
|
+
raise ValueError(f"不支持的后端类型: {backend}")
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
集群文件存储模块 - 支持对象存储
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import mimetypes
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StorageBackend:
|
|
14
|
+
"""存储后端抽象基类"""
|
|
15
|
+
|
|
16
|
+
def upload_file(self, local_path: str, remote_path: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
上传文件到存储
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
local_path: 本地文件路径
|
|
22
|
+
remote_path: 远程存储路径
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
可访问的 URL
|
|
26
|
+
"""
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def download_file(self, remote_path: str, local_path: str) -> bool:
|
|
30
|
+
"""下载文件到本地"""
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def delete_file(self, remote_path: str) -> bool:
|
|
34
|
+
"""删除文件"""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def list_files(self, prefix: str) -> List[str]:
|
|
38
|
+
"""列出指定前缀的所有文件"""
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def get_url(self, remote_path: str, expires: int = 3600) -> str:
|
|
42
|
+
"""获取文件访问URL"""
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LocalStorageBackend(StorageBackend):
|
|
47
|
+
"""
|
|
48
|
+
本地文件系统存储(开发/单机环境)
|
|
49
|
+
适用于开发测试,生产环境请使用对象存储
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, base_dir: str = "/tmp/agent_storage"):
|
|
53
|
+
self.base_dir = Path(base_dir)
|
|
54
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
def upload_file(self, local_path: str, remote_path: str) -> str:
|
|
57
|
+
"""复制文件到存储目录"""
|
|
58
|
+
target_path = self.base_dir / remote_path
|
|
59
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
import shutil
|
|
62
|
+
shutil.copy2(local_path, target_path)
|
|
63
|
+
|
|
64
|
+
return f"file://{target_path}"
|
|
65
|
+
|
|
66
|
+
def download_file(self, remote_path: str, local_path: str) -> bool:
|
|
67
|
+
"""从存储目录复制文件"""
|
|
68
|
+
source_path = self.base_dir / remote_path
|
|
69
|
+
if not source_path.exists():
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
import shutil
|
|
73
|
+
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
shutil.copy2(source_path, local_path)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
def delete_file(self, remote_path: str) -> bool:
|
|
78
|
+
"""删除文件"""
|
|
79
|
+
target_path = self.base_dir / remote_path
|
|
80
|
+
if target_path.exists():
|
|
81
|
+
target_path.unlink()
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def list_files(self, prefix: str) -> List[str]:
|
|
86
|
+
"""列出文件"""
|
|
87
|
+
prefix_path = self.base_dir / prefix
|
|
88
|
+
if not prefix_path.exists():
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
files = []
|
|
92
|
+
for file_path in prefix_path.rglob("*"):
|
|
93
|
+
if file_path.is_file():
|
|
94
|
+
rel_path = file_path.relative_to(self.base_dir)
|
|
95
|
+
files.append(str(rel_path))
|
|
96
|
+
return files
|
|
97
|
+
|
|
98
|
+
def get_url(self, remote_path: str, expires: int = 3600) -> str:
|
|
99
|
+
"""获取文件URL"""
|
|
100
|
+
target_path = self.base_dir / remote_path
|
|
101
|
+
return f"file://{target_path}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OSSStorageBackend(StorageBackend):
|
|
105
|
+
"""阿里云 OSS 存储"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, access_key: str, secret_key: str, endpoint: str, bucket: str):
|
|
108
|
+
try:
|
|
109
|
+
import oss2
|
|
110
|
+
except ImportError:
|
|
111
|
+
raise ImportError("请安装 oss2: pip install oss2")
|
|
112
|
+
|
|
113
|
+
auth = oss2.Auth(access_key, secret_key)
|
|
114
|
+
self.bucket = oss2.Bucket(auth, endpoint, bucket)
|
|
115
|
+
self.bucket_name = bucket
|
|
116
|
+
self.endpoint = endpoint
|
|
117
|
+
|
|
118
|
+
def upload_file(self, local_path: str, remote_path: str) -> str:
|
|
119
|
+
"""上传文件到 OSS"""
|
|
120
|
+
# 自动检测 Content-Type
|
|
121
|
+
content_type, _ = mimetypes.guess_type(local_path)
|
|
122
|
+
|
|
123
|
+
headers = {}
|
|
124
|
+
if content_type:
|
|
125
|
+
headers['Content-Type'] = content_type
|
|
126
|
+
|
|
127
|
+
self.bucket.put_object_from_file(remote_path, local_path, headers=headers)
|
|
128
|
+
|
|
129
|
+
return f"https://{self.bucket_name}.{self.endpoint}/{remote_path}"
|
|
130
|
+
|
|
131
|
+
def download_file(self, remote_path: str, local_path: str) -> bool:
|
|
132
|
+
"""从 OSS 下载文件"""
|
|
133
|
+
try:
|
|
134
|
+
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
self.bucket.get_object_to_file(remote_path, local_path)
|
|
136
|
+
return True
|
|
137
|
+
except Exception:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def delete_file(self, remote_path: str) -> bool:
|
|
141
|
+
"""删除 OSS 文件"""
|
|
142
|
+
try:
|
|
143
|
+
self.bucket.delete_object(remote_path)
|
|
144
|
+
return True
|
|
145
|
+
except Exception:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def list_files(self, prefix: str) -> List[str]:
|
|
149
|
+
"""列出 OSS 文件"""
|
|
150
|
+
files = []
|
|
151
|
+
for obj in oss2.ObjectIterator(self.bucket, prefix=prefix):
|
|
152
|
+
files.append(obj.key)
|
|
153
|
+
return files
|
|
154
|
+
|
|
155
|
+
def get_url(self, remote_path: str, expires: int = 3600) -> str:
|
|
156
|
+
"""获取 OSS 签名 URL"""
|
|
157
|
+
return self.bucket.sign_url('GET', remote_path, expires)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class S3StorageBackend(StorageBackend):
|
|
161
|
+
"""AWS S3 / MinIO 存储"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, access_key: str, secret_key: str, endpoint: str, bucket: str, region: str = "us-east-1"):
|
|
164
|
+
try:
|
|
165
|
+
import boto3
|
|
166
|
+
except ImportError:
|
|
167
|
+
raise ImportError("请安装 boto3: pip install boto3")
|
|
168
|
+
|
|
169
|
+
self.s3_client = boto3.client(
|
|
170
|
+
's3',
|
|
171
|
+
aws_access_key_id=access_key,
|
|
172
|
+
aws_secret_access_key=secret_key,
|
|
173
|
+
endpoint_url=endpoint if endpoint else None,
|
|
174
|
+
region_name=region
|
|
175
|
+
)
|
|
176
|
+
self.bucket = bucket
|
|
177
|
+
self.endpoint = endpoint
|
|
178
|
+
|
|
179
|
+
def upload_file(self, local_path: str, remote_path: str) -> str:
|
|
180
|
+
"""上传文件到 S3"""
|
|
181
|
+
content_type, _ = mimetypes.guess_type(local_path)
|
|
182
|
+
|
|
183
|
+
extra_args = {}
|
|
184
|
+
if content_type:
|
|
185
|
+
extra_args['ContentType'] = content_type
|
|
186
|
+
|
|
187
|
+
self.s3_client.upload_file(local_path, self.bucket, remote_path, ExtraArgs=extra_args)
|
|
188
|
+
|
|
189
|
+
if self.endpoint:
|
|
190
|
+
return f"{self.endpoint}/{self.bucket}/{remote_path}"
|
|
191
|
+
return f"https://{self.bucket}.s3.amazonaws.com/{remote_path}"
|
|
192
|
+
|
|
193
|
+
def download_file(self, remote_path: str, local_path: str) -> bool:
|
|
194
|
+
"""从 S3 下载文件"""
|
|
195
|
+
try:
|
|
196
|
+
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
self.s3_client.download_file(self.bucket, remote_path, local_path)
|
|
198
|
+
return True
|
|
199
|
+
except Exception:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def delete_file(self, remote_path: str) -> bool:
|
|
203
|
+
"""删除 S3 文件"""
|
|
204
|
+
try:
|
|
205
|
+
self.s3_client.delete_object(Bucket=self.bucket, Key=remote_path)
|
|
206
|
+
return True
|
|
207
|
+
except Exception:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
def list_files(self, prefix: str) -> List[str]:
|
|
211
|
+
"""列出 S3 文件"""
|
|
212
|
+
files = []
|
|
213
|
+
paginator = self.s3_client.get_paginator('list_objects_v2')
|
|
214
|
+
for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
|
|
215
|
+
if 'Contents' in page:
|
|
216
|
+
for obj in page['Contents']:
|
|
217
|
+
files.append(obj['Key'])
|
|
218
|
+
return files
|
|
219
|
+
|
|
220
|
+
def get_url(self, remote_path: str, expires: int = 3600) -> str:
|
|
221
|
+
"""获取 S3 签名 URL"""
|
|
222
|
+
return self.s3_client.generate_presigned_url(
|
|
223
|
+
'get_object',
|
|
224
|
+
Params={'Bucket': self.bucket, 'Key': remote_path},
|
|
225
|
+
ExpiresIn=expires
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class COSStorageBackend(StorageBackend):
|
|
230
|
+
"""腾讯云 COS 存储"""
|
|
231
|
+
|
|
232
|
+
def __init__(self, secret_id: str, secret_key: str, region: str, bucket: str):
|
|
233
|
+
try:
|
|
234
|
+
from qcloud_cos import CosConfig, CosS3Client
|
|
235
|
+
except ImportError:
|
|
236
|
+
raise ImportError("请安装 cos-python-sdk-v5: pip install cos-python-sdk-v5")
|
|
237
|
+
|
|
238
|
+
config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key)
|
|
239
|
+
self.client = CosS3Client(config)
|
|
240
|
+
self.bucket = bucket
|
|
241
|
+
self.region = region
|
|
242
|
+
|
|
243
|
+
def upload_file(self, local_path: str, remote_path: str) -> str:
|
|
244
|
+
"""上传文件到 COS"""
|
|
245
|
+
content_type, _ = mimetypes.guess_type(local_path)
|
|
246
|
+
|
|
247
|
+
with open(local_path, 'rb') as f:
|
|
248
|
+
self.client.put_object(
|
|
249
|
+
Bucket=self.bucket,
|
|
250
|
+
Body=f,
|
|
251
|
+
Key=remote_path,
|
|
252
|
+
ContentType=content_type or 'application/octet-stream'
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return f"https://{self.bucket}.cos.{self.region}.myqcloud.com/{remote_path}"
|
|
256
|
+
|
|
257
|
+
def download_file(self, remote_path: str, local_path: str) -> bool:
|
|
258
|
+
"""从 COS 下载文件"""
|
|
259
|
+
try:
|
|
260
|
+
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
response = self.client.get_object(Bucket=self.bucket, Key=remote_path)
|
|
262
|
+
response['Body'].get_stream_to_file(local_path)
|
|
263
|
+
return True
|
|
264
|
+
except Exception:
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
def delete_file(self, remote_path: str) -> bool:
|
|
268
|
+
"""删除 COS 文件"""
|
|
269
|
+
try:
|
|
270
|
+
self.client.delete_object(Bucket=self.bucket, Key=remote_path)
|
|
271
|
+
return True
|
|
272
|
+
except Exception:
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
def list_files(self, prefix: str) -> List[str]:
|
|
276
|
+
"""列出 COS 文件"""
|
|
277
|
+
files = []
|
|
278
|
+
marker = ""
|
|
279
|
+
while True:
|
|
280
|
+
response = self.client.list_objects(Bucket=self.bucket, Prefix=prefix, Marker=marker)
|
|
281
|
+
if 'Contents' in response:
|
|
282
|
+
for obj in response['Contents']:
|
|
283
|
+
files.append(obj['Key'])
|
|
284
|
+
if response['IsTruncated'] == 'false':
|
|
285
|
+
break
|
|
286
|
+
marker = response['NextMarker']
|
|
287
|
+
return files
|
|
288
|
+
|
|
289
|
+
def get_url(self, remote_path: str, expires: int = 3600) -> str:
|
|
290
|
+
"""获取 COS 签名 URL"""
|
|
291
|
+
return self.client.get_presigned_download_url(
|
|
292
|
+
Bucket=self.bucket,
|
|
293
|
+
Key=remote_path,
|
|
294
|
+
Expired=expires
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def create_storage_backend(
|
|
299
|
+
backend_type: str = "local",
|
|
300
|
+
**kwargs
|
|
301
|
+
) -> StorageBackend:
|
|
302
|
+
"""
|
|
303
|
+
工厂函数:创建存储后端
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
backend_type: 后端类型 'local', 'oss', 's3', 'cos'
|
|
307
|
+
**kwargs: 各后端的配置参数
|
|
308
|
+
- local: base_dir
|
|
309
|
+
- oss: access_key, secret_key, endpoint, bucket
|
|
310
|
+
- s3: access_key, secret_key, endpoint, bucket, region
|
|
311
|
+
- cos: secret_id, secret_key, region, bucket
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
StorageBackend 实例
|
|
315
|
+
"""
|
|
316
|
+
if backend_type == "local":
|
|
317
|
+
return LocalStorageBackend(kwargs.get("base_dir", "/tmp/agent_storage"))
|
|
318
|
+
elif backend_type == "oss":
|
|
319
|
+
return OSSStorageBackend(
|
|
320
|
+
access_key=kwargs["access_key"],
|
|
321
|
+
secret_key=kwargs["secret_key"],
|
|
322
|
+
endpoint=kwargs["endpoint"],
|
|
323
|
+
bucket=kwargs["bucket"]
|
|
324
|
+
)
|
|
325
|
+
elif backend_type == "s3":
|
|
326
|
+
return S3StorageBackend(
|
|
327
|
+
access_key=kwargs["access_key"],
|
|
328
|
+
secret_key=kwargs["secret_key"],
|
|
329
|
+
endpoint=kwargs.get("endpoint"),
|
|
330
|
+
bucket=kwargs["bucket"],
|
|
331
|
+
region=kwargs.get("region", "us-east-1")
|
|
332
|
+
)
|
|
333
|
+
elif backend_type == "cos":
|
|
334
|
+
return COSStorageBackend(
|
|
335
|
+
secret_id=kwargs["secret_id"],
|
|
336
|
+
secret_key=kwargs["secret_key"],
|
|
337
|
+
region=kwargs["region"],
|
|
338
|
+
bucket=kwargs["bucket"]
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
raise ValueError(f"不支持的存储后端: {backend_type}")
|
htmlgen_mcp/config.py
CHANGED
|
@@ -25,14 +25,14 @@ class ProjectConfig:
|
|
|
25
25
|
"""获取默认的项目输出目录(跨平台)
|
|
26
26
|
|
|
27
27
|
优先级:
|
|
28
|
-
1. 环境变量 WEB_AGENT_OUTPUT_DIR
|
|
28
|
+
1. 环境变量 WEB_AGENT_OUTPUT_DIR / WEB_AGENT_PROJECT_ROOT
|
|
29
29
|
2. 系统特定的文档目录
|
|
30
30
|
3. 用户主目录下的隐藏目录
|
|
31
31
|
4. 系统临时目录
|
|
32
32
|
"""
|
|
33
33
|
|
|
34
34
|
# 1. 检查环境变量(所有平台通用)
|
|
35
|
-
env_dir = os.environ.get('WEB_AGENT_OUTPUT_DIR')
|
|
35
|
+
env_dir = os.environ.get('WEB_AGENT_OUTPUT_DIR') or os.environ.get('WEB_AGENT_PROJECT_ROOT')
|
|
36
36
|
if env_dir:
|
|
37
37
|
output_dir = Path(env_dir)
|
|
38
38
|
try:
|
|
@@ -111,7 +111,7 @@ class ProjectConfig:
|
|
|
111
111
|
try:
|
|
112
112
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
113
113
|
print(f"⚠️ 使用临时目录: {temp_dir}")
|
|
114
|
-
print(f"💡 建议设置环境变量 WEB_AGENT_OUTPUT_DIR 到更合适的位置")
|
|
114
|
+
print(f"💡 建议设置环境变量 WEB_AGENT_OUTPUT_DIR 或 WEB_AGENT_PROJECT_ROOT 到更合适的位置")
|
|
115
115
|
return temp_dir
|
|
116
116
|
except Exception as e:
|
|
117
117
|
# 如果连临时目录都无法创建,使用当前工作目录
|
|
@@ -280,7 +280,7 @@ def get_project_directory(project_name: str = None) -> str:
|
|
|
280
280
|
project_dir = config.create_project_directory(project_name, use_timestamp=True)
|
|
281
281
|
|
|
282
282
|
print(f"📁 项目将生成在: {project_dir}")
|
|
283
|
-
print(f"💡 提示: 可通过设置环境变量 WEB_AGENT_OUTPUT_DIR 来自定义输出目录")
|
|
283
|
+
print(f"💡 提示: 可通过设置环境变量 WEB_AGENT_OUTPUT_DIR 或 WEB_AGENT_PROJECT_ROOT 来自定义输出目录")
|
|
284
284
|
|
|
285
285
|
return str(project_dir)
|
|
286
286
|
|
|
@@ -323,4 +323,4 @@ __all__ = [
|
|
|
323
323
|
'get_project_directory',
|
|
324
324
|
'list_recent_projects',
|
|
325
325
|
'clean_temp_projects'
|
|
326
|
-
]
|
|
326
|
+
]
|
htmlgen_mcp/web_agent_server.py
CHANGED
|
@@ -42,13 +42,34 @@ DEFAULT_BASE_URL = os.environ.get(
|
|
|
42
42
|
|
|
43
43
|
mcp = FastMCP("smart-web-agent")
|
|
44
44
|
|
|
45
|
-
# MCP
|
|
45
|
+
# MCP 服务持久化目录:优先使用项目根目录下的 .mcp_state,可通过环境变量覆盖
|
|
46
46
|
DEFAULT_MCP_STORAGE = Path.home() / ".mcp"
|
|
47
47
|
MCP_SERVICE_NAME = os.environ.get("MCP_SERVICE_NAME", "make_web")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
)
|
|
51
|
-
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_mcp_data_root() -> Path:
|
|
51
|
+
"""解析 MCP 数据目录,优先使用共享项目目录"""
|
|
52
|
+
env_dir = os.environ.get("MCP_DATA_DIR")
|
|
53
|
+
if env_dir:
|
|
54
|
+
path = Path(env_dir)
|
|
55
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
shared_dir = Path(DEFAULT_PROJECT_ROOT) / ".mcp_state"
|
|
59
|
+
try:
|
|
60
|
+
shared_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
return shared_dir
|
|
62
|
+
except Exception:
|
|
63
|
+
fallback = DEFAULT_MCP_STORAGE / MCP_SERVICE_NAME
|
|
64
|
+
fallback.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
print(
|
|
66
|
+
f"⚠️ 无法在 {shared_dir} 创建 MCP 数据目录,改用 {fallback}",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
return fallback
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
MCP_DATA_ROOT = _resolve_mcp_data_root()
|
|
52
73
|
|
|
53
74
|
# 简单的缓存:记录最近一次生成的计划,避免“create_simple_site → execute_plan”时需手动传递
|
|
54
75
|
PLAN_CACHE_DIR = MCP_DATA_ROOT / "plan_cache"
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
htmlgen_mcp/__init__.py,sha256=8jambwGFxu8RNWes1BUEGDHErV-LcvDaABWqNja9GW0,114
|
|
2
|
-
htmlgen_mcp/config.py,sha256=
|
|
2
|
+
htmlgen_mcp/config.py,sha256=TXDAvNFmeCI4RIznrBhZhhvWLgXAw6MO6j83prJxFsE,11373
|
|
3
3
|
htmlgen_mcp/sse_optimizations.py,sha256=_UTpgLtxgNAiiEkO5lPihOi1-eEQk6R4ejNParufrrc,6932
|
|
4
|
-
htmlgen_mcp/web_agent_server.py,sha256=
|
|
4
|
+
htmlgen_mcp/web_agent_server.py,sha256=UfjO3DtiHekr61nJ0z-Ne9cMXTqzjRLuIfIr85wBhIY,48091
|
|
5
5
|
htmlgen_mcp/agents/__init__.py,sha256=Xydfjzw9s9O6I5Ixx6EmsTdXu26136NDPUAqt9B1hzE,121
|
|
6
6
|
htmlgen_mcp/agents/ai_content_generator.py,sha256=tWGC9cY6Wp7MB1P9J7uCv8LUdiS02rgS6vxaNHD7KQk,10311
|
|
7
|
+
htmlgen_mcp/agents/cluster_state.py,sha256=eDAqVm_A94_x7YfmhKKQ6T1FFt_4XQOyUiHdiTPF7Qg,14615
|
|
8
|
+
htmlgen_mcp/agents/cluster_storage.py,sha256=TJTl_uk4mBHf6nZ1Es0LzPzXfneUITVW-JXTP0gZSdE,11534
|
|
7
9
|
htmlgen_mcp/agents/quick_generator.py,sha256=U16MOazMdBBz0MjtfsFuc-dTLFHpPbXzGG0Yuy5ljwE,9561
|
|
8
10
|
htmlgen_mcp/agents/smart_web_agent.py,sha256=gIlHqE2Zvf8IwXQCBVSzteYN0u7C6e3n5D7jCDlbR-8,99054
|
|
9
11
|
htmlgen_mcp/agents/web_tools/__init__.py,sha256=2Km6T6tMkjMFKoEWkrsSuLUyr_yfHq78_V_oreqQOMc,2472
|
|
@@ -24,8 +26,8 @@ htmlgen_mcp/agents/web_tools/simple_css.py,sha256=kj9X3sHHhj1wGwBVL20j6w2qIHXRdx
|
|
|
24
26
|
htmlgen_mcp/agents/web_tools/simple_js.py,sha256=xMiuF-u-h_IIkUONZIa4Xf8vKB5mcXxwQf5b_BIcpoE,12174
|
|
25
27
|
htmlgen_mcp/agents/web_tools/simple_templates.py,sha256=-Rs-SsWpGZT2hiwa3jZNVDHOMZOo1vV2pWbmBdR30os,6471
|
|
26
28
|
htmlgen_mcp/agents/web_tools/validation.py,sha256=bNA6aWXrCSi7sPqQw5bBR3XF69gRf85D5jSMi996CtI,2069
|
|
27
|
-
htmlgen_mcp-0.3.
|
|
28
|
-
htmlgen_mcp-0.3.
|
|
29
|
-
htmlgen_mcp-0.3.
|
|
30
|
-
htmlgen_mcp-0.3.
|
|
31
|
-
htmlgen_mcp-0.3.
|
|
29
|
+
htmlgen_mcp-0.3.2.dist-info/METADATA,sha256=ZbrJ1I9ijuFaPO0LDrRrVZS1r8d82_qLZ6xM-EP_dN0,5161
|
|
30
|
+
htmlgen_mcp-0.3.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
31
|
+
htmlgen_mcp-0.3.2.dist-info/entry_points.txt,sha256=w7ufTQJobIxT3FYI24yKsCEwEQvBOWhNjckUd9Amu_k,66
|
|
32
|
+
htmlgen_mcp-0.3.2.dist-info/top_level.txt,sha256=KnglzX4ekV8SQkHTsJg2_nTBXz2TxaYLdvoMMovHLNk,12
|
|
33
|
+
htmlgen_mcp-0.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|