"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: () =>
} ); // ============================================================================ // ============================================================================ // 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; thoughts?: string; status?: string; // e.g., "Thinking...", "Writing SQL...", etc. } interface ChatPanelProps { onMapUpdate?: (geojson: any) => void; layers?: MapLayer[]; } // ============================================================================ // Loading Indicator Component // ============================================================================ function LoadingDots() { return (
); } // ============================================================================ // 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 (
{/* Main Message Bubble */}
{/* Status Indicator (inside bubble when loading) */} {isLoading && (
{m.status}
)} {/* Content with Markdown */} {hasContent && (
(
), code: ({ node, ...props }) => ( ) }} > {m.content}
)} {/* Inline status when content is present but still loading more */} {hasContent && m.status && (
{m.status}
)}
{/* Thought Process (Collapsible) - Auto-collapses when done */} {hasThoughts && (
{isThinkingOpen && (
{m.thoughts}
)}
)} {/* Chart Visualization */} {m.chart && (
)} {/* Data Citations */} {m.citations && m.citations.length > 0 && (
{m.citations.map((citation, idx) => ( {citation} ))}
)} {/* SQL Query and Raw Data (collapsible) */} {(m.sql || m.rawData) && (
View SQL & Raw Data
{m.sql && (
{m.sql}
)} {m.rawData && m.rawData.length > 0 && m.rawData[0] && (
{Object.keys(m.rawData[0] || {}).map((key) => ( ))} {m.rawData.map((row, idx) => ( {Object.values(row).map((val: any, vIdx) => ( ))} ))}
{key}
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
)}
)}
); } // ============================================================================ // Main ChatPanel Component // ============================================================================ export default function ChatPanel({ onMapUpdate, layers = [] }: ChatPanelProps) { const [messages, setMessages] = useState([ { 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(null); // Slash command state const [showSuggestions, setShowSuggestions] = useState(false); const [suggestionQuery, setSuggestionQuery] = useState(""); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0); const inputRef = useRef(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 = ` ${layer.name} `; // 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) => { 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 (
{/* Header */}

GeoQuery

Gemini 3 Flash
{/* Messages */}
{messages.map((m, i) => ( ))}
{/* Input */}
{/* Suggestions Popup */} {showSuggestions && filteredLayers.length > 0 && (
Reference Map Layer
{filteredLayers.map((layer, idx) => ( ))}
)}
{/* Send Button */}

GeoQuery can query datasets and visualize results as maps and charts.
Use '/' to reference layers.

); }