htmlgen-mcp 0.2.0__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.
- MCP/__init__.py +6 -0
- MCP/web_agent_server.py +1257 -0
- agents/__init__.py +6 -0
- agents/smart_web_agent.py +2384 -0
- agents/web_tools/__init__.py +84 -0
- agents/web_tools/bootstrap.py +49 -0
- agents/web_tools/browser.py +28 -0
- agents/web_tools/colors.py +137 -0
- agents/web_tools/css.py +1473 -0
- agents/web_tools/edgeone_deploy.py +541 -0
- agents/web_tools/html_templates.py +1770 -0
- agents/web_tools/images.py +600 -0
- agents/web_tools/images_fixed.py +195 -0
- agents/web_tools/js.py +235 -0
- agents/web_tools/navigation.py +386 -0
- agents/web_tools/project.py +34 -0
- agents/web_tools/simple_builder.py +346 -0
- agents/web_tools/simple_css.py +475 -0
- agents/web_tools/simple_js.py +454 -0
- agents/web_tools/simple_templates.py +220 -0
- agents/web_tools/validation.py +65 -0
- htmlgen_mcp-0.2.0.dist-info/METADATA +171 -0
- htmlgen_mcp-0.2.0.dist-info/RECORD +26 -0
- htmlgen_mcp-0.2.0.dist-info/WHEEL +5 -0
- htmlgen_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- htmlgen_mcp-0.2.0.dist-info/top_level.txt +2 -0
MCP/web_agent_server.py
ADDED
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Smart Web Agent MCP 服务
|
|
4
|
+
|
|
5
|
+
基于 SmartWebAgent 提供网页生成的规划与执行接口,兼容 Model Context Protocol。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import traceback
|
|
16
|
+
import zipfile
|
|
17
|
+
import tempfile
|
|
18
|
+
import aiohttp
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
# 确保项目根目录在模块搜索路径中
|
|
22
|
+
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
PROJECT_ROOT = os.path.dirname(CURRENT_DIR)
|
|
24
|
+
if PROJECT_ROOT not in sys.path:
|
|
25
|
+
sys.path.insert(0, PROJECT_ROOT)
|
|
26
|
+
|
|
27
|
+
from fastmcp import FastMCP # type: ignore
|
|
28
|
+
|
|
29
|
+
import uuid
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from agents.smart_web_agent import SmartWebAgent
|
|
33
|
+
|
|
34
|
+
DEFAULT_PROJECT_ROOT = os.path.abspath(
|
|
35
|
+
os.environ.get("WEB_AGENT_PROJECT_ROOT", os.path.join(PROJECT_ROOT, "projects"))
|
|
36
|
+
)
|
|
37
|
+
DEFAULT_MODEL = os.environ.get("WEB_AGENT_MODEL", "qwen3-coder-plus-2025-09-23")
|
|
38
|
+
DEFAULT_BASE_URL = os.environ.get(
|
|
39
|
+
"OPENAI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
mcp = FastMCP("smart-web-agent")
|
|
43
|
+
|
|
44
|
+
# MCP 服务持久化目录:默认为 ~/.mcp/make_web,可通过环境变量覆盖
|
|
45
|
+
DEFAULT_MCP_STORAGE = Path.home() / ".mcp"
|
|
46
|
+
MCP_SERVICE_NAME = os.environ.get("MCP_SERVICE_NAME", "make_web")
|
|
47
|
+
MCP_DATA_ROOT = Path(
|
|
48
|
+
os.environ.get("MCP_DATA_DIR", DEFAULT_MCP_STORAGE / MCP_SERVICE_NAME)
|
|
49
|
+
)
|
|
50
|
+
MCP_DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# 简单的缓存:记录最近一次生成的计划,避免“create_simple_site → execute_plan”时需手动传递
|
|
53
|
+
PLAN_CACHE_DIR = MCP_DATA_ROOT / "plan_cache"
|
|
54
|
+
PLAN_CACHE_DIR.mkdir(exist_ok=True)
|
|
55
|
+
|
|
56
|
+
PROGRESS_LOG_DIR = MCP_DATA_ROOT / "progress_logs"
|
|
57
|
+
PROGRESS_LOG_DIR.mkdir(exist_ok=True)
|
|
58
|
+
|
|
59
|
+
JOB_STATE_DIR = MCP_DATA_ROOT / "jobs" / "state"
|
|
60
|
+
JOB_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
CONTEXT_CACHE_DIR = MCP_DATA_ROOT / "context_cache"
|
|
63
|
+
CONTEXT_CACHE_DIR.mkdir(exist_ok=True)
|
|
64
|
+
|
|
65
|
+
_PLAN_CACHE: dict[tuple[str, str], Dict[str, Any]] = {}
|
|
66
|
+
_PLAN_CACHE_BY_ID: dict[str, Dict[str, Any]] = {}
|
|
67
|
+
_PROGRESS_LOG_BY_ID: dict[str, str] = {}
|
|
68
|
+
_PROGRESS_LOG_BY_JOB: dict[str, str] = {}
|
|
69
|
+
_JOB_REGISTRY: dict[str, Dict[str, Any]] = {}
|
|
70
|
+
_CONTEXT_CACHE_BY_ID: dict[str, Dict[str, Any]] = {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _job_state_path(job_id: str) -> Path:
|
|
74
|
+
return JOB_STATE_DIR / f"{job_id}.json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _persist_job_state(job_id: str) -> None:
|
|
78
|
+
job = _JOB_REGISTRY.get(job_id)
|
|
79
|
+
if not job:
|
|
80
|
+
return
|
|
81
|
+
job_copy = {k: v for k, v in job.items() if k not in {"agent"}}
|
|
82
|
+
job_copy["updated_at"] = time.time()
|
|
83
|
+
path = _job_state_path(job_id)
|
|
84
|
+
try:
|
|
85
|
+
path.write_text(
|
|
86
|
+
json.dumps(job_copy, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _load_job_states() -> None:
|
|
93
|
+
for path in JOB_STATE_DIR.glob("*.json"):
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
96
|
+
job_id = data.get("job_id") or path.stem
|
|
97
|
+
if not job_id:
|
|
98
|
+
continue
|
|
99
|
+
if data.get("status") == "running":
|
|
100
|
+
data["status"] = "stopped"
|
|
101
|
+
data["message"] = "任务在服务器重启时中断,请重新执行"
|
|
102
|
+
_JOB_REGISTRY[job_id] = data
|
|
103
|
+
progress_log = data.get("progress_log")
|
|
104
|
+
if progress_log:
|
|
105
|
+
if not os.path.isabs(progress_log):
|
|
106
|
+
progress_log = os.path.join(PROJECT_ROOT, progress_log)
|
|
107
|
+
_PROGRESS_LOG_BY_JOB[job_id] = progress_log
|
|
108
|
+
plan_id = data.get("plan_id")
|
|
109
|
+
if plan_id and plan_id not in _PROGRESS_LOG_BY_ID:
|
|
110
|
+
_PROGRESS_LOG_BY_ID[plan_id] = progress_log
|
|
111
|
+
except Exception:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_load_job_states()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _context_cache_path(context_id: str) -> Path:
|
|
119
|
+
return CONTEXT_CACHE_DIR / f"{context_id}.json"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_context_cache() -> None:
|
|
123
|
+
for path in CONTEXT_CACHE_DIR.glob("*.json"):
|
|
124
|
+
try:
|
|
125
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
126
|
+
context_id = data.get("context_id") or path.stem
|
|
127
|
+
if not context_id:
|
|
128
|
+
continue
|
|
129
|
+
data.setdefault("path", str(path))
|
|
130
|
+
_CONTEXT_CACHE_BY_ID[context_id] = data
|
|
131
|
+
except Exception:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_load_context_cache()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_cached_context(context_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
139
|
+
if not context_id:
|
|
140
|
+
return None
|
|
141
|
+
cached = _CONTEXT_CACHE_BY_ID.get(context_id)
|
|
142
|
+
if cached:
|
|
143
|
+
return cached
|
|
144
|
+
path = _context_cache_path(context_id)
|
|
145
|
+
if path.exists():
|
|
146
|
+
try:
|
|
147
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
148
|
+
data.setdefault("path", str(path))
|
|
149
|
+
_CONTEXT_CACHE_BY_ID[context_id] = data
|
|
150
|
+
return data
|
|
151
|
+
except Exception:
|
|
152
|
+
return None
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_project_directory(project_root: Optional[str]) -> str:
|
|
157
|
+
base = project_root or DEFAULT_PROJECT_ROOT
|
|
158
|
+
abs_path = os.path.abspath(base)
|
|
159
|
+
os.makedirs(abs_path, exist_ok=True)
|
|
160
|
+
return abs_path
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_agent(
|
|
164
|
+
project_directory: str,
|
|
165
|
+
model: Optional[str] = None,
|
|
166
|
+
*,
|
|
167
|
+
show_code: bool = False,
|
|
168
|
+
verbose: bool = False,
|
|
169
|
+
save_output: bool = False,
|
|
170
|
+
) -> SmartWebAgent:
|
|
171
|
+
return SmartWebAgent(
|
|
172
|
+
project_directory=project_directory,
|
|
173
|
+
model=model or DEFAULT_MODEL,
|
|
174
|
+
show_code=show_code,
|
|
175
|
+
verbose=verbose,
|
|
176
|
+
save_output=save_output,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _prepare_agent_run(agent: SmartWebAgent, description: str) -> None:
|
|
181
|
+
agent.execution_start_time = time.time()
|
|
182
|
+
agent.execution_history = []
|
|
183
|
+
agent.created_files = []
|
|
184
|
+
agent.latest_user_request = description
|
|
185
|
+
agent.current_plan = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _decode_plan(agent: SmartWebAgent, plan: Any) -> Dict[str, Any]:
|
|
189
|
+
if isinstance(plan, str):
|
|
190
|
+
plan = json.loads(plan)
|
|
191
|
+
if not isinstance(plan, dict):
|
|
192
|
+
raise ValueError("plan 应该是 JSON 对象")
|
|
193
|
+
source_description = plan.pop("__source_description", None)
|
|
194
|
+
plan.pop("__plan_id", None)
|
|
195
|
+
plan.pop("__plan_path", None)
|
|
196
|
+
if source_description:
|
|
197
|
+
agent.latest_user_request = source_description
|
|
198
|
+
repaired = agent._repair_plan_tools_sequence(plan)
|
|
199
|
+
if not agent._validate_plan(repaired):
|
|
200
|
+
raise ValueError("执行计划不完整或格式错误")
|
|
201
|
+
agent.current_plan = repaired
|
|
202
|
+
return repaired
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _create_plan(agent: SmartWebAgent, description: str) -> Dict[str, Any]:
|
|
206
|
+
_prepare_agent_run(agent, description)
|
|
207
|
+
enhanced = agent._enhance_user_input(description)
|
|
208
|
+
plan = agent._get_execution_plan_with_retry(enhanced)
|
|
209
|
+
if not plan:
|
|
210
|
+
raise RuntimeError("未能生成执行计划,请检查描述或模型配置")
|
|
211
|
+
if not isinstance(plan, dict):
|
|
212
|
+
raise ValueError("模型返回的计划格式异常,应为 JSON 对象")
|
|
213
|
+
plan_id = uuid.uuid4().hex
|
|
214
|
+
plan_path = PLAN_CACHE_DIR / f"{plan_id}.json"
|
|
215
|
+
|
|
216
|
+
plan_for_storage = plan.copy()
|
|
217
|
+
plan_for_storage["__source_description"] = description
|
|
218
|
+
plan_path.write_text(
|
|
219
|
+
json.dumps(plan_for_storage, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
plan["__source_description"] = description
|
|
223
|
+
|
|
224
|
+
_PLAN_CACHE_BY_ID[plan_id] = {
|
|
225
|
+
"plan": plan,
|
|
226
|
+
"project_directory": agent.project_directory,
|
|
227
|
+
"description": description,
|
|
228
|
+
"source_description": description,
|
|
229
|
+
"path": str(plan_path),
|
|
230
|
+
"plan_id": plan_id,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
cache_key = (agent.project_directory, description)
|
|
234
|
+
_PLAN_CACHE[cache_key] = {
|
|
235
|
+
"plan": plan,
|
|
236
|
+
"plan_id": plan_id,
|
|
237
|
+
"description": description,
|
|
238
|
+
"source_description": description,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
plan["__plan_id"] = plan_id
|
|
242
|
+
plan["__plan_path"] = str(plan_path)
|
|
243
|
+
return plan
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _execute_plan(
|
|
247
|
+
agent: SmartWebAgent,
|
|
248
|
+
plan: Dict[str, Any],
|
|
249
|
+
*,
|
|
250
|
+
progress_log_path: Optional[str] = None,
|
|
251
|
+
) -> Dict[str, Any]:
|
|
252
|
+
progress_events: list[Dict[str, Any]] = []
|
|
253
|
+
|
|
254
|
+
def _collect(event: Dict[str, Any]) -> None:
|
|
255
|
+
if isinstance(event, dict):
|
|
256
|
+
progress_events.append(event)
|
|
257
|
+
if progress_log_path:
|
|
258
|
+
try:
|
|
259
|
+
log_record = dict(event)
|
|
260
|
+
log_record.setdefault("timestamp", time.time())
|
|
261
|
+
with open(progress_log_path, "a", encoding="utf-8") as log_file:
|
|
262
|
+
log_file.write(json.dumps(log_record, ensure_ascii=False))
|
|
263
|
+
log_file.write("\n")
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
results = agent._execute_plan_with_recovery(
|
|
268
|
+
plan,
|
|
269
|
+
confirm_each_step=False, # 后台执行模式,不需要确认
|
|
270
|
+
progress_callback=_collect,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if any(
|
|
274
|
+
r.get("status") == "success"
|
|
275
|
+
and r.get("tool") in {"create_html_file", "create_css_file"}
|
|
276
|
+
for r in results
|
|
277
|
+
):
|
|
278
|
+
agent._run_consistency_review(plan)
|
|
279
|
+
|
|
280
|
+
report = agent._generate_execution_report(plan, results)
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"report": report,
|
|
284
|
+
"progress": progress_events,
|
|
285
|
+
"results": results,
|
|
286
|
+
"created_files": list(agent.created_files),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# @mcp.tool()
|
|
291
|
+
# async def plan_site(
|
|
292
|
+
# description: str,
|
|
293
|
+
# project_root: Optional[str] = None,
|
|
294
|
+
# context_id: Optional[str] = None,
|
|
295
|
+
# context_content: Optional[str] = None,
|
|
296
|
+
# ) -> Dict[str, Any]:
|
|
297
|
+
# """根据需求与上下文生成网页构建计划,所有信息都会交由 AI 模型统一分析。
|
|
298
|
+
#
|
|
299
|
+
# ⚠️ 重要参数说明:
|
|
300
|
+
# - description: 网站建设需求或目标,侧重描述要实现的结构、功能、风格
|
|
301
|
+
# - context_content: 🔥 核心参数!网页制作所需的全部原始文本或数据
|
|
302
|
+
# * 例如:地图查询结果、咖啡馆信息、产品数据、营业时间、地址等
|
|
303
|
+
# * 这是模型获取上下文内容的唯一入口,不会自动从 description 推断
|
|
304
|
+
# * 请把需要引用的完整信息直接放入该参数
|
|
305
|
+
# * 支持各种格式:文本、JSON字符串、结构化数据等
|
|
306
|
+
#
|
|
307
|
+
# 其他参数:
|
|
308
|
+
# - project_root: 可选,自定义项目根目录;缺省时使用默认目录
|
|
309
|
+
# - context_id: 可选,引用已缓存的上下文快照以复用历史资料
|
|
310
|
+
#
|
|
311
|
+
# 返回值说明:
|
|
312
|
+
# - status: 操作状态 ("success" 或 "error")
|
|
313
|
+
# - plan_id: 生成的计划唯一标识符,用于后续执行
|
|
314
|
+
# - plan_path: 计划 JSON 文件的保存路径
|
|
315
|
+
# - project_directory: 解析后的项目目录路径
|
|
316
|
+
# - model: 使用的 AI 模型名称
|
|
317
|
+
#
|
|
318
|
+
# 💡 使用提示:
|
|
319
|
+
# 如果你先用其他工具(如地图查询)获取了数据,请将结果完整传递给 context_content,
|
|
320
|
+
# 这样 AI 就能基于真实数据生成个性化网站。
|
|
321
|
+
# """
|
|
322
|
+
# try:
|
|
323
|
+
# project_dir = _resolve_project_directory(project_root)
|
|
324
|
+
# agent = _build_agent(project_dir)
|
|
325
|
+
#
|
|
326
|
+
# # 直接使用 description,如果有额外的上下文则附加
|
|
327
|
+
# final_description = description
|
|
328
|
+
#
|
|
329
|
+
# # 如果提供了 context_content,附加到描述中
|
|
330
|
+
# if context_content:
|
|
331
|
+
# final_description = f"{description}\n\n【附加内容】\n{context_content}"
|
|
332
|
+
#
|
|
333
|
+
# # 如果提供了 context_id,尝试获取缓存的内容
|
|
334
|
+
# elif context_id:
|
|
335
|
+
# cached = _resolve_cached_context(context_id)
|
|
336
|
+
# if cached:
|
|
337
|
+
# cached_content = cached.get("context")
|
|
338
|
+
# if cached_content:
|
|
339
|
+
# # 尝试解析JSON格式的增强数据
|
|
340
|
+
# try:
|
|
341
|
+
# enhanced_data = json.loads(cached_content)
|
|
342
|
+
# if "original_content" in enhanced_data:
|
|
343
|
+
# cached_content = enhanced_data["original_content"]
|
|
344
|
+
# except (json.JSONDecodeError, TypeError):
|
|
345
|
+
# pass # 使用原始内容
|
|
346
|
+
#
|
|
347
|
+
# if cached_content:
|
|
348
|
+
# final_description = f"{description}\n\n【缓存内容】\n{cached_content}"
|
|
349
|
+
#
|
|
350
|
+
# # 让 AI 模型直接处理所有内容
|
|
351
|
+
# plan = await asyncio.to_thread(_create_plan, agent, final_description)
|
|
352
|
+
# plan_id = plan.pop("__plan_id", None)
|
|
353
|
+
# plan_path = plan.pop("__plan_path", None)
|
|
354
|
+
#
|
|
355
|
+
# return {
|
|
356
|
+
# "status": "success",
|
|
357
|
+
# "plan_id": plan_id,
|
|
358
|
+
# "plan_path": plan_path,
|
|
359
|
+
# "project_directory": project_dir,
|
|
360
|
+
# "model": agent.model,
|
|
361
|
+
# "message": "计划已生成,AI模型已分析所提供的全部内容"
|
|
362
|
+
# }
|
|
363
|
+
# except Exception as exc:
|
|
364
|
+
# return {
|
|
365
|
+
# "status": "error",
|
|
366
|
+
# "message": str(exc),
|
|
367
|
+
# "traceback": traceback.format_exc(),
|
|
368
|
+
# }
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@mcp.tool()
|
|
372
|
+
async def execute_plan(
|
|
373
|
+
plan_id: str,
|
|
374
|
+
*,
|
|
375
|
+
project_root: str,
|
|
376
|
+
# auto_plan: bool = False, # 已禁用,没有实际作用
|
|
377
|
+
# confirm_each_step: bool = False, # 后台执行模式下用户无法交互确认
|
|
378
|
+
# show_code: bool = False, # 后台执行时用户看不到输出
|
|
379
|
+
# verbose: bool = False, # 后台执行时详细日志意义不大
|
|
380
|
+
save_output: bool = False,
|
|
381
|
+
progress_log: Optional[str] = None,
|
|
382
|
+
auto_upload: bool = False,
|
|
383
|
+
upload_url: str = "https://www.mcpcn.cc/api/fileUploadAndDownload/uploadMcpFile",
|
|
384
|
+
) -> Dict[str, Any]:
|
|
385
|
+
"""执行网页构建计划,始终以后台模式运行。
|
|
386
|
+
|
|
387
|
+
参数详细说明:
|
|
388
|
+
- plan_id: 计划的唯一标识符
|
|
389
|
+
由 plan_site 工具返回的计划ID,用于从缓存或文件系统中查找对应的执行计划。
|
|
390
|
+
例如:"a1b2c3d4e5f6..." 这样的32位十六进制字符串。
|
|
391
|
+
|
|
392
|
+
- project_root: 网站文件生成的目标目录路径
|
|
393
|
+
指定项目文件的输出位置,可以是绝对路径或相对路径。
|
|
394
|
+
例如:"/path/to/my/website" 或 "./my-project"
|
|
395
|
+
如果目录不存在,系统会自动创建。
|
|
396
|
+
|
|
397
|
+
- save_output: 是否保存执行过程的详细输出到文件
|
|
398
|
+
True: 会自动创建进度日志文件,记录所有执行步骤和结果
|
|
399
|
+
False: 不创建进度日志文件,只保留基本的任务状态信息
|
|
400
|
+
建议在调试或需要详细追踪时设置为 True
|
|
401
|
+
|
|
402
|
+
- progress_log: 自定义进度日志文件的保存路径(可选)
|
|
403
|
+
如果指定:使用该路径保存进度日志(JSONL格式)
|
|
404
|
+
如果未指定但 save_output=True:自动在 ~/.mcp/make_web/progress_logs 目录创建时间戳命名的日志文件(可通过环境变量覆盖)
|
|
405
|
+
如果都未指定:不记录进度日志
|
|
406
|
+
路径可以是绝对路径或相对于 project_root 的相对路径
|
|
407
|
+
|
|
408
|
+
- auto_upload: 是否在构建完成后自动上传到MCP服务器
|
|
409
|
+
True: 构建完成后自动打包为ZIP并上传,返回访问URL
|
|
410
|
+
False: 仅构建,不上传
|
|
411
|
+
默认为 False
|
|
412
|
+
|
|
413
|
+
- upload_url: 文件上传API地址
|
|
414
|
+
默认为 "https://www.mcpcn.cc/api/fileUploadAndDownload/uploadMcpFile"
|
|
415
|
+
只在 auto_upload=True 时生效
|
|
416
|
+
|
|
417
|
+
执行流程:
|
|
418
|
+
- 任务始终在后台异步执行,立即返回 job_id 和 progress_log 路径
|
|
419
|
+
- 使用 get_progress(job_id=..., limit=...) 可以实时查询执行状态和进度
|
|
420
|
+
- 支持通过进度日志文件追踪详细的执行步骤和结果
|
|
421
|
+
- 执行完成后可以获取完整的执行报告和生成的文件列表
|
|
422
|
+
- 🎯 新增:auto_upload=True时,构建完成(100%)后自动上传并返回访问URL
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
project_dir = _resolve_project_directory(project_root)
|
|
426
|
+
|
|
427
|
+
# 移除 auto_plan 检查,因为参数已被移除
|
|
428
|
+
|
|
429
|
+
if progress_log:
|
|
430
|
+
progress_log_path = (
|
|
431
|
+
progress_log
|
|
432
|
+
if os.path.isabs(progress_log)
|
|
433
|
+
else os.path.join(project_dir, progress_log)
|
|
434
|
+
)
|
|
435
|
+
elif save_output:
|
|
436
|
+
progress_log_path = os.path.join(
|
|
437
|
+
PROGRESS_LOG_DIR, f"agent_progress_{int(time.time())}.jsonl"
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
progress_log_path = None
|
|
441
|
+
|
|
442
|
+
if progress_log_path:
|
|
443
|
+
try:
|
|
444
|
+
Path(progress_log_path).parent.mkdir(parents=True, exist_ok=True)
|
|
445
|
+
Path(progress_log_path).write_text("", encoding="utf-8")
|
|
446
|
+
except Exception:
|
|
447
|
+
progress_log_path = None
|
|
448
|
+
|
|
449
|
+
agent = _build_agent(
|
|
450
|
+
project_dir,
|
|
451
|
+
save_output=save_output,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# 通过 plan_id 查询计划
|
|
455
|
+
cached_by_id = _PLAN_CACHE_BY_ID.get(plan_id)
|
|
456
|
+
|
|
457
|
+
# 尝试多种文件命名格式
|
|
458
|
+
possible_paths = [
|
|
459
|
+
PLAN_CACHE_DIR / f"{plan_id}.json", # 标准格式
|
|
460
|
+
PLAN_CACHE_DIR
|
|
461
|
+
/ f"simple_site_plan_{plan_id}.json", # create_simple_site格式
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
plan_path = None
|
|
465
|
+
for path in possible_paths:
|
|
466
|
+
if path.exists():
|
|
467
|
+
plan_path = path
|
|
468
|
+
break
|
|
469
|
+
|
|
470
|
+
if not cached_by_id and plan_path:
|
|
471
|
+
try:
|
|
472
|
+
cached_plan_file = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
473
|
+
source_description = None
|
|
474
|
+
if isinstance(cached_plan_file, dict):
|
|
475
|
+
source_description = cached_plan_file.get("__source_description")
|
|
476
|
+
# 提取实际的plan部分,而不是整个文件内容
|
|
477
|
+
actual_plan = cached_plan_file.get("plan")
|
|
478
|
+
if not actual_plan:
|
|
479
|
+
# 如果没有plan字段,可能是旧格式,直接使用文件内容
|
|
480
|
+
actual_plan = cached_plan_file
|
|
481
|
+
|
|
482
|
+
cached_by_id = {
|
|
483
|
+
"plan": actual_plan, # 传递实际的plan内容
|
|
484
|
+
"project_directory": project_dir,
|
|
485
|
+
"plan_id": plan_id,
|
|
486
|
+
"description": source_description
|
|
487
|
+
or cached_plan_file.get("description"),
|
|
488
|
+
"source_description": source_description,
|
|
489
|
+
}
|
|
490
|
+
_PLAN_CACHE_BY_ID[plan_id] = cached_by_id
|
|
491
|
+
except Exception:
|
|
492
|
+
cached_by_id = None
|
|
493
|
+
|
|
494
|
+
if not cached_by_id:
|
|
495
|
+
raise ValueError(
|
|
496
|
+
f"未找到 plan_id '{plan_id}' 对应的计划,请先调用 create_simple_site 生成计划"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
plan_dict = _decode_plan(agent, cached_by_id.get("plan"))
|
|
500
|
+
effective_description = (
|
|
501
|
+
cached_by_id.get("source_description")
|
|
502
|
+
or cached_by_id.get("description")
|
|
503
|
+
or plan_dict.get("task_analysis")
|
|
504
|
+
or plan_dict.get("project_name")
|
|
505
|
+
or plan_dict.get("site_type")
|
|
506
|
+
or "Web Project Execution"
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
_prepare_agent_run(agent, effective_description)
|
|
510
|
+
agent.current_plan = plan_dict
|
|
511
|
+
|
|
512
|
+
# 始终以后台模式执行
|
|
513
|
+
job_id = uuid.uuid4().hex
|
|
514
|
+
job_info = {
|
|
515
|
+
"job_id": job_id,
|
|
516
|
+
"status": "running",
|
|
517
|
+
"plan_id": plan_id,
|
|
518
|
+
"description": effective_description,
|
|
519
|
+
"project_directory": project_dir,
|
|
520
|
+
"model": agent.model,
|
|
521
|
+
"progress_log": progress_log_path,
|
|
522
|
+
"started_at": time.time(),
|
|
523
|
+
"updated_at": time.time(),
|
|
524
|
+
}
|
|
525
|
+
_JOB_REGISTRY[job_id] = job_info
|
|
526
|
+
|
|
527
|
+
if plan_id and progress_log_path:
|
|
528
|
+
_PROGRESS_LOG_BY_ID[plan_id] = progress_log_path
|
|
529
|
+
if progress_log_path:
|
|
530
|
+
_PROGRESS_LOG_BY_JOB[job_id] = progress_log_path
|
|
531
|
+
|
|
532
|
+
_persist_job_state(job_id)
|
|
533
|
+
|
|
534
|
+
asyncio.create_task(
|
|
535
|
+
_run_execution_job(
|
|
536
|
+
job_id,
|
|
537
|
+
agent,
|
|
538
|
+
plan_dict,
|
|
539
|
+
progress_log_path=progress_log_path,
|
|
540
|
+
auto_upload=auto_upload,
|
|
541
|
+
upload_url=upload_url,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
if auto_upload:
|
|
546
|
+
message = (
|
|
547
|
+
"执行已在后台启动(含自动上传):调用 get_progress(job_id='{}', limit=20) "
|
|
548
|
+
"可获取构建和上传进度。完成后将返回网站访问URL。"
|
|
549
|
+
).format(job_id)
|
|
550
|
+
else:
|
|
551
|
+
message = (
|
|
552
|
+
"执行已在后台启动:调用 get_progress(job_id='{}', limit=20) "
|
|
553
|
+
"或传入 progress_log='{}' 可获取实时进度"
|
|
554
|
+
).format(job_id, progress_log_path or "<未启用进度日志>")
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
"status": "started",
|
|
558
|
+
"job_id": job_id,
|
|
559
|
+
"plan_id": plan_id,
|
|
560
|
+
"progress_log": progress_log_path,
|
|
561
|
+
"auto_upload": auto_upload,
|
|
562
|
+
"upload_url": upload_url if auto_upload else None,
|
|
563
|
+
"message": message,
|
|
564
|
+
}
|
|
565
|
+
except Exception as exc:
|
|
566
|
+
return {
|
|
567
|
+
"status": "error",
|
|
568
|
+
"message": str(exc),
|
|
569
|
+
"traceback": traceback.format_exc(),
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
async def _run_execution_job(
|
|
574
|
+
job_id: str,
|
|
575
|
+
agent: SmartWebAgent,
|
|
576
|
+
plan_dict: Dict[str, Any],
|
|
577
|
+
*,
|
|
578
|
+
progress_log_path: Optional[str],
|
|
579
|
+
auto_upload: bool = False,
|
|
580
|
+
upload_url: str = "https://www.mcpcn.cc/api/fileUploadAndDownload/uploadMcpFile",
|
|
581
|
+
) -> None:
|
|
582
|
+
job_info = _JOB_REGISTRY.get(job_id)
|
|
583
|
+
if not job_info:
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
result = await asyncio.to_thread(
|
|
588
|
+
_execute_plan,
|
|
589
|
+
agent,
|
|
590
|
+
plan_dict,
|
|
591
|
+
progress_log_path=progress_log_path,
|
|
592
|
+
)
|
|
593
|
+
job_info["status"] = "completed"
|
|
594
|
+
job_info["result"] = result
|
|
595
|
+
job_info["completed_at"] = time.time()
|
|
596
|
+
_persist_job_state(job_id)
|
|
597
|
+
|
|
598
|
+
# 🎯 关键:任务完成后自动上传
|
|
599
|
+
if auto_upload and job_info.get("project_directory"):
|
|
600
|
+
try:
|
|
601
|
+
# 记录上传开始
|
|
602
|
+
job_info["upload_status"] = "uploading"
|
|
603
|
+
_persist_job_state(job_id)
|
|
604
|
+
|
|
605
|
+
upload_result = await upload_project_to_mcp_server(
|
|
606
|
+
folder_path=job_info["project_directory"], upload_url=upload_url
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# 记录上传结果
|
|
610
|
+
job_info["upload_result"] = upload_result
|
|
611
|
+
job_info["upload_status"] = upload_result["status"]
|
|
612
|
+
if upload_result["status"] == "success":
|
|
613
|
+
job_info["website_url"] = upload_result["url"]
|
|
614
|
+
job_info["upload_completed_at"] = time.time()
|
|
615
|
+
|
|
616
|
+
# 添加到进度日志
|
|
617
|
+
if progress_log_path:
|
|
618
|
+
upload_event = {
|
|
619
|
+
"timestamp": time.time(),
|
|
620
|
+
"type": "upload_completed",
|
|
621
|
+
"status": "success",
|
|
622
|
+
"website_url": upload_result["url"],
|
|
623
|
+
"message": upload_result["message"],
|
|
624
|
+
}
|
|
625
|
+
try:
|
|
626
|
+
with open(
|
|
627
|
+
progress_log_path, "a", encoding="utf-8"
|
|
628
|
+
) as log_file:
|
|
629
|
+
log_file.write(
|
|
630
|
+
json.dumps(upload_event, ensure_ascii=False)
|
|
631
|
+
)
|
|
632
|
+
log_file.write("\n")
|
|
633
|
+
except Exception:
|
|
634
|
+
pass
|
|
635
|
+
|
|
636
|
+
_persist_job_state(job_id)
|
|
637
|
+
except Exception as upload_exc:
|
|
638
|
+
job_info["upload_status"] = "failed"
|
|
639
|
+
job_info["upload_error"] = str(upload_exc)
|
|
640
|
+
_persist_job_state(job_id)
|
|
641
|
+
|
|
642
|
+
except Exception as exc:
|
|
643
|
+
job_info["status"] = "failed"
|
|
644
|
+
job_info["error"] = {
|
|
645
|
+
"message": str(exc),
|
|
646
|
+
"traceback": traceback.format_exc(),
|
|
647
|
+
}
|
|
648
|
+
_persist_job_state(job_id)
|
|
649
|
+
finally:
|
|
650
|
+
job_info["updated_at"] = time.time()
|
|
651
|
+
_persist_job_state(job_id)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@mcp.tool()
|
|
655
|
+
async def create_simple_site(
|
|
656
|
+
description: str,
|
|
657
|
+
site_title: str = "我的网站",
|
|
658
|
+
project_root: Optional[str] = None,
|
|
659
|
+
model: Optional[str] = None,
|
|
660
|
+
context_id: Optional[str] = None,
|
|
661
|
+
context_content: Optional[str] = None,
|
|
662
|
+
) -> Dict[str, Any]:
|
|
663
|
+
"""使用AI分析需求,生成简单但美观的网站计划。
|
|
664
|
+
|
|
665
|
+
⚠️ 重要参数说明:
|
|
666
|
+
- description: 网站需求描述,例如"个人作品展示网站"、"小餐厅官网"、"博客网站"等
|
|
667
|
+
- site_title: 网站标题
|
|
668
|
+
- context_content: 🔥 核心参数!用于传递网页制作所需的所有原始数据内容
|
|
669
|
+
* 例如:咖啡馆列表、产品介绍、菜单内容、地址信息、营业时间等
|
|
670
|
+
* 这是AI获取具体业务信息的唯一渠道,请务必将查询到的详细信息完整传入
|
|
671
|
+
* 如果有地图查询结果、API返回数据等,都应该放在这个参数中
|
|
672
|
+
* 格式可以是文本、JSON字符串或结构化数据的字符串表示
|
|
673
|
+
|
|
674
|
+
其他参数:
|
|
675
|
+
- project_root: 项目根目录,缺省使用默认目录
|
|
676
|
+
- model: 使用的AI模型,缺省使用默认模型
|
|
677
|
+
- context_id: 可选,引用已缓存的上下文快照以复用历史资料
|
|
678
|
+
|
|
679
|
+
图片配置参数(可选):
|
|
680
|
+
- image_style: 图片风格 (professional|artistic|minimal|vibrant|luxury)
|
|
681
|
+
- image_topics: 用户自定义的图片主题列表,如 ["modern office", "team collaboration"]
|
|
682
|
+
- include_gallery: 是否在网站中包含图片画廊功能
|
|
683
|
+
- image_provider: 图片提供商 (pollinations|dicebear|robohash)
|
|
684
|
+
|
|
685
|
+
返回值说明:
|
|
686
|
+
- status: 操作状态 ("success" 或 "error")
|
|
687
|
+
- plan_id: 生成的计划唯一标识符,用于后续执行
|
|
688
|
+
- plan_path: 计划 JSON 文件的保存路径
|
|
689
|
+
- project_directory: 解析后的项目目录路径
|
|
690
|
+
- plan: 生成的简化执行计划概览
|
|
691
|
+
- context_id: 上下文缓存ID(如果使用了上下文)
|
|
692
|
+
|
|
693
|
+
使用流程:
|
|
694
|
+
1. 调用此工具生成计划,获得 plan_id
|
|
695
|
+
2. 使用 plan_id 调用 execute_plan 执行构建
|
|
696
|
+
3. 使用 plan_id 调用 get_progress 查询进度
|
|
697
|
+
|
|
698
|
+
💡 使用提示:
|
|
699
|
+
如果你有地图查询结果、API数据等,请将完整信息传递给 context_content 参数,
|
|
700
|
+
这样AI就能基于真实数据来生成个性化的网站内容。
|
|
701
|
+
"""
|
|
702
|
+
try:
|
|
703
|
+
# 使用指定模型或默认模型
|
|
704
|
+
used_model = model or DEFAULT_MODEL
|
|
705
|
+
|
|
706
|
+
# 确定项目目录
|
|
707
|
+
if project_root:
|
|
708
|
+
if not os.path.isabs(project_root):
|
|
709
|
+
project_directory = os.path.join(DEFAULT_PROJECT_ROOT, project_root)
|
|
710
|
+
else:
|
|
711
|
+
project_directory = project_root
|
|
712
|
+
else:
|
|
713
|
+
# 使用站点标题作为目录名
|
|
714
|
+
safe_title = "".join(
|
|
715
|
+
c if c.isalnum() or c in "._-" else "_" for c in site_title
|
|
716
|
+
)
|
|
717
|
+
project_directory = os.path.join(DEFAULT_PROJECT_ROOT, safe_title)
|
|
718
|
+
|
|
719
|
+
# 处理上下文
|
|
720
|
+
context_data = ""
|
|
721
|
+
actual_context_id = context_id
|
|
722
|
+
|
|
723
|
+
if context_id and context_id in _CONTEXT_CACHE_BY_ID:
|
|
724
|
+
# 使用缓存的上下文
|
|
725
|
+
cached_context = _CONTEXT_CACHE_BY_ID[context_id]
|
|
726
|
+
context_data = cached_context.get("content", "")
|
|
727
|
+
elif context_content:
|
|
728
|
+
# 使用新提供的上下文内容
|
|
729
|
+
context_data = context_content
|
|
730
|
+
# 生成新的上下文ID并缓存
|
|
731
|
+
actual_context_id = str(uuid.uuid4())
|
|
732
|
+
_CONTEXT_CACHE_BY_ID[actual_context_id] = {
|
|
733
|
+
"content": context_content,
|
|
734
|
+
"created_at": time.time(),
|
|
735
|
+
"site_title": site_title,
|
|
736
|
+
"description": description,
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
# 创建AI代理进行分析
|
|
740
|
+
agent = SmartWebAgent(
|
|
741
|
+
project_directory=project_directory,
|
|
742
|
+
model=used_model,
|
|
743
|
+
show_code=False,
|
|
744
|
+
verbose=False,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
# 构建针对简单网站的提示,包含上下文和图片要求
|
|
748
|
+
context_section = ""
|
|
749
|
+
if context_data:
|
|
750
|
+
context_section = f"""
|
|
751
|
+
|
|
752
|
+
**上下文内容**:
|
|
753
|
+
{context_data}
|
|
754
|
+
|
|
755
|
+
**重要说明**: 请根据上述上下文内容来定制网站的具体内容,确保生成的网页与提供的信息高度匹配。"""
|
|
756
|
+
|
|
757
|
+
simple_prompt = f"""请为以下需求设计一个简单但美观的网站:
|
|
758
|
+
|
|
759
|
+
**网站需求**: {description}
|
|
760
|
+
**网站标题**: {site_title}{context_section}
|
|
761
|
+
|
|
762
|
+
**设计要求**:
|
|
763
|
+
1. 保持简洁但要有视觉吸引力
|
|
764
|
+
2. 使用轻量级CSS(不超过300行)
|
|
765
|
+
3. 包含基础但实用的功能
|
|
766
|
+
4. 响应式设计,适配移动端
|
|
767
|
+
5. 良好的配色和排版
|
|
768
|
+
6. 智能图片集成,根据内容类型匹配合适主题
|
|
769
|
+
7. 如果有上下文内容,请充分利用这些信息来丰富网页内容
|
|
770
|
+
|
|
771
|
+
请生成一个包含3-6个步骤的简化执行计划,每个步骤要简洁明确。
|
|
772
|
+
重点关注:页面结构、样式设计、图片集成、必要功能。避免复杂特效。
|
|
773
|
+
|
|
774
|
+
输出格式要求:
|
|
775
|
+
- 每个步骤都要具体可执行
|
|
776
|
+
- 优先使用简单模板函数而非复杂模板
|
|
777
|
+
- 注重实用性和美观性的平衡
|
|
778
|
+
- 充分利用提供的上下文信息来生成个性化内容
|
|
779
|
+
"""
|
|
780
|
+
|
|
781
|
+
# 生成简化计划(仅规划,不执行)
|
|
782
|
+
plan = agent._get_execution_plan(simple_prompt)
|
|
783
|
+
|
|
784
|
+
# 在计划中标记为简单网站类型和相关信息
|
|
785
|
+
plan["site_type"] = "simple"
|
|
786
|
+
plan["complexity"] = "简单但美观"
|
|
787
|
+
plan["css_limit"] = "不超过300行"
|
|
788
|
+
plan["model_used"] = used_model
|
|
789
|
+
plan["has_context"] = bool(context_data)
|
|
790
|
+
if actual_context_id:
|
|
791
|
+
plan["context_id"] = actual_context_id
|
|
792
|
+
|
|
793
|
+
# 生成唯一的计划ID
|
|
794
|
+
plan_id = str(uuid.uuid4())
|
|
795
|
+
|
|
796
|
+
# 构建完整的源描述(包含上下文)
|
|
797
|
+
source_description = description
|
|
798
|
+
if context_data:
|
|
799
|
+
source_description = f"{description}\n\n【附加内容】\n{context_data}"
|
|
800
|
+
|
|
801
|
+
# 在计划中添加源描述字段
|
|
802
|
+
plan["__source_description"] = source_description
|
|
803
|
+
plan["__plan_id"] = plan_id
|
|
804
|
+
|
|
805
|
+
# 保存计划到缓存(结构与 create_simple_site 保持一致,便于 execute_plan 复用逻辑)
|
|
806
|
+
cached_entry = {
|
|
807
|
+
"plan": plan,
|
|
808
|
+
"project_directory": project_directory,
|
|
809
|
+
"description": description,
|
|
810
|
+
"source_description": source_description,
|
|
811
|
+
"site_title": site_title,
|
|
812
|
+
"plan_id": plan_id,
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
_PLAN_CACHE_BY_ID[plan_id] = cached_entry
|
|
816
|
+
cache_key = (project_directory, description)
|
|
817
|
+
_PLAN_CACHE[cache_key] = cached_entry
|
|
818
|
+
|
|
819
|
+
# 将上下文信息关联到计划
|
|
820
|
+
if actual_context_id:
|
|
821
|
+
_PROGRESS_LOG_BY_ID[plan_id] = actual_context_id
|
|
822
|
+
|
|
823
|
+
# 保存计划到文件
|
|
824
|
+
plan_filename = f"simple_site_plan_{plan_id}.json"
|
|
825
|
+
plan_path = PLAN_CACHE_DIR / plan_filename
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
# 构建完整的源描述(包含上下文)
|
|
829
|
+
source_description = description
|
|
830
|
+
if context_data:
|
|
831
|
+
source_description = f"{description}\n\n【附加内容】\n{context_data}"
|
|
832
|
+
|
|
833
|
+
plan_data = {
|
|
834
|
+
"plan_id": plan_id,
|
|
835
|
+
"site_title": site_title,
|
|
836
|
+
"description": description,
|
|
837
|
+
"project_directory": project_directory,
|
|
838
|
+
"model": used_model,
|
|
839
|
+
"plan_type": "simple_site",
|
|
840
|
+
"created_at": time.time(),
|
|
841
|
+
"plan": plan,
|
|
842
|
+
"__source_description": source_description, # 添加完整的源描述字段
|
|
843
|
+
}
|
|
844
|
+
if actual_context_id:
|
|
845
|
+
plan_data["context_id"] = actual_context_id
|
|
846
|
+
plan_data["has_context"] = True
|
|
847
|
+
|
|
848
|
+
with open(plan_path, "w", encoding="utf-8") as f:
|
|
849
|
+
json.dump(plan_data, f, ensure_ascii=False, indent=2)
|
|
850
|
+
except Exception:
|
|
851
|
+
# 文件保存失败不影响主流程
|
|
852
|
+
pass
|
|
853
|
+
|
|
854
|
+
# 生成计划概览
|
|
855
|
+
tools_sequence = plan.get("tools_sequence", [])
|
|
856
|
+
plan_overview = {
|
|
857
|
+
"description": plan.get("description", "简单网站构建计划"),
|
|
858
|
+
"steps": len(tools_sequence),
|
|
859
|
+
"step_list": [
|
|
860
|
+
step.get("description", step.get("tool", "")) for step in tools_sequence
|
|
861
|
+
],
|
|
862
|
+
"estimated_files": plan.get("estimated_files", "3-5个文件"),
|
|
863
|
+
"features": plan.get("features", ["响应式设计", "轻量级样式", "基础交互"]),
|
|
864
|
+
"has_context": bool(context_data),
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
result = {
|
|
868
|
+
"status": "success",
|
|
869
|
+
"message": f"简单网站计划生成成功,包含{len(tools_sequence)}个执行步骤",
|
|
870
|
+
"plan_id": plan_id,
|
|
871
|
+
"plan_path": str(plan_path),
|
|
872
|
+
"project_directory": project_directory,
|
|
873
|
+
"plan": plan_overview,
|
|
874
|
+
"model_used": used_model,
|
|
875
|
+
"next_step": f"使用 execute_plan(plan_id='{plan_id}') 开始构建网站",
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if actual_context_id:
|
|
879
|
+
result["context_id"] = actual_context_id
|
|
880
|
+
result["context_used"] = True
|
|
881
|
+
|
|
882
|
+
return result
|
|
883
|
+
|
|
884
|
+
except Exception as exc:
|
|
885
|
+
return {
|
|
886
|
+
"status": "error",
|
|
887
|
+
"message": str(exc),
|
|
888
|
+
"traceback": traceback.format_exc(),
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
@mcp.tool()
|
|
893
|
+
async def get_progress(
|
|
894
|
+
plan_id: Optional[str] = None,
|
|
895
|
+
job_id: Optional[str] = None,
|
|
896
|
+
log_path: Optional[str] = None,
|
|
897
|
+
limit: int = 20,
|
|
898
|
+
) -> Dict[str, Any]:
|
|
899
|
+
"""查询网页构建任务的执行进度和状态。
|
|
900
|
+
|
|
901
|
+
参数说明:
|
|
902
|
+
- plan_id: create_simple_site 返回的 ID,可定位默认的进度日志。
|
|
903
|
+
- job_id: execute_plan(background=true) 返回的任务 ID,可直接查询后台任务状态。
|
|
904
|
+
- log_path: 进度日志 JSONL 文件的路径(绝对或相对),优先级最高。
|
|
905
|
+
- limit: 返回的最新事件数量(默认 20 条)。
|
|
906
|
+
|
|
907
|
+
使用提示:
|
|
908
|
+
1. 推荐直接传入 execute_plan 返回的 job_id 和 progress_log。
|
|
909
|
+
2. 若未提供 log_path,本工具会按 job_id -> plan_id 的顺序尝试查找已缓存的日志。
|
|
910
|
+
3. 返回内容包括最新事件列表、日志路径以及(若有)任务快照或结果摘要,可用于持续追踪构建进度。
|
|
911
|
+
"""
|
|
912
|
+
|
|
913
|
+
try:
|
|
914
|
+
if limit <= 0:
|
|
915
|
+
limit = 20
|
|
916
|
+
|
|
917
|
+
job_info = None
|
|
918
|
+
resolved_path = None
|
|
919
|
+
|
|
920
|
+
if job_id:
|
|
921
|
+
job_info = _JOB_REGISTRY.get(job_id)
|
|
922
|
+
if job_info and not plan_id:
|
|
923
|
+
plan_id = job_info.get("plan_id")
|
|
924
|
+
if job_id in _PROGRESS_LOG_BY_JOB:
|
|
925
|
+
resolved_path = _PROGRESS_LOG_BY_JOB[job_id]
|
|
926
|
+
elif job_info and job_info.get("progress_log"):
|
|
927
|
+
resolved_path = job_info.get("progress_log")
|
|
928
|
+
|
|
929
|
+
if not resolved_path and plan_id and plan_id in _PROGRESS_LOG_BY_ID:
|
|
930
|
+
resolved_path = _PROGRESS_LOG_BY_ID[plan_id]
|
|
931
|
+
|
|
932
|
+
if log_path:
|
|
933
|
+
resolved_path = log_path
|
|
934
|
+
|
|
935
|
+
if resolved_path:
|
|
936
|
+
if not os.path.isabs(resolved_path):
|
|
937
|
+
candidate = os.path.join(PROJECT_ROOT, resolved_path)
|
|
938
|
+
if os.path.exists(candidate):
|
|
939
|
+
resolved_path = candidate
|
|
940
|
+
else:
|
|
941
|
+
alt = os.path.sep + resolved_path.lstrip(os.path.sep)
|
|
942
|
+
if os.path.exists(alt):
|
|
943
|
+
resolved_path = alt
|
|
944
|
+
|
|
945
|
+
if not resolved_path or not os.path.exists(resolved_path):
|
|
946
|
+
return {
|
|
947
|
+
"status": "error",
|
|
948
|
+
"message": "未找到进度日志,请确认 job_id/plan_id 或提供 log_path(注意绝对路径需以/开头,扩展名为 .jsonl)",
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
events: list[Dict[str, Any]] = []
|
|
952
|
+
total_lines = 0
|
|
953
|
+
with open(resolved_path, "r", encoding="utf-8") as f:
|
|
954
|
+
lines = f.readlines()
|
|
955
|
+
total_lines = len(lines)
|
|
956
|
+
for line in lines[-limit:]:
|
|
957
|
+
line = line.strip()
|
|
958
|
+
if not line:
|
|
959
|
+
continue
|
|
960
|
+
try:
|
|
961
|
+
events.append(json.loads(line))
|
|
962
|
+
except Exception:
|
|
963
|
+
continue
|
|
964
|
+
|
|
965
|
+
response: Dict[str, Any] = {
|
|
966
|
+
"status": "success",
|
|
967
|
+
"plan_id": plan_id,
|
|
968
|
+
"job_id": job_id,
|
|
969
|
+
"log_path": resolved_path,
|
|
970
|
+
"events": events,
|
|
971
|
+
"total_records": total_lines,
|
|
972
|
+
"returned": len(events),
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if job_info:
|
|
976
|
+
snapshot_keys = [
|
|
977
|
+
"job_id",
|
|
978
|
+
"status",
|
|
979
|
+
"plan_id",
|
|
980
|
+
"progress_log",
|
|
981
|
+
"started_at",
|
|
982
|
+
"updated_at",
|
|
983
|
+
"completed_at",
|
|
984
|
+
"project_directory",
|
|
985
|
+
"model",
|
|
986
|
+
"upload_status",
|
|
987
|
+
"website_url",
|
|
988
|
+
"upload_completed_at",
|
|
989
|
+
]
|
|
990
|
+
job_snapshot = {
|
|
991
|
+
k: job_info.get(k) for k in snapshot_keys if job_info.get(k) is not None
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if job_info.get("status") == "completed":
|
|
995
|
+
job_snapshot["result_summary"] = {
|
|
996
|
+
"report": job_info.get("result", {}).get("report"),
|
|
997
|
+
"created_files": job_info.get("result", {}).get("created_files"),
|
|
998
|
+
}
|
|
999
|
+
# 添加上传结果信息
|
|
1000
|
+
if job_info.get("upload_result"):
|
|
1001
|
+
job_snapshot["upload_result"] = job_info.get("upload_result")
|
|
1002
|
+
|
|
1003
|
+
if job_info.get("status") == "failed":
|
|
1004
|
+
job_snapshot["error"] = job_info.get("error")
|
|
1005
|
+
|
|
1006
|
+
# 添加上传错误信息
|
|
1007
|
+
if job_info.get("upload_error"):
|
|
1008
|
+
job_snapshot["upload_error"] = job_info.get("upload_error")
|
|
1009
|
+
|
|
1010
|
+
response["job"] = job_snapshot
|
|
1011
|
+
|
|
1012
|
+
return response
|
|
1013
|
+
except Exception as exc:
|
|
1014
|
+
return {
|
|
1015
|
+
"status": "error",
|
|
1016
|
+
"message": str(exc),
|
|
1017
|
+
"traceback": traceback.format_exc(),
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
@mcp.tool()
|
|
1022
|
+
async def upload_project_to_mcp_server(
|
|
1023
|
+
folder_path: str,
|
|
1024
|
+
upload_url: str = "https://www.mcpcn.cc/api/fileUploadAndDownload/uploadMcpFile",
|
|
1025
|
+
) -> Dict[str, Any]:
|
|
1026
|
+
"""将项目文件夹打包成ZIP并上传到MCP服务器。
|
|
1027
|
+
|
|
1028
|
+
参数说明:
|
|
1029
|
+
- folder_path: 项目文件夹的绝对路径
|
|
1030
|
+
- upload_url: 上传API地址,默认为MCP服务器地址
|
|
1031
|
+
|
|
1032
|
+
返回值:
|
|
1033
|
+
- status: 上传状态 ("success" 或 "error")
|
|
1034
|
+
- url: 上传成功后返回的文件访问URL
|
|
1035
|
+
- zip_path: 临时ZIP文件路径(用于调试)
|
|
1036
|
+
- message: 状态信息
|
|
1037
|
+
"""
|
|
1038
|
+
try:
|
|
1039
|
+
# 验证文件夹路径
|
|
1040
|
+
if not os.path.exists(folder_path):
|
|
1041
|
+
return {"status": "error", "message": f"项目文件夹不存在: {folder_path}"}
|
|
1042
|
+
|
|
1043
|
+
if not os.path.isdir(folder_path):
|
|
1044
|
+
return {"status": "error", "message": f"路径不是文件夹: {folder_path}"}
|
|
1045
|
+
|
|
1046
|
+
# 创建临时ZIP文件
|
|
1047
|
+
project_name = os.path.basename(folder_path.rstrip("/"))
|
|
1048
|
+
temp_dir = tempfile.gettempdir()
|
|
1049
|
+
zip_filename = f"{project_name}_{int(time.time())}.zip"
|
|
1050
|
+
zip_path = os.path.join(temp_dir, zip_filename)
|
|
1051
|
+
|
|
1052
|
+
# 打包项目文件
|
|
1053
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
|
1054
|
+
for root, dirs, files in os.walk(folder_path):
|
|
1055
|
+
for file in files:
|
|
1056
|
+
file_path = os.path.join(root, file)
|
|
1057
|
+
# 计算相对路径,保持目录结构
|
|
1058
|
+
arcname = os.path.relpath(file_path, folder_path)
|
|
1059
|
+
zipf.write(file_path, arcname)
|
|
1060
|
+
|
|
1061
|
+
# 检查ZIP文件大小
|
|
1062
|
+
zip_size = os.path.getsize(zip_path)
|
|
1063
|
+
if zip_size > 50 * 1024 * 1024: # 50MB限制
|
|
1064
|
+
os.remove(zip_path)
|
|
1065
|
+
return {
|
|
1066
|
+
"status": "error",
|
|
1067
|
+
"message": f"ZIP文件过大: {zip_size / 1024 / 1024:.1f}MB,超过50MB限制",
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
# 上传文件
|
|
1071
|
+
async with aiohttp.ClientSession() as session:
|
|
1072
|
+
with open(zip_path, "rb") as f:
|
|
1073
|
+
data = aiohttp.FormData()
|
|
1074
|
+
data.add_field(
|
|
1075
|
+
"file", f, filename=zip_filename, content_type="application/zip"
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
async with session.post(upload_url, data=data) as response:
|
|
1079
|
+
response_text = await response.text()
|
|
1080
|
+
|
|
1081
|
+
if response.status != 200:
|
|
1082
|
+
return {
|
|
1083
|
+
"status": "error",
|
|
1084
|
+
"message": f"上传失败,HTTP {response.status}: {response_text}",
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
# 解析响应JSON
|
|
1088
|
+
try:
|
|
1089
|
+
result = json.loads(response_text)
|
|
1090
|
+
if result.get("code") == 0 and result.get("data", {}).get(
|
|
1091
|
+
"url"
|
|
1092
|
+
):
|
|
1093
|
+
# 清理临时文件
|
|
1094
|
+
try:
|
|
1095
|
+
os.remove(zip_path)
|
|
1096
|
+
except:
|
|
1097
|
+
pass
|
|
1098
|
+
|
|
1099
|
+
return {
|
|
1100
|
+
"status": "success",
|
|
1101
|
+
"url": result["data"]["url"],
|
|
1102
|
+
"message": f"项目 '{project_name}' 上传成功",
|
|
1103
|
+
"zip_size": f"{zip_size / 1024:.1f}KB",
|
|
1104
|
+
}
|
|
1105
|
+
else:
|
|
1106
|
+
return {
|
|
1107
|
+
"status": "error",
|
|
1108
|
+
"message": f"上传失败: {result.get('msg', '未知错误')}",
|
|
1109
|
+
"response": response_text,
|
|
1110
|
+
}
|
|
1111
|
+
except json.JSONDecodeError:
|
|
1112
|
+
return {
|
|
1113
|
+
"status": "error",
|
|
1114
|
+
"message": f"响应解析失败: {response_text}",
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
except Exception as exc:
|
|
1118
|
+
# 清理临时文件
|
|
1119
|
+
if "zip_path" in locals() and os.path.exists(zip_path):
|
|
1120
|
+
try:
|
|
1121
|
+
os.remove(zip_path)
|
|
1122
|
+
except:
|
|
1123
|
+
pass
|
|
1124
|
+
|
|
1125
|
+
return {
|
|
1126
|
+
"status": "error",
|
|
1127
|
+
"message": str(exc),
|
|
1128
|
+
"traceback": traceback.format_exc(),
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
@mcp.tool()
|
|
1133
|
+
async def deploy_folder_or_zip(
|
|
1134
|
+
folder_path: str, env: str = "Production"
|
|
1135
|
+
) -> Dict[str, Any]:
|
|
1136
|
+
"""将构建好的网站文件夹或ZIP文件部署到EdgeOne Pages。
|
|
1137
|
+
|
|
1138
|
+
参数说明:
|
|
1139
|
+
- folder_path: 本地文件夹或ZIP文件的绝对路径
|
|
1140
|
+
指定要部署的前端构建产物位置,可以是:
|
|
1141
|
+
* 构建好的静态网站文件夹(如 ./dist, ./build 等)
|
|
1142
|
+
* 包含网站文件的ZIP压缩包
|
|
1143
|
+
系统会自动检测路径类型并采用相应的上传策略
|
|
1144
|
+
|
|
1145
|
+
- env: 部署环境(可选)
|
|
1146
|
+
* "Production": 生产环境部署,使用自定义域名(如已配置)
|
|
1147
|
+
* "Preview": 预览环境部署,生成临时预览链接
|
|
1148
|
+
默认为 "Production"
|
|
1149
|
+
|
|
1150
|
+
环境变量要求:
|
|
1151
|
+
- EDGEONE_PAGES_API_TOKEN: EdgeOne Pages API访问令牌(必需)
|
|
1152
|
+
- EDGEONE_PAGES_PROJECT_NAME: 项目名称(可选,未指定时自动创建临时项目)
|
|
1153
|
+
|
|
1154
|
+
返回值说明:
|
|
1155
|
+
- status: 部署状态 ("success" 或 "error")
|
|
1156
|
+
- deployment_logs: 详细的部署过程日志
|
|
1157
|
+
- result: 部署结果信息
|
|
1158
|
+
* type: 域名类型 ("custom" 自定义域名 或 "temporary" 临时域名)
|
|
1159
|
+
* url: 网站访问URL
|
|
1160
|
+
* project_id: EdgeOne项目ID
|
|
1161
|
+
* project_name: 项目名称
|
|
1162
|
+
* console_url: EdgeOne控制台管理链接
|
|
1163
|
+
|
|
1164
|
+
使用场景:
|
|
1165
|
+
1. 将本地开发的静态网站部署上线
|
|
1166
|
+
2. 将构建工具(如Webpack、Vite等)生成的dist目录部署
|
|
1167
|
+
3. 将打包好的网站ZIP文件快速部署
|
|
1168
|
+
4. 创建网站的预览版本进行测试
|
|
1169
|
+
|
|
1170
|
+
部署流程:
|
|
1171
|
+
1. 验证本地路径和文件
|
|
1172
|
+
2. 检测可用的API端点
|
|
1173
|
+
3. 获取或创建EdgeOne项目
|
|
1174
|
+
4. 上传文件到腾讯云COS
|
|
1175
|
+
5. 创建部署任务并等待完成
|
|
1176
|
+
6. 生成访问链接和管理信息
|
|
1177
|
+
|
|
1178
|
+
⚠️ 注意事项:
|
|
1179
|
+
- 需要有效的EdgeOne Pages API令牌
|
|
1180
|
+
- 确保网络连接正常,上传可能需要一些时间
|
|
1181
|
+
- 大文件或大量文件的上传会相应增加部署时间
|
|
1182
|
+
- 临时域名链接包含时效性访问令牌
|
|
1183
|
+
"""
|
|
1184
|
+
try:
|
|
1185
|
+
# 导入EdgeOne部署工具
|
|
1186
|
+
from agents.web_tools.edgeone_deploy import deploy_folder_or_zip_to_edgeone
|
|
1187
|
+
|
|
1188
|
+
# 验证环境变量
|
|
1189
|
+
api_token = os.getenv("EDGEONE_PAGES_API_TOKEN")
|
|
1190
|
+
if not api_token:
|
|
1191
|
+
return {
|
|
1192
|
+
"status": "error",
|
|
1193
|
+
"message": "Missing EDGEONE_PAGES_API_TOKEN environment variable. Please set your EdgeOne Pages API token.",
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
# 验证路径格式
|
|
1197
|
+
if not os.path.isabs(folder_path):
|
|
1198
|
+
return {
|
|
1199
|
+
"status": "error",
|
|
1200
|
+
"message": f"Path must be absolute: {folder_path}",
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
# 验证环境参数
|
|
1204
|
+
if env not in ["Production", "Preview"]:
|
|
1205
|
+
return {
|
|
1206
|
+
"status": "error",
|
|
1207
|
+
"message": "env must be 'Production' or 'Preview'",
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
# 执行部署
|
|
1211
|
+
result_json = await asyncio.to_thread(
|
|
1212
|
+
deploy_folder_or_zip_to_edgeone, folder_path, env
|
|
1213
|
+
)
|
|
1214
|
+
result = json.loads(result_json)
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
"status": "success",
|
|
1218
|
+
"message": f"Deployment to {env} environment completed successfully",
|
|
1219
|
+
"deployment_logs": result.get("deployment_logs", ""),
|
|
1220
|
+
"result": result.get("result", {}),
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
except Exception as exc:
|
|
1224
|
+
error_message = str(exc)
|
|
1225
|
+
|
|
1226
|
+
# 如果是EdgeOne部署错误,尝试解析JSON格式的错误信息
|
|
1227
|
+
try:
|
|
1228
|
+
if error_message.startswith("{"):
|
|
1229
|
+
error_data = json.loads(error_message)
|
|
1230
|
+
return {
|
|
1231
|
+
"status": "error",
|
|
1232
|
+
"message": error_data.get("error", error_message),
|
|
1233
|
+
"deployment_logs": error_data.get("deployment_logs", ""),
|
|
1234
|
+
"traceback": traceback.format_exc(),
|
|
1235
|
+
}
|
|
1236
|
+
except:
|
|
1237
|
+
pass
|
|
1238
|
+
|
|
1239
|
+
return {
|
|
1240
|
+
"status": "error",
|
|
1241
|
+
"message": error_message,
|
|
1242
|
+
"traceback": traceback.format_exc(),
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def main() -> None:
|
|
1247
|
+
transport = os.environ.get("MCP_TRANSPORT", "stdio")
|
|
1248
|
+
print("🚀 Smart Web Agent MCP 服务器已启动")
|
|
1249
|
+
print(f"📁 默认项目根目录: {DEFAULT_PROJECT_ROOT}")
|
|
1250
|
+
print(f"🤖 默认模型: {DEFAULT_MODEL}")
|
|
1251
|
+
print(f"🌐 默认API地址: {DEFAULT_BASE_URL}")
|
|
1252
|
+
print("🌐 EdgeOne Pages 部署工具已加载")
|
|
1253
|
+
mcp.run(transport=transport)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
if __name__ == "__main__":
|
|
1257
|
+
main()
|