|
|
"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"; |
|
|
|
|
|
|
|
|
const ChartRenderer = dynamic( |
|
|
() => import("./charts/ChartRenderer"), |
|
|
{ ssr: false, loading: () => <div className="h-64 bg-slate-100 animate-pulse rounded-xl" /> } |
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
interface ChatPanelProps { |
|
|
onMapUpdate?: (geojson: any) => void; |
|
|
layers?: MapLayer[]; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
const [isThinkingOpen, setIsThinkingOpen] = useState(false); |
|
|
|
|
|
useEffect(() => { |
|
|
if (m.status === "Thinking..." || m.status === "Reasoning...") { |
|
|
setIsThinkingOpen(true); |
|
|
} else if (!m.status && hasThoughts) { |
|
|
|
|
|
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> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
|
|
}, [messages]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const updateLastMessage = useCallback((updater: (msg: Message) => Message) => { |
|
|
setMessages(prev => { |
|
|
const newMsgs = [...prev]; |
|
|
const lastMsgIndex = newMsgs.length - 1; |
|
|
|
|
|
newMsgs[lastMsgIndex] = updater({ ...newMsgs[lastMsgIndex] }); |
|
|
return newMsgs; |
|
|
}); |
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const filteredLayers = layers.filter(layer => |
|
|
layer.name.toLowerCase().includes(suggestionQuery.toLowerCase()) |
|
|
); |
|
|
|
|
|
const handleInput = () => { |
|
|
if (!inputRef.current) return; |
|
|
|
|
|
const text = inputRef.current.innerText; |
|
|
setInput(text); |
|
|
|
|
|
|
|
|
const selection = window.getSelection(); |
|
|
if (!selection || selection.rangeCount === 0) { |
|
|
setShowSuggestions(false); |
|
|
return; |
|
|
} |
|
|
|
|
|
const range = selection.getRangeAt(0); |
|
|
const node = range.startContainer; |
|
|
|
|
|
|
|
|
if (node.nodeType === Node.TEXT_NODE && node.textContent) { |
|
|
const textBeforeCursor = node.textContent.slice(0, range.startOffset); |
|
|
const lastSlashIndex = textBeforeCursor.lastIndexOf("/"); |
|
|
|
|
|
if (lastSlashIndex !== -1) { |
|
|
|
|
|
const query = textBeforeCursor.slice(lastSlashIndex + 1); |
|
|
if (!query.includes(" ")) { |
|
|
setShowSuggestions(true); |
|
|
setSuggestionQuery(query); |
|
|
setActiveSuggestionIndex(0); |
|
|
|
|
|
|
|
|
slashContext.current = { node, index: lastSlashIndex }; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
const { node, index } = slashContext.current; |
|
|
|
|
|
|
|
|
if (!document.contains(node)) return; |
|
|
|
|
|
const selection = window.getSelection(); |
|
|
const range = document.createRange(); |
|
|
|
|
|
try { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const endOffset = index + 1 + suggestionQuery.length; |
|
|
|
|
|
|
|
|
const safeEndOffset = Math.min(endOffset, (node.textContent?.length || 0)); |
|
|
|
|
|
range.setStart(node, index); |
|
|
range.setEnd(node, safeEndOffset); |
|
|
range.deleteContents(); |
|
|
|
|
|
|
|
|
const chip = document.createElement("span"); |
|
|
chip.contentEditable = "false"; |
|
|
|
|
|
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"; |
|
|
|
|
|
|
|
|
chip.style.backgroundColor = `${layer.style.color}26`; |
|
|
chip.style.color = layer.style.color; |
|
|
chip.style.borderColor = `${layer.style.color}40`; |
|
|
|
|
|
chip.dataset.layerId = layer.id; |
|
|
chip.dataset.layerName = layer.name; |
|
|
|
|
|
|
|
|
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> |
|
|
`; |
|
|
|
|
|
|
|
|
range.insertNode(chip); |
|
|
|
|
|
|
|
|
const space = document.createTextNode("\u00A0"); |
|
|
range.setStartAfter(chip); |
|
|
range.setEndAfter(chip); |
|
|
range.insertNode(space); |
|
|
|
|
|
|
|
|
range.setStartAfter(space); |
|
|
range.setEndAfter(space); |
|
|
|
|
|
if (selection) { |
|
|
selection.removeAllRanges(); |
|
|
selection.addRange(range); |
|
|
} |
|
|
|
|
|
setShowSuggestions(false); |
|
|
setInput(inputRef.current.innerText); |
|
|
|
|
|
|
|
|
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(); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sendMessage = useCallback(async () => { |
|
|
if (!inputRef.current || (!inputRef.current.innerText.trim() && !input.trim()) || loading) return; |
|
|
|
|
|
|
|
|
let fullMessage = ""; |
|
|
const nodes = inputRef.current.childNodes; |
|
|
let displayMessage = ""; |
|
|
|
|
|
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) { |
|
|
|
|
|
fullMessage += ` Layer ${el.dataset.layerName} (ID: ${el.dataset.layerId}) `; |
|
|
displayMessage += ` ${el.dataset.layerName} `; |
|
|
} else { |
|
|
fullMessage += el.innerText; |
|
|
displayMessage += el.innerText; |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
if (!fullMessage.trim()) return; |
|
|
|
|
|
setMessages(prev => [...prev, { role: "user", content: displayMessage }]); |
|
|
|
|
|
|
|
|
if (inputRef.current) inputRef.current.innerHTML = ""; |
|
|
setInput(""); |
|
|
|
|
|
setLoading(true); |
|
|
setShowSuggestions(false); |
|
|
|
|
|
|
|
|
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 }) |
|
|
}); |
|
|
|
|
|
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() || ""; |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
if (currentEventType === "result" && data.geojson && onMapUpdate) { |
|
|
onMapUpdate(data.geojson); |
|
|
} |
|
|
|
|
|
|
|
|
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 |
|
|
})); |
|
|
} 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); |
|
|
|
|
|
updateLastMessage(msg => ({ ...msg, status: undefined })); |
|
|
} |
|
|
}, [input, loading, messages, onMapUpdate, updateLastMessage, layers]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
} |
|
|
|