yuvrajsingh6
Initial commit: Analytical Finance Chatbot with Next.js frontend and FastAPI backend
c5b5cc8
"use client";
import { useState, useRef, useEffect } from 'react';
import { Send, User, Bot, Loader2 } from 'lucide-react';
import { api } from '@/lib/api';
import { useRouter } from 'next/navigation';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export default function ChatInterface({ conversationId, initialMessages = [] }) {
const [messages, setMessages] = useState(initialMessages);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef(null);
const router = useRouter();
const [streamingContent, setStreamingContent] = useState('');
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessage = input.trim();
setInput('');
setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
setIsLoading(true);
setStreamingContent('');
let currentId = conversationId;
try {
// If no conversation ID, create one first
if (!currentId) {
const newConv = await api.createConversation();
if (!newConv) throw new Error("Failed to create conversation");
currentId = newConv.conversation_id;
// Update URL without reloading
window.history.pushState({}, '', `/c/${currentId}`);
// Or use router.replace if prefer Next.js way, but we want to stay mounted
// We might need to handle this carefully.
// For now, let's just proceed with the currentId for the request.
}
// Start streaming request
const response = await fetch(api.getChatEndpoint(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversation_id: currentId,
user_message: [{ type: 'text', text: userMessage }]
}),
});
if (!response.ok) throw new Error('Network response was not ok');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
let fullAssistantMessage = '';
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value, { stream: !done });
// Parse SSE format: "data: {...}\n\n"
const lines = chunkValue.split('\n\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
if (dataStr === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(dataStr);
if (data.content) {
fullAssistantMessage += data.content;
setStreamingContent(fullAssistantMessage);
}
if (data.error) {
console.error("Stream error:", data.error);
}
} catch (e) {
// ignore partial json
}
}
}
}
// Finalize message
setMessages(prev => [...prev, { role: 'assistant', content: fullAssistantMessage }]);
setStreamingContent('');
setIsLoading(false);
// If we created a new chat, update the sidebar without reloading the chat component
if (!conversationId && currentId) {
window.dispatchEvent(new Event('chat-update'));
// Ensure the router knows about the new path for future navigations,
// but do it silently if possible or just rely on pushState.
// We already did pushState.
} else {
// Triggers sidebar update for existing chats too (timestamp update)
window.dispatchEvent(new Event('chat-update'));
}
} catch (error) {
console.error("Error sending message:", error);
setMessages(prev => [...prev, { role: 'system', content: "Error sending message. Please try again." }]);
setIsLoading(false);
}
};
return (
<div className="flex flex-col h-full max-w-3xl mx-auto w-full">
{/* Messages Area */}
<div className="flex-1 overflow-y-auto w-full p-4 md:p-6 pb-32">
{messages.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-center opacity-50">
<h1 className="text-4xl font-semibold mb-8">Analytical Chat</h1>
<p className="max-w-md text-sm">Ask anything about your data. The AI will analyze and provide insights.</p>
</div>
)}
{messages.map((msg, idx) => (
<div key={idx} className={`group w-full text-gray-800 dark:text-gray-100 border-b border-black/5 dark:border-white/5 ${msg.role === 'assistant' ? 'bg-[var(--ai-msg-bg)]' : 'bg-[var(--user-msg-bg)]'
}`}>
<div className="text-base gap-4 md:gap-6 md:max-w-2xl lg:max-w-[38rem] xl:max-w-3xl flex lg:px-0 m-auto p-4 md:py-6">
<div className="w-[30px] flex flex-col relative items-end">
<div className={`relative h-[30px] w-[30px] p-1 rounded-sm flex items-center justify-center ${msg.role === 'user' ? 'bg-black text-white dark:bg-white dark:text-black' : 'bg-green-500 text-white'}`}>
{msg.role === 'user' ? <User size={18} /> : <Bot size={18} />}
</div>
</div>
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
<div className="flex flex-grow flex-col gap-3">
<div className="min-h-[20px] flex flex-col items-start gap-4 whitespace-pre-wrap break-words prose prose-neutral dark:prose-invert max-w-none text-black dark:text-gray-100">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ node, ...props }) => <div className="overflow-x-auto my-4 w-full"><table className="border-collapse table-auto w-full text-sm" {...props} /></div>,
th: ({ node, ...props }) => <th className="border-b dark:border-white/20 border-black/10 px-4 py-2 text-left font-semibold" {...props} />,
td: ({ node, ...props }) => <td className="border-b dark:border-white/10 border-black/5 px-4 py-2" {...props} />,
p: ({ node, ...props }) => <p className="mb-2 last:mb-0" {...props} />,
ul: ({ node, ...props }) => <ul className="list-disc pl-4 mb-4" {...props} />,
ol: ({ node, ...props }) => <ol className="list-decimal pl-4 mb-4" {...props} />,
li: ({ node, ...props }) => <li className="mb-1" {...props} />,
code: ({ node, inline, className, children, ...props }) => {
return inline ?
<code className="bg-black/10 dark:bg-white/10 rounded-sm px-1 py-0.5 font-mono text-sm" {...props}>{children}</code> :
<code className="block bg-black/10 dark:bg-white/10 rounded-md p-4 font-mono text-sm overflow-x-auto my-2" {...props}>{children}</code>
}
}}
>
{msg.content}
</ReactMarkdown>
</div>
</div>
</div>
</div>
</div>
))}
{/* Streaming Message Bubble */}
{(isLoading && streamingContent) && (
<div className="group w-full text-gray-800 dark:text-gray-100 border-b border-black/5 dark:border-white/5 bg-[var(--ai-msg-bg)]">
<div className="text-base gap-4 md:gap-6 md:max-w-2xl lg:max-w-[38rem] xl:max-w-3xl flex lg:px-0 m-auto p-4 md:py-6">
<div className="w-[30px] flex flex-col relative items-end">
<div className="relative h-[30px] w-[30px] p-1 rounded-sm flex items-center justify-center bg-green-500 text-white">
<Bot size={18} />
</div>
</div>
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
<div className="flex flex-grow flex-col gap-3">
<div className="min-h-[20px] flex flex-col items-start gap-4 whitespace-pre-wrap break-words prose prose-neutral dark:prose-invert max-w-none text-black dark:text-gray-100">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ node, ...props }) => <div className="overflow-x-auto my-4 w-full"><table className="border-collapse table-auto w-full text-sm" {...props} /></div>,
th: ({ node, ...props }) => <th className="border-b dark:border-white/20 border-black/10 px-4 py-2 text-left font-semibold" {...props} />,
td: ({ node, ...props }) => <td className="border-b dark:border-white/10 border-black/5 px-4 py-2" {...props} />,
}}
>
{streamingContent}
</ReactMarkdown>
<span className="w-2 h-4 bg-gray-500 inline-block animate-pulse" />
</div>
</div>
</div>
</div>
</div>
)}
{(isLoading && !streamingContent) && (
<div className="flex justify-center p-4"><Loader2 className="animate-spin text-gray-400" /></div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="absolute bottom-0 left-0 w-full border-t md:border-t-0 dark:border-white/20 md:border-transparent md:dark:border-transparent md:bg-gradient-to-t from-white dark:from-[var(--background-start-rgb)] to-transparent pt-0 md:pt-2">
<div className="stretch mx-2 flex flex-row gap-3 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-2xl xl:max-w-3xl">
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex flex-col w-full py-2 flex-grow md:py-3 md:pl-4 relative border border-black/10 dark:border-gray-900/50 text-black dark:text-white bg-white dark:bg-gray-700 rounded-md shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]">
<form onSubmit={handleSubmit} className="flex flex-row w-full items-center">
<input
className="m-0 w-full resize-none border-0 bg-transparent p-0 pr-7 focus:ring-0 focus-visible:ring-0 dark:bg-transparent pl-2 md:pl-0 outline-none overflow-y-hidden h-[24px]"
placeholder="Send a message..."
value={input}
onChange={(e) => setInput(e.target.value)}
autoFocus
/>
<button
type="submit"
disabled={isLoading || input.length === 0}
className="absolute p-1 rounded-md text-gray-500 bottom-1.5 right-1 md:bottom-2.5 md:right-2 hover:bg-gray-100 dark:hover:bg-gray-900 disabled:hover:bg-transparent disabled:opacity-40 transition-colors"
>
<Send size={16} />
</button>
</form>
</div>
<div className="px-2 py-2 text-center text-xs text-gray-600 dark:text-gray-300 md:px-[60px]">
<span>AI can make mistakes. Consider checking important information.</span>
</div>
</div>
</div>
</div>
</div>
);
}