├── .gitignore ├── README.md ├── app ├── api │ ├── generate │ │ └── route.ts │ └── stream │ │ └── route.ts ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── chat-interface.tsx ├── client-map.tsx ├── map-container.tsx ├── map-error.tsx ├── model-settings.tsx ├── suggested-queries.tsx ├── theme-provider.tsx └── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── chart.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── navigation-menu.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ ├── use-mobile.tsx │ └── use-toast.ts ├── hooks ├── use-mobile.tsx └── use-toast.ts ├── lib ├── client-api.ts ├── settings.ts ├── store.ts └── utils.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── placeholder-logo.png ├── placeholder-logo.svg ├── placeholder-user.jpg ├── placeholder.jpg └── placeholder.svg ├── styles └── globals.css ├── tailwind.config.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # next.js 7 | /.next/ 8 | /out/ 9 | 10 | # production 11 | /build 12 | 13 | # debug 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | .pnpm-debug.log* 18 | 19 | # env files 20 | .env* 21 | 22 | # vercel 23 | .vercel 24 | 25 | # typescript 26 | *.tsbuildinfo 27 | next-env.d.ts -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #

ChatMap

2 | 3 |

对话式地图智能体

4 | 5 | > 本项目受 [SmartMap](https://www.smartmap.space/) 启发,旨在开源复现并拓展其功能。 6 | 7 | ## Demo 8 | 9 | 演示站点:[ChatMap](https://www.chatmap.space/) 10 | 11 | 网站截图: 12 | 13 | ![image](https://github.com/user-attachments/assets/f74f0fcc-eb16-425a-b691-62e15e1a603b) 14 | 15 | ![image](https://github.com/user-attachments/assets/947cb3b3-a213-438e-9235-cff00803104a) 16 | 17 | ## 部署 18 | 19 | 1. Vercel 一键部署: 20 | 21 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FSeanium%2FChatMap) 22 | 23 | 2. 设置 API: 24 | 25 | ![image](https://github.com/user-attachments/assets/011505a6-5e0f-4483-92a6-2b9cda76cee9) 26 | -------------------------------------------------------------------------------- /app/api/stream/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from "next/server" 2 | import OpenAI from "openai" 3 | 4 | interface Message { 5 | role: string; 6 | content: string; 7 | } 8 | 9 | interface ModelSettings { 10 | provider: string; 11 | baseURL: string; 12 | model: string; 13 | apiKey: string; 14 | temperature: number; 15 | } 16 | 17 | export async function POST(req: NextRequest) { 18 | try { 19 | // 解析请求体 20 | const body = await req.json(); 21 | const conversationHistory: Message[] = body.conversationHistory || []; 22 | let settings: ModelSettings | null = body.settings || null; 23 | 24 | // 验证设置 25 | if (!settings) { 26 | // 尝试从请求头中获取旧版API密钥 27 | const legacyApiKey = req.headers.get("x-api-key"); 28 | if (!legacyApiKey) { 29 | return new Response( 30 | JSON.stringify({ error: "缺少模型配置或API密钥" }), 31 | { 32 | status: 401, 33 | headers: { "Content-Type": "application/json" } 34 | } 35 | ); 36 | } 37 | 38 | // 使用旧版API密钥 39 | settings = { 40 | provider: "openai", 41 | baseURL: "https://api.openai.com/v1", 42 | model: "gpt-4o", 43 | apiKey: legacyApiKey, 44 | temperature: 0.7, 45 | }; 46 | } 47 | 48 | // 验证对话历史 49 | if (!Array.isArray(conversationHistory) || conversationHistory.length === 0) { 50 | return new Response( 51 | JSON.stringify({ error: "缺少有效的对话历史" }), 52 | { 53 | status: 400, 54 | headers: { "Content-Type": "application/json" } 55 | } 56 | ); 57 | } 58 | 59 | // 根据提供商调整API路径 60 | let baseURL = settings.baseURL; 61 | 62 | // 确保baseURL不包含具体的API路径(如'/chat/completions') 63 | if (settings.provider === "openai" && baseURL.includes("/chat/completions")) { 64 | baseURL = baseURL.replace("/chat/completions", ""); 65 | } else if (settings.provider === "siliconflow" && baseURL.includes("/chat/completions")) { 66 | baseURL = baseURL.replace("/chat/completions", ""); 67 | } 68 | 69 | console.log(`[Stream API] 使用API端点: ${baseURL}, 模型: ${settings.model}, 提供商: ${settings.provider}`); 70 | 71 | // 创建OpenAI客户端 72 | const openai = new OpenAI({ 73 | apiKey: settings.apiKey, 74 | baseURL: baseURL, 75 | }); 76 | 77 | // 构建系统消息 78 | const systemMessage: OpenAI.Chat.Completions.ChatCompletionMessageParam = { 79 | role: "system", 80 | content: `你是ChatMap,一个专门提供地理、旅行和位置信息的AI助手。 81 | 请生成友好、有帮助的自然语言回答,内容专注于用户的实际需求。 82 | 83 | 当用户询问地理位置相关问题时,请提供准确的回答: 84 | 1. 如果用户询问的是多个地点(如"北京的旅游景点"、"纽约的餐厅"),清晰列出这些地点,简洁描述每个地点。 85 | 86 | 2. 如果用户询问的是路线或行程(如"从北京到上海的路线"、"东京一日游"),特别注意: 87 | - 按顺序列出路线上的地点,明确指出访问顺序 88 | - 使用"先...然后...最后..."或"第一天...第二天..."等词语表达顺序关系 89 | - 说明如何从一个地点到另一个地点 90 | - 当用户询问包含"路线"、"路径"、"行程"、"游览路线"、"旅游线路"、"怎么去"等词语时, 91 | 尽量以路线顺序方式组织回答,而不是简单列举地点 92 | 93 | 3. 如果用户询问的是单个地点或一般信息,提供关于该地点的详细信息。 94 | 95 | 你的回答应当是自然流畅的文本,不需要刻意标注坐标或地理信息。后续会有专门的系统提取地理位置信息用于地图显示。 96 | 97 | 保持语言简洁、条理清晰,让用户容易理解。确保回答准确、有帮助,能为用户提供实际价值。` 98 | }; 99 | 100 | // 准备消息 101 | const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ 102 | systemMessage, 103 | ...conversationHistory.map(msg => ({ 104 | role: msg.role as "user" | "assistant" | "system", 105 | content: msg.content 106 | })) 107 | ]; 108 | 109 | try { 110 | // 创建流式完成请求 111 | const stream = await openai.chat.completions.create({ 112 | model: settings.model, 113 | messages: messages, 114 | temperature: settings.temperature, 115 | stream: true, 116 | }); 117 | 118 | // 设置响应头 119 | const headers = new Headers(); 120 | headers.set("Content-Type", "text/event-stream"); 121 | headers.set("Cache-Control", "no-cache"); 122 | headers.set("Connection", "keep-alive"); 123 | 124 | // 创建一个新的流 125 | const encoder = new TextEncoder(); 126 | 127 | const responseStream = new ReadableStream({ 128 | async start(controller) { 129 | for await (const chunk of stream) { 130 | if (chunk.choices[0]?.delta?.content) { 131 | controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); 132 | } 133 | } 134 | controller.enqueue(encoder.encode("data: [DONE]\n\n")); 135 | controller.close(); 136 | }, 137 | cancel() { 138 | // 当流被取消时,关闭 OpenAI 流 139 | if (stream.controller) { 140 | stream.controller.abort(); 141 | } 142 | } 143 | }); 144 | 145 | // 返回流式响应 146 | return new Response(responseStream, { 147 | headers: headers, 148 | }); 149 | } catch (apiError) { 150 | console.error("API调用错误:", apiError); 151 | throw new Error(`API调用失败: ${apiError instanceof Error ? apiError.message : '未知API错误'}`); 152 | } 153 | 154 | } catch (error) { 155 | console.error("流式响应处理错误:", error); 156 | 157 | // 友好的错误消息 158 | const errorMessage = error instanceof Error ? error.message : "未知错误"; 159 | 160 | return new Response( 161 | JSON.stringify({ error: `流式响应生成失败: ${errorMessage}` }), 162 | { 163 | status: 500, 164 | headers: { "Content-Type": "application/json" } 165 | } 166 | ); 167 | } 168 | } 169 | 170 | export async function GET(req: NextRequest) { 171 | return new Response( 172 | JSON.stringify({ message: "流式API端点就绪" }), 173 | { 174 | status: 200, 175 | headers: { "Content-Type": "application/json" } 176 | } 177 | ); 178 | } -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | /* Custom marker styles */ 79 | .custom-marker-icon { 80 | background: transparent; 81 | border: none; 82 | } 83 | 84 | /* Leaflet container styles */ 85 | .leaflet-container { 86 | height: 100%; 87 | width: 100%; 88 | z-index: 1; /* Explicitly set a base z-index for the map container */ 89 | } 90 | 91 | /* Ensure Leaflet controls are also below the overlay if they have high z-index */ 92 | .leaflet-control { 93 | z-index: 2; /* Should be above map panes but below dialog overlay */ 94 | } 95 | 96 | /* Leaflet popup styles */ 97 | .leaflet-popup-content { 98 | padding: 5px; 99 | max-width: 250px; 100 | } 101 | 102 | .leaflet-popup-content h3 { 103 | margin-top: 0; 104 | margin-bottom: 8px; 105 | font-weight: bold; 106 | } 107 | 108 | .leaflet-popup-content p { 109 | margin: 0; 110 | } 111 | 112 | /* Apply a higher z-index to Radix UI Dialog Overlay */ 113 | /* You might need to inspect the exact class or data attribute Radix/Shadcn uses */ 114 | /* This is a common pattern, but might need adjustment */ 115 | @layer components { 116 | [data-radix-dialog-overlay], 117 | [data-radix-alert-dialog-overlay] { 118 | z-index: 1040 !important; /* Ensure this is below DialogContent (1050) but above map */ 119 | } 120 | 121 | /* If using shadcn/ui, the overlay might be a div directly inside the portal */ 122 | /* Or it might have a specific class like .DialogOverlay (less common now) */ 123 | /* Example if it's a direct child of the portal when open: */ 124 | /* 125 | .DialogPortal > div[data-state='open'][data-radix-overlay] { 126 | z-index: 1040 !important; 127 | } 128 | */ 129 | } 130 | 131 | /* Commenting out the individual pane z-index overrides for now, 132 | as Leaflet's default z-indices for panes are usually well below 1000. 133 | The issue is more likely with the container or specific high-z-index elements 134 | on the map, or the overlay not applying correctly. 135 | If the above doesn't work, we might need to inspect specific map elements. 136 | */ 137 | 138 | /* 139 | .leaflet-map-pane { 140 | z-index: 10 !important; 141 | } 142 | .leaflet-tile-pane { 143 | z-index: 20 !important; 144 | } 145 | .leaflet-overlay-pane { 146 | z-index: 40 !important; 147 | } 148 | .leaflet-shadow-pane { 149 | z-index: 50 !important; 150 | } 151 | .leaflet-marker-pane { 152 | z-index: 60 !important; 153 | } 154 | .leaflet-tooltip-pane { 155 | z-index: 65 !important; 156 | } 157 | .leaflet-popup-pane { 158 | z-index: 70 !important; 159 | } 160 | */ 161 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import './globals.css' 3 | 4 | export const metadata: Metadata = { 5 | title: 'ChatMap', 6 | description: 'An intelligent map and AI assistant specialized in geography, travel, and location-based information.', 7 | generator: 'ChatMap', 8 | } 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: Readonly<{ 13 | children: React.ReactNode 14 | }>) { 15 | return ( 16 | 17 | {children} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react" 2 | import MapContainer from "@/components/map-container" 3 | import ChatInterface from "@/components/chat-interface" 4 | import { Toaster } from "@/components/ui/toaster" 5 | import ModelSettings from "@/components/model-settings" 6 | import { Github } from "lucide-react" 7 | import { Button } from "@/components/ui/button" 8 | 9 | export default function Home() { 10 | return ( 11 |
12 |
13 |
14 | 26 | 27 | 28 | 29 | 30 |

ChatMap

31 |
32 |
33 | 34 | 40 | 44 | 45 |
46 |
47 | 48 |
49 | 加载地图中...
}> 50 | 51 | 52 | 53 | 54 | 55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/client-map.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useRef, useState } from "react" 4 | import MapError from "@/components/map-error" 5 | import type L from "leaflet"; // Import Leaflet type 6 | 7 | // Define interfaces for props and markers 8 | interface Marker { 9 | id: string 10 | title: string 11 | description: string 12 | latitude: number 13 | longitude: number 14 | } 15 | 16 | interface ClientMapProps { 17 | markers: Marker[] 18 | setMapInstance: (map: any) => void 19 | task_type?: string // 添加任务类型属性 20 | } 21 | 22 | export default function ClientMap({ markers, setMapInstance, task_type = "LOCATION_LIST" }: ClientMapProps) { 23 | const mapContainer = useRef(null) 24 | const [mapError, setMapError] = useState(null) 25 | const [isMapInitialized, setIsMapInitialized] = useState(false) 26 | // useRef to hold the map instance, to ensure cleanup targets the correct instance 27 | const mapRef = useRef(null); 28 | 29 | useEffect(() => { 30 | // Guard against re-initialization if already initialized by this instance 31 | if (!mapContainer.current || isMapInitialized) { 32 | return; 33 | } 34 | 35 | let isActive = true; // Flag to manage async operations for mounted component 36 | 37 | const initializeMap = async () => { 38 | try { 39 | const Leaflet = (await import("leaflet")).default; // Dynamically import Leaflet 40 | if (!isActive || !mapContainer.current) return; 41 | 42 | // Fix Leaflet icon issues 43 | if (Leaflet.Icon && Leaflet.Icon.Default && Leaflet.Icon.Default.prototype) { 44 | delete (Leaflet.Icon.Default.prototype as any)._getIconUrl; 45 | Leaflet.Icon.Default.mergeOptions({ 46 | iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", 47 | iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", 48 | shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", 49 | }); 50 | } 51 | 52 | // Defensive check: if the container DOM node already has leaflet's internal ID 53 | if ((mapContainer.current as any)._leaflet_id) { 54 | console.warn( 55 | "Leaflet map container already had _leaflet_id. This might indicate an incomplete cleanup from a previous instance or a StrictMode interaction." 56 | ); 57 | // Attempt to remove any existing map instance on this container before creating a new one 58 | // This is a more aggressive cleanup for problematic scenarios. 59 | if (mapRef.current) { 60 | mapRef.current.remove(); 61 | mapRef.current = null; 62 | } 63 | // Try to clear the id, though this is delving into Leaflet internals 64 | delete (mapContainer.current as any)._leaflet_id; 65 | } 66 | 67 | const newMapInstance = Leaflet.map(mapContainer.current).setView([35, 105], 4); 68 | mapRef.current = newMapInstance; // Store new map instance in ref 69 | 70 | if (!isActive) { 71 | newMapInstance.remove(); // Clean up if component unmounted during init 72 | mapRef.current = null; 73 | return; 74 | } 75 | 76 | Leaflet.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { 77 | attribution: '© OpenStreetMap contributors', 78 | maxZoom: 19, 79 | }).addTo(newMapInstance); 80 | 81 | const leafletMarkersLayer = Leaflet.layerGroup().addTo(newMapInstance); 82 | 83 | // Manage global window properties carefully if they are essential 84 | (window as any).leafletMap = newMapInstance; // For existing compatibility 85 | (window as any).leafletMarkersLayer = leafletMarkersLayer; 86 | (window as any).L = Leaflet; 87 | 88 | setMapInstance(newMapInstance); 89 | setIsMapInitialized(true); // Mark as initialized 90 | 91 | } catch (error) { 92 | if (isActive) { 93 | console.error("Error initializing map:", error); 94 | setMapError("初始化地图时出错。请检查控制台获取更多信息。"); 95 | setIsMapInitialized(false); // Ensure flag is false if init failed 96 | } 97 | } 98 | }; 99 | 100 | initializeMap(); 101 | 102 | // Cleanup function returned by useEffect 103 | return () => { 104 | isActive = false; // Prevent async operations from affecting unmounted component 105 | 106 | // Remove event listeners associated with this map instance if any were added directly here 107 | // (e.g., window resize listeners specific to this map instance) 108 | // window.removeEventListener("resize", specificResizeHandler); 109 | 110 | 111 | if (mapRef.current) { 112 | mapRef.current.remove(); // Use the ref to remove the correct map instance 113 | mapRef.current = null; 114 | } 115 | 116 | // Clean up global properties if they were set by this instance 117 | // Be cautious if other parts of the app might rely on these independently 118 | delete (window as any).leafletMap; 119 | delete (window as any).leafletMarkersLayer; 120 | // delete (window as any).L; // L is the Leaflet module, typically not deleted 121 | 122 | setIsMapInitialized(false); // Reset initialization flag 123 | }; 124 | // eslint-disable-next-line react-hooks/exhaustive-deps 125 | }, [setMapInstance]); // Dependency array from original code. 126 | // Consider if isMapInitialized should be here or if the guard is enough. 127 | // Keeping original [setMapInstance] for now as the primary trigger logic. 128 | 129 | // Effect for updating markers 130 | useEffect(() => { 131 | // Ensure map and Leaflet module (from window or direct import) are available 132 | const L = (window as any).L as typeof import('leaflet'); 133 | const currentMap = mapRef.current; // Use map instance from ref 134 | const markersLayer = (window as any).leafletMarkersLayer as L.LayerGroup; 135 | 136 | if (!isMapInitialized || !currentMap || !markersLayer || !L) return; 137 | 138 | markersLayer.clearLayers(); 139 | const leafletMarkers: L.Marker[] = []; 140 | 141 | markers.forEach((marker, index) => { 142 | // 所有地点使用相同的标记样式 143 | let markerColor = "blue"; 144 | let zIndexOffset = 0; 145 | let markerLabel = `${index + 1}`; 146 | 147 | // 所有地点使用默认的蓝色标记 148 | 149 | const customIcon = L.divIcon({ 150 | className: "custom-marker-icon", 151 | html: ` 152 |
153 | ${markerLabel} 154 |
155 |
156 | `, 157 | iconSize: [32, 32], 158 | iconAnchor: [16, 32], 159 | popupAnchor: [0, -32], 160 | }); 161 | 162 | const leafletMarker = L.marker([marker.latitude, marker.longitude], { 163 | icon: customIcon, 164 | zIndexOffset: zIndexOffset 165 | }) 166 | .bindPopup(`

${marker.title}

${marker.description}

`) 167 | .addTo(markersLayer); 168 | leafletMarkers.push(leafletMarker); 169 | }); 170 | 171 | if (leafletMarkers.length > 0) { 172 | const group = L.featureGroup(leafletMarkers); 173 | currentMap.fitBounds(group.getBounds(), { 174 | padding: [50, 50], 175 | maxZoom: 15, 176 | }); 177 | } 178 | }, [markers, isMapInitialized, task_type]); // 添加task_type到依赖 179 | 180 | // Effect for updating polylines 181 | useEffect(() => { 182 | const L = (window as any).L as typeof import('leaflet'); 183 | const currentMap = mapRef.current; 184 | 185 | if (!isMapInitialized || !currentMap || !L) return; 186 | 187 | // Remove existing polyline (if tracked, e.g., on window or a ref) 188 | if ((window as any).leafletRoute) { 189 | (window as any).leafletRoute.remove(); 190 | delete (window as any).leafletRoute; 191 | } 192 | 193 | // 只有在路线任务类型时才显示路线 194 | if (task_type === "ROUTE" && markers && markers.length > 1) { 195 | // 直接从地标数据中提取坐标用于路线绘制 196 | const routeCoordinates = markers.map(marker => [marker.latitude, marker.longitude] as [number, number]); 197 | 198 | // 为路线任务提供更明显的样式 199 | const polylineOptions = { 200 | color: "#4285F4", 201 | weight: 5, 202 | opacity: 0.8, 203 | dashArray: "10, 10" // 设置为虚线 204 | }; 205 | 206 | (window as any).leafletRoute = L.polyline(routeCoordinates, polylineOptions).addTo(currentMap); 207 | } 208 | }, [markers, isMapInitialized, task_type]); // 移除polylines,改为依赖markers 209 | 210 | if (mapError) { 211 | return 212 | } 213 | 214 | return ( 215 |
216 | 246 |
247 | ); 248 | } 249 | 250 | // Add global type definitions if still needed, but prefer importing L types 251 | declare global { 252 | interface Window { 253 | leafletMap?: L.Map; // Make it optional as it's a temporary solution 254 | leafletMarkersLayer?: L.LayerGroup; 255 | leafletRoute?: L.Polyline; 256 | L?: typeof import('leaflet'); // Leaflet module 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /components/map-container.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type React from "react" 4 | 5 | import { useState, useEffect } from "react" 6 | import { useMapStore } from "@/lib/store" 7 | import MapError from "@/components/map-error" 8 | 9 | export default function MapContainer() { 10 | const { markers, setMapInstance, task_type } = useMapStore() 11 | const [mapError, setMapError] = useState(null) 12 | const [MapComponent, setMapComponent] = useState | null>(null) 13 | 14 | // Load the map component only on the client side 15 | useEffect(() => { 16 | // Add Leaflet CSS from CDN 17 | const linkElement = document.createElement("link") 18 | linkElement.rel = "stylesheet" 19 | linkElement.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" 20 | linkElement.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" 21 | linkElement.crossOrigin = "" 22 | document.head.appendChild(linkElement) 23 | 24 | // Dynamically import the map component 25 | import("@/components/client-map") 26 | .then((module) => { 27 | setMapComponent(() => module.default) 28 | }) 29 | .catch((error) => { 30 | console.error("Error loading map component:", error) 31 | setMapError("加载地图组件时出错。请检查控制台获取更多信息。") 32 | }) 33 | 34 | return () => { 35 | // Clean up the CSS link when component unmounts 36 | document.head.removeChild(linkElement) 37 | } 38 | }, []) 39 | 40 | if (mapError) { 41 | return 42 | } 43 | 44 | if (!MapComponent) { 45 | return ( 46 |
47 |
48 |
49 | ) 50 | } 51 | 52 | return 53 | } 54 | -------------------------------------------------------------------------------- /components/map-error.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { AlertCircle } from "lucide-react" 4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 5 | 6 | interface MapErrorProps { 7 | message: string 8 | } 9 | 10 | export default function MapError({ message }: MapErrorProps) { 11 | return ( 12 |
13 | 14 | 15 | 地图加载错误 16 | {message} 17 | 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /components/suggested-queries.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | 5 | const SUGGESTED_QUERIES = [ 6 | { 7 | title: "法国普罗旺斯薰衣草花田最佳观赏路线", 8 | category: "nature", 9 | }, 10 | { 11 | title: "北京胡同深度一日游路线", 12 | category: "culture", 13 | }, 14 | { 15 | title: "撒哈拉沙漠最著名的绿洲城市", 16 | category: "travel", 17 | }, 18 | { 19 | title: "西西里岛最地道的传统美食餐厅", 20 | category: "food", 21 | }, 22 | { 23 | title: "东京奥运会场馆位置和交通指南", 24 | category: "sports", 25 | }, 26 | { 27 | title: "新西兰南岛自驾十日游路线规划", 28 | category: "travel", 29 | }, 30 | { 31 | title: "里约热内卢狂欢节最佳观赏地点", 32 | category: "culture", 33 | }, 34 | { 35 | title: "印度金三角旅游路线及景点推荐", 36 | category: "history", 37 | }, 38 | { 39 | title: "北欧四国夏季极光观测点", 40 | category: "nature", 41 | }, 42 | { 43 | title: "马达加斯加特有物种分布地图", 44 | category: "science", 45 | }, 46 | { 47 | title: "伊斯坦布尔跨欧亚两洲一日游", 48 | category: "travel", 49 | }, 50 | { 51 | title: "澳大利亚大堡礁最佳潜水地点", 52 | category: "adventure", 53 | }, 54 | ] 55 | 56 | interface SuggestedQueriesProps { 57 | onSelectQuery: (query: string) => void 58 | } 59 | 60 | export default function SuggestedQueries({ onSelectQuery }: SuggestedQueriesProps) { 61 | return ( 62 |
63 | {SUGGESTED_QUERIES.map((query, index) => ( 64 | 72 | ))} 73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { 5 | ThemeProvider as NextThemesProvider, 6 | type ThemeProviderProps, 7 | } from 'next-themes' 8 | 9 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 13 | }, 14 | }, 15 | defaultVariants: { 16 | variant: "default", 17 | }, 18 | }, 19 | ) 20 | 21 | const Alert = React.forwardRef< 22 | HTMLDivElement, 23 | React.HTMLAttributes & VariantProps 24 | >(({ className, variant, ...props }, ref) => ( 25 |
26 | )) 27 | Alert.displayName = "Alert" 28 | 29 | const AlertTitle = React.forwardRef>( 30 | ({ className, ...props }, ref) => ( 31 |
32 | ), 33 | ) 34 | AlertTitle.displayName = "AlertTitle" 35 | 36 | const AlertDescription = React.forwardRef>( 37 | ({ className, ...props }, ref) => ( 38 |
39 | ), 40 | ) 41 | AlertDescription.displayName = "AlertDescription" 42 | 43 | export { Alert, AlertTitle, AlertDescription } 44 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>