Download frontend/src/components/ChatPanel.tsx from GerardCB/GeoQuery: direct link, hf CLI and curl.
- Browser
- Download file 30.6 kB
-
https://huggingface.co/spaces/GerardCB/GeoQuery/resolve/main/frontend/src/components/ChatPanel.tsx
- Command line
-
hf download hf://spaces/GerardCB/GeoQuery/frontend/src/components/ChatPanel.tsx
-
curl -L -o ChatPanel.tsx https://huggingface.co/spaces/GerardCB/GeoQuery/resolve/main/frontend/src/components/ChatPanel.tsx
30.6 kB
| "use client"; | |
| import { useState, useRef, useEffect, useCallback } from "react"; | |
| import { Send, Map as MapIcon, Database, Sparkles, BarChart3, Brain } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import dynamic from "next/dynamic"; | |
| import ReactMarkdown from "react-markdown"; | |
| import remarkGfm from "remark-gfm"; | |
| // Dynamic import for chart renderer to avoid SSR issues | |
| const ChartRenderer = dynamic( | |
| () => import("./charts/ChartRenderer"), | |
| { ssr: false, loading: () => <div className="h-64 bg-slate-100 animate-pulse rounded-xl" /> } | |
| ); | |
| // ============================================================================ | |
| // ============================================================================ | |
| // Types | |
| // ============================================================================ | |
| import type { MapLayer } from "./MapViewer"; | |
| interface ChartData { | |
| type: 'bar' | 'line' | 'pie' | 'donut'; | |
| title?: string; | |
| data: Array<{ [key: string]: any }>; | |
| xKey?: string; | |
| yKey?: string; | |
| lines?: Array<{ key: string; color?: string; name?: string }>; | |
| } | |
| interface Message { | |
| role: "user" | "assistant"; | |
| content: string; | |
| sql?: string; | |
| citations?: string[]; | |
| intent?: string; | |
| chart?: ChartData; | |
| rawData?: Array<any>; | |
| thoughts?: string; | |
| status?: string; // e.g., "Thinking...", "Writing SQL...", etc. | |
| } | |
| interface ChatPanelProps { | |
| onMapUpdate?: (geojson: any) => void; | |
| layers?: MapLayer[]; | |
| } | |
| // ============================================================================ | |
| // Loading Indicator Component | |
| // ============================================================================ | |
| function LoadingDots() { | |
| return ( | |
| <div className="flex gap-1"> | |
| <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> | |
| <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} /> | |
| <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} /> | |
| </div> | |
| ); | |
| } | |
| // ============================================================================ | |
| // Message Bubble Component | |
| // ============================================================================ | |
| interface MessageBubbleProps { | |
| message: Message; | |
| } | |
| function MessageBubble({ message: m }: MessageBubbleProps) { | |
| const isUser = m.role === "user"; | |
| const isLoading = !!m.status && !m.content; | |
| const hasContent = !!m.content; | |
| const hasThoughts = !!m.thoughts; | |
| // Auto-collapse reasoning when generation is done | |
| const [isThinkingOpen, setIsThinkingOpen] = useState(false); | |
| useEffect(() => { | |
| if (m.status === "Thinking..." || m.status === "Reasoning...") { | |
| setIsThinkingOpen(true); | |
| } else if (!m.status && hasThoughts) { | |
| // Collapse when done | |
| setIsThinkingOpen(false); | |
| } | |
| }, [m.status, hasThoughts]); | |
| return ( | |
| <div className={cn( | |
| "flex flex-col", | |
| isUser ? "ml-auto items-end max-w-[90%]" : "mr-auto items-start w-full" | |
| )}> | |
| {/* Main Message Bubble */} | |
| <div className={cn( | |
| "px-4 py-3 rounded-2xl text-sm leading-relaxed shadow-sm", | |
| isUser | |
| ? "bg-gradient-to-br from-indigo-600 to-indigo-700 text-white rounded-br-md max-w-full" | |
| : "bg-white text-slate-700 rounded-bl-md border border-slate-100 max-w-[95%]" | |
| )}> | |
| {/* Status Indicator (inside bubble when loading) */} | |
| {isLoading && ( | |
| <div className="flex items-center gap-2 text-slate-400"> | |
| <LoadingDots /> | |
| <span className="text-sm italic">{m.status}</span> | |
| </div> | |
| )} | |
| {/* Content with Markdown */} | |
| {hasContent && ( | |
| <div className={cn( | |
| "prose prose-sm max-w-none break-words", | |
| isUser ? "prose-invert text-white" : "prose-slate" | |
| )}> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| components={{ | |
| pre: ({ node, ...props }) => ( | |
| <div className="overflow-auto w-full my-2 bg-black/10 rounded-lg p-2" {...props as any} /> | |
| ), | |
| code: ({ node, ...props }) => ( | |
| <code className="bg-black/10 rounded px-1 py-0.5" {...props as any} /> | |
| ) | |
| }} | |
| > | |
| {m.content} | |
| </ReactMarkdown> | |
| </div> | |
| )} | |
| {/* Inline status when content is present but still loading more */} | |
| {hasContent && m.status && ( | |
| <div className="mt-2 flex items-center gap-2 text-xs text-slate-400 italic border-t border-slate-100 pt-2"> | |
| <LoadingDots /> | |
| <span>{m.status}</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Thought Process (Collapsible) - Auto-collapses when done */} | |
| {hasThoughts && ( | |
| <div className="mt-2 w-full max-w-[95%] group"> | |
| <button | |
| onClick={() => setIsThinkingOpen(!isThinkingOpen)} | |
| className="text-xs text-indigo-500 cursor-pointer hover:text-indigo-700 flex items-center gap-1 font-medium select-none list-none outline-none" | |
| > | |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-indigo-50 rounded-full border border-indigo-100"> | |
| <Brain className="w-3 h-3 text-indigo-500" /> | |
| <span>AI Reasoning</span> | |
| <span className="ml-1 opacity-50">{isThinkingOpen ? "▼" : "▶"}</span> | |
| </div> | |
| </button> | |
| {isThinkingOpen && ( | |
| <div className="mt-2 p-3 bg-slate-50 rounded-xl border border-slate-100 text-xs text-slate-600 space-y-2 animate-in slide-in-from-top-2 duration-200"> | |
| <div className="flex gap-2"> | |
| <div className="w-0.5 bg-indigo-200 rounded-full shrink-0" /> | |
| <div className="leading-relaxed prose prose-xs max-w-none prose-slate whitespace-pre-wrap break-words w-full"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{m.thoughts}</ReactMarkdown> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Chart Visualization */} | |
| {m.chart && ( | |
| <div className="mt-3 w-full max-w-md"> | |
| <ChartRenderer chart={m.chart} /> | |
| </div> | |
| )} | |
| {/* Data Citations */} | |
| {m.citations && m.citations.length > 0 && ( | |
| <div className="mt-2 flex flex-wrap gap-1.5"> | |
| {m.citations.map((citation, idx) => ( | |
| <span | |
| key={idx} | |
| className="inline-flex items-center gap-1 text-xs px-2 py-1 bg-blue-50 text-blue-700 rounded-full border border-blue-100" | |
| > | |
| <Database className="w-3 h-3" /> | |
| {citation} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| {/* SQL Query and Raw Data (collapsible) */} | |
| {(m.sql || m.rawData) && ( | |
| <details className="mt-2 w-full max-w-[95%]"> | |
| <summary className="text-xs text-slate-500 cursor-pointer hover:text-slate-700 flex items-center gap-1"> | |
| <BarChart3 className="w-3 h-3" /> | |
| View SQL & Raw Data | |
| </summary> | |
| <div className="mt-2 space-y-3"> | |
| {m.sql && ( | |
| <div className="text-xs font-mono bg-slate-800 text-green-400 p-3 rounded-lg overflow-x-auto whitespace-pre-wrap"> | |
| {m.sql} | |
| </div> | |
| )} | |
| {m.rawData && m.rawData.length > 0 && m.rawData[0] && ( | |
| <div className="overflow-x-auto border rounded-lg max-h-60"> | |
| <table className="min-w-full divide-y divide-slate-200 text-xs"> | |
| <thead className="bg-slate-50 sticky top-0"> | |
| <tr> | |
| {Object.keys(m.rawData[0] || {}).map((key) => ( | |
| <th key={key} className="px-3 py-2 text-left font-medium text-slate-500 uppercase tracking-wider"> | |
| {key} | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody className="bg-white divide-y divide-slate-200"> | |
| {m.rawData.map((row, idx) => ( | |
| <tr key={idx} className="hover:bg-slate-50"> | |
| {Object.values(row).map((val: any, vIdx) => ( | |
| <td key={vIdx} className="px-3 py-2 whitespace-nowrap text-slate-600"> | |
| {typeof val === 'object' ? JSON.stringify(val) : String(val)} | |
| </td> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </div> | |
| </details> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ============================================================================ | |
| // Main ChatPanel Component | |
| // ============================================================================ | |
| export default function ChatPanel({ onMapUpdate, layers = [] }: ChatPanelProps) { | |
| const [messages, setMessages] = useState<Message[]>([ | |
| { | |
| role: "assistant", | |
| content: "Welcome! I'm GeoQuery, your Territorial Intelligence Assistant for Panama." | |
| } | |
| ]); | |
| const [input, setInput] = useState(""); | |
| const [loading, setLoading] = useState(false); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| // Slash command state | |
| const [showSuggestions, setShowSuggestions] = useState(false); | |
| const [suggestionQuery, setSuggestionQuery] = useState(""); | |
| const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0); | |
| const inputRef = useRef<HTMLDivElement>(null); | |
| const [cursorPosition, setCursorPosition] = useState<{ top: number, left: number } | null>(null); | |
| const slashContext = useRef<{ node: Node, index: number } | null>(null); | |
| // Auto-scroll to bottom when new messages arrive | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages]); | |
| // ======================================================================== | |
| // Message Updater - Properly handles immutable state updates | |
| // ======================================================================== | |
| const updateLastMessage = useCallback((updater: (msg: Message) => Message) => { | |
| setMessages(prev => { | |
| const newMsgs = [...prev]; | |
| const lastMsgIndex = newMsgs.length - 1; | |
| // Create a new message object to avoid mutation | |
| newMsgs[lastMsgIndex] = updater({ ...newMsgs[lastMsgIndex] }); | |
| return newMsgs; | |
| }); | |
| }, []); | |
| // ======================================================================== | |
| // Slash Command Logic & Rich Text Input | |
| // ======================================================================== | |
| // Filter layers based on query | |
| const filteredLayers = layers.filter(layer => | |
| layer.name.toLowerCase().includes(suggestionQuery.toLowerCase()) | |
| ); | |
| const handleInput = () => { | |
| if (!inputRef.current) return; | |
| const text = inputRef.current.innerText; | |
| setInput(text); // Keep simple text state for disabled check | |
| // Detect slash command using Selection API | |
| const selection = window.getSelection(); | |
| if (!selection || selection.rangeCount === 0) { | |
| setShowSuggestions(false); | |
| return; | |
| } | |
| const range = selection.getRangeAt(0); | |
| const node = range.startContainer; | |
| // Only trigger if we are in a text node | |
| if (node.nodeType === Node.TEXT_NODE && node.textContent) { | |
| const textBeforeCursor = node.textContent.slice(0, range.startOffset); | |
| const lastSlashIndex = textBeforeCursor.lastIndexOf("/"); | |
| if (lastSlashIndex !== -1) { | |
| // Check if contains spaces (simple heuristic to stop looking too far back) | |
| const query = textBeforeCursor.slice(lastSlashIndex + 1); | |
| if (!query.includes(" ")) { | |
| setShowSuggestions(true); | |
| setSuggestionQuery(query); | |
| setActiveSuggestionIndex(0); | |
| // Save context for replacement | |
| slashContext.current = { node, index: lastSlashIndex }; | |
| // Get coordinates for popup | |
| const rect = range.getBoundingClientRect(); | |
| const inputRect = inputRef.current.getBoundingClientRect(); | |
| setCursorPosition({ | |
| top: rect.top - inputRect.top, | |
| left: rect.left - inputRect.left | |
| }); | |
| return; | |
| } | |
| } | |
| } | |
| setShowSuggestions(false); | |
| }; | |
| const handleSuggestionSelect = (layer: MapLayer) => { | |
| if (!inputRef.current || !slashContext.current) return; | |
| // Use stored context | |
| const { node, index } = slashContext.current; | |
| // Validate node is still in DOM (basic check) | |
| if (!document.contains(node)) return; | |
| const selection = window.getSelection(); | |
| const range = document.createRange(); | |
| try { | |
| // Calculate end index: slash index + 1 (for '/') + query length | |
| // But query length might have changed since last render? | |
| // Better: Replace from slash index to the CURRENT cursor (if we still have focus) | |
| // OR simpler: Replace from slash index to (slash index + 1 + suggestionQuery.length) | |
| // We'll trust suggestionQuery state as it drives the filtering | |
| const endOffset = index + 1 + suggestionQuery.length; | |
| // Safety check limits | |
| const safeEndOffset = Math.min(endOffset, (node.textContent?.length || 0)); | |
| range.setStart(node, index); | |
| range.setEnd(node, safeEndOffset); | |
| range.deleteContents(); | |
| // Create the chip | |
| const chip = document.createElement("span"); | |
| chip.contentEditable = "false"; | |
| // Fixed height 20px (h-5), smaller text, constrained width | |
| chip.className = "inline-flex items-center gap-1 px-1.5 h-5 mx-1 rounded-full text-[11px] font-medium select-none align-middle transition-transform hover:scale-105 cursor-default max-w-[160px] truncate border"; | |
| // Style: 15% opacity background, matching border | |
| chip.style.backgroundColor = `${layer.style.color}26`; // 26 = ~15% opacity | |
| chip.style.color = layer.style.color; | |
| chip.style.borderColor = `${layer.style.color}40`; // 25% opacity border | |
| chip.dataset.layerId = layer.id; | |
| chip.dataset.layerName = layer.name; | |
| // Dot icon | |
| chip.innerHTML = ` | |
| <span style="background-color: ${layer.style.color}" class="w-1.5 h-1.5 rounded-full inline-block shrink-0"></span> | |
| <span class="truncate">${layer.name}</span> | |
| `; | |
| // Insert chip | |
| range.insertNode(chip); | |
| // Add a space after | |
| const space = document.createTextNode("\u00A0"); | |
| range.setStartAfter(chip); | |
| range.setEndAfter(chip); | |
| range.insertNode(space); | |
| // Restore Selection to end | |
| range.setStartAfter(space); | |
| range.setEndAfter(space); | |
| if (selection) { | |
| selection.removeAllRanges(); | |
| selection.addRange(range); | |
| } | |
| setShowSuggestions(false); | |
| setInput(inputRef.current.innerText); // update state | |
| // Force focus back | |
| inputRef.current.focus(); | |
| } catch (e) { | |
| console.error("Error inserting chip:", e); | |
| } | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { | |
| if (showSuggestions && filteredLayers.length > 0) { | |
| if (e.key === "ArrowDown") { | |
| e.preventDefault(); | |
| setActiveSuggestionIndex(prev => (prev + 1) % filteredLayers.length); | |
| } else if (e.key === "ArrowUp") { | |
| e.preventDefault(); | |
| setActiveSuggestionIndex(prev => (prev - 1 + filteredLayers.length) % filteredLayers.length); | |
| } else if (e.key === "Enter" || e.key === "Tab") { | |
| e.preventDefault(); | |
| handleSuggestionSelect(filteredLayers[activeSuggestionIndex]); | |
| } else if (e.key === "Escape") { | |
| setShowSuggestions(false); | |
| } | |
| return; | |
| } | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }; | |
| // ======================================================================== | |
| // Send Message Handler | |
| // ======================================================================== | |
| const sendMessage = useCallback(async () => { | |
| if (!inputRef.current || (!inputRef.current.innerText.trim() && !input.trim()) || loading) return; | |
| // Construct message with IDs from DOM | |
| let fullMessage = ""; | |
| const nodes = inputRef.current.childNodes; | |
| let displayMessage = ""; // For user chat bubble (clean text) | |
| nodes.forEach(node => { | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| fullMessage += node.textContent; | |
| displayMessage += node.textContent; | |
| } else if (node.nodeType === Node.ELEMENT_NODE) { | |
| const el = node as HTMLElement; | |
| if (el.dataset.layerId) { | |
| // It's a chip | |
| fullMessage += ` Layer ${el.dataset.layerName} (ID: ${el.dataset.layerId}) `; | |
| displayMessage += ` ${el.dataset.layerName} `; | |
| } else { | |
| fullMessage += el.innerText; | |
| displayMessage += el.innerText; | |
| } | |
| } | |
| }); | |
| // Fallback if empty (shouldn't happen with check above) | |
| if (!fullMessage.trim()) return; | |
| setMessages(prev => [...prev, { role: "user", content: displayMessage }]); | |
| // Clear input | |
| if (inputRef.current) inputRef.current.innerHTML = ""; | |
| setInput(""); | |
| setLoading(true); | |
| setShowSuggestions(false); | |
| // Add placeholder assistant message with initial status | |
| setMessages(prev => [...prev, { | |
| role: "assistant", | |
| content: "", | |
| thoughts: "", | |
| status: "Thinking..." | |
| }]); | |
| try { | |
| const history = messages.map(m => ({ role: m.role, content: m.content })); | |
| const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; | |
| const response = await fetch(`${apiUrl}/chat/stream`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ message: fullMessage, history }) // Send ID-enriched message | |
| }); | |
| const reader = response.body?.getReader(); | |
| const decoder = new TextDecoder(); | |
| if (!reader) { | |
| updateLastMessage(msg => ({ ...msg, content: "Error: No response stream", status: undefined })); | |
| return; | |
| } | |
| let buffer = ""; | |
| let currentEventType: string | null = null; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value, { stream: true }); | |
| buffer += chunk; | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ""; // Keep partial line in buffer | |
| for (const line of lines) { | |
| if (line.startsWith("event:")) { | |
| currentEventType = line.slice(6).trim(); | |
| } else if (line.startsWith("data:")) { | |
| const dataStr = line.slice(5).trim(); | |
| if (!dataStr) continue; | |
| try { | |
| const data = JSON.parse(dataStr); | |
| // Handle map updates OUTSIDE state updater | |
| if (currentEventType === "result" && data.geojson && onMapUpdate) { | |
| onMapUpdate(data.geojson); | |
| } | |
| // Update message based on event type | |
| if (currentEventType === "chunk") { | |
| if (data.type === "text") { | |
| updateLastMessage(msg => ({ | |
| ...msg, | |
| content: msg.content + data.content | |
| })); | |
| } else if (data.type === "thought") { | |
| updateLastMessage(msg => ({ | |
| ...msg, | |
| thoughts: (msg.thoughts || "") + data.content, | |
| status: "Reasoning..." | |
| })); | |
| } | |
| } else if (currentEventType === "result") { | |
| updateLastMessage(msg => ({ | |
| ...msg, | |
| content: data.response || msg.content, | |
| sql: data.sql_query, | |
| chart: data.chart_data, | |
| rawData: data.raw_data, | |
| citations: data.data_citations, | |
| status: undefined // Clear status on completion | |
| })); | |
| } else if (currentEventType === "status") { | |
| updateLastMessage(msg => ({ | |
| ...msg, | |
| status: data.status | |
| })); | |
| } | |
| } catch (e) { | |
| console.error("Error parsing SSE JSON:", e); | |
| } | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| console.error("Stream error:", err); | |
| setMessages(prev => [...prev, { | |
| role: "assistant", | |
| content: "Error connecting to server. Please try again." | |
| }]); | |
| } finally { | |
| setLoading(false); | |
| // Ensure status is cleared after stream ends | |
| updateLastMessage(msg => ({ ...msg, status: undefined })); | |
| } | |
| }, [input, loading, messages, onMapUpdate, updateLastMessage, layers]); | |
| // ======================================================================== | |
| // Render | |
| // ======================================================================== | |
| return ( | |
| <div className="flex flex-col h-full bg-gradient-to-b from-slate-50 to-white border-r shadow-lg relative"> | |
| {/* Header */} | |
| <div className="p-4 border-b flex items-center justify-between bg-white/80 backdrop-blur-sm"> | |
| <h1 className="font-bold text-lg flex items-center gap-2"> | |
| <div className="p-1.5 bg-indigo-600 rounded-lg"> | |
| <MapIcon className="w-4 h-4 text-white" /> | |
| </div> | |
| <span className="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent"> | |
| GeoQuery | |
| </span> | |
| </h1> | |
| <div className="flex items-center gap-1.5 text-xs font-medium px-2 py-1 bg-emerald-50 text-emerald-700 rounded-full border border-emerald-100"> | |
| <Sparkles className="w-3 h-3" /> | |
| Gemini 3 Flash | |
| </div> | |
| </div> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto p-4 space-y-4"> | |
| {messages.map((m, i) => ( | |
| <MessageBubble key={i} message={m} /> | |
| ))} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* Input */} | |
| <div className="p-4 border-t bg-white/80 backdrop-blur-sm relative"> | |
| {/* Suggestions Popup */} | |
| {showSuggestions && filteredLayers.length > 0 && ( | |
| <div | |
| className="absolute bg-white rounded-xl shadow-xl border border-slate-200 overflow-hidden z-20 max-h-48 overflow-y-auto w-64 animate-in fade-in slide-in-from-bottom-2 duration-200" | |
| style={{ | |
| bottom: "100%", // Always anchor to bottom of input area for now, simpler than dynamic text coords | |
| left: cursorPosition ? Math.min(cursorPosition.left + 20, 300) : 20, | |
| marginBottom: "10px" | |
| }} | |
| > | |
| <div className="p-2 bg-slate-50 text-xs text-slate-500 border-b border-slate-100 font-medium flex items-center gap-1"> | |
| <Sparkles className="w-3 h-3 text-indigo-400" /> | |
| Reference Map Layer | |
| </div> | |
| {filteredLayers.map((layer, idx) => ( | |
| <button | |
| key={layer.id} | |
| onMouseDown={(e) => { | |
| e.preventDefault(); // Prevent losing focus from input | |
| handleSuggestionSelect(layer); | |
| }} | |
| className={cn( | |
| "w-full px-3 py-2 flex items-center gap-2 text-sm text-left hover:bg-slate-50 transition-colors", | |
| idx === activeSuggestionIndex ? "bg-indigo-50 text-indigo-700" : "text-slate-700" | |
| )} | |
| > | |
| <div | |
| className="w-2.5 h-2.5 rounded-full shrink-0 ring-2 ring-white shadow-sm" | |
| style={{ backgroundColor: layer.style.color }} | |
| /> | |
| <span className="truncate font-medium">{layer.name}</span> | |
| {layer.pointMarker?.icon && <span className="text-base ml-auto opacity-70">{layer.pointMarker.icon}</span>} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| <div className="flex items-end gap-2 bg-white border border-slate-200 rounded-2xl px-4 py-3 shadow-sm focus-within:ring-2 focus-within:ring-indigo-100 focus-within:border-indigo-300 transition-all"> | |
| <div | |
| ref={inputRef} | |
| contentEditable | |
| className="flex-1 outline-none text-sm text-gray-900 leading-relaxed min-h-[20px] max-h-32 overflow-y-auto whitespace-pre-wrap break-words empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400" | |
| data-placeholder="Ask about Panama's data..." | |
| onInput={handleInput} | |
| onKeyDown={handleKeyDown} | |
| // Prevent tab interactions escaping the editor improperly | |
| suppressContentEditableWarning | |
| /> | |
| {/* Send Button */} | |
| <button | |
| onClick={sendMessage} | |
| disabled={loading || (!input.trim() && !inputRef.current?.innerText.trim())} | |
| className={cn( | |
| "p-2 rounded-xl transition-all shrink-0 pb-2", | |
| loading || (!input.trim() && !inputRef.current?.innerText.trim()) | |
| ? "bg-slate-100 text-slate-400 cursor-not-allowed" | |
| : "bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm hover:shadow" | |
| )} | |
| > | |
| <Send className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| <p className="text-xs text-slate-400 mt-2 text-center"> | |
| GeoQuery can query datasets and visualize results as maps and charts. | |
| <br /> | |
| Use '/' to reference layers. | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |