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.

@@ -0,0 +1,2384 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 智能批量工具调用Web Agent - 2025年最佳实践
5
+ 预先规划,用户确认,批量执行 - 完美解决轮数不确定问题
6
+ 增强版:包含错误恢复、重试机制、进度显示等优化
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import json
12
+ import copy
13
+ import click
14
+ import time
15
+ import textwrap
16
+ from pathlib import Path
17
+ from typing import List, Dict, Optional, Tuple, Any
18
+ from openai import OpenAI
19
+ from dotenv import load_dotenv
20
+ from datetime import datetime
21
+ import traceback
22
+
23
+ # 确保UTF-8编码支持(容忍异常字符)
24
+ if sys.stdout.encoding != "utf-8":
25
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
26
+ else:
27
+ sys.stdout.reconfigure(errors="replace")
28
+ if sys.stderr.encoding != "utf-8":
29
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
30
+ else:
31
+ sys.stderr.reconfigure(errors="replace")
32
+
33
+ # 导入工具函数
34
+ from .web_tools import *
35
+
36
+
37
+ class SmartWebAgent:
38
+ def __init__(
39
+ self,
40
+ project_directory: str,
41
+ model: str = "qwen3-coder-plus-2025-09-23",
42
+ show_code: bool = False,
43
+ verbose: bool = False,
44
+ show_plan_stream: bool = False,
45
+ save_output: bool = False,
46
+ ):
47
+ self.project_directory = project_directory
48
+ self.model = model
49
+ self.show_code = show_code
50
+ self.verbose = verbose # 新增:详细输出模式
51
+ self.show_plan_stream = show_plan_stream # 新增:流式显示计划生成
52
+ self.save_output = save_output # 新增:保存输出到日志
53
+ api_key, base_url = self._resolve_api_credentials()
54
+ self.client = self._build_client(api_key, base_url)
55
+
56
+ # 工具函数映射
57
+ self.tool_functions = {
58
+ "create_project_structure": create_project_structure,
59
+ "create_html_file": create_html_file,
60
+ "create_css_file": create_css_file,
61
+ "create_js_file": create_js_file,
62
+ "add_bootstrap": add_bootstrap,
63
+ "create_responsive_navbar": create_responsive_navbar,
64
+ "fetch_generated_images": fetch_generated_images,
65
+ "inject_images": inject_images,
66
+ "open_in_browser": open_in_browser,
67
+ "validate_html": validate_html,
68
+ "check_mobile_friendly": check_mobile_friendly,
69
+ # 新增:专用页面生成工具(餐饮类)
70
+ "create_menu_page": create_menu_page,
71
+ "create_about_page": create_about_page,
72
+ "create_contact_page": create_contact_page,
73
+ }
74
+
75
+ # 执行历史记录
76
+ self.execution_history = []
77
+ self.created_files = []
78
+ self.execution_start_time = None
79
+
80
+ # 日志文件设置
81
+ if self.save_output:
82
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
83
+ self.log_file = os.path.join(
84
+ project_directory, f"agent_log_{timestamp}.txt"
85
+ )
86
+ self._log(
87
+ f"=== Agent 执行日志 ===\n时间: {datetime.now()}\n目录: {project_directory}\n"
88
+ )
89
+
90
+ def _resolve_api_credentials(self) -> Tuple[Optional[str], Optional[str]]:
91
+ """解析API凭据,支持多家兼容厂商"""
92
+ load_dotenv()
93
+
94
+ # 从环境变量获取API密钥和基础URL
95
+ api_key = os.getenv("OPENAI_API_KEY") or os.getenv("AI_API_KEY")
96
+ base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("AI_BASE_URL")
97
+
98
+ # 如果未配置,给出提示
99
+ if not api_key:
100
+ print(
101
+ "Warning: No API key found. Please set OPENAI_API_KEY or AI_API_KEY environment variable."
102
+ )
103
+ return None, None
104
+
105
+ # 默认基础URL(阿里云兼容模式)
106
+ if not base_url:
107
+ base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
108
+
109
+ return api_key, base_url
110
+
111
+ def _build_client(
112
+ self, api_key: Optional[str], base_url: Optional[str]
113
+ ) -> Optional[OpenAI]:
114
+ """根据凭据初始化 OpenAI 客户端"""
115
+ if not api_key:
116
+ return None
117
+ try:
118
+ if base_url:
119
+ return OpenAI(base_url=base_url, api_key=api_key)
120
+ return OpenAI(api_key=api_key)
121
+ except Exception:
122
+ return None
123
+
124
+ def _step_requires_content(self, tool_name: str) -> bool:
125
+ """判断该步骤是否需要即时生成代码内容"""
126
+ return tool_name in {"create_html_file", "create_css_file", "create_js_file"}
127
+
128
+ def _plan_outline_for_prompt(self, plan: dict, limit: int = 8) -> str:
129
+ outline: list[str] = []
130
+ steps = plan.get("tools_sequence", []) or []
131
+ for spec in steps[:limit]:
132
+ step_no = spec.get("step") or len(outline) + 1
133
+ outline.append(
134
+ f"{step_no}. {spec.get('tool', 'unknown_tool')} - {spec.get('description', '')}"
135
+ )
136
+ if len(steps) > limit:
137
+ outline.append("...")
138
+ return "\n".join(outline) or "无计划步骤"
139
+
140
+ def _recent_execution_summary(self, limit: int = 3) -> str:
141
+ if not self.execution_history:
142
+ return "暂无执行记录"
143
+ recent: list[str] = []
144
+ for item in self.execution_history[-limit:]:
145
+ message = item.get("result", "")
146
+ if isinstance(message, str) and len(message) > 80:
147
+ message = message[:77] + "..."
148
+ recent.append(f"步骤{item.get('step')} {item.get('tool')}: {message}")
149
+ return "\n".join(recent)
150
+
151
+ def _collect_existing_assets(
152
+ self, plan: dict, tool_spec: dict, max_files: int = 5, max_chars: int = 800
153
+ ) -> str:
154
+ project_root = Path(self._project_root(plan)).resolve()
155
+ if not project_root.exists():
156
+ return "暂无已生成内容"
157
+
158
+ params = tool_spec.get("params", {}) or {}
159
+ raw_target = params.get("file_path")
160
+ target_path: Optional[Path] = None
161
+ if raw_target:
162
+ candidate = Path(raw_target)
163
+ if not candidate.is_absolute():
164
+ candidate = (project_root / candidate).resolve()
165
+ else:
166
+ candidate = candidate.resolve()
167
+ if candidate.exists():
168
+ target_path = candidate
169
+
170
+ tool_name = tool_spec.get("tool", "")
171
+ if tool_name == "create_css_file":
172
+ patterns = ["*.css", "*.html"]
173
+ elif tool_name == "create_js_file":
174
+ patterns = ["*.js", "*.html", "*.css"]
175
+ else:
176
+ patterns = ["*.html", "*.css"]
177
+
178
+ snippets: list[str] = []
179
+ seen: set[Path] = set()
180
+ total_chars = 0
181
+
182
+ def add_file(fp: Path, label: str) -> None:
183
+ nonlocal total_chars
184
+ resolved = fp.resolve()
185
+ if not resolved.exists() or not resolved.is_file():
186
+ return
187
+ if resolved in seen:
188
+ return
189
+ try:
190
+ raw_text = resolved.read_text(encoding="utf-8", errors="ignore")
191
+ except Exception:
192
+ return
193
+ snippet = raw_text.strip()
194
+ if not snippet:
195
+ return
196
+ if len(snippet) > max_chars:
197
+ snippet = snippet[:max_chars] + "..."
198
+ try:
199
+ rel_path = resolved.relative_to(project_root)
200
+ except ValueError:
201
+ rel_path = resolved.name
202
+ snippets.append(f"[{label}] {rel_path}:{snippet}")
203
+ seen.add(resolved)
204
+ total_chars += len(snippet)
205
+
206
+ if target_path:
207
+ add_file(target_path, "当前文件")
208
+
209
+ for pattern in patterns:
210
+ for fp in sorted(project_root.rglob(pattern)):
211
+ if len(snippets) >= max_files or total_chars >= max_files * max_chars:
212
+ break
213
+ if target_path and fp.resolve() == target_path:
214
+ continue
215
+ add_file(fp, "已有文件")
216
+ if len(snippets) >= max_files or total_chars >= max_files * max_chars:
217
+ break
218
+
219
+ if not snippets:
220
+ return "暂无已生成内容"
221
+ return "\n\n".join(snippets)
222
+
223
+ def _code_generation_system_prompt(self) -> str:
224
+ return (
225
+ "你是通义千问 Qwen 上的网页生成工程师,专注高端站点开发。"
226
+ "按照网站构建计划逐步产出高质量代码,保持语义化、可访问性、响应式与性能优化。"
227
+ "所有回复必须是JSON,对象包含content字段,值为需要写入文件的完整代码字符串,不能包含Markdown或其它解释。"
228
+ )
229
+
230
+ def _build_code_generation_prompt(self, tool_spec: dict, plan: dict) -> str:
231
+ tool_name = tool_spec.get("tool", "")
232
+ params = tool_spec.get("params", {}) or {}
233
+ description = tool_spec.get("description", "")
234
+ rationale = tool_spec.get("rationale", "")
235
+ user_need = getattr(self, "latest_user_request", "")
236
+ plan_outline = self._plan_outline_for_prompt(plan)
237
+ previous = self._recent_execution_summary()
238
+ color_scheme = plan.get("color_scheme") or {}
239
+ param_clone = {k: v for k, v in params.items() if k != "content"}
240
+ param_json = json.dumps(param_clone, ensure_ascii=False, indent=2)
241
+ project_context = self._collect_existing_assets(plan, tool_spec)
242
+ instructions = ""
243
+ if tool_name == "create_html_file":
244
+ instructions = (
245
+ "生成完整HTML5文档,包含<head>、<body>、语义化结构、meta描述、OpenGraph标签和响应式布局。"
246
+ "如果提供nav_items,请渲染导航并正确标记active状态。"
247
+ "结合步骤描述组织Hero区、服务/功能区、CTA、页脚等模块,融入设计风格与品牌调性。"
248
+ )
249
+ elif tool_name == "create_css_file":
250
+ instructions = (
251
+ "生成覆盖站点的CSS样式表,构建变量系统、排版、栅格与间距、动画、暗色模式切换、组件样式。"
252
+ "结合color_scheme定义CSS变量,提供按钮、卡片、导航、section等现代样式。"
253
+ )
254
+ elif tool_name == "create_js_file":
255
+ instructions = (
256
+ "生成现代前端脚本,包含平滑滚动、导航栏滚动态、IntersectionObserver显隐动画、"
257
+ "返回顶部、主题切换、表单校验、数字动画等交互,确保模块化与可维护性。"
258
+ )
259
+ else:
260
+ instructions = "根据步骤描述生成与该工具匹配的内容。"
261
+
262
+ if color_scheme:
263
+ instructions += (
264
+ f" 请优先使用配色方案: {json.dumps(color_scheme, ensure_ascii=False)}。"
265
+ )
266
+
267
+ prompt = textwrap.dedent(
268
+ f"""
269
+ 用户原始需求:
270
+ {user_need}
271
+
272
+ 执行纲要概览:
273
+ {plan_outline}
274
+
275
+ 已完成步骤:
276
+ {previous}
277
+
278
+ 已有项目上下文:
279
+ {project_context}
280
+
281
+ 当前步骤: {description} ({tool_name})
282
+ 步骤目的: {rationale}
283
+ 目标参数:
284
+ {param_json}
285
+
286
+ 生成要求:
287
+ {instructions}
288
+
289
+ 输出JSON,格式:
290
+ {{"content": "<代码字符串>"}}
291
+ """
292
+ ).strip()
293
+ return prompt
294
+
295
+ def _generate_step_content(self, tool_spec: dict, plan: dict) -> str:
296
+ if self.client is None:
297
+ return ""
298
+ prompt = self._build_code_generation_prompt(tool_spec, plan)
299
+ try:
300
+ response = self.client.chat.completions.create(
301
+ model=self.model,
302
+ messages=[
303
+ {
304
+ "role": "system",
305
+ "content": self._code_generation_system_prompt(),
306
+ },
307
+ {"role": "user", "content": prompt},
308
+ ],
309
+ response_format={"type": "json_object"},
310
+ )
311
+ message = response.choices[0].message.content or ""
312
+ try:
313
+ data = json.loads(message)
314
+ except json.JSONDecodeError:
315
+ data = {"content": message}
316
+ content = data.get("content") or data.get("code") or ""
317
+ if self.save_output:
318
+ step_id = tool_spec.get("step")
319
+ self._log(
320
+ f"=== 生成内容 Step {step_id} ({tool_spec.get('tool')}) ===\n{content}\n"
321
+ )
322
+ return content
323
+ except Exception as exc:
324
+ print(f"⚠️ 内容生成失败: {exc}")
325
+ if self.verbose:
326
+ print(traceback.format_exc())
327
+ return ""
328
+
329
+ def _ensure_step_content(self, tool_spec: dict, params: dict, plan: dict) -> dict:
330
+ tool_name = tool_spec.get("tool", "")
331
+ if not self._step_requires_content(tool_name):
332
+ return params
333
+ if params.get("content"):
334
+ return params
335
+ if self.client is None:
336
+ return params
337
+ print("🧠 正在生成代码内容,请稍候...")
338
+ content = self._generate_step_content(tool_spec, plan)
339
+ if not content:
340
+ print("⚠️ 未能生成内容,将使用工具默认模板。")
341
+ return params
342
+ params["content"] = content
343
+ tool_spec.setdefault("params", {})["content"] = content
344
+ if self.show_code or self.verbose:
345
+ preview = content[:500]
346
+ print("📝 内容预览:")
347
+ print("=" * 40)
348
+ print(preview)
349
+ if len(content) > 500:
350
+ print(f"... (共 {len(content)} 字符)")
351
+ print("=" * 40)
352
+ return params
353
+
354
+ def _log(self, message: str):
355
+ """记录日志到文件"""
356
+ if self.save_output and hasattr(self, "log_file"):
357
+ try:
358
+ with open(self.log_file, "a", encoding="utf-8") as f:
359
+ f.write(message + "\n")
360
+ except Exception:
361
+ pass # 日志失败不影响主流程
362
+
363
+ def run(
364
+ self,
365
+ user_input: str,
366
+ auto_execute: bool = False,
367
+ confirm_each_step: bool = None,
368
+ progress_callback=None,
369
+ ):
370
+ """智能批量工具调用 - 增强版流程"""
371
+ self.execution_start_time = time.time()
372
+ self.execution_history = []
373
+ self.created_files = []
374
+ self.latest_user_request = user_input
375
+ self.current_plan: dict[str, Any] | None = None
376
+
377
+ # 默认策略:
378
+ # - auto_execute=True 时,关闭逐步确认
379
+ # - auto_execute=False 时,开启逐步确认
380
+ if confirm_each_step is None:
381
+ confirm_each_step = not auto_execute
382
+
383
+ print("🧠 第一步:智能规划任务...")
384
+ print("=" * 60)
385
+
386
+ # 增强用户输入,添加默认要求
387
+ enhanced_input = self._enhance_user_input(user_input)
388
+
389
+ # 第一步:让模型制定详细的执行计划(带重试机制,含离线回退)
390
+ plan = self._get_execution_plan_with_retry(enhanced_input)
391
+ if not plan:
392
+ return "❌ 无法生成执行计划,请重试"
393
+
394
+ # 显示执行计划
395
+ self._display_execution_plan(plan)
396
+ # 进度:计划已生成
397
+ if callable(progress_callback):
398
+ try:
399
+ progress_callback(
400
+ {
401
+ "type": "plan",
402
+ "status": "ready",
403
+ "percent": 0.0,
404
+ "description": "执行计划已生成",
405
+ "thought": plan.get("task_analysis"),
406
+ }
407
+ )
408
+ except Exception:
409
+ pass
410
+
411
+ # 询问用户确认
412
+ if not auto_execute:
413
+ confirm = self._get_user_confirmation(plan)
414
+ if not confirm:
415
+ return "❌ 用户取消执行"
416
+
417
+ # 第二步:执行计划
418
+ print(f"\n🚀 开始执行任务...")
419
+ print("=" * 60)
420
+
421
+ results = self._execute_plan_with_recovery(
422
+ plan,
423
+ confirm_each_step=confirm_each_step,
424
+ progress_callback=progress_callback,
425
+ )
426
+
427
+ if any(
428
+ r.get("status") == "success"
429
+ and r.get("tool") in {"create_html_file", "create_css_file"}
430
+ for r in results
431
+ ):
432
+ self._run_consistency_review(plan)
433
+
434
+ # 生成执行报告
435
+ report = self._generate_execution_report(plan, results)
436
+
437
+ return report
438
+
439
+ def _get_execution_plan_with_retry(
440
+ self, user_input: str, max_retries: int = 3
441
+ ) -> Optional[dict]:
442
+ """获取执行计划,带重试机制"""
443
+ # 若无客户端,直接走离线计划
444
+ if self.client is None:
445
+ plan = self._build_fallback_plan(user_input)
446
+ plan = self._repair_plan_tools_sequence(plan)
447
+ self.current_plan = plan
448
+ return plan if self._validate_plan(plan) else None
449
+
450
+ for attempt in range(max_retries):
451
+ try:
452
+ print(
453
+ f"⚡ 正在分析需求并生成执行计划... (尝试 {attempt + 1}/{max_retries})"
454
+ )
455
+ plan = self._get_execution_plan(user_input)
456
+ plan = self._repair_plan_tools_sequence(plan)
457
+ if self._validate_plan(plan):
458
+ self.current_plan = plan
459
+ return plan
460
+ except Exception as e:
461
+ print(f"⚠️ 生成计划失败: {str(e)}")
462
+ if attempt < max_retries - 1:
463
+ time.sleep(2) # 等待2秒后重试
464
+
465
+ # 远程多次失败后启用离线回退
466
+ print("🔁 使用离线回退计划")
467
+ plan = self._build_fallback_plan(user_input)
468
+ plan = self._repair_plan_tools_sequence(plan)
469
+ self.current_plan = plan
470
+ return plan if self._validate_plan(plan) else None
471
+
472
+ def _get_execution_plan(self, user_input: str) -> dict:
473
+ """获取执行计划 - 支持流式输出"""
474
+
475
+ if self.client is None:
476
+ # 由上层回退
477
+ raise RuntimeError("没有可用的LLM客户端")
478
+
479
+ # 第一步:让模型制定详细的执行计划
480
+ planning_prompt_template = """你是一个专业的网页设计与前端开发专家,精通现代Web技术、设计系统和用户体验。
481
+ 请分析用户需求并制定执行计划,创建高质量、现代化、专业的网站。
482
+
483
+ 🎯 核心设计理念:
484
+ • 现代美观:采用当前流行的设计趋势(如新拟态、毛玻璃、渐变、3D效果)
485
+ • 用户体验:移动优先、快速加载、流畅交互、无障碍访问
486
+ • 视觉层次:合理的留白、清晰的信息架构、引导性的视觉流
487
+ • 品牌一致:统一的设计语言、配色方案、字体系统
488
+ • 多页面协同:根据需求规划多页面站点结构,若用户未指定,也默认覆盖首页、关于、服务/产品、联系等关键页面,所有页面需保持导航一致性与互链
489
+
490
+ 用户需求:<<USER_INPUT>>
491
+ 工作目录:<<PROJECT_DIR>>
492
+
493
+ 可用工具:
494
+ - create_project_structure(project_name, project_path): 创建项目目录结构
495
+ - create_html_file(file_path, title, content): 创建HTML文件
496
+ - create_css_file(file_path, content): 创建CSS文件
497
+ - create_js_file(file_path, content): 创建JavaScript文件
498
+ - add_bootstrap(project_path): 添加Bootstrap框架
499
+ - create_responsive_navbar(file_path, brand_name, nav_items): 创建响应式导航栏
500
+ - fetch_generated_images(project_path, provider, prompts, count, size, seed, save, subdir, prefix): 获取/下载生成图片
501
+ - inject_images(file_path, provider, topics, size, seed, save, subdir, prefix): 将生成图片注入到HTML(支持 data-bg-topic / data-topic)
502
+ - open_in_browser(file_path): 在浏览器中预览
503
+ - validate_html(file_path): 验证HTML语法
504
+ - check_mobile_friendly(file_path): 检查移动端友好性
505
+ - create_menu_page(file_path, project_name): 专用“菜单”页面(餐饮/咖啡站点,分类清晰、价格醒目)
506
+ - create_about_page(file_path, project_name): 专用“关于我们”页面(品牌故事/理念/团队)
507
+ - create_contact_page(file_path, project_name): 专用“联系我们”页面(营业时间/地址/表单/地图)
508
+
509
+ 输出JSON格式的执行计划:
510
+ {
511
+ "task_analysis": "详细的任务分析,包括网站类型、风格定位、目标用户",
512
+ "project_name": "项目名称(英文,如:modern-portfolio)",
513
+ "site_type": "网站类型",
514
+ "design_style": "设计风格",
515
+ "color_scheme": {
516
+ "primary": "#主色",
517
+ "secondary": "#辅助色",
518
+ "accent": "#强调色"
519
+ },
520
+ "estimated_time": "预计执行时间",
521
+ "tools_sequence": [
522
+ {
523
+ "step": 1,
524
+ "tool": "工具名",
525
+ "params": {},
526
+ "description": "步骤描述",
527
+ "rationale": "执行原因"
528
+ }
529
+ ]
530
+ }
531
+
532
+ 📋 执行规范:
533
+
534
+ 1. **项目结构**(严格遵循):
535
+ - 第1步:create_project_structure - 创建完整目录结构
536
+ - 第2步:create_css_file - 创建样式文件(assets/css/style.css)
537
+ - 第3步:create_js_file - 创建脚本文件(assets/js/main.js)
538
+ - 第4步:create_html_file - 创建主页面
539
+ - 第5步:add_bootstrap - 添加框架支持
540
+ - 第6步:create_responsive_navbar - 创建导航组件
541
+ - 🎨 **第7步:inject_images - 智能图片注入(必须包含!)**
542
+ - 第8-9步:验证和检查
543
+ - 第10步:open_in_browser - 预览效果
544
+ - 如需求涉及多页面,请为每个页面单独安排 create_html_file → create_responsive_navbar(带跨页面链接)→ inject_images / 验证等步骤,确保导航项指向正确的 html 文件
545
+
546
+ ⚠️ **重要:第7步图片注入是必需的!**
547
+ 使用 inject_images 为网站添加美观的AI生成图片:
548
+ - provider="pollinations": 万能AI图片生成(场景、产品图)
549
+ - provider="dicebear": SVG头像(团队成员、用户头像)
550
+ - provider="robohash": 个性化头像(可爱风格)
551
+
552
+ 图片注入要求:
553
+ - 每个页面创建后必须立即跟随图片注入步骤
554
+ - 根据页面类型和用户需求智能选择图片主题
555
+ - 为不同区域使用合适的图片尺寸
556
+ - 确保图片主题与网站整体风格一致
557
+ - 支持用户自定义图片风格和主题
558
+ - 智能匹配行业特定的图片内容
559
+
560
+ 2. **网站类型适配**:
561
+ 📱 **作品集/Portfolio**:
562
+ - Hero区:个人介绍+技能标签+CTA
563
+ - 作品展示:网格布局+悬停效果+分类筛选
564
+ - 关于我:个人故事+技能进度条+工作经历时间线
565
+ - 客户评价:轮播展示
566
+ - 联系方式:表单+社交媒体链接
567
+
568
+ 🏢 **企业官网**:
569
+ - Hero区:价值主张+视频背景(占位)+双CTA按钮
570
+ - 服务介绍:图标卡片+悬停动画
571
+ - 数据展示:动态数字+图表占位
572
+ - 团队介绍:人员卡片+职位信息
573
+ - 合作伙伴:Logo墙+滚动动画
574
+
575
+ 🍔 **餐厅网站 / 咖啡店**(强烈建议使用专用工具):
576
+ - Hero区:餐厅氛围图+预约/到店CTA
577
+ - 菜单展示:使用 create_menu_page(分类Tab+价格标签+推荐标记)
578
+ - 关于我们:使用 create_about_page(品牌故事/理念/团队)
579
+ - 联系我们:使用 create_contact_page(营业时间+地址+表单+地图占位)
580
+ - 图片:为菜单/关于/联系分别注入咖啡/甜点/门店环境类主题
581
+
582
+ 🛍️ **电商着陆页**:
583
+ - Hero区:产品大图+限时优惠倒计时
584
+ - 产品特性:对比表格+规格参数
585
+ - 用户评价:评分分布+真实评论
586
+ - FAQ:折叠面板+搜索功能
587
+ - 购买区:价格方案+支付图标
588
+
589
+ 📰 **博客/内容站**:
590
+ - Hero区:精选文章+订阅框
591
+ - 文章列表:卡片布局+阅读时间+标签
592
+ - 侧边栏:分类导航+热门文章+广告位
593
+ - 作者信息:头像+简介+社交链接
594
+
595
+ 3. **CSS生成要求**:
596
+ ```css
597
+ /* 必须包含的设计系统 */
598
+ :root {
599
+ /* 色彩系统 */
600
+ --primary: #主色;
601
+ --primary-rgb: r,g,b;
602
+ --secondary: #辅助色;
603
+ --accent: #强调色;
604
+ --gradient-1: linear-gradient(...);
605
+ --gradient-2: radial-gradient(...);
606
+
607
+ /* 间距系统 */
608
+ --space-xs: 0.5rem;
609
+ --space-sm: 1rem;
610
+ --space-md: 2rem;
611
+ --space-lg: 3rem;
612
+ --space-xl: 5rem;
613
+
614
+ /* 阴影系统 */
615
+ --shadow-sm: 0 2px 4px rgba(0,0,0,0.05);
616
+ --shadow-md: 0 4px 6px rgba(0,0,0,0.07);
617
+ --shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
618
+ --shadow-xl: 0 20px 25px rgba(0,0,0,0.15);
619
+
620
+ /* 动画时长 */
621
+ --transition-fast: 150ms;
622
+ --transition-base: 250ms;
623
+ --transition-slow: 400ms;
624
+ }
625
+ ```
626
+
627
+ **现代效果实现**:
628
+ - 毛玻璃:backdrop-filter: blur(10px)
629
+ - 新拟态:多层阴影组合
630
+ - 渐变叠加:background-blend-mode
631
+ - 平滑滚动:scroll-behavior: smooth
632
+ - 视差效果:transform3d + perspective
633
+ - 文字渐变:background-clip: text
634
+ - 悬停缩放:transform: scale(1.05)
635
+ - 加载动画:@keyframes + animation
636
+
637
+ 4. **JavaScript功能增强**:
638
+ - 平滑滚动导航
639
+ - 滚动显示动画(IntersectionObserver)
640
+ - 导航栏滚动变化(透明→实色)
641
+ - 返回顶部按钮
642
+ - 表单验证反馈
643
+ - 图片懒加载
644
+ - 数字动态增长
645
+ - 打字机效果
646
+ - 主题切换(明/暗)
647
+
648
+ 5. **HTML内容要求**:
649
+ - 语义化标签:header, nav, main, section, article, aside, footer
650
+ - SEO优化:合理的h1-h6层级,meta描述
651
+ - 性能优化:图片lazy loading,关键CSS内联
652
+ - 无障碍:ARIA标签,焦点管理,键盘导航
653
+ - 微数据:结构化数据标记(组织、产品、评论)
654
+
655
+ 6. **响应式断点**:
656
+ - 移动端优先:320px起
657
+ - 平板:768px
658
+ - 桌面:1024px
659
+ - 大屏:1440px
660
+ - 超大屏:1920px
661
+
662
+ 7. **性能优化**:
663
+ - 关键CSS内联
664
+ - 字体预加载
665
+ - 图片格式:WebP + fallback
666
+ - 代码分割:异步加载非关键JS
667
+ - 缓存策略:设置合理的cache headers
668
+
669
+ 8. **质量保证**:
670
+ - 代码整洁:合理缩进,注释清晰
671
+ - 跨浏览器:Chrome, Firefox, Safari, Edge兼容
672
+ - 性能分数:Lighthouse得分>90
673
+ - 安全性:XSS防护,HTTPS就绪
674
+
675
+ ⚠️ 当前阶段仅需输出执行纲要,不要直接生成 HTML/CSS/JS 代码。对于 create_html_file / create_css_file / create_js_file 请将 params.content 留空字符串或直接省略,该内容会在后续步骤单独生成。
676
+ 请为每个步骤提供清晰的 description(做什么)与 rationale(为什么要这么做),方便用户确认。
677
+
678
+ 只输出JSON格式,不要其他内容。"""
679
+
680
+ # 使用实际值替换占位符
681
+ planning_prompt = planning_prompt_template.replace(
682
+ "<<USER_INPUT>>", user_input
683
+ ).replace("<<PROJECT_DIR>>", self.project_directory)
684
+
685
+ # 获取执行计划 - 支持流式输出
686
+ if self.show_plan_stream:
687
+ print("\n📝 AI思考中(实时显示):")
688
+ print("-" * 60)
689
+
690
+ # 启用流式输出
691
+ response = self.client.chat.completions.create(
692
+ model=self.model,
693
+ messages=[{"role": "user", "content": planning_prompt}],
694
+ response_format={"type": "json_object"},
695
+ stream=True, # 启用流式
696
+ )
697
+
698
+ # 收集流式响应
699
+ full_content = ""
700
+ for chunk in response:
701
+ if chunk.choices[0].delta.content:
702
+ content = chunk.choices[0].delta.content
703
+ full_content += content
704
+ # 实时显示生成的JSON(仅在verbose模式)
705
+ if self.verbose:
706
+ print(content, end="", flush=True)
707
+
708
+ if self.verbose:
709
+ print("\n" + "-" * 60)
710
+ else:
711
+ print("✅ 计划生成完成")
712
+ print("-" * 60)
713
+
714
+ plan_content = full_content
715
+ else:
716
+ # 非流式模式
717
+ response = self.client.chat.completions.create(
718
+ model=self.model,
719
+ messages=[{"role": "user", "content": planning_prompt}],
720
+ response_format={"type": "json_object"},
721
+ )
722
+ plan_content = response.choices[0].message.content
723
+
724
+ # 保存原始计划到日志
725
+ if self.save_output:
726
+ self._log(f"\n=== 原始执行计划 ===\n{plan_content}\n")
727
+
728
+ # 默认不打印原始JSON,避免干扰交互;如需调试可开启环境变量 DEBUG_PLAN=1
729
+ if os.getenv("DEBUG_PLAN") == "1":
730
+ print(plan_content)
731
+
732
+ try:
733
+ plan = json.loads(plan_content)
734
+ except json.JSONDecodeError as e:
735
+ print(f"⚠️ JSON解析失败: {e}")
736
+ if self.verbose:
737
+ print(f"原始内容前500字符: {plan_content[:500]}...")
738
+ raise
739
+
740
+ self.current_plan = plan
741
+ return plan
742
+
743
+ # ---------------- 离线回退:确定性执行计划 ----------------
744
+ def _slugify(self, text: str, default: str = "web-project") -> str:
745
+ allow = "abcdefghijklmnopqrstuvwxyz0123456789-"
746
+ slug = []
747
+ text = (text or "").lower().strip().replace(" ", "-")
748
+ for ch in text:
749
+ if ch.isalnum():
750
+ slug.append(ch)
751
+ elif ch in ["_", "-", "/", "\\", "."]:
752
+ slug.append("-")
753
+ s = "".join(slug).strip("-")
754
+ return s[:32] or default
755
+
756
+ def _build_fallback_plan(self, user_input: str) -> dict:
757
+ """在无网络/无密钥时的本地执行计划:生成一个现代化的基础站点骨架"""
758
+ project_name = self._slugify(user_input)
759
+ project_root = os.path.join(self.project_directory, project_name)
760
+
761
+ # 简单餐饮类识别(咖啡/餐厅/菜单关键字)
762
+ key = user_input.lower()
763
+ is_restaurant = any(
764
+ k in key
765
+ for k in [
766
+ "餐厅",
767
+ "餐馆",
768
+ "咖啡",
769
+ "咖啡店",
770
+ "cafe",
771
+ "coffee",
772
+ "菜单",
773
+ "menu",
774
+ ]
775
+ )
776
+
777
+ if is_restaurant:
778
+ nav_structure = [
779
+ {"name": "首页", "href": "index.html"},
780
+ {"name": "菜单", "href": "menu.html"},
781
+ {"name": "关于我们", "href": "about.html"},
782
+ {"name": "联系我们", "href": "contact.html"},
783
+ ]
784
+ else:
785
+ nav_structure = [
786
+ {"name": "首页", "href": "index.html"},
787
+ {"name": "关于我们", "href": "about.html"},
788
+ {"name": "服务体系", "href": "services.html"},
789
+ {"name": "联系我们", "href": "contact.html"},
790
+ ]
791
+
792
+ def build_nav(active_href: str) -> list:
793
+ return [
794
+ {**item, "active": item["href"] == active_href}
795
+ for item in nav_structure
796
+ ]
797
+
798
+ plan: dict = {
799
+ "task_analysis": "离线回退:根据描述创建现代化基础网站骨架",
800
+ "project_name": project_name,
801
+ "site_type": "restaurant" if is_restaurant else "basic-landing",
802
+ "design_style": "modern, responsive, glassmorphism",
803
+ "color_scheme": {
804
+ "primary": "#0d6efd",
805
+ "secondary": "#6c757d",
806
+ "accent": "#6610f2",
807
+ },
808
+ "estimated_time": "约10秒",
809
+ "tools_sequence": [],
810
+ }
811
+
812
+ steps = plan["tools_sequence"]
813
+ # 1-3 基础设施
814
+ steps.append(
815
+ {
816
+ "step": 1,
817
+ "tool": "create_project_structure",
818
+ "params": {
819
+ "project_name": project_name,
820
+ "project_path": self.project_directory,
821
+ },
822
+ "description": "创建项目目录结构",
823
+ "rationale": "确保 assets/css, assets/js 等目录就绪",
824
+ }
825
+ )
826
+ # 为不同站点类型提供更有“品牌感”的默认配色
827
+ cafe_palette = (
828
+ {
829
+ "primary": "#6B4F3A", # Coffee Brown
830
+ "secondary": "#8C5E3C", # Deep Caramel
831
+ "accent": "#D0A97A", # Latte Cream
832
+ "neutral_light": "#F7F3EE",
833
+ "neutral_dark": "#201A16",
834
+ }
835
+ if is_restaurant
836
+ else {"primary": "#0d6efd", "secondary": "#6c757d", "accent": "#6610f2"}
837
+ )
838
+
839
+ steps.append(
840
+ {
841
+ "step": 2,
842
+ "tool": "create_css_file",
843
+ "params": {
844
+ "file_path": os.path.join(project_root, "assets/css/style.css"),
845
+ "content": "",
846
+ "palette": cafe_palette,
847
+ },
848
+ "description": "创建全局样式文件",
849
+ "rationale": "提供设计系统、响应式、动画等基础样式,并注入更契合场景的品牌配色",
850
+ }
851
+ )
852
+ steps.append(
853
+ {
854
+ "step": 3,
855
+ "tool": "create_js_file",
856
+ "params": {
857
+ "file_path": os.path.join(project_root, "assets/js/main.js"),
858
+ "content": "",
859
+ },
860
+ "description": "创建全局脚本文件",
861
+ "rationale": "提供导航、滚动显示、返回顶部等基础交互",
862
+ }
863
+ )
864
+
865
+ # 页面创建
866
+ steps.append(
867
+ {
868
+ "step": 4,
869
+ "tool": "create_html_file",
870
+ "params": {
871
+ "file_path": os.path.join(project_root, "index.html"),
872
+ "title": project_name.title(),
873
+ "content": "",
874
+ "style": "ultra_modern",
875
+ },
876
+ "description": "创建首页",
877
+ "rationale": "生成结构化HTML并挂接CSS/JS",
878
+ }
879
+ )
880
+
881
+ if is_restaurant:
882
+ steps.append(
883
+ {
884
+ "step": 5,
885
+ "tool": "create_menu_page",
886
+ "params": {
887
+ "file_path": os.path.join(project_root, "menu.html"),
888
+ "project_name": project_name.title(),
889
+ },
890
+ "description": "创建菜单页面",
891
+ "rationale": "餐饮类站点专用模板,分类清晰、价格醒目",
892
+ }
893
+ )
894
+ steps.append(
895
+ {
896
+ "step": 6,
897
+ "tool": "create_about_page",
898
+ "params": {
899
+ "file_path": os.path.join(project_root, "about.html"),
900
+ "project_name": project_name.title(),
901
+ "context": {
902
+ "site_type": "restaurant",
903
+ "project_description": plan.get("task_analysis"),
904
+ "nav_items": build_nav("about.html"),
905
+ },
906
+ },
907
+ "description": "创建关于页面",
908
+ "rationale": "品牌故事、理念与团队展示",
909
+ }
910
+ )
911
+ steps.append(
912
+ {
913
+ "step": 7,
914
+ "tool": "create_contact_page",
915
+ "params": {
916
+ "file_path": os.path.join(project_root, "contact.html"),
917
+ "project_name": project_name.title(),
918
+ },
919
+ "description": "创建联系页面",
920
+ "rationale": "营业时间、地址、联系表单与地图占位",
921
+ }
922
+ )
923
+ else:
924
+ steps.append(
925
+ {
926
+ "step": 5,
927
+ "tool": "create_about_page",
928
+ "params": {
929
+ "file_path": os.path.join(project_root, "about.html"),
930
+ "project_name": project_name.title(),
931
+ "context": {
932
+ "site_type": plan.get("site_type"),
933
+ "project_description": plan.get("task_analysis"),
934
+ "nav_items": build_nav("about.html"),
935
+ },
936
+ },
937
+ "description": "创建关于页面",
938
+ "rationale": "补充团队故事、理念与品牌背景",
939
+ }
940
+ )
941
+ steps.append(
942
+ {
943
+ "step": 6,
944
+ "tool": "create_html_file",
945
+ "params": {
946
+ "file_path": os.path.join(project_root, "services.html"),
947
+ "title": f"{project_name.title()} · 服务体系",
948
+ "content": "",
949
+ "style": "creative_gradient",
950
+ },
951
+ "description": "创建服务页面",
952
+ "rationale": "呈现产品/服务矩阵与亮点",
953
+ }
954
+ )
955
+ steps.append(
956
+ {
957
+ "step": 7,
958
+ "tool": "create_html_file",
959
+ "params": {
960
+ "file_path": os.path.join(project_root, "contact.html"),
961
+ "title": f"{project_name.title()} · 联系我们",
962
+ "content": "",
963
+ "style": "minimal_elegant",
964
+ },
965
+ "description": "创建联系页面",
966
+ "rationale": "提供表单、地图与联系方式",
967
+ }
968
+ )
969
+
970
+ # 框架与导航
971
+ steps.append(
972
+ {
973
+ "step": 8,
974
+ "tool": "add_bootstrap",
975
+ "params": {"project_path": project_root},
976
+ "description": "接入Bootstrap以增强组件与响应式",
977
+ "rationale": "快速获得导航栏、栅格与表单样式",
978
+ }
979
+ )
980
+ # 为每个页面插入导航
981
+ for idx, page in enumerate(
982
+ ["index.html"]
983
+ + (
984
+ ["menu.html", "about.html", "contact.html"]
985
+ if is_restaurant
986
+ else ["about.html", "services.html", "contact.html"]
987
+ ),
988
+ start=9,
989
+ ):
990
+ steps.append(
991
+ {
992
+ "step": idx,
993
+ "tool": "create_responsive_navbar",
994
+ "params": {
995
+ "file_path": os.path.join(project_root, page),
996
+ "brand_name": project_name.title(),
997
+ "nav_items": build_nav(page),
998
+ },
999
+ "description": f"同步 {page} 导航",
1000
+ "rationale": "保持跨页面导航一致、定位正确",
1001
+ }
1002
+ )
1003
+
1004
+ # 图片注入
1005
+ next_step = steps[-1]["step"] + 1
1006
+ steps.append(
1007
+ {
1008
+ "step": next_step,
1009
+ "tool": "inject_images",
1010
+ "params": {
1011
+ "file_path": os.path.join(project_root, "index.html"),
1012
+ "provider": "pollinations",
1013
+ "topics": ["cozy coffee hero, gradient glassmorphism"],
1014
+ "size": "1200x800",
1015
+ "seed": 42,
1016
+ "save": True,
1017
+ "subdir": "assets/images",
1018
+ "prefix": "img",
1019
+ },
1020
+ "description": "首页图片注入",
1021
+ "rationale": "让页面更具视觉表现",
1022
+ }
1023
+ )
1024
+ next_step += 1
1025
+ if is_restaurant:
1026
+ steps.append(
1027
+ {
1028
+ "step": next_step,
1029
+ "tool": "inject_images",
1030
+ "params": {
1031
+ "file_path": os.path.join(project_root, "menu.html"),
1032
+ "provider": "pollinations",
1033
+ "topics": ["latte art", "espresso shot", "pastry dessert"],
1034
+ "size": "1024x768",
1035
+ "seed": 7,
1036
+ "save": True,
1037
+ "subdir": "assets/images",
1038
+ "prefix": "menu",
1039
+ },
1040
+ "description": "为菜单页注入图片",
1041
+ "rationale": "展示咖啡/甜点,更贴合餐饮场景",
1042
+ }
1043
+ )
1044
+ next_step += 1
1045
+ steps.append(
1046
+ {
1047
+ "step": next_step,
1048
+ "tool": "inject_images",
1049
+ "params": {
1050
+ "file_path": os.path.join(project_root, "about.html"),
1051
+ "provider": "pollinations",
1052
+ "topics": ["barista portrait", "coffee roasting", "cafe community"],
1053
+ "size": "1024x768",
1054
+ "seed": 11,
1055
+ "save": True,
1056
+ "subdir": "assets/images",
1057
+ "prefix": "about",
1058
+ },
1059
+ "description": "关于页图片注入",
1060
+ "rationale": "呈现团队与品牌氛围",
1061
+ }
1062
+ )
1063
+ next_step += 1
1064
+ steps.append(
1065
+ {
1066
+ "step": next_step,
1067
+ "tool": "inject_images",
1068
+ "params": {
1069
+ "file_path": os.path.join(project_root, "contact.html"),
1070
+ "provider": "pollinations",
1071
+ "topics": ["coffee shop storefront", "map pin"],
1072
+ "size": "1024x768",
1073
+ "seed": 13,
1074
+ "save": True,
1075
+ "subdir": "assets/images",
1076
+ "prefix": "contact",
1077
+ },
1078
+ "description": "联系页图片注入",
1079
+ "rationale": "增强门店信息表现",
1080
+ }
1081
+ )
1082
+ next_step += 1
1083
+
1084
+ # 校验与预览
1085
+ steps.append(
1086
+ {
1087
+ "step": next_step,
1088
+ "tool": "validate_html",
1089
+ "params": {"file_path": os.path.join(project_root, "index.html")},
1090
+ "description": "验证首页HTML结构",
1091
+ "rationale": "保证基础结构完整",
1092
+ }
1093
+ )
1094
+ next_step += 1
1095
+ if is_restaurant:
1096
+ steps.append(
1097
+ {
1098
+ "step": next_step,
1099
+ "tool": "validate_html",
1100
+ "params": {"file_path": os.path.join(project_root, "menu.html")},
1101
+ "description": "验证菜单页HTML结构",
1102
+ "rationale": "避免语法问题",
1103
+ }
1104
+ )
1105
+ next_step += 1
1106
+ steps.append(
1107
+ {
1108
+ "step": next_step,
1109
+ "tool": "check_mobile_friendly",
1110
+ "params": {"file_path": os.path.join(project_root, "index.html")},
1111
+ "description": "检查移动端友好性",
1112
+ "rationale": "确认viewport与响应式",
1113
+ }
1114
+ )
1115
+ next_step += 1
1116
+ steps.append(
1117
+ {
1118
+ "step": next_step,
1119
+ "tool": "open_in_browser",
1120
+ "params": {"file_path": os.path.join(project_root, "index.html")},
1121
+ "description": "本地预览页面",
1122
+ "rationale": "快速查看效果(可在无头环境忽略)",
1123
+ }
1124
+ )
1125
+
1126
+ return plan
1127
+
1128
+ def _repair_plan_tools_sequence(self, plan: dict) -> dict:
1129
+ """修复模型返回的错误结构:
1130
+ - 有时会把步骤对象错放成顶层 key(如 "create_js_file": {...})。
1131
+ - 这里尝试将这些对象归并回 tools_sequence。
1132
+ """
1133
+ if not isinstance(plan, dict):
1134
+ return plan
1135
+
1136
+ seq = plan.get("tools_sequence")
1137
+ if not isinstance(seq, list):
1138
+ seq = []
1139
+
1140
+ known = {
1141
+ "create_project_structure",
1142
+ "create_html_file",
1143
+ "create_css_file",
1144
+ "create_js_file",
1145
+ "add_bootstrap",
1146
+ "create_responsive_navbar",
1147
+ "fetch_generated_images",
1148
+ "inject_images",
1149
+ "open_in_browser",
1150
+ "validate_html",
1151
+ "check_mobile_friendly",
1152
+ }
1153
+
1154
+ # 收集顶层误放的步骤
1155
+ extra_steps = []
1156
+ for key, val in list(plan.items()):
1157
+ if key in known and isinstance(val, dict):
1158
+ step_obj = dict(val)
1159
+ # 确保 tool 字段正确
1160
+ step_obj.setdefault("tool", key)
1161
+ # 只接受包含 params 的对象
1162
+ if not isinstance(step_obj.get("params"), (dict, str, type(None))):
1163
+ continue
1164
+ extra_steps.append(step_obj)
1165
+
1166
+ # 合并
1167
+ all_steps = []
1168
+ for obj in seq + extra_steps:
1169
+ if not isinstance(obj, dict):
1170
+ continue
1171
+ tool = obj.get("tool")
1172
+ if not tool:
1173
+ continue
1174
+ # 兜底字段
1175
+ obj.setdefault("step", len(all_steps) + 1)
1176
+ obj.setdefault("description", f"Run {tool}")
1177
+ if not isinstance(obj.get("params"), dict):
1178
+ # 将字符串参数尝试包装为 file_path
1179
+ p = obj.get("params")
1180
+ obj["params"] = {"file_path": p} if isinstance(p, str) else {}
1181
+ all_steps.append(obj)
1182
+
1183
+ # 为导航中引用的页面补齐生成步骤(若规划中缺失)
1184
+ page_tools = {
1185
+ "create_html_file",
1186
+ "create_menu_page",
1187
+ "create_about_page",
1188
+ "create_contact_page",
1189
+ }
1190
+
1191
+ existing_pages = set()
1192
+ for obj in all_steps:
1193
+ if obj.get("tool") in page_tools:
1194
+ params = (
1195
+ obj.get("params") if isinstance(obj.get("params"), dict) else {}
1196
+ )
1197
+ file_path = params.get("file_path")
1198
+ if file_path:
1199
+ existing_pages.add(os.path.basename(file_path))
1200
+
1201
+ nav_required: dict[str, str] = {}
1202
+ nav_step_refs: list[tuple[int, int]] = [] # (index, step)
1203
+ nav_templates: list[dict[str, Any]] = []
1204
+ for idx, obj in enumerate(all_steps):
1205
+ if obj.get("tool") != "create_responsive_navbar":
1206
+ continue
1207
+ params = obj.get("params") if isinstance(obj.get("params"), dict) else {}
1208
+ nav_items = params.get("nav_items") or []
1209
+ if isinstance(nav_items, list):
1210
+ cleaned_items = []
1211
+ for item in nav_items:
1212
+ if not isinstance(item, dict):
1213
+ cleaned_items.append(item)
1214
+ continue
1215
+ href = str(item.get("href", "")).strip()
1216
+ if href and href.lower().endswith(".html"):
1217
+ normalized = href.lstrip("./")
1218
+ basename = os.path.basename(normalized)
1219
+ if basename != href:
1220
+ item = dict(item)
1221
+ item["href"] = basename
1222
+ nav_required.setdefault(
1223
+ basename, str(item.get("name") or basename)
1224
+ )
1225
+ cleaned_items.append(item)
1226
+ params["nav_items"] = cleaned_items
1227
+ nav_step_refs.append((idx, obj.get("step", idx + 1)))
1228
+ nav_templates.append(
1229
+ {
1230
+ "params": copy.deepcopy(params) if isinstance(params, dict) else {},
1231
+ "step": obj.get("step", idx + 1),
1232
+ }
1233
+ )
1234
+
1235
+ if nav_required:
1236
+ project_slug = plan.get("project_name") or "web-project"
1237
+ project_label = (
1238
+ project_slug.replace("-", " ").strip().title() or "Web Project"
1239
+ )
1240
+ # 选择插入位置:默认落在首个导航步骤之前
1241
+ insert_anchor = (
1242
+ min((s for _, s in nav_step_refs), default=len(all_steps) + 1)
1243
+ if nav_step_refs
1244
+ else len(all_steps) + 1
1245
+ )
1246
+
1247
+ for offset, (href, label) in enumerate(nav_required.items()):
1248
+ basename = os.path.basename(href)
1249
+ if basename in existing_pages:
1250
+ continue
1251
+
1252
+ lower = basename.lower()
1253
+ if "about" in lower:
1254
+ tool_name = "create_about_page"
1255
+ params = {
1256
+ "file_path": basename,
1257
+ "project_name": project_label,
1258
+ }
1259
+ elif any(key in lower for key in ["contact", "connect", "联系"]):
1260
+ tool_name = "create_contact_page"
1261
+ params = {"file_path": basename, "project_name": project_label}
1262
+ elif "menu" in lower:
1263
+ tool_name = "create_menu_page"
1264
+ params = {"file_path": basename, "project_name": project_label}
1265
+ else:
1266
+ tool_name = "create_html_file"
1267
+ params = {
1268
+ "file_path": basename,
1269
+ "title": f"{project_label} · {label}",
1270
+ "content": "",
1271
+ "style": "minimal_elegant",
1272
+ }
1273
+
1274
+ context_payload = {
1275
+ "site_type": plan.get("site_type"),
1276
+ "project_description": plan.get("task_analysis"),
1277
+ "project_name": project_label,
1278
+ "target_page": basename,
1279
+ }
1280
+ if nav_templates:
1281
+ context_payload["nav_items"] = nav_templates[0]["params"].get(
1282
+ "nav_items"
1283
+ )
1284
+
1285
+ if tool_name == "create_about_page":
1286
+ params["context"] = context_payload
1287
+
1288
+ new_step = {
1289
+ "tool": tool_name,
1290
+ "description": f"创建导航引用页面: {basename}",
1291
+ "rationale": "确保导航链接的页面实际存在,避免访问404",
1292
+ "params": params,
1293
+ "step": insert_anchor - 0.5 + offset * 0.01,
1294
+ }
1295
+ all_steps.append(new_step)
1296
+ existing_pages.add(basename)
1297
+
1298
+ # 为新创建的页面同步生成导航栏
1299
+ if nav_templates:
1300
+ template_params = copy.deepcopy(nav_templates[0]["params"])
1301
+ nav_items_tpl = template_params.get("nav_items") or []
1302
+ cloned_items = []
1303
+ for entry in nav_items_tpl:
1304
+ if isinstance(entry, dict):
1305
+ cloned = dict(entry)
1306
+ cloned["active"] = cloned.get("href") == basename
1307
+ cloned.setdefault("href", basename)
1308
+ cloned_items.append(cloned)
1309
+ else:
1310
+ cloned_items.append(entry)
1311
+ template_params["nav_items"] = cloned_items
1312
+ template_params["file_path"] = basename
1313
+ nav_step = {
1314
+ "tool": "create_responsive_navbar",
1315
+ "description": f"为 {basename} 注入统一导航",
1316
+ "rationale": "保持跨页面导航一致性",
1317
+ "params": template_params,
1318
+ "step": insert_anchor - 0.4 + offset * 0.01,
1319
+ }
1320
+ all_steps.append(nav_step)
1321
+
1322
+ # 为每一个页面创建步骤,确保紧跟一个 inject_images(未存在时自动追加)
1323
+ for idx, obj in list(enumerate(all_steps)):
1324
+ tool = obj.get("tool")
1325
+ if tool not in page_tools:
1326
+ continue
1327
+ params = obj.get("params") if isinstance(obj.get("params"), dict) else {}
1328
+ file_path = params.get("file_path")
1329
+ if not file_path:
1330
+ continue
1331
+ # 检查是否已有对应的注入步骤
1332
+ already = False
1333
+ for later in all_steps[idx + 1 :]:
1334
+ if later.get("tool") == "inject_images":
1335
+ p = (
1336
+ later.get("params")
1337
+ if isinstance(later.get("params"), dict)
1338
+ else {}
1339
+ )
1340
+ if p.get("file_path") == file_path:
1341
+ already = True
1342
+ break
1343
+ if already:
1344
+ continue
1345
+ prefix = os.path.splitext(os.path.basename(file_path))[0]
1346
+ inject_step = {
1347
+ "tool": "inject_images",
1348
+ "description": f"为页面注入智能生成的图片: {prefix}",
1349
+ "rationale": "保证每个页面 data-topic 占位得到实际图片,避免空白",
1350
+ "params": {
1351
+ "file_path": file_path,
1352
+ "provider": "pollinations",
1353
+ "topics": None,
1354
+ "size": "1200x800",
1355
+ "seed": 42,
1356
+ "save": False,
1357
+ "subdir": "assets/images",
1358
+ "prefix": prefix,
1359
+ },
1360
+ "step": obj.get("step", idx + 2),
1361
+ }
1362
+ # 插入紧随其后
1363
+ all_steps.insert(idx + 1, inject_step)
1364
+
1365
+ # 按 step 排序并重新编号
1366
+ all_steps.sort(key=lambda x: x.get("step", 0))
1367
+ for i, obj in enumerate(all_steps, start=1):
1368
+ obj["step"] = i
1369
+
1370
+ plan["tools_sequence"] = all_steps
1371
+ return plan
1372
+
1373
+ def _display_execution_plan(self, plan: dict):
1374
+ """显示执行计划"""
1375
+ print("\n" + "=" * 60)
1376
+ print("📋 智能执行计划")
1377
+ print("=" * 60)
1378
+ print(f"🎯 任务分析: {plan.get('task_analysis', 'N/A')}")
1379
+ print(f"📁 项目名称: {plan.get('project_name', 'N/A')}")
1380
+ print(f"🎨 网站类型: {plan.get('site_type', 'N/A')}")
1381
+ print(f"🎭 设计风格: {plan.get('design_style', 'N/A')}")
1382
+
1383
+ if "color_scheme" in plan:
1384
+ colors = plan["color_scheme"]
1385
+ print(
1386
+ f"🎨 配色方案: 主色 {colors.get('primary', 'N/A')} | 辅助 {colors.get('secondary', 'N/A')} | 强调 {colors.get('accent', 'N/A')}"
1387
+ )
1388
+
1389
+ print(f"⏱️ 预计耗时: {plan.get('estimated_time', 'N/A')}")
1390
+ print(f"📊 总步骤数: {len(plan.get('tools_sequence', []))}")
1391
+
1392
+ print(f"\n🛠️ 执行步骤预览:")
1393
+ for tool_spec in plan.get("tools_sequence", []):
1394
+ step = tool_spec.get("step", 0)
1395
+ description = tool_spec.get("description", "N/A")
1396
+ tool_name = tool_spec.get("tool", "unknown_tool")
1397
+ rationale = tool_spec.get("rationale", "")
1398
+ print(f" {step}. {description} ({tool_name})")
1399
+ if rationale:
1400
+ print(f" 理由: {rationale}")
1401
+
1402
+ print("💡 提示:HTML/CSS/JS 将在执行阶段逐步生成,可在每一步使用 d 查看详情。")
1403
+ print("=" * 60)
1404
+
1405
+ def _get_user_confirmation(self, plan: dict) -> bool:
1406
+ """获取用户确认"""
1407
+ while True:
1408
+ try:
1409
+ confirm = input(f"\n✅ 是否执行此计划?(y/N/d): ").lower().strip()
1410
+ except EOFError:
1411
+ confirm = "n"
1412
+
1413
+ if confirm in ["y", "yes"]:
1414
+ return True
1415
+ elif confirm in ["", "n", "no"]:
1416
+ return False
1417
+ elif confirm == "d":
1418
+ # 显示详细参数
1419
+ self._display_detailed_params(plan)
1420
+ else:
1421
+ print("请输入 y(执行)、n(取消) 或 d(查看详细参数)")
1422
+
1423
+ def _display_detailed_params(self, plan: dict):
1424
+ """显示详细参数"""
1425
+ print("\n📝 详细参数预览:")
1426
+ for tool_spec in plan.get("tools_sequence", []):
1427
+ step = tool_spec.get("step", 0)
1428
+ print(f"\n步骤 {step}: {tool_spec.get('tool', 'N/A')}")
1429
+ print(
1430
+ f"参数: {json.dumps(tool_spec.get('params', {}), ensure_ascii=False, indent=2)}"
1431
+ )
1432
+
1433
+ # 如果有content参数,单独显示
1434
+ if "content" in tool_spec.get("params", {}) and tool_spec.get(
1435
+ "params", {}
1436
+ ).get("content"):
1437
+ print("生成的代码内容:")
1438
+ content = tool_spec.get("params", {}).get("content", "")
1439
+ # 只显示前1000个字符
1440
+ if len(content) > 1000:
1441
+ print(f"{content[:1000]}...")
1442
+ else:
1443
+ print(content)
1444
+
1445
+ def _execute_plan_with_recovery(
1446
+ self, plan: dict, confirm_each_step: bool = False, progress_callback=None
1447
+ ) -> List[dict]:
1448
+ """执行计划,带错误恢复机制 - 增强输出
1449
+ progress_callback: 可选回调,签名 progress_callback(dict),用于上报:
1450
+ {type:'step'|'plan'|'done', status, step, total, percent, description, tool, message, rationale}
1451
+ """
1452
+ tools_sequence = plan.get("tools_sequence", [])
1453
+ total_steps = len(tools_sequence)
1454
+ results = []
1455
+ success_count = 0
1456
+ failed_critical = False
1457
+
1458
+ for i, tool_spec in enumerate(tools_sequence):
1459
+ step = tool_spec.get("step", i + 1)
1460
+ tool_name = tool_spec.get("tool", "unknown_tool")
1461
+ raw_params = tool_spec.get("params", {})
1462
+ params = self._normalize_tool_params(tool_name, raw_params, plan)
1463
+ description = tool_spec.get("description", "N/A")
1464
+
1465
+ # 显示进度
1466
+ progress = (i + 1) / total_steps * 100
1467
+ print(f"\n[{step}/{total_steps}] ({progress:.1f}%) {description}")
1468
+ print(f"🔧 执行工具: {tool_name}")
1469
+
1470
+ skip_step = False
1471
+ user_cancelled = False
1472
+
1473
+ if confirm_each_step:
1474
+ while True:
1475
+ try:
1476
+ preview = {
1477
+ k: v
1478
+ for k, v in (raw_params or {}).items()
1479
+ if k in ("file_path", "project_path", "title")
1480
+ }
1481
+ ans = (
1482
+ input(
1483
+ f"继续执行步骤 {step}? (y=执行 / s=跳过 / d=详情 / q=终止) [{preview}]: "
1484
+ )
1485
+ .strip()
1486
+ .lower()
1487
+ )
1488
+ except EOFError:
1489
+ ans = "y"
1490
+
1491
+ if ans in ("", "y", "yes"):
1492
+ break
1493
+ if ans == "s":
1494
+ results.append(
1495
+ {
1496
+ "step": step,
1497
+ "tool": tool_name,
1498
+ "status": "skipped",
1499
+ "message": "用户跳过",
1500
+ "description": description,
1501
+ }
1502
+ )
1503
+ print("⏭️ 跳过此步骤")
1504
+ skip_step = True
1505
+ break
1506
+ if ans == "d":
1507
+ params = self._ensure_step_content(tool_spec, params, plan)
1508
+ detail_params = tool_spec.get("params", {}) or {}
1509
+ print(
1510
+ f"参数详情: {json.dumps(detail_params, ensure_ascii=False, indent=2)}"
1511
+ )
1512
+ content_text = detail_params.get("content")
1513
+ if content_text:
1514
+ preview_text = content_text[:500]
1515
+ print("内容预览(前500字符):")
1516
+ print("=" * 40)
1517
+ print(preview_text)
1518
+ if len(content_text) > 500:
1519
+ print(f"... (共 {len(content_text)} 字符)")
1520
+ print("=" * 40)
1521
+ continue
1522
+ if ans == "q":
1523
+ print("⛔ 用户终止执行")
1524
+ user_cancelled = True
1525
+ break
1526
+ print("请输入 y(执行)、s(跳过)、d(查看详情) 或 q(终止)")
1527
+
1528
+ if user_cancelled:
1529
+ break
1530
+ if skip_step:
1531
+ continue
1532
+
1533
+ params = self._ensure_step_content(tool_spec, params, plan)
1534
+
1535
+ # 记录执行开始时间
1536
+ step_start_time = time.time()
1537
+
1538
+ # 进度:步骤开始
1539
+ if callable(progress_callback):
1540
+ try:
1541
+ progress_callback(
1542
+ {
1543
+ "type": "step",
1544
+ "status": "start",
1545
+ "step": step,
1546
+ "total": total_steps,
1547
+ "percent": (i / max(1, total_steps)) * 100.0,
1548
+ "tool": tool_name,
1549
+ "description": description,
1550
+ "rationale": tool_spec.get("rationale"),
1551
+ }
1552
+ )
1553
+ except Exception:
1554
+ pass
1555
+
1556
+ # 执行工具(带重试)
1557
+ result = self._execute_tool_with_retry(tool_name, params, description, step)
1558
+
1559
+ # 记录执行时间
1560
+ step_duration = time.time() - step_start_time
1561
+ result["duration"] = step_duration
1562
+
1563
+ results.append(result)
1564
+
1565
+ if result["status"] == "success":
1566
+ success_count += 1
1567
+ print(f"✅ 成功 ({step_duration:.2f}秒): {result['message']}")
1568
+ if callable(progress_callback):
1569
+ try:
1570
+ progress_callback(
1571
+ {
1572
+ "type": "step",
1573
+ "status": "success",
1574
+ "step": step,
1575
+ "total": total_steps,
1576
+ "percent": ((i + 1) / max(1, total_steps)) * 100.0,
1577
+ "tool": tool_name,
1578
+ "description": description,
1579
+ "message": result.get("message"),
1580
+ }
1581
+ )
1582
+ except Exception:
1583
+ pass
1584
+
1585
+ # 显示生成文件的路径和大小
1586
+ if "file_path" in params:
1587
+ file_path = params["file_path"]
1588
+ self.created_files.append(file_path)
1589
+ if os.path.exists(file_path):
1590
+ size = os.path.getsize(file_path)
1591
+ print(f" 📁 文件: {file_path} ({size} 字节)")
1592
+ elif result["status"] == "skipped":
1593
+ print(f"⏭️ 跳过: {result['message']}")
1594
+ else:
1595
+ print(f"❌ 失败: {result['message']}")
1596
+ if callable(progress_callback):
1597
+ try:
1598
+ progress_callback(
1599
+ {
1600
+ "type": "step",
1601
+ "status": "failed",
1602
+ "step": step,
1603
+ "total": total_steps,
1604
+ "percent": ((i + 1) / max(1, total_steps)) * 100.0,
1605
+ "tool": tool_name,
1606
+ "description": description,
1607
+ "message": result.get("message"),
1608
+ }
1609
+ )
1610
+ except Exception:
1611
+ pass
1612
+ # 显示详细错误信息
1613
+ if "error_detail" in result:
1614
+ detail = result["error_detail"]
1615
+ print(f" 错误类型: {detail.get('type')}")
1616
+ print(f" 错误详情: {detail.get('message')}")
1617
+ if self.verbose:
1618
+ print(
1619
+ f" 参数: {json.dumps(detail.get('params', {}), ensure_ascii=False, indent=2)[:500]}"
1620
+ )
1621
+
1622
+ # 检查是否是关键步骤
1623
+ if self._is_critical_step(tool_name):
1624
+ print("⚠️ 关键步骤失败,停止执行")
1625
+ failed_critical = True
1626
+ break
1627
+
1628
+ # 显示预计剩余时间
1629
+ if i < total_steps - 1:
1630
+ avg_time = sum(r.get("duration", 0) for r in results) / len(results)
1631
+ remaining_time = avg_time * (total_steps - i - 1)
1632
+ print(f"⏳ 预计剩余时间: {remaining_time:.1f}秒")
1633
+
1634
+ print("-" * 50)
1635
+
1636
+ # 进度:结束
1637
+ if callable(progress_callback):
1638
+ try:
1639
+ progress_callback(
1640
+ {
1641
+ "type": "done",
1642
+ "status": "completed",
1643
+ "percent": 100.0,
1644
+ "description": "全部步骤完成",
1645
+ }
1646
+ )
1647
+ except Exception:
1648
+ pass
1649
+ return results
1650
+
1651
+ def _execute_tool_with_retry(
1652
+ self,
1653
+ tool_name: str,
1654
+ params: dict,
1655
+ description: str,
1656
+ step: int,
1657
+ max_retries: int = 2,
1658
+ ) -> dict:
1659
+ """执行工具,带重试机制 - 增强输出"""
1660
+ for attempt in range(max_retries):
1661
+ try:
1662
+ # 检查工具是否存在
1663
+ if tool_name not in self.tool_functions:
1664
+ return {
1665
+ "step": step,
1666
+ "tool": tool_name,
1667
+ "status": "failed",
1668
+ "message": f"未知工具: {tool_name}",
1669
+ "description": description,
1670
+ }
1671
+
1672
+ # 显示关键参数
1673
+ if "file_path" in params:
1674
+ print(f"📁 文件路径: {params['file_path']}")
1675
+ if "title" in params:
1676
+ print(f"📄 页面标题: {params['title']}")
1677
+
1678
+ # === 新增:显示工具执行详情 ===
1679
+ if self.verbose:
1680
+ print(f"\n🔍 执行详情:")
1681
+ print(f" - 工具: {tool_name}")
1682
+ params_preview = json.dumps(params, ensure_ascii=False, indent=2)
1683
+ if len(params_preview) > 500:
1684
+ params_preview = params_preview[:500] + "..."
1685
+ print(f" - 参数: {params_preview}")
1686
+
1687
+ # 执行工具
1688
+ result = self.tool_functions[tool_name](**params)
1689
+
1690
+ # === 新增:显示实际生成的内容 ===
1691
+ if tool_name in [
1692
+ "create_html_file",
1693
+ "create_css_file",
1694
+ "create_js_file",
1695
+ ]:
1696
+ content = params.get("content", "")
1697
+ if content and self.verbose:
1698
+ print(f"\n📄 生成内容预览(前500字符):")
1699
+ print("=" * 40)
1700
+ preview = content[:500] if len(content) > 500 else content
1701
+ print(preview)
1702
+ if len(content) > 500:
1703
+ print(f"... (共 {len(content)} 字符)")
1704
+ print("=" * 40)
1705
+
1706
+ # 记录到历史
1707
+ self.execution_history.append(
1708
+ {
1709
+ "step": step,
1710
+ "tool": tool_name,
1711
+ "params": params,
1712
+ "result": result,
1713
+ "timestamp": datetime.now().isoformat(),
1714
+ }
1715
+ )
1716
+
1717
+ # 保存到日志
1718
+ if self.save_output:
1719
+ self._log(f"\n步骤 {step}: {tool_name}\n结果: {result}\n")
1720
+
1721
+ # 成功后按需展示代码片段
1722
+ try:
1723
+ if self.show_code and tool_name in (
1724
+ "create_html_file",
1725
+ "create_css_file",
1726
+ "create_js_file",
1727
+ "create_responsive_navbar",
1728
+ ):
1729
+ fp = params.get("file_path")
1730
+ if isinstance(fp, str) and fp:
1731
+ self._preview_file(fp)
1732
+ except Exception as _e:
1733
+ # 预览失败不影响主流程
1734
+ print(f"ℹ️ 代码预览跳过: {str(_e)}")
1735
+
1736
+ return {
1737
+ "step": step,
1738
+ "tool": tool_name,
1739
+ "status": "success",
1740
+ "message": result,
1741
+ "description": description,
1742
+ }
1743
+
1744
+ except Exception as e:
1745
+ # === 改进错误信息 ===
1746
+ error_msg = str(e)
1747
+ error_detail = traceback.format_exc()
1748
+
1749
+ print(f"\n❌ 错误详情:")
1750
+ print(f" - 工具: {tool_name}")
1751
+ print(f" - 错误类型: {type(e).__name__}")
1752
+ print(f" - 错误信息: {error_msg}")
1753
+ if self.verbose:
1754
+ print(f" - 堆栈跟踪:\n{error_detail[:1000]}")
1755
+ params_preview = json.dumps(params, ensure_ascii=False, indent=2)
1756
+ if len(params_preview) > 500:
1757
+ params_preview = params_preview[:500] + "..."
1758
+ print(f" - 参数: {params_preview}")
1759
+
1760
+ if attempt < max_retries - 1:
1761
+ print(f"⚠️ 执行失败,重试中... ({attempt + 1}/{max_retries})")
1762
+ time.sleep(1)
1763
+ else:
1764
+ # 返回更详细的错误信息
1765
+ return {
1766
+ "step": step,
1767
+ "tool": tool_name,
1768
+ "status": "failed",
1769
+ "message": f"{type(e).__name__}: {error_msg}",
1770
+ "error_detail": {
1771
+ "type": type(e).__name__,
1772
+ "message": error_msg,
1773
+ "traceback": error_detail[:1000],
1774
+ "params": params,
1775
+ },
1776
+ "description": description,
1777
+ }
1778
+
1779
+ def _is_critical_step(self, tool_name: str) -> bool:
1780
+ """判断是否是关键步骤"""
1781
+ critical_tools = ["create_project_structure", "create_html_file"]
1782
+ return tool_name in critical_tools
1783
+
1784
+ def _run_consistency_review(self, plan: dict) -> None:
1785
+ if self.client is None:
1786
+ print("ℹ️ 自动巡检跳过:当前无可用模型。")
1787
+ return
1788
+
1789
+ project_root = Path(self._project_root(plan)).resolve()
1790
+ if not project_root.exists():
1791
+ return
1792
+
1793
+ collected: list[str] = []
1794
+ total_chars = 0
1795
+ max_chars = 8000
1796
+ for pattern in ("*.html", "*.css"):
1797
+ for fp in sorted(project_root.rglob(pattern)):
1798
+ if total_chars >= max_chars:
1799
+ break
1800
+ try:
1801
+ content = fp.read_text(encoding="utf-8", errors="ignore").strip()
1802
+ except Exception:
1803
+ continue
1804
+ if not content:
1805
+ continue
1806
+ snippet = content if len(content) <= 2000 else content[:2000] + "..."
1807
+ try:
1808
+ rel = fp.relative_to(project_root)
1809
+ except ValueError:
1810
+ rel = fp.name
1811
+ context_entry = f"[{rel}]\n{snippet}"
1812
+ collected.append(context_entry)
1813
+ total_chars += len(snippet)
1814
+ if total_chars >= max_chars:
1815
+ break
1816
+
1817
+ if not collected:
1818
+ return
1819
+
1820
+ outline = self._plan_outline_for_prompt(plan, limit=12)
1821
+ context_block = "\n\n".join(collected)
1822
+ prompt = textwrap.dedent(
1823
+ f"""
1824
+ 你是一名资深前端代码审查专家,需要检查整个网站在设计语言、组件命名、排版、配色与可访问性方面是否一致。
1825
+ 用户原始需求:
1826
+ {self.latest_user_request}
1827
+
1828
+ 执行纲要概览:
1829
+ {outline}
1830
+
1831
+ 以下是网站已生成的 HTML/CSS 核心文件,请结合整体风格给出需要统一或改进的要点,并提供简明行动建议:
1832
+ {context_block}
1833
+ """
1834
+ ).strip()
1835
+
1836
+ try:
1837
+ response = self.client.chat.completions.create(
1838
+ model=self.model,
1839
+ messages=[
1840
+ {
1841
+ "role": "system",
1842
+ "content": "你是一名细致的前端代码审查专家,关注一致性、命名、排版与可访问性。",
1843
+ },
1844
+ {"role": "user", "content": prompt},
1845
+ ],
1846
+ )
1847
+ summary = response.choices[0].message.content.strip()
1848
+ print("\n🔍 自动巡检建议:")
1849
+ print(summary)
1850
+ if self.save_output:
1851
+ self._log(f"\n=== 自动巡检报告 ===\n{summary}\n")
1852
+ except Exception as exc:
1853
+ print(f"⚠️ 自动巡检失败: {exc}")
1854
+ if self.verbose:
1855
+ print(traceback.format_exc())
1856
+
1857
+ def _generate_execution_report(self, plan: dict, results: List[dict]) -> str:
1858
+ """生成执行报告"""
1859
+ planned_total = len(plan.get("tools_sequence", []))
1860
+ success_count = sum(1 for r in results if r["status"] == "success")
1861
+ failed_count = sum(1 for r in results if r["status"] == "failed")
1862
+ skipped_logged = sum(1 for r in results if r["status"] == "skipped")
1863
+ # 若中途终止,未执行的视为跳过
1864
+ skipped_missing = max(0, planned_total - len(results))
1865
+ skipped_count = skipped_logged + skipped_missing
1866
+
1867
+ # 计算总执行时间
1868
+ total_duration = time.time() - self.execution_start_time
1869
+
1870
+ print("\n" + "=" * 60)
1871
+ print("📊 执行报告")
1872
+ print("=" * 60)
1873
+ print(f"✅ 成功步骤: {success_count}/{planned_total}")
1874
+ print(f"❌ 失败步骤: {failed_count}/{planned_total}")
1875
+ print(f"⏭️ 跳过步骤: {skipped_count}/{planned_total}")
1876
+ print(f"⏱️ 总执行时间: {total_duration:.2f}秒")
1877
+ print(
1878
+ f"📁 项目位置: {self.project_directory}/{plan.get('project_name', 'N/A')}"
1879
+ )
1880
+
1881
+ if self.created_files:
1882
+ print(f"\n📄 创建的文件:")
1883
+ for file_path in self.created_files:
1884
+ print(f" - {file_path}")
1885
+
1886
+ if success_count == planned_total and failed_count == 0:
1887
+ print("\n🌟 所有步骤执行成功!")
1888
+ status_msg = "完美完成"
1889
+ elif success_count > 0:
1890
+ print("\n⚠️ 部分步骤执行失败,请检查错误信息")
1891
+ status_msg = "部分完成"
1892
+ else:
1893
+ print("\n❌ 执行失败,请检查错误信息")
1894
+ status_msg = "执行失败"
1895
+
1896
+ print("\n📋 详细结果:")
1897
+ for result in results:
1898
+ status_icon = (
1899
+ "✅"
1900
+ if result["status"] == "success"
1901
+ else "❌"
1902
+ if result["status"] == "failed"
1903
+ else "⏭️"
1904
+ )
1905
+ duration = result.get("duration", 0)
1906
+ print(
1907
+ f" {status_icon} 步骤{result['step']}: {result['description']} ({duration:.2f}秒)"
1908
+ )
1909
+ if result["status"] == "failed":
1910
+ print(f" 错误: {result['message']}")
1911
+
1912
+ denom = planned_total if planned_total else 1
1913
+ return f"\n🎉 执行完成!状态: {status_msg} | 成功率: {success_count}/{planned_total} ({success_count / denom * 100:.1f}%)"
1914
+
1915
+ # ---------------- 代码预览工具 ----------------
1916
+ def _preview_file(
1917
+ self, file_path: str, max_lines: int = 120, max_chars: int = 10000
1918
+ ):
1919
+ """在控制台打印文件前若干行,避免刷屏"""
1920
+ try:
1921
+ if not os.path.exists(file_path):
1922
+ print(f"⚠️ 预览失败:文件不存在 {file_path}")
1923
+ return
1924
+ print("\n" + "-" * 60)
1925
+ print(f"📄 代码预览: {file_path} (前 {max_lines} 行, ≤{max_chars} 字符)")
1926
+ print("-" * 60)
1927
+ printed = 0
1928
+ total_chars = 0
1929
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
1930
+ for line in f:
1931
+ if printed >= max_lines or total_chars >= max_chars:
1932
+ break
1933
+ # 避免超长行
1934
+ if len(line) > 800:
1935
+ line = line[:800] + "…\n"
1936
+ print(line.rstrip("\n"))
1937
+ printed += 1
1938
+ total_chars += len(line)
1939
+ print("-" * 60)
1940
+ if printed == 0:
1941
+ print("(空文件或无法读取内容)")
1942
+ else:
1943
+ print(f"↑ 已显示 {printed} 行")
1944
+ except Exception as e:
1945
+ print(f"⚠️ 预览异常: {str(e)}")
1946
+
1947
+ def quick_templates(self):
1948
+ """提供快速模板选择"""
1949
+ templates = {
1950
+ "1": "创建一个现代化的个人作品集网站,展示我的设计作品",
1951
+ "2": "创建一个专业的企业官网,展示公司业务和服务",
1952
+ "3": "创建一个高端餐厅网站,包含菜单展示和预订功能",
1953
+ "4": "创建一个科技产品着陆页,强调产品特性和用户价值",
1954
+ "5": "创建一个教育培训网站,展示课程信息和师资力量",
1955
+ "6": "创建一个SaaS产品官网,突出功能特点和定价方案",
1956
+ "7": "创建一个时尚博客网站,注重内容展示和阅读体验",
1957
+ "8": "创建一个活动会议网站,包含日程安排和报名功能",
1958
+ "9": "创建一个公益组织网站,展示使命愿景和项目成果",
1959
+ "10": "创建一个创意工作室网站,体现创新和艺术感",
1960
+ }
1961
+
1962
+ print("\n🎯 快速模板选择:")
1963
+ print("=" * 50)
1964
+ for key, desc in templates.items():
1965
+ print(f" {key:2}. {desc}")
1966
+ print("=" * 50)
1967
+
1968
+ choice = input("\n选择模板编号 (1-10) 或直接描述需求: ").strip()
1969
+
1970
+ if choice in templates:
1971
+ selected = templates[choice]
1972
+ print(f"\n✅ 已选择: {selected}")
1973
+ return selected
1974
+ else:
1975
+ return choice
1976
+
1977
+ def _enhance_user_input(self, user_input: str) -> str:
1978
+ """增强用户输入,添加默认质量要求"""
1979
+ # 检查是否已包含关键词
1980
+ quality_keywords = ["现代", "专业", "高质量", "响应式", "美观"]
1981
+ has_quality_req = any(keyword in user_input for keyword in quality_keywords)
1982
+
1983
+ if not has_quality_req:
1984
+ # 添加默认质量要求
1985
+ enhanced = f"{user_input}。要求:现代化设计,响应式布局,专业美观,动画流畅,用户体验优秀。"
1986
+ else:
1987
+ enhanced = user_input
1988
+
1989
+ return enhanced
1990
+
1991
+ def _validate_plan(self, plan: dict) -> bool:
1992
+ """验证执行计划的完整性"""
1993
+ required_fields = ["task_analysis", "project_name", "tools_sequence"]
1994
+ for field in required_fields:
1995
+ if field not in plan:
1996
+ print(f"⚠️ 计划缺少必要字段: {field}")
1997
+ return False
1998
+
1999
+ if (
2000
+ not isinstance(plan["tools_sequence"], list)
2001
+ or len(plan["tools_sequence"]) == 0
2002
+ ):
2003
+ print("⚠️ 工具序列为空或格式错误")
2004
+ return False
2005
+
2006
+ return True
2007
+
2008
+ # ---------------- 参数规范化与容错 ----------------
2009
+ def _project_root(self, plan: dict) -> str:
2010
+ project_name = plan.get("project_name", "project")
2011
+ return os.path.join(self.project_directory, project_name)
2012
+
2013
+ def _is_inside(self, base: str, path: str) -> bool:
2014
+ try:
2015
+ base = os.path.abspath(base)
2016
+ path = os.path.abspath(path)
2017
+ return os.path.commonpath([base]) == os.path.commonpath([base, path])
2018
+ except Exception:
2019
+ return False
2020
+
2021
+ def _normalize_tool_params(self, tool_name: str, params: dict, plan: dict) -> dict:
2022
+ params = dict(params or {})
2023
+ project_root = self._project_root(plan)
2024
+
2025
+ # 强制 project_path 到项目根目录(用于 add_bootstrap 等)
2026
+ if tool_name in ["add_bootstrap"]:
2027
+ params["project_path"] = project_root
2028
+
2029
+ # create_project_structure 固定为在工作目录下创建项目名目录
2030
+ if tool_name == "create_project_structure":
2031
+ params["project_name"] = plan.get("project_name")
2032
+ params["project_path"] = self.project_directory
2033
+ return params
2034
+
2035
+ # 标准化 file_path
2036
+ if "file_path" in params:
2037
+ input_path = params.get("file_path") or ""
2038
+ # 若给的是目录,则兜底到 index.html
2039
+ if os.path.isdir(input_path):
2040
+ input_path = os.path.join(input_path, "index.html")
2041
+
2042
+ # 将相对路径解析到项目根
2043
+ if not os.path.isabs(input_path):
2044
+ input_path = os.path.join(project_root, input_path)
2045
+
2046
+ # 针对不同工具限定路径与扩展名
2047
+ if tool_name == "create_css_file":
2048
+ # 目标必须在 assets/css
2049
+ filename = os.path.basename(input_path) or "style.css"
2050
+ if not filename.endswith(".css"):
2051
+ filename += ".css"
2052
+ input_path = os.path.join(project_root, "assets", "css", filename)
2053
+
2054
+ elif tool_name == "create_js_file":
2055
+ filename = os.path.basename(input_path) or "main.js"
2056
+ if not filename.endswith(".js"):
2057
+ filename += ".js"
2058
+ input_path = os.path.join(project_root, "assets", "js", filename)
2059
+
2060
+ elif tool_name in [
2061
+ "create_html_file",
2062
+ "create_menu_page",
2063
+ "create_about_page",
2064
+ "create_contact_page",
2065
+ "validate_html",
2066
+ "check_mobile_friendly",
2067
+ "open_in_browser",
2068
+ ]:
2069
+ # 统一落在项目根,默认 index.html
2070
+ filename = os.path.basename(input_path) or "index.html"
2071
+ if not filename.endswith(".html"):
2072
+ filename = "index.html"
2073
+ input_path = os.path.join(project_root, filename)
2074
+
2075
+ # 最终确保在项目根内部
2076
+ if not self._is_inside(project_root, input_path):
2077
+ # 回退到项目内的合理位置
2078
+ if tool_name == "create_css_file":
2079
+ input_path = os.path.join(
2080
+ project_root, "assets", "css", "style.css"
2081
+ )
2082
+ elif tool_name == "create_js_file":
2083
+ input_path = os.path.join(project_root, "assets", "js", "main.js")
2084
+ elif tool_name in [
2085
+ "create_html_file",
2086
+ "create_menu_page",
2087
+ "create_about_page",
2088
+ "create_contact_page",
2089
+ "validate_html",
2090
+ "check_mobile_friendly",
2091
+ "open_in_browser",
2092
+ "create_responsive_navbar",
2093
+ ]:
2094
+ input_path = os.path.join(project_root, "index.html")
2095
+
2096
+ params["file_path"] = input_path
2097
+
2098
+ # 规范化导航项(增强别名兼容)
2099
+ if tool_name == "create_responsive_navbar":
2100
+ nav_items = params.get("nav_items")
2101
+ if isinstance(nav_items, str):
2102
+ try:
2103
+ nav_items = json.loads(nav_items)
2104
+ except Exception:
2105
+ nav_items = None
2106
+
2107
+ # 情况1:字符串数组 -> 结构化
2108
+ if (
2109
+ isinstance(nav_items, list)
2110
+ and nav_items
2111
+ and isinstance(nav_items[0], str)
2112
+ ):
2113
+ nav_items = [
2114
+ {"name": name, "href": f"#{self._slugify(name)}", "active": i == 0}
2115
+ for i, name in enumerate(nav_items)
2116
+ ]
2117
+
2118
+ # 情况2:字典数组但使用了别名键 -> 归一化
2119
+ if (
2120
+ isinstance(nav_items, list)
2121
+ and nav_items
2122
+ and isinstance(nav_items[0], dict)
2123
+ ):
2124
+ normalized = []
2125
+ for i, item in enumerate(nav_items):
2126
+ if not isinstance(item, dict):
2127
+ normalized.append(
2128
+ {"name": str(item), "href": "#", "active": i == 0}
2129
+ )
2130
+ continue
2131
+ name = (
2132
+ item.get("name")
2133
+ or item.get("text")
2134
+ or item.get("title")
2135
+ or item.get("label")
2136
+ )
2137
+ href = item.get("href") or item.get("url") or item.get("link")
2138
+ if not name:
2139
+ name = f"导航{i + 1}"
2140
+ if not href:
2141
+ href = f"#{self._slugify(name)}"
2142
+ active = item.get("active")
2143
+ if active is None:
2144
+ active = i == 0
2145
+ normalized.append(
2146
+ {"name": name, "href": href, "active": bool(active)}
2147
+ )
2148
+ nav_items = normalized
2149
+
2150
+ params["nav_items"] = nav_items
2151
+
2152
+ cta = params.get("cta")
2153
+ if isinstance(cta, str):
2154
+ try:
2155
+ cta = json.loads(cta)
2156
+ except Exception:
2157
+ cta = None
2158
+ if isinstance(cta, dict):
2159
+ params["cta"] = cta
2160
+ else:
2161
+ params.pop("cta", None)
2162
+
2163
+ if tool_name == "create_about_page":
2164
+ ctx = params.get("context")
2165
+ if isinstance(ctx, str):
2166
+ try:
2167
+ ctx = json.loads(ctx)
2168
+ except Exception:
2169
+ ctx = None
2170
+ if not isinstance(ctx, dict):
2171
+ ctx = None
2172
+ params["context"] = ctx
2173
+
2174
+ # 对 fetch_generated_images:强制 project_path 为项目根;清洗 prompts
2175
+ if tool_name == "fetch_generated_images":
2176
+ params["project_path"] = project_root
2177
+ prm = params.get("prompts")
2178
+ if isinstance(prm, str):
2179
+ s = prm.strip()
2180
+ if s.startswith("["):
2181
+ try:
2182
+ prm = json.loads(s)
2183
+ except Exception:
2184
+ prm = [p.strip() for p in s.split(",") if p.strip()]
2185
+ else:
2186
+ prm = [p.strip() for p in s.split(",") if p.strip()]
2187
+ if prm is not None and not isinstance(prm, list):
2188
+ prm = [str(prm)]
2189
+ params["prompts"] = prm
2190
+
2191
+ # 对 inject_images:标准化 file_path,清洗 topics 列表
2192
+ if tool_name == "inject_images":
2193
+ # 若计划未提供具体页面,默认回退到首页
2194
+ target_path = params.get("file_path")
2195
+ if not target_path:
2196
+ params["file_path"] = os.path.join(project_root, "index.html")
2197
+ else:
2198
+ # 确保路径位于项目内且指向HTML文件
2199
+ normalized_path = target_path
2200
+ if not os.path.isabs(normalized_path):
2201
+ normalized_path = os.path.join(project_root, normalized_path)
2202
+ if not normalized_path.endswith(".html"):
2203
+ normalized_path = os.path.splitext(normalized_path)[0] + ".html"
2204
+ if not self._is_inside(project_root, normalized_path):
2205
+ normalized_path = os.path.join(project_root, "index.html")
2206
+ params["file_path"] = normalized_path
2207
+ tps = params.get("topics")
2208
+ if isinstance(tps, str):
2209
+ s = tps.strip()
2210
+ if s.startswith("["):
2211
+ try:
2212
+ tps = json.loads(s)
2213
+ except Exception:
2214
+ tps = [p.strip() for p in s.split(",") if p.strip()]
2215
+ else:
2216
+ tps = [p.strip() for p in s.split(",") if p.strip()]
2217
+ if tps is not None and not isinstance(tps, list):
2218
+ tps = [str(tps)]
2219
+ params["topics"] = tps
2220
+
2221
+ # 对需要AI生成内容的工具,确保在联网模式下不沿用旧的 content
2222
+ if self.client is not None and self._step_requires_content(tool_name):
2223
+ if params.get("content"):
2224
+ params["content"] = ""
2225
+
2226
+ # 最后一步:按工具白名单过滤参数,剔除 description/rationale 等无关键
2227
+ allowed = {
2228
+ "create_project_structure": {"project_name", "project_path"},
2229
+ "create_html_file": {"file_path", "title", "content"},
2230
+ "create_css_file": {"file_path", "content"},
2231
+ "create_js_file": {"file_path", "content"},
2232
+ "add_bootstrap": {"project_path"},
2233
+ "create_responsive_navbar": {"file_path", "brand_name", "nav_items", "cta"},
2234
+ "create_about_page": {"file_path", "project_name", "context", "theme"},
2235
+ "fetch_generated_images": {
2236
+ "project_path",
2237
+ "provider",
2238
+ "prompts",
2239
+ "count",
2240
+ "size",
2241
+ "seed",
2242
+ "save",
2243
+ "subdir",
2244
+ "prefix",
2245
+ },
2246
+ "inject_images": {
2247
+ "file_path",
2248
+ "provider",
2249
+ "topics",
2250
+ "size",
2251
+ "seed",
2252
+ "save",
2253
+ "subdir",
2254
+ "prefix",
2255
+ },
2256
+ "validate_html": {"file_path"},
2257
+ "check_mobile_friendly": {"file_path"},
2258
+ "open_in_browser": {"file_path"},
2259
+ }.get(tool_name, set())
2260
+
2261
+ if allowed:
2262
+ params = {k: v for k, v in params.items() if k in allowed}
2263
+
2264
+ return params
2265
+
2266
+
2267
+ @click.command()
2268
+ @click.argument(
2269
+ "project_directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)
2270
+ )
2271
+ @click.option("--model", default="qwen3-coder-plus-2025-09-23", help="AI模型选择")
2272
+ @click.option("--template", is_flag=True, help="使用快速模板选择")
2273
+ @click.option("--yes", is_flag=True, help="直接执行计划(跳过确认)")
2274
+ @click.option(
2275
+ "--confirm-each/--no-confirm-each", default=True, help="每个步骤执行前进行确认"
2276
+ )
2277
+ @click.option(
2278
+ "--show-code/--no-show-code",
2279
+ default=False,
2280
+ help="在每个创建/修改文件的步骤后打印代码片段",
2281
+ )
2282
+ @click.option("--verbose", is_flag=True, help="显示详细执行信息")
2283
+ @click.option("--save-output", is_flag=True, help="保存所有生成的内容到日志文件")
2284
+ @click.option("--stream", is_flag=True, help="启用流式输出显示AI思考过程")
2285
+ def main(
2286
+ project_directory,
2287
+ model,
2288
+ template,
2289
+ yes,
2290
+ confirm_each,
2291
+ show_code,
2292
+ verbose,
2293
+ save_output,
2294
+ stream,
2295
+ ):
2296
+ """
2297
+ 🧠 智能批量Web Agent - 2025年最佳实践
2298
+
2299
+ 特点:
2300
+ ✅ 预先规划 - 用户可以看到完整执行计划
2301
+ ✅ 成本可控 - 只需要1次API调用
2302
+ ✅ 灵活强大 - 支持复杂的自定义需求
2303
+ ✅ 逐步执行 - 每个步骤执行前可确认
2304
+ ✅ 流式输出 - 实时显示AI思考过程
2305
+ ✅ 详细输出 - 可查看生成的所有内容
2306
+
2307
+ 使用示例:
2308
+ python smart_web_agent.py ./projects
2309
+ python smart_web_agent.py ./projects --template
2310
+ python smart_web_agent.py ./projects --verbose --stream
2311
+ python smart_web_agent.py ./projects --yes --save-output
2312
+ """
2313
+
2314
+ project_dir = os.path.abspath(project_directory)
2315
+ agent = SmartWebAgent(
2316
+ project_directory=project_dir,
2317
+ model=model,
2318
+ show_code=show_code,
2319
+ verbose=verbose,
2320
+ show_plan_stream=stream,
2321
+ save_output=save_output,
2322
+ )
2323
+
2324
+ print("🧠 智能批量Web Agent启动!")
2325
+ print(f"📁 工作目录: {project_dir}")
2326
+ print(f"🤖 使用模型: {model}")
2327
+ print("⚡ 特点: 预先规划 + 逐步执行 = 成本可控 + 结果可预期")
2328
+
2329
+ if show_code:
2330
+ print("👀 代码预览已开启:创建/修改文件后将展示前120行")
2331
+ if verbose:
2332
+ print("🔍 详细模式已开启:将显示详细执行信息和内容预览")
2333
+ if stream:
2334
+ print("⚡ 流式输出已开启:将实时显示AI思考过程")
2335
+ if save_output:
2336
+ print(f"💾 日志保存已开启:日志将保存到 agent_log_*.txt")
2337
+
2338
+ print("\n" + "=" * 60)
2339
+ print("💡 使用说明:")
2340
+ print("1. 描述您的需求")
2341
+ print("2. AI分析并生成执行计划")
2342
+ print("3. 您确认计划后逐步执行")
2343
+ print("4. 每个步骤执行前可确认")
2344
+ print("=" * 60)
2345
+
2346
+ while True:
2347
+ try:
2348
+ if template:
2349
+ user_input = agent.quick_templates()
2350
+ template = False # 只在第一次使用
2351
+ else:
2352
+ user_input = input(
2353
+ "\n🎯 请描述您的网页制作需求 (输入 'quit' 退出, 'template' 选择模板): "
2354
+ ).strip()
2355
+
2356
+ if user_input.lower() in ["quit", "exit", "退出"]:
2357
+ print("👋 感谢使用智能批量Web Agent!")
2358
+ break
2359
+
2360
+ if user_input.lower() == "template":
2361
+ user_input = agent.quick_templates()
2362
+
2363
+ if not user_input:
2364
+ print("❌ 请输入具体需求")
2365
+ continue
2366
+
2367
+ print("\n" + "🔄" * 20)
2368
+ # --yes 仅跳过“计划确认”,是否逐步确认由 --confirm-each 控制
2369
+ result = agent.run(
2370
+ user_input, auto_execute=yes, confirm_each_step=confirm_each
2371
+ )
2372
+ print("\n" + "🎉" * 20)
2373
+ print(result)
2374
+
2375
+ except KeyboardInterrupt:
2376
+ print("\n\n👋 用户中断,再见!")
2377
+ break
2378
+ except Exception as e:
2379
+ print(f"\n❌ 发生错误: {str(e)}")
2380
+ print("请重试或检查配置")
2381
+
2382
+
2383
+ if __name__ == "__main__":
2384
+ main()