├── .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 |  14 | 15 |  16 | 17 | ## 部署 18 | 19 | 1. Vercel 一键部署: 20 | 21 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FSeanium%2FChatMap) 22 | 23 | 2. 设置 API: 24 | 25 |  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 |${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