jjyaoao 8 месяцев назад
Родитель
Сommit
ca9519c427
47 измененных файлов с 8223 добавлено и 5 удалено
  1. 2 2
      README.md
  2. 212 0
      code/chapter13/helloagents-trip-planner/README.md
  3. 30 0
      code/chapter13/helloagents-trip-planner/backend/.env.example
  4. 51 0
      code/chapter13/helloagents-trip-planner/backend/.gitignore
  5. 4 0
      code/chapter13/helloagents-trip-planner/backend/app/__init__.py
  6. 2 0
      code/chapter13/helloagents-trip-planner/backend/app/agents/__init__.py
  7. 429 0
      code/chapter13/helloagents-trip-planner/backend/app/agents/trip_planner_agent.py
  8. 2 0
      code/chapter13/helloagents-trip-planner/backend/app/api/__init__.py
  9. 99 0
      code/chapter13/helloagents-trip-planner/backend/app/api/main.py
  10. 2 0
      code/chapter13/helloagents-trip-planner/backend/app/api/routes/__init__.py
  11. 163 0
      code/chapter13/helloagents-trip-planner/backend/app/api/routes/map.py
  12. 129 0
      code/chapter13/helloagents-trip-planner/backend/app/api/routes/poi.py
  13. 86 0
      code/chapter13/helloagents-trip-planner/backend/app/api/routes/trip.py
  14. 111 0
      code/chapter13/helloagents-trip-planner/backend/app/config.py
  15. 2 0
      code/chapter13/helloagents-trip-planner/backend/app/models/__init__.py
  16. 206 0
      code/chapter13/helloagents-trip-planner/backend/app/models/schemas.py
  17. 2 0
      code/chapter13/helloagents-trip-planner/backend/app/services/__init__.py
  18. 269 0
      code/chapter13/helloagents-trip-planner/backend/app/services/amap_service.py
  19. 37 0
      code/chapter13/helloagents-trip-planner/backend/app/services/llm_service.py
  20. 86 0
      code/chapter13/helloagents-trip-planner/backend/app/services/unsplash_service.py
  21. 28 0
      code/chapter13/helloagents-trip-planner/backend/requirements.txt
  22. 16 0
      code/chapter13/helloagents-trip-planner/backend/run.py
  23. 5 0
      code/chapter13/helloagents-trip-planner/frontend/.env.example
  24. 29 0
      code/chapter13/helloagents-trip-planner/frontend/.gitignore
  25. 14 0
      code/chapter13/helloagents-trip-planner/frontend/index.html
  26. 2244 0
      code/chapter13/helloagents-trip-planner/frontend/package-lock.json
  27. 27 0
      code/chapter13/helloagents-trip-planner/frontend/package.json
  28. 28 0
      code/chapter13/helloagents-trip-planner/frontend/src/App.vue
  29. 31 0
      code/chapter13/helloagents-trip-planner/frontend/src/main.ts
  30. 65 0
      code/chapter13/helloagents-trip-planner/frontend/src/services/api.ts
  31. 95 0
      code/chapter13/helloagents-trip-planner/frontend/src/types/index.ts
  32. 649 0
      code/chapter13/helloagents-trip-planner/frontend/src/views/Home.vue
  33. 1434 0
      code/chapter13/helloagents-trip-planner/frontend/src/views/Result.vue
  34. 32 0
      code/chapter13/helloagents-trip-planner/frontend/tsconfig.json
  35. 23 0
      code/chapter13/helloagents-trip-planner/frontend/vite.config.ts
  36. 2 2
      docs/README.md
  37. 1577 1
      docs/chapter13/第十三章 智能旅行助手.md
  38. BIN
      docs/images/13-figures/13-1.png
  39. BIN
      docs/images/13-figures/13-2.png
  40. BIN
      docs/images/13-figures/13-3.png
  41. BIN
      docs/images/13-figures/13-4.png
  42. BIN
      docs/images/13-figures/13-5.png
  43. BIN
      docs/images/13-figures/13-6.png
  44. BIN
      docs/images/13-figures/13-7.png
  45. BIN
      docs/images/13-figures/13-8.png
  46. BIN
      docs/images/13-figures/13-table-1.png
  47. BIN
      docs/images/13-figures/13-table-2.png

+ 2 - 2
README.md

@@ -56,7 +56,7 @@
 | [第十一章 Agentic-RL](./docs/chapter11/第十一章%20Agentic-RL.md) | 基于LLM的智能体强化学习 | 🚧 |
 | [第十二章 智能体性能评估](./docs/chapter12/第十二章%20智能体性能评估.md) | 核心指标、基准测试与评估框架 | ✅ |
 | <strong>第四部分:综合案例进阶</strong> |  |  |
-| [第十三章 智能旅行助手](./docs/chapter13/第十三章%20智能旅行助手.md) | RAG与多智能体协作的真实世界应用 | 🚧 |
+| [第十三章 智能旅行助手](./docs/chapter13/第十三章%20智能旅行助手.md) | MCP与多智能体协作的真实世界应用 | ✅ |
 | [第十四章 自动化深度研究智能体](./docs/chapter14/第十四章%20自动化深度研究智能体.md) | DeepResearch Agent 复现与解析 | 🚧 |
 | [第十五章 构建赛博小镇](./docs/chapter15/第十五章%20构建赛博小镇.md) | Agent 与游戏的结合,模拟社会动态 | 🚧 |
 | <strong>第五部分:毕业设计及未来展望</strong> |  |  |
@@ -111,7 +111,7 @@
 - [陈思州-项目负责人](https://github.com/jjyaoao) (Datawhale成员)
 - [孙韬-项目负责人](https://github.com/fengju0213) (Datawhale成员)  
 - [姜舒凡-项目负责人](https://github.com/Tsumugii24) (Datawhale成员)
-- [Jason-Datawhale意向成员](https://github.com/HeteroCat) (第五章Coze\Dify\FastGPT内容贡献者, Agent开发工程师)
+- [Jason-Datawhale意向成员](https://github.com/HeteroCat) (Agent开发工程师, 第五章内容贡献者)
 
 ### 特别感谢
 - 感谢 [@Sm1les](https://github.com/Sm1les) 对本项目的帮助与支持

+ 212 - 0
code/chapter13/helloagents-trip-planner/README.md

@@ -0,0 +1,212 @@
+# HelloAgents智能旅行助手 🌍✈️
+
+基于HelloAgents框架构建的智能旅行规划助手,集成高德地图MCP服务,提供个性化的旅行计划生成。
+
+## ✨ 功能特点
+
+- 🤖 **AI驱动的旅行规划**: 基于HelloAgents框架的SimpleAgent,智能生成详细的多日旅程
+- 🗺️ **高德地图集成**: 通过MCP协议接入高德地图服务,支持景点搜索、路线规划、天气查询
+- 🧠 **智能工具调用**: Agent自动调用高德地图MCP工具,获取实时POI、路线和天气信息
+- 🎨 **现代化前端**: Vue3 + TypeScript + Vite,响应式设计,流畅的用户体验
+- 📱 **完整功能**: 包含住宿、交通、餐饮和景点游览时间推荐
+
+## 🏗️ 技术栈
+
+### 后端
+- **框架**: HelloAgents (基于SimpleAgent)
+- **API**: FastAPI
+- **MCP工具**: amap-mcp-server (高德地图)
+- **LLM**: 支持多种LLM提供商(OpenAI, DeepSeek等)
+
+### 前端
+- **框架**: Vue 3 + TypeScript
+- **构建工具**: Vite
+- **UI组件库**: Ant Design Vue
+- **地图服务**: 高德地图 JavaScript API
+- **HTTP客户端**: Axios
+
+## 📁 项目结构
+
+```
+helloagents-trip-planner/
+├── backend/                    # 后端服务
+│   ├── app/
+│   │   ├── agents/            # Agent实现
+│   │   │   └── trip_planner_agent.py
+│   │   ├── api/               # FastAPI路由
+│   │   │   ├── main.py
+│   │   │   └── routes/
+│   │   │       ├── trip.py
+│   │   │       └── map.py
+│   │   ├── services/          # 服务层
+│   │   │   ├── amap_service.py
+│   │   │   └── llm_service.py
+│   │   ├── models/            # 数据模型
+│   │   │   └── schemas.py
+│   │   └── config.py          # 配置管理
+│   ├── requirements.txt
+│   ├── .env.example
+│   └── .gitignore
+├── frontend/                   # 前端应用
+│   ├── src/
+│   │   ├── components/        # Vue组件
+│   │   ├── services/          # API服务
+│   │   ├── types/             # TypeScript类型
+│   │   └── views/             # 页面视图
+│   ├── package.json
+│   └── vite.config.ts
+└── README.md
+```
+
+## 🚀 快速开始
+
+### 前提条件
+
+- Python 3.10+
+- Node.js 16+
+- 高德地图API密钥 (Web服务API)
+- LLM API密钥 (OpenAI/DeepSeek等)
+
+### 后端安装
+
+1. 进入后端目录
+```bash
+cd backend
+```
+
+2. 创建虚拟环境
+```bash
+python -m venv venv
+source venv/bin/activate  # Windows: venv\Scripts\activate
+```
+
+3. 安装依赖
+```bash
+pip install -r requirements.txt
+```
+
+4. 配置环境变量
+```bash
+cp .env.example .env
+# 编辑.env文件,填入你的API密钥
+```
+
+5. 启动后端服务
+```bash
+uvicorn app.api.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+### 前端安装
+
+1. 进入前端目录
+```bash
+cd frontend
+```
+
+2. 安装依赖
+```bash
+npm install
+```
+
+3. 配置环境变量
+```bash
+# 创建.env文件,配置高德地图Web API Key
+echo "VITE_AMAP_WEB_KEY=your_amap_web_key" > .env
+```
+
+4. 启动开发服务器
+```bash
+npm run dev
+```
+
+5. 打开浏览器访问 `http://localhost:5173`
+
+## 📝 使用指南
+
+1. 在首页填写旅行信息:
+   - 目的地城市
+   - 旅行日期和天数
+   - 交通方式偏好
+   - 住宿偏好
+   - 旅行风格标签
+
+2. 点击"生成旅行计划"按钮
+
+3. 系统将:
+   - 调用HelloAgents Agent生成初步计划
+   - Agent自动调用高德地图MCP工具搜索景点
+   - Agent获取天气信息和路线规划
+   - 整合所有信息生成完整行程
+
+4. 查看结果:
+   - 每日详细行程
+   - 景点信息与地图标记
+   - 交通路线规划
+   - 天气预报
+   - 餐饮推荐
+
+## 🔧 核心实现
+
+### HelloAgents Agent集成
+
+```python
+from hello_agents import SimpleAgent, HelloAgentsLLM
+from hello_agents.tools import MCPTool
+
+# 创建高德地图MCP工具
+amap_tool = MCPTool(
+    name="amap",
+    server_command=["uvx", "amap-mcp-server"],
+    env={"AMAP_MAPS_API_KEY": "your_api_key"},
+    auto_expand=True
+)
+
+# 创建旅行规划Agent
+agent = SimpleAgent(
+    name="旅行规划助手",
+    llm=HelloAgentsLLM(),
+    system_prompt="你是一个专业的旅行规划助手..."
+)
+
+# 添加工具
+agent.add_tool(amap_tool)
+```
+
+### MCP工具调用
+
+Agent可以自动调用以下高德地图MCP工具:
+- `maps_text_search`: 搜索景点POI
+- `maps_weather`: 查询天气
+- `maps_direction_walking_by_address`: 步行路线规划
+- `maps_direction_driving_by_address`: 驾车路线规划
+- `maps_direction_transit_integrated_by_address`: 公共交通路线规划
+
+## 📄 API文档
+
+启动后端服务后,访问 `http://localhost:8000/docs` 查看完整的API文档。
+
+主要端点:
+- `POST /api/trip/plan` - 生成旅行计划
+- `GET /api/map/poi` - 搜索POI
+- `GET /api/map/weather` - 查询天气
+- `POST /api/map/route` - 规划路线
+
+## 🤝 贡献指南
+
+欢迎提交Pull Request或Issue!
+
+## 📜 开源协议
+
+CC BY-NC-SA 4.0
+
+## 🙏 致谢
+
+- [HelloAgents](https://github.com/datawhalechina/Hello-Agents) - 智能体教程
+- [HelloAgents框架](https://github.com/jjyaoao/HelloAgents) - 智能体框架
+- [高德地图开放平台](https://lbs.amap.com/) - 地图服务
+- [amap-mcp-server](https://github.com/sugarforever/amap-mcp-server) - 高德地图MCP服务器
+
+---
+
+**HelloAgents智能旅行助手** - 让旅行计划变得简单而智能 🌈
+

+ 30 - 0
code/chapter13/helloagents-trip-planner/backend/.env.example

@@ -0,0 +1,30 @@
+
+# LLM配置 (从HelloAgents继承,如需覆盖可在此配置)
+# 模型名称
+LLM_MODEL_ID=your-model-name
+
+# API密钥
+LLM_API_KEY=your-api-key-here
+
+# 服务地址
+LLM_BASE_URL=your-api-base-url
+
+# 超时时间(可选,默认60秒)
+LLM_TIMEOUT=60
+
+# 服务器配置
+HOST=0.0.0.0
+PORT=8000
+
+# CORS配置
+CORS_ORIGINS=http://localhost:5173,http://localhost:3000
+
+# 日志级别
+LOG_LEVEL=INFO
+
+# Unsplash API Credentials
+UNSPLASH_ACCESS_KEY="8qLuEyiiM2hAAjWhf0TavkVYI2gUPwDNxksY2WZuvWM"
+UNSPLASH_SECRET_KEY="u9ve0Awc_4ErCrr7G9ngOVSj8OCVnksoQtYlHdJBhmo"
+
+# 高德地图API配置
+AMAP_API_KEY=your_amap_api_key_here

+ 51 - 0
code/chapter13/helloagents-trip-planner/backend/.gitignore

@@ -0,0 +1,51 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# 虚拟环境
+venv/
+env/
+ENV/
+.venv
+
+# 环境变量
+.env
+.env.local
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# 日志
+*.log
+
+# 测试
+.pytest_cache/
+.coverage
+htmlcov/
+
+# 操作系统
+.DS_Store
+Thumbs.db
+

+ 4 - 0
code/chapter13/helloagents-trip-planner/backend/app/__init__.py

@@ -0,0 +1,4 @@
+"""HelloAgents智能旅行助手 - 后端应用"""
+
+__version__ = "1.0.0"
+

+ 2 - 0
code/chapter13/helloagents-trip-planner/backend/app/agents/__init__.py

@@ -0,0 +1,2 @@
+"""智能体模块"""
+

+ 429 - 0
code/chapter13/helloagents-trip-planner/backend/app/agents/trip_planner_agent.py

@@ -0,0 +1,429 @@
+"""多智能体旅行规划系统"""
+
+import json
+from typing import Dict, Any, List
+from hello_agents import SimpleAgent
+from hello_agents.tools import MCPTool
+from ..services.llm_service import get_llm
+from ..models.schemas import TripRequest, TripPlan, DayPlan, Attraction, Meal, WeatherInfo, Location, Hotel
+from ..config import get_settings
+
+# ============ Agent提示词 ============
+
+ATTRACTION_AGENT_PROMPT = """你是景点搜索专家。你的任务是根据城市和用户偏好搜索合适的景点。
+
+**重要提示:**
+你必须使用工具来搜索景点!不要自己编造景点信息!
+
+**工具调用格式:**
+使用maps_text_search工具时,必须严格按照以下格式:
+`[TOOL_CALL:amap_maps_text_search:keywords=景点关键词,city=城市名]`
+
+**示例:**
+用户: "搜索北京的历史文化景点"
+你的回复: [TOOL_CALL:amap_maps_text_search:keywords=历史文化,city=北京]
+
+用户: "搜索上海的公园"
+你的回复: [TOOL_CALL:amap_maps_text_search:keywords=公园,city=上海]
+
+**注意:**
+1. 必须使用工具,不要直接回答
+2. 格式必须完全正确,包括方括号和冒号
+3. 参数用逗号分隔
+"""
+
+WEATHER_AGENT_PROMPT = """你是天气查询专家。你的任务是查询指定城市的天气信息。
+
+**重要提示:**
+你必须使用工具来查询天气!不要自己编造天气信息!
+
+**工具调用格式:**
+使用maps_weather工具时,必须严格按照以下格式:
+`[TOOL_CALL:amap_maps_weather:city=城市名]`
+
+**示例:**
+用户: "查询北京天气"
+你的回复: [TOOL_CALL:amap_maps_weather:city=北京]
+
+用户: "上海的天气怎么样"
+你的回复: [TOOL_CALL:amap_maps_weather:city=上海]
+
+**注意:**
+1. 必须使用工具,不要直接回答
+2. 格式必须完全正确,包括方括号和冒号
+"""
+
+HOTEL_AGENT_PROMPT = """你是酒店推荐专家。你的任务是根据城市和景点位置推荐合适的酒店。
+
+**重要提示:**
+你必须使用工具来搜索酒店!不要自己编造酒店信息!
+
+**工具调用格式:**
+使用maps_text_search工具搜索酒店时,必须严格按照以下格式:
+`[TOOL_CALL:amap_maps_text_search:keywords=酒店,city=城市名]`
+
+**示例:**
+用户: "搜索北京的酒店"
+你的回复: [TOOL_CALL:amap_maps_text_search:keywords=酒店,city=北京]
+
+**注意:**
+1. 必须使用工具,不要直接回答
+2. 格式必须完全正确,包括方括号和冒号
+3. 关键词使用"酒店"或"宾馆"
+"""
+
+PLANNER_AGENT_PROMPT = """你是行程规划专家。你的任务是根据景点信息和天气信息,生成详细的旅行计划。
+
+请严格按照以下JSON格式返回旅行计划:
+```json
+{
+  "city": "城市名称",
+  "start_date": "YYYY-MM-DD",
+  "end_date": "YYYY-MM-DD",
+  "days": [
+    {
+      "date": "YYYY-MM-DD",
+      "day_index": 0,
+      "description": "第1天行程概述",
+      "transportation": "交通方式",
+      "accommodation": "住宿类型",
+      "hotel": {
+        "name": "酒店名称",
+        "address": "酒店地址",
+        "location": {"longitude": 116.397128, "latitude": 39.916527},
+        "price_range": "300-500元",
+        "rating": "4.5",
+        "distance": "距离景点2公里",
+        "type": "经济型酒店",
+        "estimated_cost": 400
+      },
+      "attractions": [
+        {
+          "name": "景点名称",
+          "address": "详细地址",
+          "location": {"longitude": 116.397128, "latitude": 39.916527},
+          "visit_duration": 120,
+          "description": "景点详细描述",
+          "category": "景点类别",
+          "ticket_price": 60
+        }
+      ],
+      "meals": [
+        {"type": "breakfast", "name": "早餐推荐", "description": "早餐描述", "estimated_cost": 30},
+        {"type": "lunch", "name": "午餐推荐", "description": "午餐描述", "estimated_cost": 50},
+        {"type": "dinner", "name": "晚餐推荐", "description": "晚餐描述", "estimated_cost": 80}
+      ]
+    }
+  ],
+  "weather_info": [
+    {
+      "date": "YYYY-MM-DD",
+      "day_weather": "晴",
+      "night_weather": "多云",
+      "day_temp": 25,
+      "night_temp": 15,
+      "wind_direction": "南风",
+      "wind_power": "1-3级"
+    }
+  ],
+  "overall_suggestions": "总体建议",
+  "budget": {
+    "total_attractions": 180,
+    "total_hotels": 1200,
+    "total_meals": 480,
+    "total_transportation": 200,
+    "total": 2060
+  }
+}
+```
+
+**重要提示:**
+1. weather_info数组必须包含每一天的天气信息
+2. 温度必须是纯数字(不要带°C等单位)
+3. 每天安排2-3个景点
+4. 考虑景点之间的距离和游览时间
+5. 每天必须包含早中晚三餐
+6. 提供实用的旅行建议
+7. **必须包含预算信息**:
+   - 景点门票价格(ticket_price)
+   - 餐饮预估费用(estimated_cost)
+   - 酒店预估费用(estimated_cost)
+   - 预算汇总(budget)包含各项总费用
+"""
+
+
+class MultiAgentTripPlanner:
+    """多智能体旅行规划系统"""
+
+    def __init__(self):
+        """初始化多智能体系统"""
+        print("🔄 开始初始化多智能体旅行规划系统...")
+
+        try:
+            settings = get_settings()
+            self.llm = get_llm()
+
+            # 创建共享的MCP工具(只创建一次)
+            print("  - 创建共享MCP工具...")
+            self.amap_tool = MCPTool(
+                name="amap",
+                description="高德地图服务",
+                server_command=["uvx", "amap-mcp-server"],
+                env={"AMAP_MAPS_API_KEY": settings.amap_api_key},
+                auto_expand=True
+            )
+
+            # 创建景点搜索Agent
+            print("  - 创建景点搜索Agent...")
+            self.attraction_agent = SimpleAgent(
+                name="景点搜索专家",
+                llm=self.llm,
+                system_prompt=ATTRACTION_AGENT_PROMPT
+            )
+            self.attraction_agent.add_tool(self.amap_tool)
+
+            # 创建天气查询Agent
+            print("  - 创建天气查询Agent...")
+            self.weather_agent = SimpleAgent(
+                name="天气查询专家",
+                llm=self.llm,
+                system_prompt=WEATHER_AGENT_PROMPT
+            )
+            self.weather_agent.add_tool(self.amap_tool)
+
+            # 创建酒店推荐Agent
+            print("  - 创建酒店推荐Agent...")
+            self.hotel_agent = SimpleAgent(
+                name="酒店推荐专家",
+                llm=self.llm,
+                system_prompt=HOTEL_AGENT_PROMPT
+            )
+            self.hotel_agent.add_tool(self.amap_tool)
+
+            # 创建行程规划Agent(不需要工具)
+            print("  - 创建行程规划Agent...")
+            self.planner_agent = SimpleAgent(
+                name="行程规划专家",
+                llm=self.llm,
+                system_prompt=PLANNER_AGENT_PROMPT
+            )
+
+            print(f"✅ 多智能体系统初始化成功")
+            print(f"   景点搜索Agent: {len(self.attraction_agent.list_tools())} 个工具")
+            print(f"   天气查询Agent: {len(self.weather_agent.list_tools())} 个工具")
+            print(f"   酒店推荐Agent: {len(self.hotel_agent.list_tools())} 个工具")
+
+        except Exception as e:
+            print(f"❌ 多智能体系统初始化失败: {str(e)}")
+            import traceback
+            traceback.print_exc()
+            raise
+    
+    def plan_trip(self, request: TripRequest) -> TripPlan:
+        """
+        使用多智能体协作生成旅行计划
+
+        Args:
+            request: 旅行请求
+
+        Returns:
+            旅行计划
+        """
+        try:
+            print(f"\n{'='*60}")
+            print(f"🚀 开始多智能体协作规划旅行...")
+            print(f"目的地: {request.city}")
+            print(f"日期: {request.start_date} 至 {request.end_date}")
+            print(f"天数: {request.travel_days}天")
+            print(f"偏好: {', '.join(request.preferences) if request.preferences else '无'}")
+            print(f"{'='*60}\n")
+
+            # 步骤1: 景点搜索Agent搜索景点
+            print("📍 步骤1: 搜索景点...")
+            attraction_query = self._build_attraction_query(request)
+            attraction_response = self.attraction_agent.run(attraction_query)
+            print(f"景点搜索结果: {attraction_response[:200]}...\n")
+
+            # 步骤2: 天气查询Agent查询天气
+            print("🌤️  步骤2: 查询天气...")
+            weather_query = f"请查询{request.city}的天气信息"
+            weather_response = self.weather_agent.run(weather_query)
+            print(f"天气查询结果: {weather_response[:200]}...\n")
+
+            # 步骤3: 酒店推荐Agent搜索酒店
+            print("🏨 步骤3: 搜索酒店...")
+            hotel_query = f"请搜索{request.city}的{request.accommodation}酒店"
+            hotel_response = self.hotel_agent.run(hotel_query)
+            print(f"酒店搜索结果: {hotel_response[:200]}...\n")
+
+            # 步骤4: 行程规划Agent整合信息生成计划
+            print("📋 步骤4: 生成行程计划...")
+            planner_query = self._build_planner_query(request, attraction_response, weather_response, hotel_response)
+            planner_response = self.planner_agent.run(planner_query)
+            print(f"行程规划结果: {planner_response[:300]}...\n")
+
+            # 解析最终计划
+            trip_plan = self._parse_response(planner_response, request)
+
+            print(f"{'='*60}")
+            print(f"✅ 旅行计划生成完成!")
+            print(f"{'='*60}\n")
+
+            return trip_plan
+
+        except Exception as e:
+            print(f"❌ 生成旅行计划失败: {str(e)}")
+            import traceback
+            traceback.print_exc()
+            return self._create_fallback_plan(request)
+    
+    def _build_attraction_query(self, request: TripRequest) -> str:
+        """构建景点搜索查询 - 直接包含工具调用"""
+        keywords = []
+        if request.preferences:
+            # 只取第一个偏好作为关键词
+            keywords = request.preferences[0]
+        else:
+            keywords = "景点"
+
+        # 直接返回工具调用格式
+        query = f"请使用amap_maps_text_search工具搜索{request.city}的{keywords}相关景点。\n[TOOL_CALL:amap_maps_text_search:keywords={keywords},city={request.city}]"
+        return query
+
+    def _build_planner_query(self, request: TripRequest, attractions: str, weather: str, hotels: str = "") -> str:
+        """构建行程规划查询"""
+        query = f"""请根据以下信息生成{request.city}的{request.travel_days}天旅行计划:
+
+**基本信息:**
+- 城市: {request.city}
+- 日期: {request.start_date} 至 {request.end_date}
+- 天数: {request.travel_days}天
+- 交通方式: {request.transportation}
+- 住宿: {request.accommodation}
+- 偏好: {', '.join(request.preferences) if request.preferences else '无'}
+
+**景点信息:**
+{attractions}
+
+**天气信息:**
+{weather}
+
+**酒店信息:**
+{hotels}
+
+**要求:**
+1. 每天安排2-3个景点
+2. 每天必须包含早中晚三餐
+3. 每天推荐一个具体的酒店(从酒店信息中选择)
+3. 考虑景点之间的距离和交通方式
+4. 返回完整的JSON格式数据
+5. 景点的经纬度坐标要真实准确
+"""
+        if request.free_text_input:
+            query += f"\n**额外要求:** {request.free_text_input}"
+
+        return query
+    
+    def _parse_response(self, response: str, request: TripRequest) -> TripPlan:
+        """
+        解析Agent响应
+        
+        Args:
+            response: Agent响应文本
+            request: 原始请求
+            
+        Returns:
+            旅行计划
+        """
+        try:
+            # 尝试从响应中提取JSON
+            # 查找JSON代码块
+            if "```json" in response:
+                json_start = response.find("```json") + 7
+                json_end = response.find("```", json_start)
+                json_str = response[json_start:json_end].strip()
+            elif "```" in response:
+                json_start = response.find("```") + 3
+                json_end = response.find("```", json_start)
+                json_str = response[json_start:json_end].strip()
+            elif "{" in response and "}" in response:
+                # 直接查找JSON对象
+                json_start = response.find("{")
+                json_end = response.rfind("}") + 1
+                json_str = response[json_start:json_end]
+            else:
+                raise ValueError("响应中未找到JSON数据")
+            
+            # 解析JSON
+            data = json.loads(json_str)
+            
+            # 转换为TripPlan对象
+            trip_plan = TripPlan(**data)
+            
+            return trip_plan
+            
+        except Exception as e:
+            print(f"⚠️  解析响应失败: {str(e)}")
+            print(f"   将使用备用方案生成计划")
+            return self._create_fallback_plan(request)
+    
+    def _create_fallback_plan(self, request: TripRequest) -> TripPlan:
+        """创建备用计划(当Agent失败时)"""
+        from datetime import datetime, timedelta
+        
+        # 解析日期
+        start_date = datetime.strptime(request.start_date, "%Y-%m-%d")
+        
+        # 创建每日行程
+        days = []
+        for i in range(request.travel_days):
+            current_date = start_date + timedelta(days=i)
+            
+            day_plan = DayPlan(
+                date=current_date.strftime("%Y-%m-%d"),
+                day_index=i,
+                description=f"第{i+1}天行程",
+                transportation=request.transportation,
+                accommodation=request.accommodation,
+                attractions=[
+                    Attraction(
+                        name=f"{request.city}景点{j+1}",
+                        address=f"{request.city}市",
+                        location=Location(longitude=116.4 + i*0.01 + j*0.005, latitude=39.9 + i*0.01 + j*0.005),
+                        visit_duration=120,
+                        description=f"这是{request.city}的著名景点",
+                        category="景点"
+                    )
+                    for j in range(2)
+                ],
+                meals=[
+                    Meal(type="breakfast", name=f"第{i+1}天早餐", description="当地特色早餐"),
+                    Meal(type="lunch", name=f"第{i+1}天午餐", description="午餐推荐"),
+                    Meal(type="dinner", name=f"第{i+1}天晚餐", description="晚餐推荐")
+                ]
+            )
+            days.append(day_plan)
+        
+        return TripPlan(
+            city=request.city,
+            start_date=request.start_date,
+            end_date=request.end_date,
+            days=days,
+            weather_info=[],
+            overall_suggestions=f"这是为您规划的{request.city}{request.travel_days}日游行程,建议提前查看各景点的开放时间。"
+        )
+
+
+# 全局多智能体系统实例
+_multi_agent_planner = None
+
+
+def get_trip_planner_agent() -> MultiAgentTripPlanner:
+    """获取多智能体旅行规划系统实例(单例模式)"""
+    global _multi_agent_planner
+
+    if _multi_agent_planner is None:
+        _multi_agent_planner = MultiAgentTripPlanner()
+
+    return _multi_agent_planner
+

+ 2 - 0
code/chapter13/helloagents-trip-planner/backend/app/api/__init__.py

@@ -0,0 +1,2 @@
+"""API模块"""
+

+ 99 - 0
code/chapter13/helloagents-trip-planner/backend/app/api/main.py

@@ -0,0 +1,99 @@
+"""FastAPI主应用"""
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from ..config import get_settings, validate_config, print_config
+from .routes import trip, poi, map as map_routes
+
+# 获取配置
+settings = get_settings()
+
+# 创建FastAPI应用
+app = FastAPI(
+    title=settings.app_name,
+    version=settings.app_version,
+    description="基于HelloAgents框架的智能旅行规划助手API",
+    docs_url="/docs",
+    redoc_url="/redoc"
+)
+
+# 配置CORS
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=settings.get_cors_origins_list(),
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+# 注册路由
+app.include_router(trip.router, prefix="/api")
+app.include_router(poi.router, prefix="/api")
+app.include_router(map_routes.router, prefix="/api")
+
+
+@app.on_event("startup")
+async def startup_event():
+    """应用启动事件"""
+    print("\n" + "="*60)
+    print(f"🚀 {settings.app_name} v{settings.app_version}")
+    print("="*60)
+    
+    # 打印配置信息
+    print_config()
+    
+    # 验证配置
+    try:
+        validate_config()
+        print("\n✅ 配置验证通过")
+    except ValueError as e:
+        print(f"\n❌ 配置验证失败:\n{e}")
+        print("\n请检查.env文件并确保所有必要的配置项都已设置")
+        raise
+    
+    print("\n" + "="*60)
+    print("📚 API文档: http://localhost:8000/docs")
+    print("📖 ReDoc文档: http://localhost:8000/redoc")
+    print("="*60 + "\n")
+
+
+@app.on_event("shutdown")
+async def shutdown_event():
+    """应用关闭事件"""
+    print("\n" + "="*60)
+    print("👋 应用正在关闭...")
+    print("="*60 + "\n")
+
+
+@app.get("/")
+async def root():
+    """根路径"""
+    return {
+        "name": settings.app_name,
+        "version": settings.app_version,
+        "status": "running",
+        "docs": "/docs",
+        "redoc": "/redoc"
+    }
+
+
+@app.get("/health")
+async def health():
+    """健康检查"""
+    return {
+        "status": "healthy",
+        "service": settings.app_name,
+        "version": settings.app_version
+    }
+
+
+if __name__ == "__main__":
+    import uvicorn
+    
+    uvicorn.run(
+        "app.api.main:app",
+        host=settings.host,
+        port=settings.port,
+        reload=True
+    )
+

+ 2 - 0
code/chapter13/helloagents-trip-planner/backend/app/api/routes/__init__.py

@@ -0,0 +1,2 @@
+"""API路由模块"""
+

+ 163 - 0
code/chapter13/helloagents-trip-planner/backend/app/api/routes/map.py

@@ -0,0 +1,163 @@
+"""地图服务API路由"""
+
+from fastapi import APIRouter, HTTPException, Query
+from typing import Optional
+from ...models.schemas import (
+    POISearchRequest,
+    POISearchResponse,
+    RouteRequest,
+    RouteResponse,
+    WeatherResponse
+)
+from ...services.amap_service import get_amap_service
+
+router = APIRouter(prefix="/map", tags=["地图服务"])
+
+
+@router.get(
+    "/poi",
+    response_model=POISearchResponse,
+    summary="搜索POI",
+    description="根据关键词搜索POI(兴趣点)"
+)
+async def search_poi(
+    keywords: str = Query(..., description="搜索关键词", example="故宫"),
+    city: str = Query(..., description="城市", example="北京"),
+    citylimit: bool = Query(True, description="是否限制在城市范围内")
+):
+    """
+    搜索POI
+    
+    Args:
+        keywords: 搜索关键词
+        city: 城市
+        citylimit: 是否限制在城市范围内
+        
+    Returns:
+        POI搜索结果
+    """
+    try:
+        # 获取服务实例
+        service = get_amap_service()
+        
+        # 搜索POI
+        pois = service.search_poi(keywords, city, citylimit)
+        
+        return POISearchResponse(
+            success=True,
+            message="POI搜索成功",
+            data=pois
+        )
+        
+    except Exception as e:
+        print(f"❌ POI搜索失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"POI搜索失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/weather",
+    response_model=WeatherResponse,
+    summary="查询天气",
+    description="查询指定城市的天气信息"
+)
+async def get_weather(
+    city: str = Query(..., description="城市名称", example="北京")
+):
+    """
+    查询天气
+    
+    Args:
+        city: 城市名称
+        
+    Returns:
+        天气信息
+    """
+    try:
+        # 获取服务实例
+        service = get_amap_service()
+        
+        # 查询天气
+        weather_info = service.get_weather(city)
+        
+        return WeatherResponse(
+            success=True,
+            message="天气查询成功",
+            data=weather_info
+        )
+        
+    except Exception as e:
+        print(f"❌ 天气查询失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"天气查询失败: {str(e)}"
+        )
+
+
+@router.post(
+    "/route",
+    response_model=RouteResponse,
+    summary="规划路线",
+    description="规划两点之间的路线"
+)
+async def plan_route(request: RouteRequest):
+    """
+    规划路线
+    
+    Args:
+        request: 路线规划请求
+        
+    Returns:
+        路线信息
+    """
+    try:
+        # 获取服务实例
+        service = get_amap_service()
+        
+        # 规划路线
+        route_info = service.plan_route(
+            origin_address=request.origin_address,
+            destination_address=request.destination_address,
+            origin_city=request.origin_city,
+            destination_city=request.destination_city,
+            route_type=request.route_type
+        )
+        
+        return RouteResponse(
+            success=True,
+            message="路线规划成功",
+            data=route_info
+        )
+        
+    except Exception as e:
+        print(f"❌ 路线规划失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"路线规划失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/health",
+    summary="健康检查",
+    description="检查地图服务是否正常"
+)
+async def health_check():
+    """健康检查"""
+    try:
+        # 检查服务是否可用
+        service = get_amap_service()
+        
+        return {
+            "status": "healthy",
+            "service": "map-service",
+            "mcp_tools_count": len(service.mcp_tool._available_tools)
+        }
+    except Exception as e:
+        raise HTTPException(
+            status_code=503,
+            detail=f"服务不可用: {str(e)}"
+        )
+

+ 129 - 0
code/chapter13/helloagents-trip-planner/backend/app/api/routes/poi.py

@@ -0,0 +1,129 @@
+"""POI相关API路由"""
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+from typing import List, Optional
+from ...services.amap_service import get_amap_service
+from ...services.unsplash_service import get_unsplash_service
+
+router = APIRouter(prefix="/poi", tags=["POI"])
+
+
+class POIDetailResponse(BaseModel):
+    """POI详情响应"""
+    success: bool
+    message: str
+    data: Optional[dict] = None
+
+
+@router.get(
+    "/detail/{poi_id}",
+    response_model=POIDetailResponse,
+    summary="获取POI详情",
+    description="根据POI ID获取详细信息,包括图片"
+)
+async def get_poi_detail(poi_id: str):
+    """
+    获取POI详情
+    
+    Args:
+        poi_id: POI ID
+        
+    Returns:
+        POI详情响应
+    """
+    try:
+        amap_service = get_amap_service()
+        
+        # 调用高德地图POI详情API
+        result = amap_service.get_poi_detail(poi_id)
+        
+        return POIDetailResponse(
+            success=True,
+            message="获取POI详情成功",
+            data=result
+        )
+        
+    except Exception as e:
+        print(f"❌ 获取POI详情失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"获取POI详情失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/search",
+    summary="搜索POI",
+    description="根据关键词搜索POI"
+)
+async def search_poi(keywords: str, city: str = "北京"):
+    """
+    搜索POI
+
+    Args:
+        keywords: 搜索关键词
+        city: 城市名称
+
+    Returns:
+        搜索结果
+    """
+    try:
+        amap_service = get_amap_service()
+        result = amap_service.search_poi(keywords, city)
+
+        return {
+            "success": True,
+            "message": "搜索成功",
+            "data": result
+        }
+
+    except Exception as e:
+        print(f"❌ 搜索POI失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"搜索POI失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/photo",
+    summary="获取景点图片",
+    description="根据景点名称从Unsplash获取图片"
+)
+async def get_attraction_photo(name: str):
+    """
+    获取景点图片
+
+    Args:
+        name: 景点名称
+
+    Returns:
+        图片URL
+    """
+    try:
+        unsplash_service = get_unsplash_service()
+
+        # 搜索景点图片
+        photo_url = unsplash_service.get_photo_url(f"{name} China landmark")
+
+        if not photo_url:
+            # 如果没找到,尝试只用景点名称搜索
+            photo_url = unsplash_service.get_photo_url(name)
+
+        return {
+            "success": True,
+            "message": "获取图片成功",
+            "data": {
+                "name": name,
+                "photo_url": photo_url
+            }
+        }
+
+    except Exception as e:
+        print(f"❌ 获取景点图片失败: {str(e)}")
+        raise HTTPException(
+            status_code=500,
+            detail=f"获取景点图片失败: {str(e)}"
+        )
+

+ 86 - 0
code/chapter13/helloagents-trip-planner/backend/app/api/routes/trip.py

@@ -0,0 +1,86 @@
+"""旅行规划API路由"""
+
+from fastapi import APIRouter, HTTPException
+from ...models.schemas import (
+    TripRequest,
+    TripPlanResponse,
+    ErrorResponse
+)
+from ...agents.trip_planner_agent import get_trip_planner_agent
+
+router = APIRouter(prefix="/trip", tags=["旅行规划"])
+
+
+@router.post(
+    "/plan",
+    response_model=TripPlanResponse,
+    summary="生成旅行计划",
+    description="根据用户输入的旅行需求,生成详细的旅行计划"
+)
+async def plan_trip(request: TripRequest):
+    """
+    生成旅行计划
+
+    Args:
+        request: 旅行请求参数
+
+    Returns:
+        旅行计划响应
+    """
+    try:
+        print(f"\n{'='*60}")
+        print(f"📥 收到旅行规划请求:")
+        print(f"   城市: {request.city}")
+        print(f"   日期: {request.start_date} - {request.end_date}")
+        print(f"   天数: {request.travel_days}")
+        print(f"{'='*60}\n")
+
+        # 获取Agent实例
+        print("🔄 获取多智能体系统实例...")
+        agent = get_trip_planner_agent()
+
+        # 生成旅行计划
+        print("🚀 开始生成旅行计划...")
+        trip_plan = agent.plan_trip(request)
+
+        print("✅ 旅行计划生成成功,准备返回响应\n")
+
+        return TripPlanResponse(
+            success=True,
+            message="旅行计划生成成功",
+            data=trip_plan
+        )
+
+    except Exception as e:
+        print(f"❌ 生成旅行计划失败: {str(e)}")
+        import traceback
+        traceback.print_exc()
+        raise HTTPException(
+            status_code=500,
+            detail=f"生成旅行计划失败: {str(e)}"
+        )
+
+
+@router.get(
+    "/health",
+    summary="健康检查",
+    description="检查旅行规划服务是否正常"
+)
+async def health_check():
+    """健康检查"""
+    try:
+        # 检查Agent是否可用
+        agent = get_trip_planner_agent()
+        
+        return {
+            "status": "healthy",
+            "service": "trip-planner",
+            "agent_name": agent.agent.name,
+            "tools_count": len(agent.agent.list_tools())
+        }
+    except Exception as e:
+        raise HTTPException(
+            status_code=503,
+            detail=f"服务不可用: {str(e)}"
+        )
+

+ 111 - 0
code/chapter13/helloagents-trip-planner/backend/app/config.py

@@ -0,0 +1,111 @@
+"""配置管理模块"""
+
+import os
+from pathlib import Path
+from typing import List
+from pydantic_settings import BaseSettings
+from dotenv import load_dotenv
+
+# 加载环境变量
+# 首先尝试加载当前目录的.env
+load_dotenv()
+
+# 然后尝试加载HelloAgents的.env(如果存在)
+helloagents_env = Path(__file__).parent.parent.parent.parent / "HelloAgents" / ".env"
+if helloagents_env.exists():
+    load_dotenv(helloagents_env, override=False)  # 不覆盖已有的环境变量
+
+
+class Settings(BaseSettings):
+    """应用配置"""
+
+    # 应用基本配置
+    app_name: str = "HelloAgents智能旅行助手"
+    app_version: str = "1.0.0"
+    debug: bool = False
+
+    # 服务器配置
+    host: str = "0.0.0.0"
+    port: int = 8000
+
+    # CORS配置 - 使用字符串,在代码中分割
+    cors_origins: str = "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000"
+
+    # 高德地图API配置
+    amap_api_key: str = ""
+
+    # Unsplash API配置
+    unsplash_access_key: str = ""
+    unsplash_secret_key: str = ""
+
+    # LLM配置 (从环境变量读取,由HelloAgents管理)
+    openai_api_key: str = ""
+    openai_base_url: str = "https://api.openai.com/v1"
+    openai_model: str = "gpt-4"
+
+    # 日志配置
+    log_level: str = "INFO"
+
+    class Config:
+        env_file = ".env"
+        case_sensitive = False
+        extra = "ignore"  # 忽略额外的环境变量
+
+    def get_cors_origins_list(self) -> List[str]:
+        """获取CORS origins列表"""
+        return [origin.strip() for origin in self.cors_origins.split(',')]
+
+
+# 创建全局配置实例
+settings = Settings()
+
+
+def get_settings() -> Settings:
+    """获取配置实例"""
+    return settings
+
+
+# 验证必要的配置
+def validate_config():
+    """验证配置是否完整"""
+    errors = []
+    warnings = []
+
+    if not settings.amap_api_key:
+        errors.append("AMAP_API_KEY未配置")
+
+    # HelloAgentsLLM会自动从LLM_API_KEY读取,不强制要求OPENAI_API_KEY
+    llm_api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
+    if not llm_api_key:
+        warnings.append("LLM_API_KEY或OPENAI_API_KEY未配置,LLM功能可能无法使用")
+
+    if errors:
+        error_msg = "配置错误:\n" + "\n".join(f"  - {e}" for e in errors)
+        raise ValueError(error_msg)
+
+    if warnings:
+        print("\n⚠️  配置警告:")
+        for w in warnings:
+            print(f"  - {w}")
+
+    return True
+
+
+# 打印配置信息(用于调试)
+def print_config():
+    """打印当前配置(隐藏敏感信息)"""
+    print(f"应用名称: {settings.app_name}")
+    print(f"版本: {settings.app_version}")
+    print(f"服务器: {settings.host}:{settings.port}")
+    print(f"高德地图API Key: {'已配置' if settings.amap_api_key else '未配置'}")
+
+    # 检查LLM配置
+    llm_api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
+    llm_base_url = os.getenv("LLM_BASE_URL") or settings.openai_base_url
+    llm_model = os.getenv("LLM_MODEL_ID") or settings.openai_model
+
+    print(f"LLM API Key: {'已配置' if llm_api_key else '未配置'}")
+    print(f"LLM Base URL: {llm_base_url}")
+    print(f"LLM Model: {llm_model}")
+    print(f"日志级别: {settings.log_level}")
+

+ 2 - 0
code/chapter13/helloagents-trip-planner/backend/app/models/__init__.py

@@ -0,0 +1,2 @@
+"""数据模型模块"""
+

+ 206 - 0
code/chapter13/helloagents-trip-planner/backend/app/models/schemas.py

@@ -0,0 +1,206 @@
+"""数据模型定义"""
+
+from typing import List, Optional, Union
+from pydantic import BaseModel, Field, field_validator
+from datetime import date
+
+
+# ============ 请求模型 ============
+
+class TripRequest(BaseModel):
+    """旅行规划请求"""
+    city: str = Field(..., description="目的地城市", example="北京")
+    start_date: str = Field(..., description="开始日期 YYYY-MM-DD", example="2025-06-01")
+    end_date: str = Field(..., description="结束日期 YYYY-MM-DD", example="2025-06-03")
+    travel_days: int = Field(..., description="旅行天数", ge=1, le=30, example=3)
+    transportation: str = Field(..., description="交通方式", example="公共交通")
+    accommodation: str = Field(..., description="住宿偏好", example="经济型酒店")
+    preferences: List[str] = Field(default=[], description="旅行偏好标签", example=["历史文化", "美食"])
+    free_text_input: Optional[str] = Field(default="", description="额外要求", example="希望多安排一些博物馆")
+    
+    class Config:
+        json_schema_extra = {
+            "example": {
+                "city": "北京",
+                "start_date": "2025-06-01",
+                "end_date": "2025-06-03",
+                "travel_days": 3,
+                "transportation": "公共交通",
+                "accommodation": "经济型酒店",
+                "preferences": ["历史文化", "美食"],
+                "free_text_input": "希望多安排一些博物馆"
+            }
+        }
+
+
+class POISearchRequest(BaseModel):
+    """POI搜索请求"""
+    keywords: str = Field(..., description="搜索关键词", example="故宫")
+    city: str = Field(..., description="城市", example="北京")
+    citylimit: bool = Field(default=True, description="是否限制在城市范围内")
+
+
+class RouteRequest(BaseModel):
+    """路线规划请求"""
+    origin_address: str = Field(..., description="起点地址", example="北京市朝阳区阜通东大街6号")
+    destination_address: str = Field(..., description="终点地址", example="北京市海淀区上地十街10号")
+    origin_city: Optional[str] = Field(default=None, description="起点城市")
+    destination_city: Optional[str] = Field(default=None, description="终点城市")
+    route_type: str = Field(default="walking", description="路线类型: walking/driving/transit")
+
+
+# ============ 响应模型 ============
+
+class Location(BaseModel):
+    """地理位置"""
+    longitude: float = Field(..., description="经度")
+    latitude: float = Field(..., description="纬度")
+
+
+class Attraction(BaseModel):
+    """景点信息"""
+    name: str = Field(..., description="景点名称")
+    address: str = Field(..., description="地址")
+    location: Location = Field(..., description="经纬度坐标")
+    visit_duration: int = Field(..., description="建议游览时间(分钟)")
+    description: str = Field(..., description="景点描述")
+    category: Optional[str] = Field(default="景点", description="景点类别")
+    rating: Optional[float] = Field(default=None, description="评分")
+    photos: Optional[List[str]] = Field(default_factory=list, description="景点图片URL列表")
+    poi_id: Optional[str] = Field(default="", description="POI ID")
+    image_url: Optional[str] = Field(default=None, description="图片URL")
+    ticket_price: int = Field(default=0, description="门票价格(元)")
+
+
+class Meal(BaseModel):
+    """餐饮信息"""
+    type: str = Field(..., description="餐饮类型: breakfast/lunch/dinner/snack")
+    name: str = Field(..., description="餐饮名称")
+    address: Optional[str] = Field(default=None, description="地址")
+    location: Optional[Location] = Field(default=None, description="经纬度坐标")
+    description: Optional[str] = Field(default=None, description="描述")
+    estimated_cost: int = Field(default=0, description="预估费用(元)")
+
+
+class Hotel(BaseModel):
+    """酒店信息"""
+    name: str = Field(..., description="酒店名称")
+    address: str = Field(default="", description="酒店地址")
+    location: Optional[Location] = Field(default=None, description="酒店位置")
+    price_range: str = Field(default="", description="价格范围")
+    rating: str = Field(default="", description="评分")
+    distance: str = Field(default="", description="距离景点距离")
+    type: str = Field(default="", description="酒店类型")
+    estimated_cost: int = Field(default=0, description="预估费用(元/晚)")
+
+
+class DayPlan(BaseModel):
+    """单日行程"""
+    date: str = Field(..., description="日期 YYYY-MM-DD")
+    day_index: int = Field(..., description="第几天(从0开始)")
+    description: str = Field(..., description="当日行程描述")
+    transportation: str = Field(..., description="交通方式")
+    accommodation: str = Field(..., description="住宿")
+    hotel: Optional[Hotel] = Field(default=None, description="推荐酒店")
+    attractions: List[Attraction] = Field(default=[], description="景点列表")
+    meals: List[Meal] = Field(default=[], description="餐饮列表")
+
+
+class WeatherInfo(BaseModel):
+    """天气信息"""
+    date: str = Field(..., description="日期 YYYY-MM-DD")
+    day_weather: str = Field(default="", description="白天天气")
+    night_weather: str = Field(default="", description="夜间天气")
+    day_temp: Union[int, str] = Field(default=0, description="白天温度")
+    night_temp: Union[int, str] = Field(default=0, description="夜间温度")
+    wind_direction: str = Field(default="", description="风向")
+    wind_power: str = Field(default="", description="风力")
+
+    @field_validator('day_temp', 'night_temp', mode='before')
+    @classmethod
+    def parse_temperature(cls, v):
+        """解析温度,移除°C等单位"""
+        if isinstance(v, str):
+            # 移除°C, ℃等单位符号
+            v = v.replace('°C', '').replace('℃', '').replace('°', '').strip()
+            try:
+                return int(v)
+            except ValueError:
+                return 0
+        return v
+
+
+class Budget(BaseModel):
+    """预算信息"""
+    total_attractions: int = Field(default=0, description="景点门票总费用")
+    total_hotels: int = Field(default=0, description="酒店总费用")
+    total_meals: int = Field(default=0, description="餐饮总费用")
+    total_transportation: int = Field(default=0, description="交通总费用")
+    total: int = Field(default=0, description="总费用")
+
+
+class TripPlan(BaseModel):
+    """旅行计划"""
+    city: str = Field(..., description="目的地城市")
+    start_date: str = Field(..., description="开始日期")
+    end_date: str = Field(..., description="结束日期")
+    days: List[DayPlan] = Field(..., description="每日行程")
+    weather_info: List[WeatherInfo] = Field(default=[], description="天气信息")
+    overall_suggestions: str = Field(..., description="总体建议")
+    budget: Optional[Budget] = Field(default=None, description="预算信息")
+
+
+class TripPlanResponse(BaseModel):
+    """旅行计划响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    data: Optional[TripPlan] = Field(default=None, description="旅行计划数据")
+
+
+class POIInfo(BaseModel):
+    """POI信息"""
+    id: str = Field(..., description="POI ID")
+    name: str = Field(..., description="名称")
+    type: str = Field(..., description="类型")
+    address: str = Field(..., description="地址")
+    location: Location = Field(..., description="经纬度坐标")
+    tel: Optional[str] = Field(default=None, description="电话")
+
+
+class POISearchResponse(BaseModel):
+    """POI搜索响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    data: List[POIInfo] = Field(default=[], description="POI列表")
+
+
+class RouteInfo(BaseModel):
+    """路线信息"""
+    distance: float = Field(..., description="距离(米)")
+    duration: int = Field(..., description="时间(秒)")
+    route_type: str = Field(..., description="路线类型")
+    description: str = Field(..., description="路线描述")
+
+
+class RouteResponse(BaseModel):
+    """路线规划响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    data: Optional[RouteInfo] = Field(default=None, description="路线信息")
+
+
+class WeatherResponse(BaseModel):
+    """天气查询响应"""
+    success: bool = Field(..., description="是否成功")
+    message: str = Field(default="", description="消息")
+    data: List[WeatherInfo] = Field(default=[], description="天气信息")
+
+
+# ============ 错误响应 ============
+
+class ErrorResponse(BaseModel):
+    """错误响应"""
+    success: bool = Field(default=False, description="是否成功")
+    message: str = Field(..., description="错误消息")
+    error_code: Optional[str] = Field(default=None, description="错误代码")
+

+ 2 - 0
code/chapter13/helloagents-trip-planner/backend/app/services/__init__.py

@@ -0,0 +1,2 @@
+"""服务模块"""
+

+ 269 - 0
code/chapter13/helloagents-trip-planner/backend/app/services/amap_service.py

@@ -0,0 +1,269 @@
+"""高德地图MCP服务封装"""
+
+from typing import List, Dict, Any, Optional
+from hello_agents.tools import MCPTool
+from ..config import get_settings
+from ..models.schemas import Location, POIInfo, WeatherInfo
+
+# 全局MCP工具实例
+_amap_mcp_tool = None
+
+
+def get_amap_mcp_tool() -> MCPTool:
+    """
+    获取高德地图MCP工具实例(单例模式)
+    
+    Returns:
+        MCPTool实例
+    """
+    global _amap_mcp_tool
+    
+    if _amap_mcp_tool is None:
+        settings = get_settings()
+        
+        if not settings.amap_api_key:
+            raise ValueError("高德地图API Key未配置,请在.env文件中设置AMAP_API_KEY")
+        
+        # 创建MCP工具
+        _amap_mcp_tool = MCPTool(
+            name="amap",
+            description="高德地图服务,支持POI搜索、路线规划、天气查询等功能",
+            server_command=["uvx", "amap-mcp-server"],
+            env={"AMAP_MAPS_API_KEY": settings.amap_api_key},
+            auto_expand=True  # 自动展开为独立工具
+        )
+        
+        print(f"✅ 高德地图MCP工具初始化成功")
+        print(f"   工具数量: {len(_amap_mcp_tool._available_tools)}")
+        
+        # 打印可用工具列表
+        if _amap_mcp_tool._available_tools:
+            print("   可用工具:")
+            for tool in _amap_mcp_tool._available_tools[:5]:  # 只打印前5个
+                print(f"     - {tool.get('name', 'unknown')}")
+            if len(_amap_mcp_tool._available_tools) > 5:
+                print(f"     ... 还有 {len(_amap_mcp_tool._available_tools) - 5} 个工具")
+    
+    return _amap_mcp_tool
+
+
+class AmapService:
+    """高德地图服务封装类"""
+    
+    def __init__(self):
+        """初始化服务"""
+        self.mcp_tool = get_amap_mcp_tool()
+    
+    def search_poi(self, keywords: str, city: str, citylimit: bool = True) -> List[POIInfo]:
+        """
+        搜索POI
+        
+        Args:
+            keywords: 搜索关键词
+            city: 城市
+            citylimit: 是否限制在城市范围内
+            
+        Returns:
+            POI信息列表
+        """
+        try:
+            # 调用MCP工具
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": "maps_text_search",
+                "arguments": {
+                    "keywords": keywords,
+                    "city": city,
+                    "citylimit": str(citylimit).lower()
+                }
+            })
+            
+            # 解析结果
+            # 注意: MCP工具返回的是字符串,需要解析
+            # 这里简化处理,实际应该解析JSON
+            print(f"POI搜索结果: {result[:200]}...")  # 打印前200字符
+            
+            # TODO: 解析实际的POI数据
+            return []
+            
+        except Exception as e:
+            print(f"❌ POI搜索失败: {str(e)}")
+            return []
+    
+    def get_weather(self, city: str) -> List[WeatherInfo]:
+        """
+        查询天气
+        
+        Args:
+            city: 城市名称
+            
+        Returns:
+            天气信息列表
+        """
+        try:
+            # 调用MCP工具
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": "maps_weather",
+                "arguments": {
+                    "city": city
+                }
+            })
+            
+            print(f"天气查询结果: {result[:200]}...")
+            
+            # TODO: 解析实际的天气数据
+            return []
+            
+        except Exception as e:
+            print(f"❌ 天气查询失败: {str(e)}")
+            return []
+    
+    def plan_route(
+        self,
+        origin_address: str,
+        destination_address: str,
+        origin_city: Optional[str] = None,
+        destination_city: Optional[str] = None,
+        route_type: str = "walking"
+    ) -> Dict[str, Any]:
+        """
+        规划路线
+        
+        Args:
+            origin_address: 起点地址
+            destination_address: 终点地址
+            origin_city: 起点城市
+            destination_city: 终点城市
+            route_type: 路线类型 (walking/driving/transit)
+            
+        Returns:
+            路线信息
+        """
+        try:
+            # 根据路线类型选择工具
+            tool_map = {
+                "walking": "maps_direction_walking_by_address",
+                "driving": "maps_direction_driving_by_address",
+                "transit": "maps_direction_transit_integrated_by_address"
+            }
+            
+            tool_name = tool_map.get(route_type, "maps_direction_walking_by_address")
+            
+            # 构建参数
+            arguments = {
+                "origin_address": origin_address,
+                "destination_address": destination_address
+            }
+            
+            # 公共交通需要城市参数
+            if route_type == "transit":
+                if origin_city:
+                    arguments["origin_city"] = origin_city
+                if destination_city:
+                    arguments["destination_city"] = destination_city
+            else:
+                # 其他路线类型也可以提供城市参数提高准确性
+                if origin_city:
+                    arguments["origin_city"] = origin_city
+                if destination_city:
+                    arguments["destination_city"] = destination_city
+            
+            # 调用MCP工具
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": tool_name,
+                "arguments": arguments
+            })
+            
+            print(f"路线规划结果: {result[:200]}...")
+            
+            # TODO: 解析实际的路线数据
+            return {}
+            
+        except Exception as e:
+            print(f"❌ 路线规划失败: {str(e)}")
+            return {}
+    
+    def geocode(self, address: str, city: Optional[str] = None) -> Optional[Location]:
+        """
+        地理编码(地址转坐标)
+
+        Args:
+            address: 地址
+            city: 城市
+
+        Returns:
+            经纬度坐标
+        """
+        try:
+            arguments = {"address": address}
+            if city:
+                arguments["city"] = city
+
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": "maps_geo",
+                "arguments": arguments
+            })
+
+            print(f"地理编码结果: {result[:200]}...")
+
+            # TODO: 解析实际的坐标数据
+            return None
+
+        except Exception as e:
+            print(f"❌ 地理编码失败: {str(e)}")
+            return None
+
+    def get_poi_detail(self, poi_id: str) -> Dict[str, Any]:
+        """
+        获取POI详情
+
+        Args:
+            poi_id: POI ID
+
+        Returns:
+            POI详情信息
+        """
+        try:
+            result = self.mcp_tool.run({
+                "action": "call_tool",
+                "tool_name": "maps_search_detail",
+                "arguments": {
+                    "id": poi_id
+                }
+            })
+
+            print(f"POI详情结果: {result[:200]}...")
+
+            # 解析结果并提取图片
+            import json
+            import re
+
+            # 尝试从结果中提取JSON
+            json_match = re.search(r'\{.*\}', result, re.DOTALL)
+            if json_match:
+                data = json.loads(json_match.group())
+                return data
+
+            return {"raw": result}
+
+        except Exception as e:
+            print(f"❌ 获取POI详情失败: {str(e)}")
+            return {}
+
+
+# 创建全局服务实例
+_amap_service = None
+
+
+def get_amap_service() -> AmapService:
+    """获取高德地图服务实例(单例模式)"""
+    global _amap_service
+    
+    if _amap_service is None:
+        _amap_service = AmapService()
+    
+    return _amap_service
+

+ 37 - 0
code/chapter13/helloagents-trip-planner/backend/app/services/llm_service.py

@@ -0,0 +1,37 @@
+"""LLM服务模块"""
+
+from hello_agents import HelloAgentsLLM
+from ..config import get_settings
+
+# 全局LLM实例
+_llm_instance = None
+
+
+def get_llm() -> HelloAgentsLLM:
+    """
+    获取LLM实例(单例模式)
+    
+    Returns:
+        HelloAgentsLLM实例
+    """
+    global _llm_instance
+    
+    if _llm_instance is None:
+        settings = get_settings()
+        
+        # HelloAgentsLLM会自动从环境变量读取配置
+        # 包括OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL等
+        _llm_instance = HelloAgentsLLM()
+        
+        print(f"✅ LLM服务初始化成功")
+        print(f"   提供商: {_llm_instance.provider}")
+        print(f"   模型: {_llm_instance.model}")
+    
+    return _llm_instance
+
+
+def reset_llm():
+    """重置LLM实例(用于测试或重新配置)"""
+    global _llm_instance
+    _llm_instance = None
+

+ 86 - 0
code/chapter13/helloagents-trip-planner/backend/app/services/unsplash_service.py

@@ -0,0 +1,86 @@
+"""Unsplash图片服务"""
+
+import requests
+from typing import List, Optional
+from ..config import get_settings
+
+class UnsplashService:
+    """Unsplash图片服务类"""
+    
+    def __init__(self):
+        """初始化服务"""
+        settings = get_settings()
+        self.access_key = settings.unsplash_access_key
+        self.base_url = "https://api.unsplash.com"
+    
+    def search_photos(self, query: str, per_page: int = 5) -> List[dict]:
+        """
+        搜索图片
+        
+        Args:
+            query: 搜索关键词
+            per_page: 每页数量
+            
+        Returns:
+            图片列表
+        """
+        try:
+            url = f"{self.base_url}/search/photos"
+            params = {
+                "query": query,
+                "per_page": per_page,
+                "client_id": self.access_key
+            }
+            
+            response = requests.get(url, params=params, timeout=10)
+            response.raise_for_status()
+            
+            data = response.json()
+            results = data.get("results", [])
+            
+            # 提取图片URL
+            photos = []
+            for photo in results:
+                photos.append({
+                    "id": photo.get("id"),
+                    "url": photo.get("urls", {}).get("regular"),
+                    "thumb": photo.get("urls", {}).get("thumb"),
+                    "description": photo.get("description") or photo.get("alt_description"),
+                    "photographer": photo.get("user", {}).get("name")
+                })
+            
+            return photos
+            
+        except Exception as e:
+            print(f"❌ Unsplash搜索失败: {str(e)}")
+            return []
+    
+    def get_photo_url(self, query: str) -> Optional[str]:
+        """
+        获取单张图片URL
+
+        Args:
+            query: 搜索关键词
+
+        Returns:
+            图片URL
+        """
+        photos = self.search_photos(query, per_page=1)
+        if photos:
+            return photos[0].get("url")
+        return None
+
+
+# 全局服务实例
+_unsplash_service = None
+
+
+def get_unsplash_service() -> UnsplashService:
+    """获取Unsplash服务实例(单例模式)"""
+    global _unsplash_service
+    
+    if _unsplash_service is None:
+        _unsplash_service = UnsplashService()
+    
+    return _unsplash_service
+

+ 28 - 0
code/chapter13/helloagents-trip-planner/backend/requirements.txt

@@ -0,0 +1,28 @@
+# HelloAgents框架
+hello-agents[protocols]>=0.2.4
+
+# FastAPI和相关依赖
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0
+pydantic>=2.0.0
+pydantic-settings>=2.0.0
+
+# HTTP客户端
+httpx>=0.27.0
+aiohttp>=3.10.0
+
+# 环境变量管理
+python-dotenv>=1.0.0
+
+# CORS支持
+python-multipart>=0.0.9
+
+# 日志
+loguru>=0.7.0
+
+# MCP相关
+fastmcp>=2.0.0
+
+# 其他工具
+python-dateutil>=2.8.2
+

+ 16 - 0
code/chapter13/helloagents-trip-planner/backend/run.py

@@ -0,0 +1,16 @@
+"""启动脚本"""
+
+import uvicorn
+from app.config import get_settings
+
+if __name__ == "__main__":
+    settings = get_settings()
+    
+    uvicorn.run(
+        "app.api.main:app",
+        host=settings.host,
+        port=settings.port,
+        reload=True,
+        log_level=settings.log_level.lower()
+    )
+

+ 5 - 0
code/chapter13/helloagents-trip-planner/frontend/.env.example

@@ -0,0 +1,5 @@
+# 后端API地址
+VITE_API_BASE_URL=http://localhost:8000
+
+# 高德地图Web API Key
+VITE_AMAP_WEB_KEY=

+ 29 - 0
code/chapter13/helloagents-trip-planner/frontend/.gitignore

@@ -0,0 +1,29 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Environment
+.env.local
+.env.*.local
+

+ 14 - 0
code/chapter13/helloagents-trip-planner/frontend/index.html

@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>HelloAgents智能旅行助手</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>
+

+ 2244 - 0
code/chapter13/helloagents-trip-planner/frontend/package-lock.json

@@ -0,0 +1,2244 @@
+{
+  "name": "helloagents-trip-planner-frontend",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "helloagents-trip-planner-frontend",
+      "version": "1.0.0",
+      "dependencies": {
+        "@amap/amap-jsapi-loader": "^1.0.1",
+        "ant-design-vue": "^4.2.6",
+        "axios": "^1.7.9",
+        "html2canvas": "^1.4.1",
+        "jspdf": "^3.0.3",
+        "vue": "^3.5.13",
+        "vue-router": "^4.5.0"
+      },
+      "devDependencies": {
+        "@types/node": "^22.10.5",
+        "@vitejs/plugin-vue": "^5.2.1",
+        "typescript": "^5.7.3",
+        "vite": "^6.0.7",
+        "vue-tsc": "^2.2.0"
+      }
+    },
+    "node_modules/@amap/amap-jsapi-loader": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
+      "integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
+      "license": "MIT"
+    },
+    "node_modules/@ant-design/colors": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
+      "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0"
+      }
+    },
+    "node_modules/@ant-design/icons-svg": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
+      "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
+      "license": "MIT"
+    },
+    "node_modules/@ant-design/icons-vue": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
+      "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-svg": "^4.2.1"
+      },
+      "peerDependencies": {
+        "vue": ">=3.0.3"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+      "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
+      "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.4"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+      "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
+      "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "3.6.1",
+      "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
+      "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.9.2",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+      "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+      "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
+      "license": "MIT"
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
+      "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
+      "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
+      "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
+      "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
+      "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
+      "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
+      "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
+      "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
+      "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
+      "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
+      "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
+      "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
+      "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
+      "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
+      "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
+      "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
+      "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
+      "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
+      "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
+      "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
+      "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
+      "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
+      "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
+      "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
+      "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
+      "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
+      "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
+      "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
+      "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
+      "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
+      "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
+      "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
+      "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
+      "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
+      "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
+      "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
+      "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
+      "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
+      "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
+      "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
+      "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
+      "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
+      "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
+      "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
+      "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
+      "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
+      "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
+      "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@simonwep/pickr": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
+      "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-js": "^3.15.1",
+        "nanopop": "^2.1.0"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.18.10",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.10.tgz",
+      "integrity": "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/pako": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
+      "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/raf": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+      "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
+      "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "2.4.15"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
+      "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@volar/typescript": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
+      "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.15",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
+      "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.4",
+        "@vue/shared": "3.5.22",
+        "entities": "^4.5.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
+      "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.22",
+        "@vue/shared": "3.5.22"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
+      "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.4",
+        "@vue/compiler-core": "3.5.22",
+        "@vue/compiler-dom": "3.5.22",
+        "@vue/compiler-ssr": "3.5.22",
+        "@vue/shared": "3.5.22",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.19",
+        "postcss": "^8.5.6",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
+      "integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.22",
+        "@vue/shared": "3.5.22"
+      }
+    },
+    "node_modules/@vue/compiler-vue2": {
+      "version": "2.7.16",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+      "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "de-indent": "^1.0.2",
+        "he": "^1.2.0"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/language-core": {
+      "version": "2.2.12",
+      "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
+      "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.15",
+        "@vue/compiler-dom": "^3.5.0",
+        "@vue/compiler-vue2": "^2.7.16",
+        "@vue/shared": "^3.5.0",
+        "alien-signals": "^1.0.3",
+        "minimatch": "^9.0.3",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
+      "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.22"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
+      "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.22",
+        "@vue/shared": "3.5.22"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
+      "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.22",
+        "@vue/runtime-core": "3.5.22",
+        "@vue/shared": "3.5.22",
+        "csstype": "^3.1.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
+      "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.22",
+        "@vue/shared": "3.5.22"
+      },
+      "peerDependencies": {
+        "vue": "3.5.22"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
+      "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
+      "license": "MIT"
+    },
+    "node_modules/alien-signals": {
+      "version": "1.0.13",
+      "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
+      "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ant-design-vue": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
+      "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-vue": "^7.0.0",
+        "@babel/runtime": "^7.10.5",
+        "@ctrl/tinycolor": "^3.5.0",
+        "@emotion/hash": "^0.9.0",
+        "@emotion/unitless": "^0.8.0",
+        "@simonwep/pickr": "~1.8.0",
+        "array-tree-filter": "^2.1.0",
+        "async-validator": "^4.0.0",
+        "csstype": "^3.1.1",
+        "dayjs": "^1.10.5",
+        "dom-align": "^1.12.1",
+        "dom-scroll-into-view": "^2.0.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.15",
+        "resize-observer-polyfill": "^1.5.1",
+        "scroll-into-view-if-needed": "^2.2.25",
+        "shallow-equal": "^1.0.0",
+        "stylis": "^4.1.3",
+        "throttle-debounce": "^5.0.0",
+        "vue-types": "^3.0.0",
+        "warning": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=12.22.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/ant-design-vue"
+      },
+      "peerDependencies": {
+        "vue": ">=3.2.0"
+      }
+    },
+    "node_modules/array-tree-filter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
+      "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
+      "license": "MIT"
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.12.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
+      "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.4",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/base64-arraybuffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+      "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/canvg": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
+      "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@babel/runtime": "^7.12.5",
+        "@types/raf": "^3.4.0",
+        "core-js": "^3.8.3",
+        "raf": "^3.4.1",
+        "regenerator-runtime": "^0.13.7",
+        "rgbcolor": "^1.0.1",
+        "stackblur-canvas": "^2.0.0",
+        "svg-pathdata": "^6.0.3"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
+      "license": "MIT"
+    },
+    "node_modules/core-js": {
+      "version": "3.46.0",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
+      "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/css-line-break": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+      "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+      "license": "MIT",
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+      "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.18",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
+      "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
+      "license": "MIT"
+    },
+    "node_modules/de-indent": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dom-align": {
+      "version": "1.12.4",
+      "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
+      "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
+      "license": "MIT"
+    },
+    "node_modules/dom-scroll-into-view": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
+      "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
+      "license": "MIT"
+    },
+    "node_modules/dompurify": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+      "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optional": true,
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.10",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
+      "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.10",
+        "@esbuild/android-arm": "0.25.10",
+        "@esbuild/android-arm64": "0.25.10",
+        "@esbuild/android-x64": "0.25.10",
+        "@esbuild/darwin-arm64": "0.25.10",
+        "@esbuild/darwin-x64": "0.25.10",
+        "@esbuild/freebsd-arm64": "0.25.10",
+        "@esbuild/freebsd-x64": "0.25.10",
+        "@esbuild/linux-arm": "0.25.10",
+        "@esbuild/linux-arm64": "0.25.10",
+        "@esbuild/linux-ia32": "0.25.10",
+        "@esbuild/linux-loong64": "0.25.10",
+        "@esbuild/linux-mips64el": "0.25.10",
+        "@esbuild/linux-ppc64": "0.25.10",
+        "@esbuild/linux-riscv64": "0.25.10",
+        "@esbuild/linux-s390x": "0.25.10",
+        "@esbuild/linux-x64": "0.25.10",
+        "@esbuild/netbsd-arm64": "0.25.10",
+        "@esbuild/netbsd-x64": "0.25.10",
+        "@esbuild/openbsd-arm64": "0.25.10",
+        "@esbuild/openbsd-x64": "0.25.10",
+        "@esbuild/openharmony-arm64": "0.25.10",
+        "@esbuild/sunos-x64": "0.25.10",
+        "@esbuild/win32-arm64": "0.25.10",
+        "@esbuild/win32-ia32": "0.25.10",
+        "@esbuild/win32-x64": "0.25.10"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/fast-png": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
+      "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/pako": "^2.0.3",
+        "iobuffer": "^5.3.2",
+        "pako": "^2.1.0"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fflate": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+      "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+      "license": "MIT"
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+      "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/html2canvas": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+      "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+      "license": "MIT",
+      "dependencies": {
+        "css-line-break": "^2.1.0",
+        "text-segmentation": "^1.0.3"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/iobuffer": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
+      "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
+      "license": "MIT"
+    },
+    "node_modules/is-plain-object": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
+      "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/jspdf": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
+      "integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.26.9",
+        "fast-png": "^6.2.0",
+        "fflate": "^0.8.1"
+      },
+      "optionalDependencies": {
+        "canvg": "^3.0.11",
+        "core-js": "^3.6.0",
+        "dompurify": "^3.2.4",
+        "html2canvas": "^1.0.0-rc.5"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+      "license": "MIT"
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.19",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
+      "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/nanopop": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz",
+      "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
+      "license": "MIT"
+    },
+    "node_modules/pako": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+      "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
+      "license": "(MIT AND Zlib)"
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/performance-now": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+      "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
+    "node_modules/raf": {
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+      "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "performance-now": "^2.1.0"
+      }
+    },
+    "node_modules/regenerator-runtime": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+      "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+      "license": "MIT"
+    },
+    "node_modules/rgbcolor": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+      "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+      "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+      "optional": true,
+      "engines": {
+        "node": ">= 0.8.15"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.52.4",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
+      "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.52.4",
+        "@rollup/rollup-android-arm64": "4.52.4",
+        "@rollup/rollup-darwin-arm64": "4.52.4",
+        "@rollup/rollup-darwin-x64": "4.52.4",
+        "@rollup/rollup-freebsd-arm64": "4.52.4",
+        "@rollup/rollup-freebsd-x64": "4.52.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.52.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.52.4",
+        "@rollup/rollup-linux-arm64-musl": "4.52.4",
+        "@rollup/rollup-linux-loong64-gnu": "4.52.4",
+        "@rollup/rollup-linux-ppc64-gnu": "4.52.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.52.4",
+        "@rollup/rollup-linux-riscv64-musl": "4.52.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.52.4",
+        "@rollup/rollup-linux-x64-gnu": "4.52.4",
+        "@rollup/rollup-linux-x64-musl": "4.52.4",
+        "@rollup/rollup-openharmony-arm64": "4.52.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.52.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.52.4",
+        "@rollup/rollup-win32-x64-gnu": "4.52.4",
+        "@rollup/rollup-win32-x64-msvc": "4.52.4",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+      "license": "MIT",
+      "dependencies": {
+        "compute-scroll-into-view": "^1.0.20"
+      }
+    },
+    "node_modules/shallow-equal": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
+      "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
+      "license": "MIT"
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stackblur-canvas": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+      "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=0.1.14"
+      }
+    },
+    "node_modules/stylis": {
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
+      "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
+      "license": "MIT"
+    },
+    "node_modules/svg-pathdata": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+      "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/text-segmentation": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+      "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+      "license": "MIT",
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "node_modules/throttle-debounce": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+      "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/utrie": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+      "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+      "license": "MIT",
+      "dependencies": {
+        "base64-arraybuffer": "^1.0.2"
+      }
+    },
+    "node_modules/vite": {
+      "version": "6.3.6",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
+      "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.4.4",
+        "picomatch": "^4.0.2",
+        "postcss": "^8.5.3",
+        "rollup": "^4.34.9",
+        "tinyglobby": "^0.2.13"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vscode-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+      "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vue": {
+      "version": "3.5.22",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
+      "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.22",
+        "@vue/compiler-sfc": "3.5.22",
+        "@vue/runtime-dom": "3.5.22",
+        "@vue/server-renderer": "3.5.22",
+        "@vue/shared": "3.5.22"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.5.1",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
+      "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "2.2.12",
+      "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
+      "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "2.4.15",
+        "@vue/language-core": "2.2.12"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    },
+    "node_modules/vue-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
+      "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
+      "license": "MIT",
+      "dependencies": {
+        "is-plain-object": "3.0.1"
+      },
+      "engines": {
+        "node": ">=10.15.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.0.0"
+      }
+    }
+  }
+}

+ 27 - 0
code/chapter13/helloagents-trip-planner/frontend/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "helloagents-trip-planner-frontend",
+  "private": true,
+  "version": "1.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@amap/amap-jsapi-loader": "^1.0.1",
+    "ant-design-vue": "^4.2.6",
+    "axios": "^1.7.9",
+    "html2canvas": "^1.4.1",
+    "jspdf": "^3.0.3",
+    "vue": "^3.5.13",
+    "vue-router": "^4.5.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.10.5",
+    "@vitejs/plugin-vue": "^5.2.1",
+    "typescript": "^5.7.3",
+    "vite": "^6.0.7",
+    "vue-tsc": "^2.2.0"
+  }
+}

+ 28 - 0
code/chapter13/helloagents-trip-planner/frontend/src/App.vue

@@ -0,0 +1,28 @@
+<template>
+  <div id="app">
+    <a-layout style="min-height: 100vh">
+      <a-layout-header style="background: #001529; padding: 0 50px">
+        <div style="color: white; font-size: 24px; font-weight: bold">
+          🌍 HelloAgents智能旅行助手
+        </div>
+      </a-layout-header>
+      <a-layout-content style="padding: 24px">
+        <router-view />
+      </a-layout-content>
+      <a-layout-footer style="text-align: center">
+        HelloAgents智能旅行助手 ©2025 基于HelloAgents框架
+      </a-layout-footer>
+    </a-layout>
+  </div>
+</template>
+
+<script setup lang="ts">
+</script>
+
+<style>
+#app {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
+    'Noto Sans', sans-serif;
+}
+</style>
+

+ 31 - 0
code/chapter13/helloagents-trip-planner/frontend/src/main.ts

@@ -0,0 +1,31 @@
+import { createApp } from 'vue'
+import { createRouter, createWebHistory } from 'vue-router'
+import Antd from 'ant-design-vue'
+import 'ant-design-vue/dist/reset.css'
+import App from './App.vue'
+import Home from './views/Home.vue'
+import Result from './views/Result.vue'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/',
+      name: 'Home',
+      component: Home
+    },
+    {
+      path: '/result',
+      name: 'Result',
+      component: Result
+    }
+  ]
+})
+
+const app = createApp(App)
+
+app.use(router)
+app.use(Antd)
+
+app.mount('#app')
+

+ 65 - 0
code/chapter13/helloagents-trip-planner/frontend/src/services/api.ts

@@ -0,0 +1,65 @@
+import axios from 'axios'
+import type { TripFormData, TripPlanResponse } from '@/types'
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
+
+const apiClient = axios.create({
+  baseURL: API_BASE_URL,
+  timeout: 120000, // 2分钟超时
+  headers: {
+    'Content-Type': 'application/json'
+  }
+})
+
+// 请求拦截器
+apiClient.interceptors.request.use(
+  (config) => {
+    console.log('发送请求:', config.method?.toUpperCase(), config.url)
+    return config
+  },
+  (error) => {
+    console.error('请求错误:', error)
+    return Promise.reject(error)
+  }
+)
+
+// 响应拦截器
+apiClient.interceptors.response.use(
+  (response) => {
+    console.log('收到响应:', response.status, response.config.url)
+    return response
+  },
+  (error) => {
+    console.error('响应错误:', error.response?.status, error.message)
+    return Promise.reject(error)
+  }
+)
+
+/**
+ * 生成旅行计划
+ */
+export async function generateTripPlan(formData: TripFormData): Promise<TripPlanResponse> {
+  try {
+    const response = await apiClient.post<TripPlanResponse>('/api/trip/plan', formData)
+    return response.data
+  } catch (error: any) {
+    console.error('生成旅行计划失败:', error)
+    throw new Error(error.response?.data?.detail || error.message || '生成旅行计划失败')
+  }
+}
+
+/**
+ * 健康检查
+ */
+export async function healthCheck(): Promise<any> {
+  try {
+    const response = await apiClient.get('/health')
+    return response.data
+  } catch (error: any) {
+    console.error('健康检查失败:', error)
+    throw new Error(error.message || '健康检查失败')
+  }
+}
+
+export default apiClient
+

+ 95 - 0
code/chapter13/helloagents-trip-planner/frontend/src/types/index.ts

@@ -0,0 +1,95 @@
+// 类型定义
+
+export interface Location {
+  longitude: number
+  latitude: number
+}
+
+export interface Attraction {
+  name: string
+  address: string
+  location: Location
+  visit_duration: number
+  description: string
+  category?: string
+  rating?: number
+  image_url?: string
+  ticket_price?: number
+}
+
+export interface Meal {
+  type: 'breakfast' | 'lunch' | 'dinner' | 'snack'
+  name: string
+  address?: string
+  location?: Location
+  description?: string
+  estimated_cost?: number
+}
+
+export interface Hotel {
+  name: string
+  address: string
+  location?: Location
+  price_range: string
+  rating: string
+  distance: string
+  type: string
+  estimated_cost?: number
+}
+
+export interface Budget {
+  total_attractions: number
+  total_hotels: number
+  total_meals: number
+  total_transportation: number
+  total: number
+}
+
+export interface DayPlan {
+  date: string
+  day_index: number
+  description: string
+  transportation: string
+  accommodation: string
+  hotel?: Hotel
+  attractions: Attraction[]
+  meals: Meal[]
+}
+
+export interface WeatherInfo {
+  date: string
+  day_weather: string
+  night_weather: string
+  day_temp: number
+  night_temp: number
+  wind_direction: string
+  wind_power: string
+}
+
+export interface TripPlan {
+  city: string
+  start_date: string
+  end_date: string
+  days: DayPlan[]
+  weather_info: WeatherInfo[]
+  overall_suggestions: string
+  budget?: Budget
+}
+
+export interface TripFormData {
+  city: string
+  start_date: string
+  end_date: string
+  travel_days: number
+  transportation: string
+  accommodation: string
+  preferences: string[]
+  free_text_input: string
+}
+
+export interface TripPlanResponse {
+  success: boolean
+  message: string
+  data?: TripPlan
+}
+

+ 649 - 0
code/chapter13/helloagents-trip-planner/frontend/src/views/Home.vue

@@ -0,0 +1,649 @@
+<template>
+  <div class="home-container">
+    <!-- 背景装饰 -->
+    <div class="bg-decoration">
+      <div class="circle circle-1"></div>
+      <div class="circle circle-2"></div>
+      <div class="circle circle-3"></div>
+    </div>
+
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <div class="icon-wrapper">
+        <span class="icon">✈️</span>
+      </div>
+      <h1 class="page-title">智能旅行助手</h1>
+      <p class="page-subtitle">基于AI的个性化旅行规划,让每一次出行都完美无忧</p>
+    </div>
+
+    <a-card class="form-card" :bordered="false">
+      <a-form
+        :model="formData"
+        layout="vertical"
+        @finish="handleSubmit"
+      >
+        <!-- 第一步:目的地和日期 -->
+        <div class="form-section">
+          <div class="section-header">
+            <span class="section-icon">📍</span>
+            <span class="section-title">目的地与日期</span>
+          </div>
+
+          <a-row :gutter="24">
+            <a-col :span="8">
+              <a-form-item name="city" :rules="[{ required: true, message: '请输入目的地城市' }]">
+                <template #label>
+                  <span class="form-label">目的地城市</span>
+                </template>
+                <a-input
+                  v-model:value="formData.city"
+                  placeholder="例如: 北京"
+                  size="large"
+                  class="custom-input"
+                >
+                  <template #prefix>
+                    <span style="color: #1890ff;">🏙️</span>
+                  </template>
+                </a-input>
+              </a-form-item>
+            </a-col>
+            <a-col :span="6">
+              <a-form-item name="start_date" :rules="[{ required: true, message: '请选择开始日期' }]">
+                <template #label>
+                  <span class="form-label">开始日期</span>
+                </template>
+                <a-date-picker
+                  v-model:value="formData.start_date"
+                  style="width: 100%"
+                  size="large"
+                  class="custom-input"
+                  placeholder="选择日期"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :span="6">
+              <a-form-item name="end_date" :rules="[{ required: true, message: '请选择结束日期' }]">
+                <template #label>
+                  <span class="form-label">结束日期</span>
+                </template>
+                <a-date-picker
+                  v-model:value="formData.end_date"
+                  style="width: 100%"
+                  size="large"
+                  class="custom-input"
+                  placeholder="选择日期"
+                />
+              </a-form-item>
+            </a-col>
+            <a-col :span="4">
+              <a-form-item>
+                <template #label>
+                  <span class="form-label">旅行天数</span>
+                </template>
+                <div class="days-display-compact">
+                  <span class="days-value">{{ formData.travel_days }}</span>
+                  <span class="days-unit">天</span>
+                </div>
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </div>
+
+        <!-- 第二步:偏好设置 -->
+        <div class="form-section">
+          <div class="section-header">
+            <span class="section-icon">⚙️</span>
+            <span class="section-title">偏好设置</span>
+          </div>
+
+          <a-row :gutter="24">
+            <a-col :span="8">
+              <a-form-item name="transportation">
+                <template #label>
+                  <span class="form-label">交通方式</span>
+                </template>
+                <a-select v-model:value="formData.transportation" size="large" class="custom-select">
+                  <a-select-option value="公共交通">🚇 公共交通</a-select-option>
+                  <a-select-option value="自驾">🚗 自驾</a-select-option>
+                  <a-select-option value="步行">🚶 步行</a-select-option>
+                  <a-select-option value="混合">🔀 混合</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col :span="8">
+              <a-form-item name="accommodation">
+                <template #label>
+                  <span class="form-label">住宿偏好</span>
+                </template>
+                <a-select v-model:value="formData.accommodation" size="large" class="custom-select">
+                  <a-select-option value="经济型酒店">💰 经济型酒店</a-select-option>
+                  <a-select-option value="舒适型酒店">🏨 舒适型酒店</a-select-option>
+                  <a-select-option value="豪华酒店">⭐ 豪华酒店</a-select-option>
+                  <a-select-option value="民宿">🏡 民宿</a-select-option>
+                </a-select>
+              </a-form-item>
+            </a-col>
+            <a-col :span="8">
+              <a-form-item name="preferences">
+                <template #label>
+                  <span class="form-label">旅行偏好</span>
+                </template>
+                <div class="preference-tags">
+                  <a-checkbox-group v-model:value="formData.preferences" class="custom-checkbox-group">
+                    <a-checkbox value="历史文化" class="preference-tag">🏛️ 历史文化</a-checkbox>
+                    <a-checkbox value="自然风光" class="preference-tag">🏞️ 自然风光</a-checkbox>
+                    <a-checkbox value="美食" class="preference-tag">🍜 美食</a-checkbox>
+                    <a-checkbox value="购物" class="preference-tag">🛍️ 购物</a-checkbox>
+                    <a-checkbox value="艺术" class="preference-tag">🎨 艺术</a-checkbox>
+                    <a-checkbox value="休闲" class="preference-tag">☕ 休闲</a-checkbox>
+                  </a-checkbox-group>
+                </div>
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </div>
+
+        <!-- 第三步:额外要求 -->
+        <div class="form-section">
+          <div class="section-header">
+            <span class="section-icon">💬</span>
+            <span class="section-title">额外要求</span>
+          </div>
+
+          <a-form-item name="free_text_input">
+            <a-textarea
+              v-model:value="formData.free_text_input"
+              placeholder="请输入您的额外要求,例如:想去看升旗、需要无障碍设施、对海鲜过敏等..."
+              :rows="3"
+              size="large"
+              class="custom-textarea"
+            />
+          </a-form-item>
+        </div>
+
+        <!-- 提交按钮 -->
+        <a-form-item>
+          <a-button
+            type="primary"
+            html-type="submit"
+            :loading="loading"
+            size="large"
+            block
+            class="submit-button"
+          >
+            <template v-if="!loading">
+              <span class="button-icon">🚀</span>
+              <span>开始规划我的旅行</span>
+            </template>
+            <template v-else>
+              <span>正在生成中...</span>
+            </template>
+          </a-button>
+        </a-form-item>
+
+        <!-- 加载进度条 -->
+        <a-form-item v-if="loading">
+          <div class="loading-container">
+            <a-progress
+              :percent="loadingProgress"
+              status="active"
+              :stroke-color="{
+                '0%': '#667eea',
+                '100%': '#764ba2',
+              }"
+              :stroke-width="10"
+            />
+            <p class="loading-status">
+              {{ loadingStatus }}
+            </p>
+          </div>
+        </a-form-item>
+      </a-form>
+    </a-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import { message } from 'ant-design-vue'
+import { generateTripPlan } from '@/services/api'
+import type { TripFormData } from '@/types'
+import type { Dayjs } from 'dayjs'
+
+const router = useRouter()
+const loading = ref(false)
+const loadingProgress = ref(0)
+const loadingStatus = ref('')
+
+const formData = reactive<TripFormData & { start_date: Dayjs | null; end_date: Dayjs | null }>({
+  city: '',
+  start_date: null,
+  end_date: null,
+  travel_days: 1,
+  transportation: '公共交通',
+  accommodation: '经济型酒店',
+  preferences: [],
+  free_text_input: ''
+})
+
+// 监听日期变化,自动计算旅行天数
+watch([() => formData.start_date, () => formData.end_date], ([start, end]) => {
+  if (start && end) {
+    const days = end.diff(start, 'day') + 1
+    if (days > 0 && days <= 30) {
+      formData.travel_days = days
+    } else if (days > 30) {
+      message.warning('旅行天数不能超过30天')
+      formData.end_date = null
+    } else {
+      message.warning('结束日期不能早于开始日期')
+      formData.end_date = null
+    }
+  }
+})
+
+const handleSubmit = async () => {
+  if (!formData.start_date || !formData.end_date) {
+    message.error('请选择日期')
+    return
+  }
+
+  loading.value = true
+  loadingProgress.value = 0
+  loadingStatus.value = '正在初始化...'
+
+  // 模拟进度更新
+  const progressInterval = setInterval(() => {
+    if (loadingProgress.value < 90) {
+      loadingProgress.value += 10
+
+      // 更新状态文本
+      if (loadingProgress.value <= 30) {
+        loadingStatus.value = '🔍 正在搜索景点...'
+      } else if (loadingProgress.value <= 50) {
+        loadingStatus.value = '🌤️ 正在查询天气...'
+      } else if (loadingProgress.value <= 70) {
+        loadingStatus.value = '🏨 正在推荐酒店...'
+      } else {
+        loadingStatus.value = '📋 正在生成行程计划...'
+      }
+    }
+  }, 500)
+
+  try {
+    const requestData: TripFormData = {
+      city: formData.city,
+      start_date: formData.start_date.format('YYYY-MM-DD'),
+      end_date: formData.end_date.format('YYYY-MM-DD'),
+      travel_days: formData.travel_days,
+      transportation: formData.transportation,
+      accommodation: formData.accommodation,
+      preferences: formData.preferences,
+      free_text_input: formData.free_text_input
+    }
+
+    const response = await generateTripPlan(requestData)
+
+    clearInterval(progressInterval)
+    loadingProgress.value = 100
+    loadingStatus.value = '✅ 完成!'
+
+    if (response.success && response.data) {
+      // 保存到sessionStorage
+      sessionStorage.setItem('tripPlan', JSON.stringify(response.data))
+
+      message.success('旅行计划生成成功!')
+
+      // 短暂延迟后跳转
+      setTimeout(() => {
+        router.push('/result')
+      }, 500)
+    } else {
+      message.error(response.message || '生成失败')
+    }
+  } catch (error: any) {
+    clearInterval(progressInterval)
+    message.error(error.message || '生成旅行计划失败,请稍后重试')
+  } finally {
+    setTimeout(() => {
+      loading.value = false
+      loadingProgress.value = 0
+      loadingStatus.value = ''
+    }, 1000)
+  }
+}
+</script>
+
+<style scoped>
+.home-container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  padding: 60px 20px;
+  position: relative;
+  overflow: hidden;
+}
+
+/* 背景装饰 */
+.bg-decoration {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  overflow: hidden;
+}
+
+.circle {
+  position: absolute;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.1);
+  animation: float 20s infinite ease-in-out;
+}
+
+.circle-1 {
+  width: 300px;
+  height: 300px;
+  top: -100px;
+  left: -100px;
+  animation-delay: 0s;
+}
+
+.circle-2 {
+  width: 200px;
+  height: 200px;
+  top: 50%;
+  right: -50px;
+  animation-delay: 5s;
+}
+
+.circle-3 {
+  width: 150px;
+  height: 150px;
+  bottom: -50px;
+  left: 30%;
+  animation-delay: 10s;
+}
+
+@keyframes float {
+  0%, 100% {
+    transform: translateY(0) rotate(0deg);
+  }
+  50% {
+    transform: translateY(-30px) rotate(180deg);
+  }
+}
+
+/* 页面标题 */
+.page-header {
+  text-align: center;
+  margin-bottom: 50px;
+  animation: fadeInDown 0.8s ease-out;
+  position: relative;
+  z-index: 1;
+}
+
+.icon-wrapper {
+  margin-bottom: 20px;
+}
+
+.icon {
+  font-size: 80px;
+  display: inline-block;
+  animation: bounce 2s infinite;
+}
+
+@keyframes bounce {
+  0%, 100% {
+    transform: translateY(0);
+  }
+  50% {
+    transform: translateY(-20px);
+  }
+}
+
+.page-title {
+  font-size: 56px;
+  font-weight: 800;
+  color: #ffffff;
+  margin-bottom: 16px;
+  text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.3);
+  letter-spacing: 2px;
+}
+
+.page-subtitle {
+  font-size: 20px;
+  color: rgba(255, 255, 255, 0.95);
+  margin: 0;
+  font-weight: 300;
+}
+
+/* 表单卡片 */
+.form-card {
+  max-width: 1400px;
+  margin: 0 auto;
+  border-radius: 24px;
+  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
+  animation: fadeInUp 0.8s ease-out;
+  position: relative;
+  z-index: 1;
+  backdrop-filter: blur(10px);
+  background: rgba(255, 255, 255, 0.98) !important;
+}
+
+/* 表单分区 */
+.form-section {
+  margin-bottom: 32px;
+  padding: 24px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  border-radius: 16px;
+  border: 1px solid #e8e8e8;
+  transition: all 0.3s ease;
+}
+
+.form-section:hover {
+  box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
+  transform: translateY(-2px);
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  padding-bottom: 12px;
+  border-bottom: 2px solid #667eea;
+}
+
+.section-icon {
+  font-size: 24px;
+  margin-right: 12px;
+}
+
+.section-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+}
+
+/* 表单标签 */
+.form-label {
+  font-size: 15px;
+  font-weight: 500;
+  color: #555;
+}
+
+/* 自定义输入框 */
+.custom-input :deep(.ant-input),
+.custom-input :deep(.ant-picker) {
+  border-radius: 12px;
+  border: 2px solid #e8e8e8;
+  transition: all 0.3s ease;
+}
+
+.custom-input :deep(.ant-input:hover),
+.custom-input :deep(.ant-picker:hover) {
+  border-color: #667eea;
+}
+
+.custom-input :deep(.ant-input:focus),
+.custom-input :deep(.ant-picker-focused) {
+  border-color: #667eea;
+  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+/* 自定义选择框 */
+.custom-select :deep(.ant-select-selector) {
+  border-radius: 12px !important;
+  border: 2px solid #e8e8e8 !important;
+  transition: all 0.3s ease;
+}
+
+.custom-select:hover :deep(.ant-select-selector) {
+  border-color: #667eea !important;
+}
+
+.custom-select :deep(.ant-select-focused .ant-select-selector) {
+  border-color: #667eea !important;
+  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1) !important;
+}
+
+/* 天数显示 - 紧凑版 */
+.days-display-compact {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 40px;
+  padding: 8px 16px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 12px;
+  color: white;
+}
+
+.days-display-compact .days-value {
+  font-size: 24px;
+  font-weight: 700;
+  margin-right: 4px;
+}
+
+.days-display-compact .days-unit {
+  font-size: 14px;
+}
+
+/* 偏好标签 */
+.preference-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.custom-checkbox-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  width: 100%;
+}
+
+.preference-tag :deep(.ant-checkbox-wrapper) {
+  margin: 0 !important;
+  padding: 8px 16px;
+  border: 2px solid #e8e8e8;
+  border-radius: 20px;
+  transition: all 0.3s ease;
+  background: white;
+  font-size: 14px;
+}
+
+.preference-tag :deep(.ant-checkbox-wrapper:hover) {
+  border-color: #667eea;
+  background: #f5f7ff;
+}
+
+.preference-tag :deep(.ant-checkbox-wrapper-checked) {
+  border-color: #667eea;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+}
+
+/* 自定义文本域 */
+.custom-textarea :deep(.ant-input) {
+  border-radius: 12px;
+  border: 2px solid #e8e8e8;
+  transition: all 0.3s ease;
+}
+
+.custom-textarea :deep(.ant-input:hover) {
+  border-color: #667eea;
+}
+
+.custom-textarea :deep(.ant-input:focus) {
+  border-color: #667eea;
+  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+/* 提交按钮 */
+.submit-button {
+  height: 56px;
+  border-radius: 28px;
+  font-size: 18px;
+  font-weight: 600;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border: none;
+  box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4);
+  transition: all 0.3s ease;
+}
+
+.submit-button:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 12px 32px rgba(102, 126, 234, 0.5);
+}
+
+.submit-button:active {
+  transform: translateY(0);
+}
+
+.button-icon {
+  margin-right: 8px;
+  font-size: 20px;
+}
+
+/* 加载容器 */
+.loading-container {
+  text-align: center;
+  padding: 24px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  border-radius: 16px;
+  border: 2px dashed #667eea;
+}
+
+.loading-status {
+  margin-top: 16px;
+  color: #667eea;
+  font-size: 18px;
+  font-weight: 500;
+}
+
+/* 动画 */
+@keyframes fadeInDown {
+  from {
+    opacity: 0;
+    transform: translateY(-30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+</style>
+

+ 1434 - 0
code/chapter13/helloagents-trip-planner/frontend/src/views/Result.vue

@@ -0,0 +1,1434 @@
+<template>
+  <div class="result-container">
+    <!-- 页面头部 -->
+    <div class="page-header">
+      <a-button class="back-button" size="large" @click="goBack">
+        ← 返回首页
+      </a-button>
+      <a-space size="middle">
+        <a-button v-if="!editMode" @click="toggleEditMode" type="default">
+          ✏️ 编辑行程
+        </a-button>
+        <a-button v-else @click="saveChanges" type="primary">
+          💾 保存修改
+        </a-button>
+        <a-button v-if="editMode" @click="cancelEdit" type="default">
+          ❌ 取消编辑
+        </a-button>
+
+        <!-- 导出按钮 -->
+        <a-dropdown v-if="!editMode">
+          <template #overlay>
+            <a-menu>
+              <a-menu-item key="image" @click="exportAsImage">
+                📷 导出为图片
+              </a-menu-item>
+              <a-menu-item key="pdf" @click="exportAsPDF">
+                📄 导出为PDF
+              </a-menu-item>
+            </a-menu>
+          </template>
+          <a-button type="default">
+            📥 导出行程 <DownOutlined />
+          </a-button>
+        </a-dropdown>
+      </a-space>
+    </div>
+
+    <div v-if="tripPlan" class="content-wrapper">
+      <!-- 侧边导航 -->
+      <div class="side-nav">
+        <a-affix :offset-top="80">
+          <a-menu mode="inline" :selected-keys="[activeSection]" @click="scrollToSection">
+            <a-menu-item key="overview">
+              <span>📋 行程概览</span>
+            </a-menu-item>
+            <a-menu-item key="budget" v-if="tripPlan.budget">
+              <span>💰 预算明细</span>
+            </a-menu-item>
+            <a-menu-item key="map">
+              <span>📍 景点地图</span>
+            </a-menu-item>
+            <a-sub-menu key="days" title="📅 每日行程">
+              <a-menu-item v-for="(day, index) in tripPlan.days" :key="`day-${index}`">
+                第{{ day.day_index + 1 }}天
+              </a-menu-item>
+            </a-sub-menu>
+            <a-menu-item key="weather" v-if="tripPlan.weather_info && tripPlan.weather_info.length > 0">
+              <span>🌤️ 天气信息</span>
+            </a-menu-item>
+          </a-menu>
+        </a-affix>
+      </div>
+
+      <!-- 主内容区 -->
+      <div class="main-content">
+        <!-- 顶部信息区:左侧概览+预算,右侧地图 -->
+        <div class="top-info-section">
+          <!-- 左侧:行程概览和预算明细 -->
+          <div class="left-info">
+            <!-- 行程概览 -->
+            <a-card id="overview" :title="`${tripPlan.city}旅行计划`" :bordered="false" class="overview-card">
+              <div class="overview-content">
+                <div class="info-item">
+                  <span class="info-label">📅 日期:</span>
+                  <span class="info-value">{{ tripPlan.start_date }} 至 {{ tripPlan.end_date }}</span>
+                </div>
+                <div class="info-item">
+                  <span class="info-label">💡 建议:</span>
+                  <span class="info-value">{{ tripPlan.overall_suggestions }}</span>
+                </div>
+              </div>
+            </a-card>
+
+            <!-- 预算明细 -->
+            <a-card id="budget" v-if="tripPlan.budget" title="💰 预算明细" :bordered="false" class="budget-card">
+              <div class="budget-grid">
+                <div class="budget-item">
+                  <div class="budget-label">景点门票</div>
+                  <div class="budget-value">¥{{ tripPlan.budget.total_attractions }}</div>
+                </div>
+                <div class="budget-item">
+                  <div class="budget-label">酒店住宿</div>
+                  <div class="budget-value">¥{{ tripPlan.budget.total_hotels }}</div>
+                </div>
+                <div class="budget-item">
+                  <div class="budget-label">餐饮费用</div>
+                  <div class="budget-value">¥{{ tripPlan.budget.total_meals }}</div>
+                </div>
+                <div class="budget-item">
+                  <div class="budget-label">交通费用</div>
+                  <div class="budget-value">¥{{ tripPlan.budget.total_transportation }}</div>
+                </div>
+              </div>
+              <div class="budget-total">
+                <span class="total-label">预估总费用</span>
+                <span class="total-value">¥{{ tripPlan.budget.total }}</span>
+              </div>
+            </a-card>
+          </div>
+
+          <!-- 右侧:地图 -->
+          <div class="right-map">
+            <a-card id="map" title="📍 景点地图" :bordered="false" class="map-card">
+              <div id="amap-container" style="width: 100%; height: 100%"></div>
+            </a-card>
+          </div>
+        </div>
+
+        <!-- 每日行程:可折叠 -->
+        <a-card title="📅 每日行程" :bordered="false" class="days-card">
+          <a-collapse v-model:activeKey="activeDays" accordion>
+            <a-collapse-panel
+              v-for="(day, index) in tripPlan.days"
+              :key="index"
+              :id="`day-${index}`"
+            >
+              <template #header>
+                <div class="day-header">
+                  <span class="day-title">第{{ day.day_index + 1 }}天</span>
+                  <span class="day-date">{{ day.date }}</span>
+                </div>
+              </template>
+
+              <!-- 行程基本信息 -->
+              <div class="day-info">
+                <div class="info-row">
+                  <span class="label">📝 行程描述:</span>
+                  <span class="value">{{ day.description }}</span>
+                </div>
+                <div class="info-row">
+                  <span class="label">🚗 交通方式:</span>
+                  <span class="value">{{ day.transportation }}</span>
+                </div>
+                <div class="info-row">
+                  <span class="label">🏨 住宿:</span>
+                  <span class="value">{{ day.accommodation }}</span>
+                </div>
+              </div>
+
+              <!-- 景点安排 -->
+              <a-divider orientation="left">🎯 景点安排</a-divider>
+              <a-list
+                :data-source="day.attractions"
+                :grid="{ gutter: 16, column: 2 }"
+              >
+                <template #renderItem="{ item, index }">
+                  <a-list-item>
+                    <a-card :title="item.name" size="small" class="attraction-card">
+                      <!-- 编辑模式下的操作按钮 -->
+                      <template #extra v-if="editMode">
+                        <a-space>
+                          <a-button
+                            size="small"
+                            @click="moveAttraction(day.day_index, index, 'up')"
+                            :disabled="index === 0"
+                          >
+                            ↑
+                          </a-button>
+                          <a-button
+                            size="small"
+                            @click="moveAttraction(day.day_index, index, 'down')"
+                            :disabled="index === day.attractions.length - 1"
+                          >
+                            ↓
+                          </a-button>
+                          <a-button
+                            size="small"
+                            danger
+                            @click="deleteAttraction(day.day_index, index)"
+                          >
+                            🗑️
+                          </a-button>
+                        </a-space>
+                      </template>
+
+                      <!-- 景点图片 -->
+                      <div class="attraction-image-wrapper">
+                        <img
+                          :src="getAttractionImage(item.name, index)"
+                          :alt="item.name"
+                          class="attraction-image"
+                          @error="handleImageError"
+                        />
+                        <div class="attraction-badge">
+                          <span class="badge-number">{{ index + 1 }}</span>
+                        </div>
+                        <div v-if="item.ticket_price" class="price-tag">
+                          ¥{{ item.ticket_price }}
+                        </div>
+                      </div>
+
+                      <!-- 编辑模式下可编辑的字段 -->
+                      <div v-if="editMode">
+                        <p><strong>地址:</strong></p>
+                        <a-input v-model:value="item.address" size="small" style="margin-bottom: 8px" />
+
+                        <p><strong>游览时长(分钟):</strong></p>
+                        <a-input-number v-model:value="item.visit_duration" :min="10" :max="480" size="small" style="width: 100%; margin-bottom: 8px" />
+
+                        <p><strong>描述:</strong></p>
+                        <a-textarea v-model:value="item.description" :rows="2" size="small" style="margin-bottom: 8px" />
+                      </div>
+
+                      <!-- 查看模式 -->
+                      <div v-else>
+                        <p><strong>地址:</strong> {{ item.address }}</p>
+                        <p><strong>游览时长:</strong> {{ item.visit_duration }}分钟</p>
+                        <p><strong>描述:</strong> {{ item.description }}</p>
+                        <p v-if="item.rating"><strong>评分:</strong> {{ item.rating }}⭐</p>
+                      </div>
+                    </a-card>
+                  </a-list-item>
+                </template>
+              </a-list>
+
+              <!-- 酒店推荐 -->
+              <a-divider v-if="day.hotel" orientation="left">🏨 住宿推荐</a-divider>
+              <a-card v-if="day.hotel" size="small" class="hotel-card">
+                <template #title>
+                  <span class="hotel-title">{{ day.hotel.name }}</span>
+                </template>
+                <a-descriptions :column="2" size="small">
+                  <a-descriptions-item label="地址">{{ day.hotel.address }}</a-descriptions-item>
+                  <a-descriptions-item label="类型">{{ day.hotel.type }}</a-descriptions-item>
+                  <a-descriptions-item label="价格范围">{{ day.hotel.price_range }}</a-descriptions-item>
+                  <a-descriptions-item label="评分">{{ day.hotel.rating }}⭐</a-descriptions-item>
+                  <a-descriptions-item label="距离" :span="2">{{ day.hotel.distance }}</a-descriptions-item>
+                </a-descriptions>
+              </a-card>
+
+              <!-- 餐饮安排 -->
+              <a-divider orientation="left">🍽️ 餐饮安排</a-divider>
+              <a-descriptions :column="1" bordered size="small">
+                <a-descriptions-item
+                  v-for="meal in day.meals"
+                  :key="meal.type"
+                  :label="getMealLabel(meal.type)"
+                >
+                  {{ meal.name }}
+                  <span v-if="meal.description"> - {{ meal.description }}</span>
+                </a-descriptions-item>
+              </a-descriptions>
+            </a-collapse-panel>
+          </a-collapse>
+        </a-card>
+
+        <a-card id="weather" v-if="tripPlan.weather_info && tripPlan.weather_info.length > 0" title="天气信息" style="margin-top: 20px" :bordered="false">
+        <a-list
+          :data-source="tripPlan.weather_info"
+          :grid="{ gutter: 16, column: 3 }"
+        >
+          <template #renderItem="{ item }">
+            <a-list-item>
+              <a-card size="small" class="weather-card">
+                <div class="weather-date">{{ item.date }}</div>
+                <div class="weather-info-row">
+                  <span class="weather-icon">☀️</span>
+                  <div>
+                    <div class="weather-label">白天</div>
+                    <div class="weather-value">{{ item.day_weather }} {{ item.day_temp }}°C</div>
+                  </div>
+                </div>
+                <div class="weather-info-row">
+                  <span class="weather-icon">🌙</span>
+                  <div>
+                    <div class="weather-label">夜间</div>
+                    <div class="weather-value">{{ item.night_weather }} {{ item.night_temp }}°C</div>
+                  </div>
+                </div>
+                <div class="weather-wind">
+                  💨 {{ item.wind_direction }} {{ item.wind_power }}
+                </div>
+              </a-card>
+            </a-list-item>
+          </template>
+        </a-list>
+        </a-card>
+      </div>
+    </div>
+
+    <a-empty v-else description="没有找到旅行计划数据">
+      <template #image>
+        <div style="font-size: 80px;">🗺️</div>
+      </template>
+      <template #description>
+        <span style="color: #999;">暂无旅行计划数据,请先创建行程</span>
+      </template>
+      <a-button type="primary" @click="goBack">返回首页创建行程</a-button>
+    </a-empty>
+
+    <!-- 回到顶部按钮 -->
+    <a-back-top :visibility-height="300">
+      <div class="back-top-button">
+        ↑
+      </div>
+    </a-back-top>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, nextTick } from 'vue'
+import { useRouter } from 'vue-router'
+import { message } from 'ant-design-vue'
+import { DownOutlined } from '@ant-design/icons-vue'
+import AMapLoader from '@amap/amap-jsapi-loader'
+import html2canvas from 'html2canvas'
+import jsPDF from 'jspdf'
+import type { TripPlan } from '@/types'
+
+const router = useRouter()
+const tripPlan = ref<TripPlan | null>(null)
+const editMode = ref(false)
+const originalPlan = ref<TripPlan | null>(null)
+const attractionPhotos = ref<Record<string, string>>({})
+const activeSection = ref('overview')
+const activeDays = ref<number[]>([0]) // 默认展开第一天
+let map: any = null
+
+onMounted(async () => {
+  const data = sessionStorage.getItem('tripPlan')
+  if (data) {
+    tripPlan.value = JSON.parse(data)
+    // 加载景点图片
+    await loadAttractionPhotos()
+    // 等待DOM渲染完成后初始化地图
+    await nextTick()
+    initMap()
+  }
+})
+
+const goBack = () => {
+  router.push('/')
+}
+
+// 滚动到指定区域
+const scrollToSection = ({ key }: { key: string }) => {
+  activeSection.value = key
+  const element = document.getElementById(key)
+  if (element) {
+    element.scrollIntoView({ behavior: 'smooth', block: 'start' })
+  }
+}
+
+// 切换编辑模式
+const toggleEditMode = () => {
+  editMode.value = true
+  // 保存原始数据用于取消编辑
+  originalPlan.value = JSON.parse(JSON.stringify(tripPlan.value))
+  message.info('进入编辑模式')
+}
+
+// 保存修改
+const saveChanges = () => {
+  editMode.value = false
+  // 更新sessionStorage
+  if (tripPlan.value) {
+    sessionStorage.setItem('tripPlan', JSON.stringify(tripPlan.value))
+  }
+  message.success('修改已保存')
+
+  // 重新初始化地图以反映更改
+  if (map) {
+    map.destroy()
+  }
+  nextTick(() => {
+    initMap()
+  })
+}
+
+// 取消编辑
+const cancelEdit = () => {
+  if (originalPlan.value) {
+    tripPlan.value = JSON.parse(JSON.stringify(originalPlan.value))
+  }
+  editMode.value = false
+  message.info('已取消编辑')
+}
+
+// 删除景点
+const deleteAttraction = (dayIndex: number, attrIndex: number) => {
+  if (!tripPlan.value) return
+
+  const day = tripPlan.value.days[dayIndex]
+  if (day.attractions.length <= 1) {
+    message.warning('每天至少需要保留一个景点')
+    return
+  }
+
+  day.attractions.splice(attrIndex, 1)
+  message.success('景点已删除')
+}
+
+// 移动景点顺序
+const moveAttraction = (dayIndex: number, attrIndex: number, direction: 'up' | 'down') => {
+  if (!tripPlan.value) return
+
+  const day = tripPlan.value.days[dayIndex]
+  const attractions = day.attractions
+
+  if (direction === 'up' && attrIndex > 0) {
+    [attractions[attrIndex], attractions[attrIndex - 1]] = [attractions[attrIndex - 1], attractions[attrIndex]]
+  } else if (direction === 'down' && attrIndex < attractions.length - 1) {
+    [attractions[attrIndex], attractions[attrIndex + 1]] = [attractions[attrIndex + 1], attractions[attrIndex]]
+  }
+}
+
+const getMealLabel = (type: string): string => {
+  const labels: Record<string, string> = {
+    breakfast: '早餐',
+    lunch: '午餐',
+    dinner: '晚餐',
+    snack: '小吃'
+  }
+  return labels[type] || type
+}
+
+// 加载所有景点图片
+const loadAttractionPhotos = async () => {
+  if (!tripPlan.value) return
+
+  const promises: Promise<void>[] = []
+
+  tripPlan.value.days.forEach(day => {
+    day.attractions.forEach(attraction => {
+      const promise = fetch(`http://localhost:8000/api/poi/photo?name=${encodeURIComponent(attraction.name)}`)
+        .then(res => res.json())
+        .then(data => {
+          if (data.success && data.data.photo_url) {
+            attractionPhotos.value[attraction.name] = data.data.photo_url
+          }
+        })
+        .catch(err => {
+          console.error(`获取${attraction.name}图片失败:`, err)
+        })
+
+      promises.push(promise)
+    })
+  })
+
+  await Promise.all(promises)
+}
+
+// 获取景点图片
+const getAttractionImage = (name: string, index: number): string => {
+  // 如果已加载真实图片,返回真实图片
+  if (attractionPhotos.value[name]) {
+    return attractionPhotos.value[name]
+  }
+
+  // 返回一个纯色占位图(避免跨域问题)
+  const colors = [
+    { start: '#667eea', end: '#764ba2' },
+    { start: '#f093fb', end: '#f5576c' },
+    { start: '#4facfe', end: '#00f2fe' },
+    { start: '#43e97b', end: '#38f9d7' },
+    { start: '#fa709a', end: '#fee140' }
+  ]
+  const colorIndex = index % colors.length
+  const { start, end } = colors[colorIndex]
+
+  // 使用base64编码避免中文问题
+  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
+    <defs>
+      <linearGradient id="grad${index}" x1="0%" y1="0%" x2="100%" y2="100%">
+        <stop offset="0%" style="stop-color:${start};stop-opacity:1" />
+        <stop offset="100%" style="stop-color:${end};stop-opacity:1" />
+      </linearGradient>
+    </defs>
+    <rect width="400" height="300" fill="url(#grad${index})"/>
+    <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" font-weight="bold" fill="white">${name}</text>
+  </svg>`
+
+  return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
+}
+
+// 图片加载失败时的处理
+const handleImageError = (event: Event) => {
+  const img = event.target as HTMLImageElement
+  // 使用灰色占位图
+  img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="400" height="300"%3E%3Crect width="400" height="300" fill="%23f0f0f0"/%3E%3Ctext x="50%25" y="50%25" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="18" fill="%23999"%3E图片加载失败%3C/text%3E%3C/svg%3E'
+}
+
+
+
+// 导出为图片
+const exportAsImage = async () => {
+  try {
+    message.loading({ content: '正在生成图片...', key: 'export', duration: 0 })
+
+    const element = document.querySelector('.main-content') as HTMLElement
+    if (!element) {
+      throw new Error('未找到内容元素')
+    }
+
+    // 创建一个独立的容器
+    const exportContainer = document.createElement('div')
+    exportContainer.style.width = element.offsetWidth + 'px'
+    exportContainer.style.backgroundColor = '#f5f7fa'
+    exportContainer.style.padding = '20px'
+
+    // 复制所有内容
+    exportContainer.innerHTML = element.innerHTML
+
+    // 处理地图截图
+    const mapContainer = document.getElementById('amap-container')
+    if (mapContainer && map) {
+      const mapCanvas = mapContainer.querySelector('canvas')
+      if (mapCanvas) {
+        const mapSnapshot = mapCanvas.toDataURL('image/png')
+        const exportMapContainer = exportContainer.querySelector('#amap-container')
+        if (exportMapContainer) {
+          exportMapContainer.innerHTML = `<img src="${mapSnapshot}" style="width:100%;height:100%;object-fit:cover;" />`
+        }
+      }
+    }
+
+    // 移除所有ant-card类,替换为纯div
+    const cards = exportContainer.querySelectorAll('.ant-card')
+    cards.forEach((card) => {
+      const cardEl = card as HTMLElement
+      try {
+        cardEl.className = '' // 移除所有类
+        cardEl.style.setProperty('background-color', '#ffffff')
+        cardEl.style.setProperty('border-radius', '12px')
+        cardEl.style.setProperty('box-shadow', '0 4px 12px rgba(0, 0, 0, 0.1)')
+        cardEl.style.setProperty('margin-bottom', '20px')
+        cardEl.style.setProperty('overflow', 'hidden')
+      } catch (err) {
+        console.error('设置卡片样式失败:', err)
+      }
+    })
+
+    // 处理卡片头部
+    const cardHeads = exportContainer.querySelectorAll('.ant-card-head')
+    cardHeads.forEach((head) => {
+      const headEl = head as HTMLElement
+      try {
+        headEl.style.setProperty('background-color', '#667eea')
+        headEl.style.setProperty('color', '#ffffff')
+        headEl.style.setProperty('padding', '16px 24px')
+        headEl.style.setProperty('font-size', '18px')
+        headEl.style.setProperty('font-weight', '600')
+      } catch (err) {
+        console.error('设置卡片头部样式失败:', err)
+      }
+    })
+
+    // 处理卡片内容
+    const cardBodies = exportContainer.querySelectorAll('.ant-card-body')
+    cardBodies.forEach((body) => {
+      const bodyEl = body as HTMLElement
+      bodyEl.style.setProperty('background-color', '#ffffff')
+      bodyEl.style.setProperty('padding', '24px')
+    })
+
+    // 处理酒店卡片头部
+    const hotelCards = exportContainer.querySelectorAll('.hotel-card')
+    hotelCards.forEach((card) => {
+      const head = card.querySelector('.ant-card-head') as HTMLElement
+      if (head) {
+        head.style.setProperty('background-color', '#1976d2')
+      }
+      (card as HTMLElement).style.setProperty('background-color', '#e3f2fd')
+    })
+
+    // 处理天气卡片
+    const weatherCards = exportContainer.querySelectorAll('.weather-card')
+    weatherCards.forEach((card) => {
+      (card as HTMLElement).style.setProperty('background-color', '#e0f7fa')
+    })
+
+    // 处理预算总计
+    const budgetTotal = exportContainer.querySelector('.budget-total')
+    if (budgetTotal) {
+      const el = budgetTotal as HTMLElement
+      el.style.setProperty('background-color', '#667eea')
+      el.style.setProperty('color', '#ffffff')
+      el.style.setProperty('padding', '20px')
+      el.style.setProperty('border-radius', '12px')
+      el.style.setProperty('margin-bottom', '20px')
+    }
+
+    // 处理预算项
+    const budgetItems = exportContainer.querySelectorAll('.budget-item')
+    budgetItems.forEach((item) => {
+      const el = item as HTMLElement
+      el.style.setProperty('background-color', '#f5f7fa')
+      el.style.setProperty('padding', '16px')
+      el.style.setProperty('border-radius', '8px')
+      el.style.setProperty('margin-bottom', '12px')
+    })
+
+    // 添加到body(隐藏)
+    exportContainer.style.position = 'absolute'
+    exportContainer.style.left = '-9999px'
+    document.body.appendChild(exportContainer)
+
+    const canvas = await html2canvas(exportContainer, {
+      backgroundColor: '#f5f7fa',
+      scale: 2,
+      logging: false,
+      useCORS: true,
+      allowTaint: true
+    })
+
+    // 移除容器
+    document.body.removeChild(exportContainer)
+
+    // 转换为图片并下载
+    const link = document.createElement('a')
+    link.download = `旅行计划_${tripPlan.value?.city}_${new Date().getTime()}.png`
+    link.href = canvas.toDataURL('image/png')
+    link.click()
+
+    message.success({ content: '图片导出成功!', key: 'export' })
+  } catch (error: any) {
+    console.error('导出图片失败:', error)
+    message.error({ content: `导出图片失败: ${error.message}`, key: 'export' })
+  }
+}
+
+// 导出为PDF
+const exportAsPDF = async () => {
+  try {
+    message.loading({ content: '正在生成PDF...', key: 'export', duration: 0 })
+
+    const element = document.querySelector('.main-content') as HTMLElement
+    if (!element) {
+      throw new Error('未找到内容元素')
+    }
+
+    // 创建一个独立的容器
+    const exportContainer = document.createElement('div')
+    exportContainer.style.width = element.offsetWidth + 'px'
+    exportContainer.style.backgroundColor = '#f5f7fa'
+    exportContainer.style.padding = '20px'
+
+    // 复制所有内容
+    exportContainer.innerHTML = element.innerHTML
+
+    // 处理地图截图
+    const mapContainer = document.getElementById('amap-container')
+    if (mapContainer && map) {
+      const mapCanvas = mapContainer.querySelector('canvas')
+      if (mapCanvas) {
+        const mapSnapshot = mapCanvas.toDataURL('image/png')
+        const exportMapContainer = exportContainer.querySelector('#amap-container')
+        if (exportMapContainer) {
+          exportMapContainer.innerHTML = `<img src="${mapSnapshot}" style="width:100%;height:100%;object-fit:cover;" />`
+        }
+      }
+    }
+
+    // 移除所有ant-card类,替换为纯div
+    const cards = exportContainer.querySelectorAll('.ant-card')
+    cards.forEach((card) => {
+      const cardEl = card as HTMLElement
+      try {
+        cardEl.className = ''
+        cardEl.style.setProperty('background-color', '#ffffff')
+        cardEl.style.setProperty('border-radius', '12px')
+        cardEl.style.setProperty('box-shadow', '0 4px 12px rgba(0, 0, 0, 0.1)')
+        cardEl.style.setProperty('margin-bottom', '20px')
+        cardEl.style.setProperty('overflow', 'hidden')
+      } catch (err) {
+        console.error('设置卡片样式失败:', err)
+      }
+    })
+
+    // 处理卡片头部
+    const cardHeads = exportContainer.querySelectorAll('.ant-card-head')
+    cardHeads.forEach((head) => {
+      const headEl = head as HTMLElement
+      try {
+        headEl.style.setProperty('background-color', '#667eea')
+        headEl.style.setProperty('color', '#ffffff')
+        headEl.style.setProperty('padding', '16px 24px')
+        headEl.style.setProperty('font-size', '18px')
+        headEl.style.setProperty('font-weight', '600')
+      } catch (err) {
+        console.error('设置卡片头部样式失败:', err)
+      }
+    })
+
+    // 处理卡片内容
+    const cardBodies = exportContainer.querySelectorAll('.ant-card-body')
+    cardBodies.forEach((body) => {
+      const bodyEl = body as HTMLElement
+      bodyEl.style.setProperty('background-color', '#ffffff')
+      bodyEl.style.setProperty('padding', '24px')
+    })
+
+    // 处理酒店卡片头部
+    const hotelCards = exportContainer.querySelectorAll('.hotel-card')
+    hotelCards.forEach((card) => {
+      const head = card.querySelector('.ant-card-head') as HTMLElement
+      if (head) {
+        head.style.setProperty('background-color', '#1976d2')
+      }
+      (card as HTMLElement).style.setProperty('background-color', '#e3f2fd')
+    })
+
+    // 处理天气卡片
+    const weatherCards = exportContainer.querySelectorAll('.weather-card')
+    weatherCards.forEach((card) => {
+      (card as HTMLElement).style.setProperty('background-color', '#e0f7fa')
+    })
+
+    // 处理预算总计
+    const budgetTotal = exportContainer.querySelector('.budget-total')
+    if (budgetTotal) {
+      const el = budgetTotal as HTMLElement
+      el.style.setProperty('background-color', '#667eea')
+      el.style.setProperty('color', '#ffffff')
+      el.style.setProperty('padding', '20px')
+      el.style.setProperty('border-radius', '12px')
+      el.style.setProperty('margin-bottom', '20px')
+    }
+
+    // 处理预算项
+    const budgetItems = exportContainer.querySelectorAll('.budget-item')
+    budgetItems.forEach((item) => {
+      const el = item as HTMLElement
+      el.style.setProperty('background-color', '#f5f7fa')
+      el.style.setProperty('padding', '16px')
+      el.style.setProperty('border-radius', '8px')
+      el.style.setProperty('margin-bottom', '12px')
+    })
+
+    // 添加到body(隐藏)
+    exportContainer.style.position = 'absolute'
+    exportContainer.style.left = '-9999px'
+    document.body.appendChild(exportContainer)
+
+    const canvas = await html2canvas(exportContainer, {
+      backgroundColor: '#f5f7fa',
+      scale: 2,
+      logging: false,
+      useCORS: true,
+      allowTaint: true
+    })
+
+    // 移除容器
+    document.body.removeChild(exportContainer)
+
+    const imgData = canvas.toDataURL('image/png')
+    const pdf = new jsPDF({
+      orientation: 'portrait',
+      unit: 'mm',
+      format: 'a4'
+    })
+
+    const imgWidth = 210 // A4宽度(mm)
+    const imgHeight = (canvas.height * imgWidth) / canvas.width
+
+    // 如果内容高度超过一页,分页处理
+    let heightLeft = imgHeight
+    let position = 0
+
+    pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
+    heightLeft -= 297 // A4高度
+
+    while (heightLeft > 0) {
+      position = heightLeft - imgHeight
+      pdf.addPage()
+      pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
+      heightLeft -= 297
+    }
+
+    pdf.save(`旅行计划_${tripPlan.value?.city}_${new Date().getTime()}.pdf`)
+
+    message.success({ content: 'PDF导出成功!', key: 'export' })
+  } catch (error: any) {
+    console.error('导出PDF失败:', error)
+    message.error({ content: `导出PDF失败: ${error.message}`, key: 'export' })
+  }
+}
+
+// 截取地图图片
+const captureMapImage = async () => {
+  if (!map) return
+
+  try {
+    // 获取地图容器
+    const mapContainer = document.getElementById('amap-container')
+    if (!mapContainer) return
+
+    // 使用高德地图的截图功能
+    const mapCanvas = mapContainer.querySelector('canvas')
+    if (mapCanvas) {
+      // 创建一个img元素替换地图容器
+      const img = document.createElement('img')
+      img.src = mapCanvas.toDataURL('image/png')
+      img.style.width = '100%'
+      img.style.height = '500px'
+      img.style.objectFit = 'cover'
+      img.id = 'map-snapshot'
+
+      // 隐藏原地图,显示截图
+      mapContainer.style.display = 'none'
+      mapContainer.parentElement?.appendChild(img)
+    }
+  } catch (error) {
+    console.error('截取地图失败:', error)
+  }
+}
+
+// 恢复地图
+const restoreMap = () => {
+  const mapContainer = document.getElementById('amap-container')
+  const snapshot = document.getElementById('map-snapshot')
+
+  if (mapContainer) {
+    mapContainer.style.display = 'block'
+  }
+
+  if (snapshot) {
+    snapshot.remove()
+  }
+}
+
+// 初始化地图
+const initMap = async () => {
+  try {
+    const AMap = await AMapLoader.load({
+      key: '25dfaf050fe024803e96badd370e8029', // 高德地图Web服务API Key
+      version: '2.0',
+      plugins: ['AMap.Marker', 'AMap.Polyline', 'AMap.InfoWindow']
+    })
+
+    // 创建地图实例
+    map = new AMap.Map('amap-container', {
+      zoom: 12,
+      center: [116.397128, 39.916527], // 默认中心点(北京)
+      viewMode: '3D'
+    })
+
+    // 添加景点标记
+    addAttractionMarkers(AMap)
+
+    message.success('地图加载成功')
+  } catch (error) {
+    console.error('地图加载失败:', error)
+    message.error('地图加载失败')
+  }
+}
+
+// 添加景点标记
+const addAttractionMarkers = (AMap: any) => {
+  if (!tripPlan.value) return
+
+  const markers: any[] = []
+  const allAttractions: any[] = []
+
+  // 收集所有景点
+  tripPlan.value.days.forEach((day, dayIndex) => {
+    day.attractions.forEach((attraction, attrIndex) => {
+      if (attraction.location && attraction.location.longitude && attraction.location.latitude) {
+        allAttractions.push({
+          ...attraction,
+          dayIndex,
+          attrIndex
+        })
+      }
+    })
+  })
+
+  // 创建标记
+  allAttractions.forEach((attraction, index) => {
+    const marker = new AMap.Marker({
+      position: [attraction.location.longitude, attraction.location.latitude],
+      title: attraction.name,
+      label: {
+        content: `<div style="background: #4CAF50; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px;">${index + 1}</div>`,
+        offset: new AMap.Pixel(0, -30)
+      }
+    })
+
+    // 创建信息窗口
+    const infoWindow = new AMap.InfoWindow({
+      content: `
+        <div style="padding: 10px;">
+          <h4 style="margin: 0 0 8px 0;">${attraction.name}</h4>
+          <p style="margin: 4px 0;"><strong>地址:</strong> ${attraction.address}</p>
+          <p style="margin: 4px 0;"><strong>游览时长:</strong> ${attraction.visit_duration}分钟</p>
+          <p style="margin: 4px 0;"><strong>描述:</strong> ${attraction.description}</p>
+          <p style="margin: 4px 0; color: #1890ff;"><strong>第${attraction.dayIndex + 1}天 景点${attraction.attrIndex + 1}</strong></p>
+        </div>
+      `,
+      offset: new AMap.Pixel(0, -30)
+    })
+
+    // 点击标记显示信息窗口
+    marker.on('click', () => {
+      infoWindow.open(map, marker.getPosition())
+    })
+
+    markers.push(marker)
+  })
+
+  // 添加标记到地图
+  map.add(markers)
+
+  // 自动调整视野以包含所有标记
+  if (allAttractions.length > 0) {
+    map.setFitView(markers)
+  }
+
+  // 绘制路线
+  drawRoutes(AMap, allAttractions)
+}
+
+// 绘制路线
+const drawRoutes = (AMap: any, attractions: any[]) => {
+  if (attractions.length < 2) return
+
+  // 按天分组绘制路线
+  const dayGroups: any = {}
+  attractions.forEach(attr => {
+    if (!dayGroups[attr.dayIndex]) {
+      dayGroups[attr.dayIndex] = []
+    }
+    dayGroups[attr.dayIndex].push(attr)
+  })
+
+  // 为每天的景点绘制路线
+  Object.values(dayGroups).forEach((dayAttractions: any) => {
+    if (dayAttractions.length < 2) return
+
+    const path = dayAttractions.map((attr: any) => [
+      attr.location.longitude,
+      attr.location.latitude
+    ])
+
+    const polyline = new AMap.Polyline({
+      path: path,
+      strokeColor: '#1890ff',
+      strokeWeight: 4,
+      strokeOpacity: 0.8,
+      strokeStyle: 'solid',
+      showDir: true // 显示方向箭头
+    })
+
+    map.add(polyline)
+  })
+}
+</script>
+
+<style scoped>
+.result-container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+  padding: 40px 20px;
+}
+
+.page-header {
+  max-width: 1200px;
+  margin: 0 auto 30px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  animation: fadeInDown 0.6s ease-out;
+}
+
+.back-button {
+  border-radius: 8px;
+  font-weight: 500;
+}
+
+/* 内容布局 */
+.content-wrapper {
+  max-width: 1400px;
+  margin: 0 auto;
+  display: flex;
+  gap: 24px;
+}
+
+.side-nav {
+  width: 240px;
+  flex-shrink: 0;
+}
+
+.side-nav :deep(.ant-menu) {
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  background: white;
+}
+
+.side-nav :deep(.ant-menu-item) {
+  margin: 4px 8px;
+  border-radius: 8px;
+  transition: all 0.3s ease;
+}
+
+.side-nav :deep(.ant-menu-item-selected) {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+}
+
+.side-nav :deep(.ant-menu-item:hover) {
+  background: rgba(102, 126, 234, 0.1);
+}
+
+.main-content {
+  flex: 1;
+  min-width: 0;
+}
+
+/* 景点图片样式 */
+.attraction-image-wrapper {
+  position: relative;
+  margin-bottom: 12px;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.attraction-image {
+  width: 100%;
+  height: 200px;
+  object-fit: cover;
+  transition: transform 0.3s ease;
+}
+
+.attraction-image-wrapper:hover .attraction-image {
+  transform: scale(1.05);
+}
+
+.attraction-badge {
+  position: absolute;
+  top: 12px;
+  left: 12px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: bold;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.badge-number {
+  font-size: 18px;
+}
+
+.price-tag {
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  background: rgba(255, 77, 79, 0.9);
+  color: white;
+  padding: 4px 12px;
+  border-radius: 12px;
+  font-weight: bold;
+  font-size: 14px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+/* 天气卡片样式 */
+.weather-card {
+  background: linear-gradient(135deg, #e0f7fa 0%, #b2ebf2 100%);
+  border: none !important;
+  transition: all 0.3s ease;
+}
+
+.weather-card:hover {
+  transform: translateY(-4px);
+  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
+}
+
+.weather-date {
+  font-size: 16px;
+  font-weight: bold;
+  color: #00796b;
+  margin-bottom: 12px;
+  text-align: center;
+}
+
+.weather-info-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.weather-icon {
+  font-size: 24px;
+}
+
+.weather-label {
+  font-size: 12px;
+  color: #666;
+}
+
+.weather-value {
+  font-size: 16px;
+  font-weight: 600;
+  color: #00796b;
+}
+
+.weather-wind {
+  margin-top: 8px;
+  padding-top: 8px;
+  border-top: 1px solid rgba(0, 121, 107, 0.2);
+  text-align: center;
+  color: #00796b;
+  font-size: 14px;
+}
+
+/* 回到顶部按钮 */
+.back-top-button {
+  width: 50px;
+  height: 50px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: bold;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.back-top-button:hover {
+  transform: scale(1.1);
+  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
+}
+
+/* 酒店卡片样式 */
+.hotel-card {
+  background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
+  border: none !important;
+}
+
+.hotel-card :deep(.ant-card-head) {
+  background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
+}
+
+.hotel-title {
+  color: white !important;
+  font-weight: 600;
+}
+
+/* 顶部信息区布局 */
+.top-info-section {
+  display: flex;
+  gap: 20px;
+  margin-bottom: 20px;
+}
+
+.left-info {
+  flex: 0 0 400px;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.right-map {
+  flex: 1;
+}
+
+/* 行程概览卡片 */
+.overview-card {
+  height: fit-content;
+}
+
+.overview-content {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.info-item {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.info-label {
+  font-size: 14px;
+  font-weight: 600;
+  color: #666;
+}
+
+.info-value {
+  font-size: 15px;
+  color: #333;
+  line-height: 1.6;
+}
+
+/* 预算卡片 */
+.budget-card {
+  height: fit-content;
+}
+
+.budget-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.budget-item {
+  text-align: center;
+  padding: 12px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  border-radius: 8px;
+  border: 1px solid #e8e8e8;
+}
+
+.budget-label {
+  font-size: 13px;
+  color: #666;
+  margin-bottom: 8px;
+}
+
+.budget-value {
+  font-size: 20px;
+  font-weight: 700;
+  color: #1890ff;
+}
+
+.budget-total {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 8px;
+  color: white;
+}
+
+.total-label {
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.total-value {
+  font-size: 28px;
+  font-weight: 700;
+}
+
+/* 地图卡片 */
+.map-card {
+  height: 100%;
+  min-height: 500px;
+}
+
+.map-card :deep(.ant-card-body) {
+  height: calc(100% - 57px);
+  padding: 0;
+}
+
+/* 每日行程卡片 */
+.days-card {
+  margin-top: 20px;
+}
+
+.day-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+}
+
+.day-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+}
+
+.day-date {
+  font-size: 14px;
+  color: #999;
+}
+
+.day-info {
+  margin-bottom: 20px;
+  padding: 16px;
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  border-radius: 8px;
+  border: 1px solid #e8e8e8;
+}
+
+.info-row {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.info-row:last-child {
+  margin-bottom: 0;
+}
+
+.info-row .label {
+  font-weight: 600;
+  color: #666;
+  min-width: 100px;
+}
+
+.info-row .value {
+  color: #333;
+  flex: 1;
+}
+
+/* 卡片样式优化 */
+:deep(.ant-card) {
+  border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  margin-bottom: 20px;
+  transition: all 0.3s ease;
+  animation: fadeInUp 0.6s ease-out;
+}
+
+:deep(.ant-card:hover) {
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+:deep(.ant-card-head) {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white !important;
+  border-radius: 12px 12px 0 0;
+  font-weight: 600;
+}
+
+:deep(.ant-card-head-title) {
+  color: white !important;
+  font-size: 18px;
+}
+
+:deep(.ant-card-head-title span) {
+  color: white !important;
+}
+
+/* Collapse样式 */
+:deep(.ant-collapse) {
+  border: none;
+  background: transparent;
+}
+
+:deep(.ant-collapse-item) {
+  margin-bottom: 16px;
+  border: 1px solid #e8e8e8;
+  border-radius: 12px;
+  overflow: hidden;
+}
+
+:deep(.ant-collapse-header) {
+  background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+  padding: 16px 20px !important;
+  font-weight: 600;
+}
+
+:deep(.ant-collapse-content) {
+  border-top: 1px solid #e8e8e8;
+}
+
+:deep(.ant-collapse-content-box) {
+  padding: 20px;
+}
+
+/* 统计卡片样式 */
+:deep(.ant-statistic-title) {
+  font-size: 14px;
+  color: #666;
+  margin-bottom: 8px;
+}
+
+:deep(.ant-statistic-content) {
+  font-size: 24px;
+  font-weight: 600;
+  color: #1890ff;
+}
+
+/* 景点卡片样式 */
+:deep(.ant-list-item) {
+  transition: all 0.3s ease;
+}
+
+:deep(.ant-list-item:hover) {
+  transform: scale(1.02);
+}
+
+/* 动画 */
+@keyframes fadeInDown {
+  from {
+    opacity: 0;
+    transform: translateY(-20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .result-container {
+    padding: 20px 10px;
+  }
+
+  .page-header {
+    flex-direction: column;
+    gap: 16px;
+  }
+}
+</style>
+

+ 32 - 0
code/chapter13/helloagents-trip-planner/frontend/tsconfig.json

@@ -0,0 +1,32 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "preserve",
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedSideEffectImports": true,
+
+    /* Path mapping */
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}
+

+ 23 - 0
code/chapter13/helloagents-trip-planner/frontend/vite.config.ts

@@ -0,0 +1,23 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, 'src')
+    }
+  },
+  server: {
+    port: 5173,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:8000',
+        changeOrigin: true
+      }
+    }
+  }
+})
+

+ 2 - 2
docs/README.md

@@ -58,7 +58,7 @@
 | [第十一章 Agentic-RL](./chapter11/第十一章%20Agentic-RL.md) | 基于LLM的智能体强化学习 | 🚧 |
 | [第十二章 智能体性能评估](./chapter12/第十二章%20智能体性能评估.md) | 核心指标、基准测试与评估框架 | ✅ |
 | <strong>第四部分:综合案例进阶</strong> |  |  |
-| [第十三章 智能旅行助手](./chapter13/第十三章%20智能旅行助手.md) | RAG与多智能体协作的真实世界应用 | 🚧 |
+| [第十三章 智能旅行助手](./chapter13/第十三章%20智能旅行助手.md) | MCP与多智能体协作的真实世界应用 | ✅ |
 | [第十四章 自动化深度研究智能体](./chapter14/第十四章%20自动化深度研究智能体.md) | DeepResearch Agent 复现与解析 | 🚧 |
 | [第十五章 构建赛博小镇](./chapter15/第十五章%20构建赛博小镇.md) | Agent 与游戏的结合,模拟社会动态 | 🚧 |
 | <strong>第五部分:毕业设计及未来展望</strong> |  |  |
@@ -113,7 +113,7 @@
 - [陈思州-项目负责人](https://github.com/jjyaoao) (Datawhale成员)
 - [孙韬-项目负责人](https://github.com/fengju0213) (Datawhale成员)  
 - [姜舒凡-项目负责人](https://github.com/Tsumugii24)(Datawhale成员)
-- [Jason-Datawhale意向成员](https://github.com/HeteroCat) (第五章Coze\Dify\FastGPT内容贡献者, Agent开发工程师)
+- [Jason-Datawhale意向成员](https://github.com/HeteroCat) (Agent开发工程师, 第五章内容贡献者)
 
 ### 特别感谢
 - 感谢 [@Sm1les](https://github.com/Sm1les) 对本项目的帮助与支持

+ 1577 - 1
docs/chapter13/第十三章 智能旅行助手.md

@@ -1,3 +1,1579 @@
 # 第十三章 智能旅行助手
 
-本章内容待补充...
+在前面的章节中,我们从零开始构建了HelloAgents框架,实现了多种智能体范式、工具系统、记忆机制、协议通信和性能评估等核心功能。从本章开始,我们将进入一个全新的阶段:**将所学知识融会贯通,构建完整的实用应用。**
+
+还记得在第一章中,我们构建的第一个智能体吗?那是一个简单的智能旅行助手,展示了`Thought-Action-Observation`循环的基本原理。本章的智能旅行助手将是一个完整的项目,包含以下核心功能:
+
+**(1)智能行程规划**:用户输入目的地、日期、偏好等信息,系统自动生成包含景点、餐饮、酒店的完整行程计划。
+
+**(2)地图可视化**:在地图上标注景点位置、绘制游览路线,让行程一目了然。
+
+**(3)预算计算**:自动计算门票、酒店、餐饮、交通费用,显示预算明细。
+
+**(4)行程编辑**:支持添加、删除、调整景点,实时更新地图。
+
+**(5)导出功能**:支持导出为PDF或图片,方便保存和分享。
+
+
+
+## 13.1 项目概述与架构设计
+
+### 13.1.1 为什么需要智能旅行助手
+
+规划一次旅行是一件既令人兴奋又令人头疼的事情。你需要在网上搜索景点信息,对比不同的攻略,查看天气预报,预订酒店,计算预算,规划路线。这个过程可能需要花费几个小时甚至几天的时间。而且即使花了这么多时间,你也不确定规划的行程是否合理,是否遗漏了什么重要的景点,预算是否准确。
+
+传统的旅行规划方式有几个痛点。首先是**信息分散**。景点信息在旅游网站上,天气信息在天气网站上,酒店信息在预订网站上,你需要在多个网站之间切换,手动整合这些信息。其次是**缺少个性化**。大部分攻略都是通用的,不考虑你的个人偏好、预算限制、出行时间等因素。最后是**难以调整**。当你想修改行程时,可能需要重新规划整个行程,因为景点的顺序、时间安排、预算都是相互关联的。
+
+AI技术为解决这些问题提供了新的可能。想象一下,你只需要告诉系统"我想去北京玩3天,喜欢历史文化,预算中等",系统就能自动为你生成一个完整的行程计划,包括每天去哪些景点、在哪里吃饭、住哪个酒店、需要多少预算。而且这个计划是可以调整的,你可以删除不喜欢的景点,调整游览顺序,系统会自动更新地图和预算。
+
+这就是我们要构建的智能旅行助手。它不仅仅是一个技术演示,而是一个真正有用的应用。通过这个项目,你会学到如何将AI技术应用到实际问题中,如何设计多智能体系统,如何构建完整的Web应用。
+
+### 13.1.2 技术架构概览
+
+系统采用经典的**前后端分离架构**,分为四个层次,如图13.1所示:
+
+<div align="center">
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-1.png" alt="" width="85%"/>
+  <p>图 13.1 智能旅行助手技术架构</p>
+</div>
+
+**(1)前端层 (Vue3+TypeScript)**:负责用户交互和数据展示,包括表单输入、结果展示、地图可视化。
+
+**(2)后端层 (FastAPI)**:负责API路由、数据验证、业务逻辑。
+
+**(3)智能体层 (HelloAgents)**:负责任务分解、工具调用、结果整合。包含4个专门的Agent。
+
+**(4)外部服务层**:提供数据和能力,包括高德地图API、Unsplash API、OpenAI API。
+
+数据流转过程如下:用户在前端填写表单 → 后端验证数据 → 调用智能体系统 → 智能体依次调用景点搜索、天气查询、酒店推荐、行程规划Agent → 每个Agent通过MCP协议调用外部API → 整合结果返回前端 → 前端渲染展示。
+
+项目的结构参考如下,提供便于定位源码:
+```
+helloagents-trip-planner/
+├── backend/                    # 后端代码
+│   ├── app/
+│   │   ├── agents/            # 智能体实现
+│   │   ├── api/               # API路由
+│   │   ├── models/            # 数据模型
+│   │   ├── services/          # 服务层
+│   │   └── config.py          # 配置文件
+│   └── requirements.txt       # Python依赖
+│
+└── frontend/                   # 前端代码
+    ├── src/
+    │   ├── views/             # 页面组件
+    │   ├── services/          # API服务
+    │   ├── types/             # 类型定义
+    │   └── router/            # 路由配置
+    └── package.json           # npm依赖
+```
+
+详细的架构设计和数据流转将在后续章节中介绍。
+
+### 13.1.3 快速体验:5分钟运行项目
+
+在深入学习实现细节之前,让我们先把项目跑起来,看看最终的效果。这样你会对整个系统有一个直观的认识。
+
+**环境要求:**
+
+- Python 3.10或更高版本
+- Node.js 16.0或更高版本
+- npm 8.0或更高版本
+
+**获取API密钥:**
+
+你需要准备以下API密钥:
+
+- LLM的API(OpenAI、DeepSeek等)
+- 高德地图Web服务Key:访问 https://console.amap.com/ 注册并创建应用
+- Unsplash Access Key:访问 https://unsplash.com/developers 注册并创建应用
+
+将所有API密钥放入`.env`文件。
+
+启动后端:
+
+```bash
+# 1. 进入后端目录
+cd helloagents-trip-planner/backend
+
+# 2. 安装依赖
+pip install -r requirements.txt
+
+# 3. 配置环境变量
+cp .env.example .env
+# 编辑.env文件,填入你的API密钥
+
+# 4. 启动后端服务
+uvicorn app.api.main:app --reload
+# 或者
+python run.py
+```
+
+成功启动后,访问 http://localhost:8000/docs 可以看到API文档。
+
+打开新的终端窗口:
+
+```bash
+# 1. 进入前端目录
+cd helloagents-trip-planner/frontend
+
+# 2. 安装依赖
+npm install
+
+# 3. 启动前端服务
+npm run dev
+```
+
+成功启动后,访问 http://localhost:5173 即可使用应用。
+
+体验核心功能:
+
+首先需在首页表单中填写目的地城市、旅行日期、偏好、预算、交通及住宿类型等信息。点击“开始规划”按钮后,系统会显示加载进度条,并很快生成结果页面,如图13.2所示。
+
+<div align="center">
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-2.png" alt="" width="85%"/>
+  <p>图 13.2 旅行助手规划进行页面</p>
+</div>
+
+随后加载成功,该页面会清晰展示行程概览、预算明细、景点地图、每日行程详情和天气信息,如图13.3,13.4所示。
+
+<div align="center">
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-3.png" alt="" width="85%"/>
+  <p>图 13.3 旅行助手规划完成页面</p>
+</div>
+
+<div align="center">
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-4.png" alt="" width="85%"/>
+  <p>图 13.4 旅行助手规划完成页面</p>
+</div>
+
+如果用户需要个性化调整,可以点击“编辑行程”按钮,自由调整景点顺序或删除某个景点,如图13.5所示。规划完成后,通过“导出行程”下拉菜单,即可将最终方案轻松保存为图片或PDF文件,方便随时查阅。
+
+<div align="center">
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-5.png" alt="" width="85%"/>
+  <p>图 13.5 旅行助手规划完成页面</p>
+</div>
+
+## 13.2 数据模型设计
+
+### 13.2.1 Web应用中的数据流转
+
+在构建智能旅行助手时,我们需要解决一个核心问题:**如何表示和传递旅行计划数据?** 
+
+我们需要理解一个完整的Web应用中数据是如何流转的。想象一下,当用户在浏览器中点击"开始规划"按钮时,会发生什么?
+
+用户在前端填写的表单数据(目的地、日期、预算等)需要通过HTTP请求发送到后端服务器。后端接收到数据后,会调用智能体系统进行处理。智能体又会调用高德地图API、Unsplash API等外部服务获取数据。这些外部API返回的数据格式各不相同,有的用`lng`,有的用`lon`,有的用`longitude`。最后,后端需要将处理好的数据返回给前端,前端再渲染成用户看到的页面。
+
+在这个过程中,数据经历了多次转换:前端表单 → HTTP请求 → 后端Python对象 → 外部API响应 → 后端Python对象 → HTTP响应 → 前端TypeScript对象 → 页面展示。如果没有统一的数据格式,每一步转换都可能出错。这就是为什么我们需要**数据模型**。
+
+### 13.2.2 从字典到Pydantic模型
+
+让我们从第一章的简单原型开始。在那个原型中,我们使用Python字典来表示景点数据:
+
+```python
+# 第一章的做法:使用字典
+attraction = {
+    "name": "故宫",
+    "location": {"lng": 116.397128,"lat": 39.916527},
+    "price": 60
+}
+
+# 访问数据
+lng = attraction["location"]["lng"]
+```
+
+这种方式在原型阶段很方便,但在实际项目中会遇到很多问题。首先是**字段名不统一**的问题。高德地图API返回的位置数据是`"116.397128,39.916527"`这样的字符串,需要手动分割成经纬度。而Unsplash API可能使用`longitude`和`latitude`。如果我们在代码中到处都用字典,就需要在每个地方都处理这些差异。
+
+其次是**类型安全**的问题。假设我们不小心把`price`写成了字符串`"60"`,在Python中这不会立即报错,但在计算总预算时就会出问题。更糟糕的是,这种错误只能在运行时才能发现,而且错误信息可能很难定位。
+
+最后是**维护性**的问题。当我们需要给景点添加新字段(比如`rating`评分)时,需要在代码的多个地方修改。如果遗漏了某个地方,就会导致数据不一致。
+
+Pydantic提供了一个解决方案。它是Python的数据验证库,可以让我们用类来定义数据结构,并自动处理验证、转换和序列化。让我们看一个简单的例子:
+
+```python
+from pydantic import BaseModel,Field
+
+class Location(BaseModel):
+    longitude: float = Field(...,description="经度")
+    latitude: float = Field(...,description="纬度")
+
+class Attraction(BaseModel):
+    name: str
+    location: Location
+    ticket_price: int = 0
+
+# 创建对象
+attraction = Attraction(
+    name="故宫",
+    location=Location(longitude=116.397128,latitude=39.916527),
+    ticket_price=60
+)
+
+# 类型安全的访问
+lng = attraction.location.longitude  # IDE会提供代码补全
+```
+
+这样做有几个好处。首先,如果我们传入了错误的类型(比如把`ticket_price`设为字符串),Pydantic会立即抛出异常,告诉我们哪里出错了。其次,IDE可以根据类型定义提供代码补全和类型检查,大大减少了拼写错误。最后,当我们需要修改数据结构时,只需要修改类定义,所有使用这个类的地方都会自动更新。
+
+### 13.2.3 Pydantic的核心概念
+
+在深入设计我们的数据模型之前,让我们先了解Pydantic的几个核心概念。Pydantic的基础是`BaseModel`类,所有的数据模型都需要继承这个类。每个字段都可以指定类型,Pydantic会自动进行类型检查和转换。
+
+字段定义使用`Field`函数,它可以指定默认值、描述、验证规则等。`...`表示这个字段是必填的,如果创建对象时没有提供这个字段,Pydantic会抛出异常。我们也可以使用`Optional`来表示可选字段,或者直接提供默认值。
+
+```python
+from pydantic import BaseModel,Field
+from typing import Optional,List
+
+class Attraction(BaseModel):
+    name: str = Field(...,description="景点名称")  # 必填
+    rating: float = Field(default=0.0,ge=0,le=5)  # 默认值,范围验证
+    visit_duration: int = Field(default=60,gt=0)  # 大于0
+    description: Optional[str] = None  # 可选字段
+```
+
+Pydantic还支持嵌套模型和列表。我们可以在一个模型中使用另一个模型作为字段类型,这样就可以构建复杂的数据结构。比如,一个景点包含位置信息,一个行程包含多个景点。
+
+```python
+class DayPlan(BaseModel):
+    date: str
+    attractions: List[Attraction]  # 景点列表
+    hotel: Optional[Hotel] = None  # 可选的酒店信息
+```
+
+最强大的功能之一是**自定义验证器**。有时候外部API返回的数据格式不符合我们的要求,我们可以使用`field_validator`装饰器来自定义验证和转换逻辑。比如,高德地图返回的温度是`"16°C"`这样的字符串,我们需要把它转换成数字:
+
+```python
+from pydantic import field_validator
+
+class WeatherInfo(BaseModel):
+    temperature: int
+    
+    @field_validator('temperature',mode='before')
+    def parse_temperature(cls,v):
+        """解析温度字符串:"16°C" -> 16"""
+        if isinstance(v,str):
+            v = v.replace('°C','').replace('℃','').strip()
+            return int(v)
+        return v
+```
+
+这个验证器会在创建对象之前自动执行,将字符串转换成整数。这样我们就不需要在代码的每个地方都手动处理温度格式了。
+
+### 13.2.4 自底向上的模型设计
+
+现在让我们开始设计智能旅行助手的数据模型。一个好的设计原则是**自底向上**:先定义最基础的模型,然后逐步组合成复杂的结构。这样做的好处是每个模型都很简单,容易理解和维护。
+
+最基础的模型是**位置信息**。无论是景点、酒店还是餐厅,都需要位置信息。我们定义一个`Location`类来表示经纬度坐标:
+
+```python
+class Location(BaseModel):
+    """位置信息(经纬度坐标)"""
+    longitude: float = Field(...,description="经度",ge=-180,le=180)
+    latitude: float = Field(...,description="纬度",ge=-90,le=90)
+```
+
+这里我们使用了范围验证(`ge`表示大于等于,`le`表示小于等于),确保经纬度的值在合理范围内。
+
+接下来是**景点信息**。一个景点包含名称、地址、位置、游览时间、描述、评分、图片和门票价格等信息。注意我们使用了`Location`作为字段类型,这就是嵌套模型:
+
+```python
+class Attraction(BaseModel):
+    """景点信息"""
+    name: str = Field(...,description="景点名称")
+    address: str = Field(...,description="地址")
+    location: Location = Field(...,description="经纬度坐标")
+    visit_duration: int = Field(...,description="建议游览时间(分钟)",gt=0)
+    description: str = Field(...,description="景点描述")
+    category: Optional[str] = Field(default="景点",description="景点类别")
+    rating: Optional[float] = Field(default=None,ge=0,le=5,description="评分")
+    image_url: Optional[str] = Field(default=None,description="图片URL")
+    ticket_price: int = Field(default=0,ge=0,description="门票价格(元)")
+```
+
+类似地,我们定义**餐饮信息**和**酒店信息**。这些模型的结构都很相似,都包含名称、地址、位置和费用等基本信息:
+
+```python
+class Meal(BaseModel):
+    """餐饮信息"""
+    type: str = Field(...,description="餐饮类型:breakfast/lunch/dinner/snack")
+    name: str = Field(...,description="餐饮名称")
+    address: Optional[str] = Field(default=None,description="地址")
+    location: Optional[Location] = Field(default=None,description="经纬度坐标")
+    description: Optional[str] = Field(default=None,description="描述")
+    estimated_cost: int = Field(default=0,description="预估费用(元)")
+
+class Hotel(BaseModel):
+    """酒店信息"""
+    name: str = Field(...,description="酒店名称")
+    address: str = Field(default="",description="酒店地址")
+    location: Optional[Location] = Field(default=None,description="酒店位置")
+    price_range: str = Field(default="",description="价格范围")
+    rating: str = Field(default="",description="评分")
+    distance: str = Field(default="",description="距离景点距离")
+    type: str = Field(default="",description="酒店类型")
+    estimated_cost: int = Field(default=0,description="预估费用(元/晚)")
+```
+
+**预算信息**是一个特殊的模型,它不包含位置信息,而是包含各项费用的汇总:
+
+```python
+class Budget(BaseModel):
+    """预算信息"""
+    total_attractions: int = Field(default=0,description="景点门票总费用")
+    total_hotels: int = Field(default=0,description="酒店总费用")
+    total_meals: int = Field(default=0,description="餐饮总费用")
+    total_transportation: int = Field(default=0,description="交通总费用")
+    total: int = Field(default=0,description="总费用")
+```
+
+现在我们可以组合这些基础模型,构建**单日行程**。一个单日行程包含日期、描述、交通方式、住宿安排、酒店、景点列表和餐饮列表:
+
+```python
+class DayPlan(BaseModel):
+    """单日行程"""
+    date: str = Field(...,description="日期")
+    day_index: int = Field(...,description="第几天(从0开始)")
+    description: str = Field(...,description="当日行程描述")
+    transportation: str = Field(...,description="交通方式")
+    accommodation: str = Field(...,description="住宿安排")
+    hotel: Optional[Hotel] = Field(default=None,description="酒店信息")
+    attractions: List[Attraction] = Field(default_factory=list,description="景点列表")
+    meals: List[Meal] = Field(default_factory=list,description="餐饮安排")
+```
+
+注意这里使用了`List[Attraction]`来表示景点列表,`default_factory=list`表示默认值是一个空列表。
+
+**天气信息**需要特殊处理,因为高德地图返回的温度格式不规范。我们使用自定义验证器来处理:
+
+```python
+class WeatherInfo(BaseModel):
+    """天气信息"""
+    date: str = Field(...,description="日期")
+    day_weather: str = Field(...,description="白天天气")
+    night_weather: str = Field(...,description="夜间天气")
+    day_temp: int = Field(...,description="白天温度(摄氏度)")
+    night_temp: int = Field(...,description="夜间温度(摄氏度)")
+    wind_direction: str = Field(...,description="风向")
+    wind_power: str = Field(...,description="风力")
+    
+    @field_validator('day_temp','night_temp',mode='before')
+    def parse_temperature(cls,v):
+        """解析温度字符串:"16°C" -> 16"""
+        if isinstance(v,str):
+            v = v.replace('°C','').replace('℃','').replace('°','').strip()
+            try:
+                return int(v)
+            except ValueError:
+                return 0  # 容错处理
+        return v
+```
+
+最后,我们定义**完整的旅行计划**。这是最顶层的模型,包含了所有的信息:
+
+```python
+class TripPlan(BaseModel):
+    """旅行计划"""
+    city: str = Field(...,description="目的地城市")
+    start_date: str = Field(...,description="开始日期")
+    end_date: str = Field(...,description="结束日期")
+    days: List[DayPlan] = Field(default_factory=list,description="每日行程")
+    weather_info: List[WeatherInfo] = Field(default_factory=list,description="天气信息")
+    overall_suggestions: str = Field(...,description="总体建议")
+    budget: Optional[Budget] = Field(default=None,description="预算信息")
+```
+
+这样,我们就完成了整个数据模型的设计。从最基础的`Location`,到`Attraction`、`Meal`、`Hotel`,再到`DayPlan`,最后到`TripPlan`,形成了一个清晰的层次结构。
+
+### 13.2.5 数据模型在Web应用中的应用
+
+现在让我们看看这些数据模型如何在实际的Web应用中使用。在FastAPI中,Pydantic模型可以直接用作请求和响应的类型定义。FastAPI会自动进行数据验证、序列化和文档生成。
+
+```python
+from fastapi import FastAPI
+from app.models.schemas import TripPlanRequest,TripPlan
+
+app = FastAPI()
+
+@app.post("/api/trip/plan",response_model=TripPlan)
+async def create_trip_plan(request: TripPlanRequest) -> TripPlan:
+    """
+    创建旅行计划
+    
+    FastAPI自动:
+    1. 验证请求数据(TripPlanRequest)
+    2. 验证响应数据(TripPlan)
+    3. 生成OpenAPI文档
+    """
+    trip_plan = await generate_trip_plan(request)
+    return trip_plan
+```
+
+当用户发送POST请求到`/api/trip/plan`时,FastAPI会自动将JSON数据转换成`TripPlanRequest`对象。如果数据格式不正确(比如缺少必填字段,或者类型不匹配),FastAPI会自动返回400错误,并告诉用户哪里出错了。
+
+在前端,我们也需要定义对应的TypeScript类型。虽然TypeScript和Python是不同的语言,但数据结构是一样的:
+
+```typescript
+interface Location {
+  longitude: number;
+  latitude: number;
+}
+
+interface Attraction {
+  name: string;
+  address: string;
+  location: Location;
+  visit_duration: number;
+  ticket_price: number;
+}
+
+interface TripPlan {
+  city: string;
+  start_date: string;
+  end_date: string;
+  days: DayPlan[];
+}
+```
+
+这样,前后端就使用了统一的数据格式。当后端返回`TripPlan`对象时,前端可以直接使用,不需要任何转换。TypeScript的类型检查也能帮助我们避免很多错误。
+
+## 13.3 多智能体协作设计
+
+### 13.3.1 为何需要多智能体
+
+在第七章中,我们学习了如何使用SimpleAgent来构建智能体。SimpleAgent的设计理念是简单直接:每次调用`run()`方法时,Agent会分析用户的问题,决定是否需要调用工具,然后返回结果。这种设计在处理简单任务时非常有效,但当面对旅行规划这样的任务时,就会遇到一些问题。
+
+如果用单个Agent来完成旅行规划。这个Agent需要做什么呢?首先,它要搜索景点信息,这需要调用高德地图的POI搜索工具。然后,它要查询天气信息,这需要调用天气查询工具。接着,它要搜索酒店信息,这又需要调用POI搜索工具。最后,它要把所有这些信息整合起来,生成一个完整的旅行计划。
+
+这听起来很简单,但实际操作时会遇到第一个问题:**工具调用的限制**。SimpleAgent每次`run()`调用只能执行一个工具。这意味着我们需要多次调用`run()`方法,每次调用处理一个任务。但这样做会带来一个新问题:如何在多次调用之间传递信息?第一次调用得到的景点信息,如何传递给第二次调用?我们需要手动管理这些中间结果,代码会变得很复杂。
+
+当然,我们可以使用ReactAgent来解决这个问题。ReactAgent可以在一次调用中执行多个工具,它会自动进行多轮思考和行动。但这又带来了新的问题:**时间成本**。ReactAgent的每一轮思考都需要调用LLM,如果需要调用三个工具,就需要至少三轮思考,这意味着至少三次LLM调用。而且这些调用是串行的,必须等前一个完成才能开始下一个,总时间会很长。
+
+第二个问题是**提示词的复杂度**。如果我们要让一个Agent完成所有任务,就需要在提示词中详细描述每个任务的执行逻辑。比如:
+
+```python
+COMPLEX_PROMPT = """你是旅行规划助手。你需要:
+1. 使用maps_text_search搜索景点,关键词根据用户偏好确定
+2. 使用maps_weather查询天气,获取未来几天的天气预报
+3. 使用maps_text_search搜索酒店,类型根据用户需求确定
+4. 整合所有信息生成旅行计划,包括每天的景点、餐饮、住宿安排
+注意:必须按顺序执行,每个工具只能调用一次,输出必须是JSON格式...
+"""
+```
+
+这样的提示词有几个问题。首先是**难以维护**。如果我们想修改景点搜索的逻辑(比如增加评分筛选),就需要修改整个提示词,很容易影响到其他部分。其次是**容易出错**。LLM需要同时理解多个任务的要求,很容易搞混不同任务的格式和参数。最后是**难以调试**。当生成的计划不符合预期时,我们很难知道是哪个环节出了问题,是景点搜索不准确,还是天气查询失败,还是整合逻辑有问题?
+
+面对这些问题,一个自然的想法是:能不能把复杂的任务分解成多个简单的任务,让不同的Agent各司其职?这就是多Agent协作的核心思想。
+
+想象一下现实世界中的旅行社。当你去旅行社咨询旅行计划时,不会只有一个人为你服务。通常会有专门的景点顾问,负责推荐景点;有酒店顾问,负责预订酒店;还有行程规划师,负责把所有信息整合成完整的行程。每个人都专注于自己擅长的领域,最后由行程规划师把所有信息汇总。这种分工协作的方式,比让一个人做所有事情要高效得多。
+
+### 13.3.2 Agent角色设计
+
+基于任务分解原则,我们设计了四个专门的Agent,如图13.6所示:
+
+<div align="center">
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-6.png" alt="" width="85%"/>
+  <p>图 13.6 多智能体协作流程</p>
+</div>
+
+- **AttractionSearchAgent(景点搜索专家)**专注于搜索景点信息。它只需要理解用户的偏好(比如"历史文化"、"自然风光"),然后调用高德地图的POI搜索工具,返回相关的景点列表。它的提示词很简单,只需要说明如何根据偏好选择关键词,如何调用工具。
+
+- **WeatherQueryAgent(天气查询专家)**专注于查询天气信息。它只需要知道城市名称,然后调用天气查询工具,返回未来几天的天气预报。它的任务非常明确,几乎不会出错。
+
+- **HotelAgent(酒店推荐专家)**专注于搜索酒店信息。它需要理解用户的住宿需求(比如"经济型"、"豪华型"),然后调用POI搜索工具,返回符合要求的酒店列表。
+
+- **PlannerAgent(行程规划专家)**负责整合所有信息。它接收前三个Agent的输出,加上用户的原始需求(日期、预算等),然后生成完整的旅行计划。它不需要调用任何外部工具,只需要专注于信息的整合和行程的安排。
+
+现在让我们详细设计每个Agent的角色和提示词。设计提示词时,我们需要考虑几个关键问题:这个Agent需要什么输入?它应该产生什么输出?它需要调用什么工具?它可能遇到什么问题?
+
+**AttractionSearchAgent**的任务是根据用户偏好搜索景点。它的输入是城市名称和用户偏好(比如"历史文化"、"自然风光")。它需要调用`amap_maps_text_search`工具,参数是关键词和城市。它的输出是景点列表,包含名称、地址、评分等信息。
+
+```python
+ATTRACTION_AGENT_PROMPT = """你是景点搜索专家。
+
+**工具调用格式:**
+`[TOOL_CALL:amap_maps_text_search:keywords=景点,city=城市名]`
+
+**示例:**
+- `[TOOL_CALL:amap_maps_text_search:keywords=景点,city=北京]`
+- `[TOOL_CALL:amap_maps_text_search:keywords=博物馆,city=上海]`
+
+**重要:**
+- 必须使用工具搜索,不要编造信息
+- 根据用户偏好({preferences})搜索{city}的景点
+"""
+```
+
+这个提示词很简洁,但包含了所有必要的信息。它明确说明了工具调用的格式,提供了具体的示例,还强调了两个重要原则:必须使用工具(不能编造),要根据用户偏好搜索。
+
+**WeatherQueryAgent**的任务更简单,只需要查询天气。它的输入是城市名称,输出是天气信息。
+
+```python
+WEATHER_AGENT_PROMPT = """你是天气查询专家。
+
+**工具调用格式:**
+`[TOOL_CALL:amap_maps_weather:city=城市名]`
+
+请查询{city}的天气信息。
+"""
+```
+
+**HotelAgent**的任务是搜索酒店。它的输入是城市名称和住宿类型,输出是酒店列表。
+
+```python
+HOTEL_AGENT_PROMPT = """你是酒店推荐专家。
+
+**工具调用格式:**
+`[TOOL_CALL:amap_maps_text_search:keywords=酒店,city=城市名]`
+
+请搜索{city}的{accommodation}酒店。
+"""
+```
+
+**PlannerAgent**是最复杂的,因为它需要整合所有信息。它的输入是用户需求和前三个Agent的输出,输出是完整的旅行计划(JSON格式)。
+
+```python
+PLANNER_AGENT_PROMPT = """你是行程规划专家。
+
+**输出格式:**
+严格按照以下JSON格式返回:
+{
+  "city": "城市名称",
+  "start_date": "YYYY-MM-DD",
+  "end_date": "YYYY-MM-DD",
+  "days": [...],
+  "weather_info": [...],
+  "overall_suggestions": "总体建议",
+  "budget": {...}
+}
+
+**规划要求:**
+1. weather_info必须包含每天的天气
+2. 温度为纯数字(不带°C)
+3. 每天安排2-3个景点
+4. 考虑景点距离和游览时间
+5. 包含早中晚三餐
+6. 提供实用建议
+7. 包含预算信息
+"""
+```
+
+### 13.3.3 Agent协作流程
+
+现在让我们看看这四个Agent如何协作完成旅行规划任务。整个流程可以分为五个步骤:
+
+```python
+class TripPlannerAgent:
+    def __init__(self):
+        self.attraction_agent = SimpleAgent(name="景点搜索"prompt=ATTRACTION_PROMPT)
+        self.weather_agent = SimpleAgent(name="天气查询", prompt=WEATHER_PROMPT)
+        self.hotel_agent = SimpleAgent(name="酒店推荐", prompt=HOTEL_PROMPT)
+        self.planner_agent = SimpleAgent(name="行程规划", prompt=PLANNER_PROMPT)
+
+    def plan_trip(self, request: TripPlanRequest) -> TripPlan:
+        # 步骤1: 景点搜索
+        attraction_response = self.attraction_agent.run(
+            f"请搜索{request.city}的{request.preferences}景点"
+        )
+
+        # 步骤2: 天气查询
+        weather_response = self.weather_agent.run(
+            f"请查询{request.city}的天气"
+        )
+
+        # 步骤3: 酒店推荐
+        hotel_response = self.hotel_agent.run(
+            f"请搜索{request.city}的{request.accommodation}酒店"
+        )
+
+        # 步骤4: 整合生成计划
+        planner_query = self._build_planner_query(
+            request, attraction_response, weather_response, hotel_response
+        )
+        planner_response = self.planner_agent.run(planner_query)
+
+        # 步骤5: 解析JSON
+        trip_plan = self._parse_trip_plan(planner_response)
+        return trip_plan
+```
+
+这个流程顺序执行四个步骤,每个步骤的输出作为下一个步骤的输入。注意我们使用了`TripPlanRequest`和`TripPlan`这两个Pydantic模型,这是在13.2节中定义的。
+
+### 13.3.4 查询构建
+
+PlannerAgent需要整合所有信息,这个查询需要包含所有必要的信息,而且要组织得清晰有序,让LLM能够准确理解。
+
+```python
+def _build_planner_query(
+    self,
+    request: TripPlanRequest,
+    attraction_response: str,
+    weather_response: str,
+    hotel_response: str
+) -> str:
+    """构建规划Agent的查询"""
+    return f"""
+请根据以下信息生成{request.city}的{request.days}日旅行计划:
+
+**用户需求:**
+- 目的地: {request.city}
+- 日期: {request.start_date} 至 {request.end_date}
+- 天数: {request.days}天
+- 偏好: {request.preferences}
+- 预算: {request.budget}
+- 交通方式: {request.transportation}
+- 住宿类型: {request.accommodation}
+
+**景点信息:**
+{attraction_response}
+
+**天气信息:**
+{weather_response}
+
+**酒店信息:**
+{hotel_response}
+
+请生成详细的旅行计划,包括每天的景点安排、餐饮推荐、住宿信息和预算明细。
+"""
+```
+
+通过这种多Agent协作的设计,我们把一个复杂的旅行规划任务分解成了四个简单的子任务。每个Agent都专注于自己擅长的领域,也为未来的功能扩展(比如添加餐厅推荐Agent、交通规划Agent)打下了良好的基础。
+
+## 13.4 MCP工具集成详解
+
+### 13.4.1 为什么不直接调用API
+
+在13.3节中,我们设计了四个Agent来协作完成旅行规划任务。其中AttractionSearchAgent、WeatherQueryAgent和HotelAgent都需要调用高德地图的API来获取数据。一个自然的问题是:为什么不直接在Agent中调用高德地图的HTTP API?
+
+让我们先看看直接调用API会是什么样子。高德地图提供了POI搜索API,我们需要构造HTTP请求,传递参数,解析响应:
+
+```python
+import requests
+
+def search_poi(keywords: str,city: str,api_key: str):
+    """直接调用高德地图POI搜索API"""
+    url = "https://restapi.amap.com/v3/place/text"
+    params = {
+        "keywords": keywords,
+        "city": city,
+        "key": api_key,
+        "output": "json"
+    }
+    response = requests.get(url,params=params)
+    data = response.json()
+    return data
+```
+
+这种方式看起来很简单,但在实际使用中会遇到几个问题。首先是**Agent无法自主调用**。在我们的HelloAgents框架中,Agent通过识别提示词中的工具调用标记(比如`[TOOL_CALL:tool_name:arg1=value1]`)来调用工具。如果我们直接在代码中调用API,Agent就失去了自主决策的能力,变成了一个简单的函数调用。
+
+其次是**参数传递复杂**。高德地图的API有很多参数,比如POI搜索有`keywords`、`city`、`types`、`offset`、`page`等十几个参数。如果我们要让Agent能够灵活使用这些参数,就需要在提示词中详细说明每个参数的含义和格式,这会让提示词变得非常复杂。
+
+第三是**响应解析困难**。高德地图API返回的是JSON格式的数据,结构比较复杂。我们需要编写代码来解析这些数据,提取我们需要的字段。如果API的响应格式发生变化,我们就需要修改解析代码。
+
+最后是**工具管理混乱**。高德地图提供了十几个不同的API(POI搜索、天气查询、路线规划等),如果我们为每个API都编写一个函数,然后手动注册到Agent的工具列表中,代码会变得很冗长。而且当我们想添加新的API时,需要修改多个地方。
+
+### 13.4.2 高德地图MCP集成
+
+MCP(Model Context Protocol)是Anthropic提出的标准化协议,用于连接LLM和外部工具。本节将介绍如何在项目中集成高德地图MCP服务器。我们的项目用的是`amap-mcp-server`,这是一个用Node.js实现的MCP服务器:
+
+<div align="center">
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-7.png" alt="" width="85%"/>
+  <p>图 13.7 amap-mcp-server工具</p>
+</div>
+
+高德地图MCP服务器提供了16个工具,主要分为以下类别,如表13.1所示:
+
+<div align="center">
+  <p>表 13.1 高德地图MCP工具分类</p>
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-table-1.png" alt="" width="85%"/>
+</div>
+
+通过MCP协议,我们可以很方便地在HelloAgents中集成:
+
+```python
+from hello_agents.tools import MCPTool
+from app.config import get_settings
+
+settings = get_settings()
+
+# 创建MCP工具
+mcp_tool = MCPTool(
+    name="amap_mcp",
+    command="npx",
+    args=["-y", "@sugarforever/amap-mcp-server"],
+    env={"AMAP_API_KEY": settings.amap_api_key},
+    auto_expand=True
+)
+```
+
+这段代码做了什么呢?首先,`command`和`args`指定了如何启动MCP服务器。`npx -y @sugarforever/amap-mcp-server`会从npm仓库下载并运行`amap-mcp-server`这个包。`env`参数传递了环境变量,这里我们传递了高德地图的API密钥。
+
+当我们创建`MCPTool`对象时,它会在后台启动MCP服务器进程,并通过标准输入输出(stdin/stdout)与服务器通信。这是MCP协议的一个特点:使用进程间通信而不是HTTP,这样更高效,也更容易管理。
+
+最关键的是`auto_expand=True`这个参数。当设置为True时,`MCPTool`会自动查询MCP服务器提供了哪些工具,然后为每个工具创建一个独立的Tool对象。这就是为什么我们只创建了一个`MCPTool`,但Agent却获得了16个工具。让我们看看这个过程:
+
+```python
+# 创建一个MCPTool
+mcp_tool = MCPTool(..., auto_expand=True)
+agent.add_tool(mcp_tool)
+
+# Agent实际上获得了16个工具!
+print(list(agent.tools.keys()))
+# ['amap_maps_text_search', 'amap_maps_weather', ...]
+```
+
+如图13.8所示,假设用户想搜索北京的景点,AttractionSearchAgent接收到查询"请搜索北京的历史文化景点"。Agent分析这个查询,决定调用`amap_maps_text_search`工具,参数是`keywords=景点,city=北京`。
+
+<div align="center">
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-8.png" alt="" width="85%"/>
+  <p>图 13.8 MCP工具调用流程</p>
+</div>
+
+Agent生成工具调用标记:`[TOOL_CALL:amap_maps_text_search:keywords=景点,city=北京]`。HelloAgents框架解析这个标记,提取工具名称和参数,然后调用对应的Tool对象。
+
+Tool对象是`MCPTool`自动创建的,它会把调用请求发送给MCP服务器。具体来说,它会构造一个JSON-RPC格式的消息,通过stdin发送给服务器进程:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "method": "tools/call",
+  "params": {
+    "name": "amap_maps_text_search",
+    "arguments": {
+      "keywords": "景点",
+      "city": "北京"
+    }
+  }
+}
+```
+
+MCP服务器接收到这个消息,解析参数,然后调用高德地图的HTTP API。它会构造HTTP请求,添加API密钥,发送请求,接收响应。
+
+高德地图API返回JSON格式的数据,包含景点列表、地址、坐标等信息。MCP服务器解析这些数据,提取关键字段,然后构造响应消息,通过stdout返回给`MCPTool`:
+
+```json
+{
+  "jsonrpc": "2.0",
+  "result": {
+    "content": [
+      {
+        "type": "text",
+        "text": "找到以下景点:\n1. 故宫博物院 - 地址:东城区景山前街4号\n2. 天坛公园 - 地址:东城区天坛路\n..."
+      }
+    ]
+  }
+}
+```
+
+`MCPTool`接收到响应,提取文本内容,返回给Agent。Agent把这个结果作为工具调用的输出,继续生成最终的回复。
+
+这个流程看起来很复杂,但对于Agent来说,它只需要知道有一个叫`amap_maps_text_search`的工具,可以搜索景点。所有的底层细节都被MCP协议和`MCPTool`封装起来了。
+
+### 13.4.3 共享MCP实例
+
+在我们的多Agent系统中,有三个Agent都需要使用高德地图的工具。那么每个Agent应该创建自己的`MCPTool`实例,还是共享同一个实例?
+
+如果每个Agent都创建一个`MCPTool`实例,这意味着会有三个服务器进程同时运行。每个进程都会独立地调用高德地图API,这可能会超过API的速率限制。而且多个进程会占用更多的内存和CPU资源。
+
+更好的做法是让所有Agent共享同一个`MCPTool`实例。这样只需要启动一个MCP服务器进程,所有的API调用都通过这个进程进行。这不仅节省资源,还可以更好地控制API调用频率。
+
+在代码中,我们在`TripPlannerAgent`的构造函数中创建一个`MCPTool`实例,然后把它添加到每个子Agent的工具列表中:
+
+```python
+class TripPlannerAgent:
+    def __init__(self):
+        settings = get_settings()
+        self.llm = HelloAgentsLLM()
+
+        # 创建共享的MCP工具实例(只创建一次)
+        self.mcp_tool = MCPTool(
+            name="amap_mcp",
+            command="npx",
+            args=["-y", "@sugarforever/amap-mcp-server"],
+            env={"AMAP_API_KEY": settings.amap_api_key},
+            auto_expand=True
+        )
+
+        # 创建多个Agent,共享同一个MCP工具
+        self.attraction_agent = SimpleAgent(
+            name="AttractionSearchAgent",
+            llm=self.llm,
+            system_prompt=ATTRACTION_AGENT_PROMPT
+        )
+        self.attraction_agent.add_tool(self.mcp_tool)  # 共享
+
+        self.weather_agent = SimpleAgent(
+            name="WeatherQueryAgent",
+            llm=self.llm,
+            system_prompt=WEATHER_AGENT_PROMPT
+        )
+        self.weather_agent.add_tool(self.mcp_tool)  # 共享
+
+        self.hotel_agent = SimpleAgent(
+            name="HotelAgent",
+            llm=self.llm,
+            system_prompt=HOTEL_AGENT_PROMPT
+        )
+        self.hotel_agent.add_tool(self.mcp_tool)  # 共享
+```
+
+这样,三个Agent都可以使用高德地图的16个工具,但底层只有一个MCP服务器进程在运行。当我们调用`TripPlannerAgent`的`plan_trip`方法时,三个Agent会依次调用工具,所有的请求都通过同一个MCP服务器发送到高德地图API。
+
+### 13.4.4 Unsplash图片API集成
+
+除了高德地图,我们还需要为景点获取图片,让旅行计划更加生动直观。我们使用Unsplash API来搜索景点图片。需要注意的是,Unsplash是国外的服务,而且是为数不多可以免费使用的图片API,所以搜索结果可能不够准确。在实际项目中,可以考虑使用必应、百度或高德的POI图片API,但这些服务通常需要付费。
+
+Unsplash API的集成比较简单,我们创建一个`UnsplashService`类来封装API调用:
+
+```python
+# backend/app/services/unsplash_service.py
+import requests
+from typing import Optional, List, Dict
+import logging
+
+logger = logging.getLogger(__name__)
+
+class UnsplashService:
+    """Unsplash图片服务"""
+
+    def __init__(self, access_key: str):
+        self.access_key = access_key
+        self.base_url = "https://api.unsplash.com"
+
+    def search_photos(self, query: str, per_page: int = 10) -> List[Dict]:
+        """搜索图片"""
+        try:
+            url = f"{self.base_url}/search/photos"
+            params = {
+                "query": query,
+                "per_page": per_page,
+                "client_id": self.access_key
+            }
+
+            response = requests.get(url, params=params, timeout=10)
+            response.raise_for_status()
+
+            data = response.json()
+            results = data.get("results", [])
+
+            # 提取图片URL
+            photos = []
+            for result in results:
+                photos.append({
+                    "url": result["urls"]["regular"],
+                    "description": result.get("description", ""),
+                    "photographer": result["user"]["name"]
+                })
+
+            return photos
+
+        except Exception as e:
+            logger.error(f"搜索图片失败: {e}")
+            return []
+
+    def get_photo_url(self, query: str) -> Optional[str]:
+        """获取单张图片URL"""
+        photos = self.search_photos(query, per_page=1)
+        return photos[0].get("url") if photos else None
+```
+
+这个服务类提供了两个方法:`search_photos`搜索多张图片,`get_photo_url`获取单张图片的URL。我们在API路由中使用这个服务,为每个景点获取图片:
+```python
+# backend/app/api/routes/trip.py
+from app.services.unsplash_service import UnsplashService
+
+unsplash_service = UnsplashService(settings.unsplash_access_key)
+
+@router.post("/plan", response_model=TripPlan)
+async def create_trip_plan(request: TripPlanRequest) -> TripPlan:
+    # 生成旅行计划
+    trip_plan = trip_planner_agent.plan_trip(request)
+
+    # 为每个景点获取图片
+    for day in trip_plan.days:
+        for attraction in day.attractions:
+            if not attraction.image_url:
+                image_url = unsplash_service.get_photo_url(
+                    f"{attraction.name} {trip_plan.city}"
+                )
+                attraction.image_url = image_url
+
+    return trip_plan
+```
+
+注意我们没有把Unsplash封装成Tool或MCP工具,而是直接在API路由中调用。这是因为图片搜索不需要Agent的智能决策,只是一个简单的数据增强步骤。如果你想让Agent能够自主决定是否需要图片,或者选择不同的图片来源,可以考虑把它封装成Tool。
+
+## 13.5 前端开发详解
+
+### 13.5.1 前后端分离的Web架构
+
+在开始前端开发之前,我们需要理解现代Web应用的架构模式。在早期的Web开发中,前端和后端是混在一起的,比如PHP、JSP这样的技术,HTML模板和业务逻辑代码写在同一个文件里。这种方式在小项目中很方便,但在大型项目中会遇到很多问题:前端和后端开发者需要频繁协调,代码难以复用,测试困难。
+
+现代Web应用普遍采用**前后端分离**的架构。后端只负责提供API接口,返回JSON格式的数据。前端是一个独立的应用,通过HTTP请求调用后端API,获取数据后渲染页面。这种架构有几个明显的优势:前端和后端可以独立开发、独立部署、独立测试;前端可以是Web应用、移动应用或桌面应用,都使用同一套后端API;前端可以使用现代的框架和工具链,提供更好的用户体验。
+
+在我们的智能旅行助手项目中,后端是用Python和FastAPI实现的,提供了一个核心API接口`POST /api/trip/plan`,接收旅行需求,返回旅行计划。前端是用Vue 3和TypeScript实现的,是一个单页应用(SPA),用户在浏览器中填写表单,点击"开始规划"按钮,前端发送HTTP请求到后端,等待响应,然后渲染结果页面。整个过程中,页面不会刷新,用户体验很流畅。
+
+前端技术栈的选择需要考虑几个因素:开发效率、性能、生态系统、学习曲线。如表13.2所示,该项目选择了以下技术栈:
+
+<div align="center">
+  <p>表 13.2 前端技术栈</p>
+  <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/13-figures/13-table-2.png" alt="" width="85%"/>
+</div>
+
+项目的目录结构是这样的:
+```
+frontend/
+├── src/
+│   ├── views/              # 页面组件
+│   │   ├── Home.vue        # 首页(表单)
+│   │   └── Result.vue      # 结果页
+│   ├── services/           # API服务
+│   │   └── api.ts
+│   ├── types/              # 类型定义
+│   │   └── index.ts
+│   ├── router/             # 路由配置
+│   │   └── index.ts
+│   ├── App.vue
+│   └── main.ts
+├── package.json
+├── vite.config.ts
+└── tsconfig.json
+```
+
+其中`views`目录存放页面组件,`services`目录存放API调用逻辑,`types`目录存放TypeScript类型定义,`router`目录存放路由配置。
+
+### 13.5.2 类型定义
+
+在13.2节中,我们在后端使用Pydantic定义了数据模型,比如`Location`、`Attraction`、`DayPlan`、`TripPlan`等。在前端,我们需要定义对应的TypeScript类型。
+
+让我们看看如何定义这些类型。首先是最基础的`Location`类型,表示经纬度坐标:
+
+```typescript
+// frontend/src/types/index.ts
+export interface Location {
+  longitude: number
+  latitude: number
+}
+```
+
+这个类型定义和后端的Pydantic模型完全对应。注意TypeScript使用`interface`关键字定义类型,字段类型用冒号分隔,不需要默认值。
+
+接下来是`Attraction`类型,表示景点信息:
+
+```typescript
+export interface Attraction {
+  name: string
+  address: string
+  location: Location
+  visit_duration: number
+  description: string
+  category?: string
+  rating?: number
+  image_url?: string
+  ticket_price?: number
+}
+```
+
+注意这里使用了`Location`类型作为字段类型,这就是嵌套类型。问号`?`表示可选字段,对应后端Pydantic模型中的`Optional`。
+
+类似地,我们定义`Meal`、`Hotel`、`Budget`、`WeatherInfo`等类型。最后是顶层的`TripPlan`类型:
+
+```typescript
+export interface TripPlan {
+  city: string
+  start_date: string
+  end_date: string
+  days: DayPlan[]
+  weather_info: WeatherInfo[]
+  overall_suggestions: string
+  budget?: Budget
+}
+```
+
+还有请求类型`TripPlanRequest`,对应后端的请求模型:
+
+```typescript
+export interface TripPlanRequest {
+  city: string
+  start_date: string
+  end_date: string
+  days: number
+  preferences: string
+  budget: string
+  transportation: string
+  accommodation: string
+}
+```
+
+这些类型定义有什么用呢?首先,当我们调用API时,TypeScript会检查我们传递的数据是否符合`TripPlanRequest`类型。如果我们不小心把`days`写成了字符串,TypeScript会立即报错。其次,当我们接收API响应时,TypeScript会检查响应数据是否符合`TripPlan`类型。如果后端返回的数据结构发生变化,前端会立即发现。最后,IDE可以根据类型定义提供代码补全,我们输入`tripPlan.`时,IDE会自动列出所有可用的字段。
+
+### 13.5.3 API服务封装
+
+有了类型定义,我们就可以封装API调用了。我们创建一个`api.ts`文件,使用Axios来发送HTTP请求:
+
+```typescript
+import axios from 'axios'
+import type { TripPlanRequest,TripPlan } from '../types'
+
+const api = axios.create({
+  baseURL: 'http://localhost:8000/api',
+  timeout: 120000, // 2分钟超时
+  headers: {
+    'Content-Type': 'application/json'
+  }
+})
+```
+
+这里我们创建了一个Axios实例,配置了基础URL、超时时间和请求头。为什么超时时间设置为2分钟?因为生成旅行计划需要调用多个Agent,每个Agent都要调用LLM和外部API,整个过程可能需要10-30秒。如果超时时间太短,请求会被中断。
+
+接下来我们添加拦截器。拦截器可以在请求发送前和响应接收后执行一些通用逻辑,比如日志记录、错误处理、认证等:
+
+```typescript
+// 请求拦截器
+api.interceptors.request.use(
+  config => {
+    console.log('发送请求:',config)
+    return config
+  },
+  error => Promise.reject(error)
+)
+
+// 响应拦截器
+api.interceptors.response.use(
+  response => {
+    console.log('收到响应:',response)
+    return response
+  },
+  error => {
+    console.error('请求失败:',error)
+    return Promise.reject(error)
+  }
+)
+```
+
+最后我们定义API函数,这是前端调用后端的唯一入口:
+
+```typescript
+// 生成旅行计划
+export const generateTripPlan = async (request: TripPlanRequest): Promise<TripPlan> => {
+  const response = await api.post<TripPlan>('/trip/plan',request)
+  return response.data
+}
+```
+
+注意这个函数的类型签名:参数是`TripPlanRequest`类型,返回值是`Promise<TripPlan>`类型。这意味着TypeScript会检查调用者传递的参数是否符合要求,也会检查返回值的使用是否正确。
+
+### 13.5.4 Home表单设计
+
+Home页面是用户的入口,包含一个表单,让用户填写旅行需求。我们使用Vue 3的Composition API来组织代码:
+
+```vue
+<script setup lang="ts">
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { message } from 'ant-design-vue'
+import { generateTripPlan } from '@/services/api'
+import type { TripPlanRequest } from '@/types'
+
+const router = useRouter()
+const loading = ref(false)
+const loadingProgress = ref(0)
+const loadingStatus = ref('')
+
+const formData = ref<TripPlanRequest>({
+  city: '',
+  start_date: '',
+  end_date: '',
+  days: 3,
+  preferences: '历史文化',
+  budget: '中等',
+  transportation: '公共交通',
+  accommodation: '经济型酒店'
+})
+</script>
+```
+
+这里我们使用`ref`来创建响应式变量。`formData`是表单数据,类型是`TripPlanRequest`。`loading`表示是否正在加载,`loadingProgress`表示加载进度,`loadingStatus`表示加载状态文本。
+
+表单提交的逻辑是这样的:
+
+```typescript
+const handleSubmit = async () => {
+  loading.value = true
+  loadingProgress.value = 0
+  
+  // 模拟进度更新
+  const progressInterval = setInterval(() => {
+    if (loadingProgress.value < 90) {
+      loadingProgress.value += 10
+      if (loadingProgress.value <= 30) loadingStatus.value = '🔍 正在搜索景点...'
+      else if (loadingProgress.value <= 50) loadingStatus.value = '🌤️ 正在查询天气...'
+      else if (loadingProgress.value <= 70) loadingStatus.value = '🏨 正在推荐酒店...'
+      else loadingStatus.value = '📋 正在生成行程计划...'
+    }
+  },500)
+  
+  try {
+    const response = await generateTripPlan(formData.value)
+    clearInterval(progressInterval)
+    loadingProgress.value = 100
+    router.push({ name: 'result',state: { tripPlan: response } })
+  } catch (error) {
+    clearInterval(progressInterval)
+    message.error('生成计划失败,请重试')
+  } finally {
+    loading.value = false
+  }
+}
+```
+
+这段代码做了几件事。首先,设置`loading`为true,显示加载状态。然后,启动一个定时器,每500毫秒更新一次进度条和状态文本。这是一个模拟的进度,因为我们无法准确知道后端的处理进度。但这样可以让用户知道系统正在工作,而不是卡住了。
+
+接着,调用`generateTripPlan`函数发送API请求。这是一个异步操作,我们使用`await`等待响应。如果请求成功,清除定时器,设置进度为100%,然后跳转到结果页面,并把旅行计划数据传递过去。如果请求失败,显示错误消息。最后,无论成功还是失败,都设置`loading`为false,隐藏加载状态。
+
+模板部分使用Ant Design Vue的组件:
+
+```vue
+<template>
+  <div class="home-container">
+    <div class="page-header">
+      <h1 class="page-title">✈️ 智能旅行助手</h1>
+      <p class="page-subtitle">基于AI的个性化旅行规划</p>
+    </div>
+    
+    <a-card class="form-card">
+      <a-form :model="formData" @finish="handleSubmit">
+        <a-form-item label="目的地城市" name="city" :rules="[{ required: true }]">
+          <a-input v-model:value="formData.city" placeholder="如:北京" />
+        </a-form-item>
+        
+        <!-- 更多表单项... -->
+        
+        <a-form-item>
+          <a-button type="primary" html-type="submit" size="large" :loading="loading">
+            开始规划
+          </a-button>
+        </a-form-item>
+        
+        <!-- 加载进度条 -->
+        <a-form-item v-if="loading">
+          <a-progress :percent="loadingProgress" status="active" />
+          <p>{{ loadingStatus }}</p>
+        </a-form-item>
+      </a-form>
+    </a-card>
+  </div>
+</template>
+```
+
+注意`v-model:value`指令,它实现了双向数据绑定。当用户在输入框中输入内容时,`formData.city`会自动更新。当`formData.city`的值改变时,输入框的内容也会自动更新。
+
+### 13.5.5 Result页面展示
+
+Result页面是整个应用的核心,展示生成的旅行计划。这个页面包含几个部分:行程概览、预算明细、地图可视化、每日行程详情、天气信息。
+
+首先是地图可视化。我们使用高德地图JS API在地图上标注景点位置:
+
+```typescript
+import AMapLoader from '@amap/amap-jsapi-loader'
+
+const initMap = async () => {
+  const AMap = await AMapLoader.load({
+    key: 'your_amap_web_key',
+    version: '2.0'
+  })
+  
+  map = new AMap.Map('amap-container',{
+    zoom: 12,
+    center: [116.397128,39.916527]
+  })
+  
+  // 添加景点标记
+  tripPlan.value.days.forEach((day) => {
+    day.attractions.forEach((attraction,index) => {
+      const marker = new AMap.Marker({
+        position: [attraction.location.longitude,attraction.location.latitude],
+        title: attraction.name,
+        label: { content: `${index + 1}`,direction: 'top' }
+      })
+      map.add(marker)
+    })
+  })
+}
+```
+
+这段代码首先加载高德地图SDK,然后创建地图实例,最后遍历所有景点,为每个景点创建一个标记(Marker)。标记的位置是景点的经纬度坐标,这些坐标是从后端的`Attraction`对象中获取的。
+
+导出功能使用`html2canvas`和`jsPDF`库。`html2canvas`可以把DOM元素转换成Canvas,然后我们可以把Canvas导出为图片或PDF:
+
+```typescript
+import html2canvas from 'html2canvas'
+import jsPDF from 'jspdf'
+
+// 导出为图片
+const exportAsImage = async () => {
+  const element = document.getElementById('trip-plan-content')
+  const canvas = await html2canvas(element,{ scale: 2 })
+  const link = document.createElement('a')
+  link.download = `${tripPlan.value.city}旅行计划.png`
+  link.href = canvas.toDataURL()
+  link.click()
+}
+
+// 导出为PDF
+const exportAsPDF = async () => {
+  const element = document.getElementById('trip-plan-content')
+  const canvas = await html2canvas(element,{ scale: 2 })
+  const imgData = canvas.toDataURL('image/png')
+  const pdf = new jsPDF('p','mm','a4')
+  const imgWidth = 210
+  const imgHeight = (canvas.height * imgWidth) / canvas.width
+  pdf.addImage(imgData,'PNG',0,0,imgWidth,imgHeight)
+  pdf.save(`${tripPlan.value.city}旅行计划.pdf`)
+}
+```
+
+通过这些前端技术,我们实现了一个完整的Web应用。用户可以在浏览器中填写表单,提交请求,等待AI生成旅行计划,然后查看详细的行程安排,在地图上看到景点位置,还可以导出为图片或PDF。整个过程流畅自然,这就是现代Web应用的魅力。
+
+## 13.6 功能实现详解
+
+本节介绍智能旅行助手的核心功能实现,包括预算计算、加载进度条、行程编辑、导出功能和侧边导航。
+
+### 13.6.1 预算计算功能
+
+在规划旅行时,预算是一个非常重要的考虑因素。用户需要知道这次旅行大概要花多少钱,钱都花在哪里。我们的智能旅行助手提供了自动预算计算功能,将费用分为四大类:景点门票、酒店住宿、餐饮和交通。
+
+预算计算的逻辑在哪里实现呢?我们选择在后端的PlannerAgent中实现。为什么不在前端计算?因为预算的估算需要基于景点的门票价格、酒店的价格范围、餐饮的标准等信息,这些信息都是PlannerAgent在生成行程时已经获取的。如果在前端计算,就需要重复这些逻辑,而且可能不准确。
+
+在PlannerAgent的提示词中,我们明确要求LLM生成预算信息:
+
+```python
+PLANNER_AGENT_PROMPT = """
+你是行程规划专家。
+
+**输出格式:**
+严格按照以下JSON格式返回:
+{
+  ...
+  "budget": {
+    "total_attractions": 180,
+    "total_hotels": 1200,
+    "total_meals": 480,
+    "total_transportation": 200,
+    "total": 2060
+  }
+}
+
+**规划要求:**
+...
+7. 包含预算信息,根据景点门票、酒店价格、餐饮标准和交通方式估算
+"""
+```
+
+LLM会根据行程中的景点、酒店、餐饮安排,估算每一项的费用。比如,如果行程中包含故宫(门票60元)、天坛(门票15元)、颐和园(门票30元),那么景点门票总费用就是105元。如果是3天2晚的行程,酒店是经济型(每晚300元),那么酒店总费用就是600元。
+
+在前端,我们使用Ant Design Vue的Statistic组件来展示预算信息。这个组件专门用于展示统计数据,支持数字动画、前缀后缀、自定义样式等:
+
+```vue
+<a-card v-if="tripPlan.budget" title="💰 预算明细">
+  <a-row :gutter="16">
+    <a-col :span="6">
+      <a-statistic title="景点门票" :value="tripPlan.budget.total_attractions" suffix="元" />
+    </a-col>
+    <a-col :span="6">
+      <a-statistic title="酒店住宿" :value="tripPlan.budget.total_hotels" suffix="元" />
+    </a-col>
+    <a-col :span="6">
+      <a-statistic title="餐饮费用" :value="tripPlan.budget.total_meals" suffix="元" />
+    </a-col>
+    <a-col :span="6">
+      <a-statistic title="交通费用" :value="tripPlan.budget.total_transportation" suffix="元" />
+    </a-col>
+  </a-row>
+  <a-divider />
+  <a-row>
+    <a-col :span="24" style="text-align: center;">
+      <a-statistic
+        title="预估总费用"
+        :value="tripPlan.budget.total"
+        suffix="元"
+        :value-style="{ color: '#cf1322',fontSize: '32px',fontWeight: 'bold' }"
+      />
+    </a-col>
+  </a-row>
+</a-card>
+```
+
+这段代码使用了栅格布局(`a-row`和`a-col`),将四项费用并排显示。每项费用使用一个`a-statistic`组件,显示标题和数值。最后用一个分隔线(`a-divider`)隔开,下面显示总费用,使用红色大字体突出显示。
+
+注意`v-if="tripPlan.budget"`这个条件渲染。因为预算信息是可选的(在Pydantic模型中定义为`Optional[Budget]`),如果LLM没有生成预算信息,这个卡片就不会显示。这体现了前端对数据的容错处理。
+
+### 13.6.2 加载进度条
+
+生成旅行计划是一个耗时的操作。后端需要依次调用AttractionSearchAgent、WeatherQueryAgent、HotelAgent和PlannerAgent,每个Agent都要调用LLM和外部API。整个过程可能需要10-30秒。如果用户点击"开始规划"按钮后,页面没有任何反馈,用户会以为系统卡住了,可能会刷新页面或重复点击。
+
+为了提升用户体验,我们添加了加载进度条和状态提示。现在只是模拟进度,可以让用户知道系统正在工作。
+
+```typescript
+const loading = ref(false)
+const loadingProgress = ref(0)
+const loadingStatus = ref('')
+
+const handleSubmit = async () => {
+  loading.value = true
+  loadingProgress.value = 0
+
+  // 模拟进度更新
+  const progressInterval = setInterval(() => {
+    if (loadingProgress.value < 90) {
+      loadingProgress.value += 10
+      if (loadingProgress.value <= 30) loadingStatus.value = '🔍 正在搜索景点...'
+      else if (loadingProgress.value <= 50) loadingStatus.value = '🌤️ 正在查询天气...'
+      else if (loadingProgress.value <= 70) loadingStatus.value = '🏨 正在推荐酒店...'
+      else loadingStatus.value = '📋 正在生成行程计划...'
+    }
+  }, 500)
+
+  try {
+    const response = await generateTripPlan(formData.value)
+    clearInterval(progressInterval)
+    loadingProgress.value = 100
+    loadingStatus.value = '✅ 完成!'
+    router.push({ name: 'result', state: { tripPlan: response } })
+  } catch (error) {
+    clearInterval(progressInterval)
+    message.error('生成计划失败')
+  } finally {
+    loading.value = false
+  }
+}
+```
+
+### 13.6.3 行程编辑功能
+
+AI生成的旅行计划虽然很智能,但可能不完全符合用户的个人需求。比如,用户可能不喜欢某个景点,想删除它;或者想调整景点的游览顺序。我们提供了行程编辑功能,让用户可以自定义行程。
+
+编辑功能的核心是**状态管理**。我们需要维护两个状态:当前的行程计划和原始的行程计划。当用户进入编辑模式时,我们保存原始计划的副本。如果用户取消编辑,就恢复原始计划。如果用户保存修改,就更新当前计划:
+
+```typescript
+const editMode = ref(false)
+const originalPlan = ref<TripPlan | null>(null)
+
+// 进入编辑模式
+const toggleEditMode = () => {
+  editMode.value = true
+  originalPlan.value = JSON.parse(JSON.stringify(tripPlan.value))
+}
+```
+
+注意这里使用了`JSON.parse(JSON.stringify(...))`来深拷贝对象。为什么不直接赋值?因为JavaScript中对象是引用类型,如果直接赋值,`originalPlan`和`tripPlan`会指向同一个对象,修改一个会影响另一个。深拷贝可以创建一个完全独立的副本。
+
+移动景点的逻辑是交换数组中两个元素的位置:
+
+```typescript
+// 移动景点
+const moveAttraction = (dayIndex: number,attractionIndex: number,direction: 'up' | 'down') => {
+  const attractions = tripPlan.value.days[dayIndex].attractions
+  const newIndex = direction === 'up' ? attractionIndex - 1 : attractionIndex + 1
+  
+  if (newIndex >= 0 && newIndex < attractions.length) {
+    [attractions[attractionIndex],attractions[newIndex]] = 
+    [attractions[newIndex],attractions[attractionIndex]]
+  }
+}
+```
+
+这里使用了ES6的解构赋值语法来交换两个元素。`[a,b] = [b,a]`是一个很优雅的交换方式,不需要临时变量。
+
+删除景点使用数组的`splice`方法:
+
+```typescript
+// 删除景点
+const deleteAttraction = (dayIndex: number,attractionIndex: number) => {
+  tripPlan.value.days[dayIndex].attractions.splice(attractionIndex,1)
+}
+```
+
+保存修改时,我们需要重新初始化地图,因为景点的位置可能发生了变化:
+
+```typescript
+// 保存修改
+const saveChanges = () => {
+  editMode.value = false
+  message.success('修改已保存')
+  initMap()  // 重新初始化地图
+}
+
+// 取消编辑
+const cancelEdit = () => {
+  if (originalPlan.value) {
+    tripPlan.value = originalPlan.value
+  }
+  editMode.value = false
+}
+```
+
+在模板中,我们根据`editMode`的值显示不同的UI。编辑模式下,每个景点旁边会显示上移、下移、删除按钮:
+
+```vue
+<div v-if="editMode" class="edit-buttons">
+  <a-button size="small" @click="moveAttraction(dayIndex,index,'up')">上移</a-button>
+  <a-button size="small" @click="moveAttraction(dayIndex,index,'down')">下移</a-button>
+  <a-button size="small" danger @click="deleteAttraction(dayIndex,index)">删除</a-button>
+</div>
+```
+
+### 13.6.4 导出功能
+
+用户生成了满意的旅行计划后,可能想保存下来或分享给朋友。我们提供了两种导出方式:导出为图片和导出为PDF。
+
+导出功能的核心是`html2canvas`库。这个库可以把DOM元素转换成Canvas,然后我们可以把Canvas导出为图片。但这里有一个技术难点:地图是用Canvas渲染的,而`html2canvas`在处理嵌套Canvas时存在兼容性问题。
+
+我们尝试了多种解决方案,包括将地图Canvas转换成图片后再导出,但由于高德地图的Canvas渲染机制和跨域限制,这个方案并没有完全解决问题。在实际项目中,可能需要考虑以下替代方案:
+
+1. **使用高德地图的静态地图API**:调用`maps_staticmap`工具生成静态地图图片,替代动态地图
+2. **分开导出**:地图和行程内容分开导出,最后在后端合并
+3. **使用截图服务**:使用Puppeteer等无头浏览器在服务端截图
+4. **简化导出内容**:导出时隐藏地图,只导出文字内容
+
+目前的实现中,我们采用了简化方案,在导出时暂时隐藏地图部分,只导出行程的文字内容和景点信息。虽然这不是最理想的方案,但可以保证导出功能的可用性。
+
+导出为图片的逻辑很简单:
+
+```typescript
+import html2canvas from 'html2canvas'
+
+const exportAsImage = async () => {
+  const element = document.getElementById('trip-plan-content')
+  if (!element) return
+  
+  const canvas = await html2canvas(element,{
+    backgroundColor: '#ffffff',
+    scale: 2,
+    useCORS: true
+  })
+  
+  const link = document.createElement('a')
+  link.download = `${tripPlan.value.city}旅行计划.png`
+  link.href = canvas.toDataURL('image/png')
+  link.click()
+  message.success('导出成功!')
+}
+```
+
+`scale: 2`表示使用2倍分辨率,这样导出的图片更清晰。`useCORS: true`允许跨域加载图片,这对于景点图片(来自Unsplash)很重要。
+
+导出为PDF需要额外的步骤:先转换成Canvas,再转换成图片,最后添加到PDF中:
+
+```typescript
+import jsPDF from 'jspdf'
+
+const exportAsPDF = async () => {
+  // 先截取地图
+  await captureMapImage()
+  
+  const element = document.getElementById('trip-plan-content')
+  if (!element) return
+  
+  const canvas = await html2canvas(element,{
+    backgroundColor: '#ffffff',
+    scale: 2,
+    useCORS: true,
+    allowTaint: true
+  })
+  
+  // 恢复地图
+  restoreMap()
+  
+  const pdf = new jsPDF('p','mm','a4')
+  const imgData = canvas.toDataURL('image/png')
+  const imgWidth = 210  // A4宽度
+  const imgHeight = (canvas.height * imgWidth) / canvas.width
+  
+  pdf.addImage(imgData,'PNG',0,0,imgWidth,imgHeight)
+  pdf.save(`${tripPlan.value.city}旅行计划.pdf`)
+  message.success('导出成功!')
+}
+```
+
+这里需要计算图片的高度,保持宽高比。A4纸的宽度是210mm,我们根据Canvas的宽高比计算出对应的高度。
+
+### 13.6.5 侧边导航与锚点跳转
+
+Result页面的内容很多,包括行程概览、预算明细、地图、每日行程、天气信息等。如果用户想快速跳转到某个部分,需要滚动很长的距离。我们提供了侧边导航和锚点跳转功能,让用户可以快速定位。
+
+侧边导航使用Ant Design Vue的Menu组件:
+
+```vue
+<a-menu
+  v-model:selectedKeys="[activeSection]"
+  mode="inline"
+  @click="scrollToSection"
+>
+  <a-menu-item key="overview">📋 行程概览</a-menu-item>
+  <a-menu-item key="budget">💰 预算明细</a-menu-item>
+  <a-menu-item key="map">🗺️ 地图</a-menu-item>
+  <a-menu-item key="days">📅 每日行程</a-menu-item>
+  <a-menu-item key="weather">🌤️ 天气</a-menu-item>
+</a-menu>
+```
+
+点击菜单项时,调用`scrollToSection`函数:
+
+```typescript
+const activeSection = ref('overview')
+
+// 滚动到指定区域
+const scrollToSection = ({ key }: { key: string }) => {
+  activeSection.value = key
+  const element = document.getElementById(key)
+  if (element) {
+    element.scrollIntoView({ behavior: 'smooth',block: 'start' })
+  }
+}
+```
+
+`scrollIntoView`是浏览器原生的API,可以让元素滚动到可视区域。`behavior: 'smooth'`表示平滑滚动,而不是瞬间跳转。`block: 'start'`表示元素的顶部对齐到可视区域的顶部。
+
+在页面的各个部分,我们需要添加对应的id:
+
+```vue
+<div id="overview">
+  <!-- 行程概览内容 -->
+</div>
+
+<div id="budget">
+  <!-- 预算明细内容 -->
+</div>
+
+<div id="map">
+  <!-- 地图内容 -->
+</div>
+```
+
+这样,当用户点击侧边导航的某个菜单项时,页面会平滑滚动到对应的部分。
+
+通过这些功能的实现,我们的智能旅行助手不仅能够生成旅行计划,还提供了丰富的交互功能:预算计算让用户了解费用,加载进度条让等待不再焦虑,行程编辑让计划更符合个人需求,导出功能让计划可以分享和保存,侧边导航让长页面易于浏览。这些功能的组合,构成了一个完整、易用、实用的Web应用。
+
+## 13.7 结语
+
+恭喜你完成了第十三章的学习!
+
+通过本章,你不仅学会了如何构建一个完整的智能旅行助手应用,更重要的是掌握了:
+
+1. **系统设计思维**: 如何将复杂问题分解为多个简单任务
+2. **工程实践能力**: 如何将理论知识转化为可运行的代码
+3. **全栈开发能力**: 如何整合前后端技术栈
+4. **AI应用开发**: 如何利用LLM构建实用的应用
+
+这个项目是一个起点,而不是终点。你可以基于这个项目:
+
+- 添加更多功能
+- 优化用户体验
+- 扩展到其他领域(如智能购物助手、智能学习助手等)
+- 部署到生产环境,服务真实用户
+
+最好的学习方式是实践。不要只是阅读代码,而是要动手修改、扩展、优化。每一次实践都会让你对多Agent系统有更深的理解。
+
+祝你在AI应用开发的道路上越走越远!
+

BIN
docs/images/13-figures/13-1.png


BIN
docs/images/13-figures/13-2.png


BIN
docs/images/13-figures/13-3.png


BIN
docs/images/13-figures/13-4.png


BIN
docs/images/13-figures/13-5.png


BIN
docs/images/13-figures/13-6.png


BIN
docs/images/13-figures/13-7.png


BIN
docs/images/13-figures/13-8.png


BIN
docs/images/13-figures/13-table-1.png


BIN
docs/images/13-figures/13-table-2.png