GerardCB's picture
Fix: Replace hardcoded localhost API URL with env var
feb395f
"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>
);
}