PetroMind_AI / frontend /components /ChatInterface.tsx
gauthamnairy's picture
Upload 41 files
609c821 verified
import React, { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
// @ts-ignore
import remarkGfm from 'remark-gfm';
import { Icons } from '../constants';
import { sendChatMessage } from '../services/geminiService';
import { ChatMessage, AIModel, AVAILABLE_MODELS } from '../types';
import CompactModelSelector from './CompactModelSelector';
interface ChatInterfaceProps {
onNavigateToPage: (page: number) => void;
}
const ChatInterface: React.FC<ChatInterfaceProps> = ({ onNavigateToPage }) => {
const [selectedModel, setSelectedModel] = useState<AIModel>(AVAILABLE_MODELS[0]);
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'model', content: "Hello! I've analyzed your document. Ask me anything about it." }
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [useRLM, setUseRLM] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSend = async () => {
if (!input.trim() || loading) return;
const userMsg: ChatMessage = { role: 'user', content: input };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
// Build conversation history from previous messages (excluding the current one we just added)
// Format: [{role: "user"|"assistant", content: "..."}]
// Skip the initial greeting and only include actual conversation
const conversationHistory = messages
.filter((msg, idx) => idx > 0) // Skip initial AI greeting
.map(msg => ({
role: msg.role === 'model' ? 'assistant' : 'user',
content: msg.content
}));
// For RLM mode, we can optionally skip history as deep research handles context differently
// But for normal chat, always pass history
const historyToSend = useRLM ? [] : conversationHistory;
const response = await sendChatMessage(userMsg.content, historyToSend, selectedModel, useRLM);
const botMsg: ChatMessage = {
role: 'model',
content: response.answer,
reasoning_trace: response.reasoning_trace
};
setMessages(prev => [...prev, botMsg]);
} catch (error) {
setMessages(prev => [...prev, { role: 'model', content: "Sorry, I encountered an error answering that. Please try again." }]);
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex flex-col h-full bg-gradient-to-b from-industrial-900/80 to-industrial-950/90 rounded-2xl border border-industrial-700/50 overflow-hidden backdrop-blur-sm shadow-2xl shadow-black/20">
{/* Header with Model Selector */}
<div className="px-3 py-2 border-b border-industrial-700/50 bg-industrial-900/60 backdrop-blur-md flex justify-between items-center shrink-0">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-petro-500 animate-pulse shadow-lg shadow-petro-500/50"></div>
<span className="text-xs font-medium text-gray-300">AI Assistant</span>
</div>
<CompactModelSelector
selectedModel={selectedModel}
onModelChange={setSelectedModel}
disabled={loading}
/>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar min-h-0">
{messages.map((msg, idx) => {
const isUser = msg.role === 'user';
// Convert page citations to clickable links
// Handle multiple formats: [Page X], [page X], (Page X), Page X, etc.
// Match [Page X], (Page X), Page X, p. X formats (case insensitive)
let markdownContent = msg.content
// Standardize [Page X] -> [Page X](citation:X)
.replace(/\[(?:page|p\.?)\s*(\d+)\]/gi, '[Page $1](citation:$1)')
// Standardize (Page X) -> [Page X](citation:X)
.replace(/\((?:page|p\.?)\s*(\d+)\)/gi, '[Page $1](citation:$1)')
// Standardize plain "Page X" if not already linked -> [Page X](citation:X)
// Negative lookbehind/ahead would be ideal but complex in JS regex for all cases,
// so we use a simpler approach that avoids double-linking
.replace(/(?<!\[|\]|\()(?:\b(?:page|p\.?)\s+(\d+))\b(?![^\[]*\]|\))/gi, '[Page $1](citation:$1)')
// Cleanup any accidental double links or malformed ones
.replace(/\[Page (\d+)\]\([^)]*citation:\d+[^)]*\)/gi, '[Page $1](citation:$1)');
return (
<div
key={idx}
className={`flex ${isUser ? 'justify-end' : 'justify-start'} animate-fadeIn`}
style={{ animationDelay: `${idx * 50}ms` }}
>
<div
className={`max-w-[90%] p-3 rounded-2xl text-sm leading-relaxed transition-all duration-300 hover:shadow-lg ${isUser
? 'bg-gradient-to-br from-petro-600 to-petro-700 text-white rounded-br-md shadow-lg shadow-petro-900/30'
: 'bg-industrial-800/80 backdrop-blur-sm text-gray-200 rounded-bl-md border border-industrial-700/50 hover:border-industrial-600/50'
}`}
>
{isUser ? (
<div className="whitespace-pre-wrap">{msg.content}</div>
) : (
<div className="flex gap-2 items-start">
<div className="mt-1 min-w-[16px] shrink-0 text-petro-400">
<div className="w-4 h-4 rounded-full bg-petro-600/20 flex items-center justify-center">
<Icons.Cpu className="w-2.5 h-2.5" />
</div>
</div>
<div className="w-full overflow-hidden">
{msg.reasoning_trace && msg.reasoning_trace.length > 0 && (
<div className="mb-2 rounded-lg bg-gradient-to-br from-industrial-900/80 to-industrial-950/60 border border-industrial-700/30 overflow-hidden backdrop-blur-sm">
<details className="group">
<summary className="flex items-center gap-2 p-2 bg-industrial-900/50 cursor-pointer text-[10px] font-medium text-gray-400 hover:text-gray-200 select-none transition-colors">
<Icons.Activity className="w-3 h-3 text-petro-500" />
<span>Deep Research ({msg.reasoning_trace.length} steps)</span>
<div className="ml-auto transform group-open:rotate-180 transition-transform duration-300">
<Icons.ChevronDown className="w-3 h-3" />
</div>
</summary>
<div className="p-2 space-y-2 bg-industrial-950/50 text-[10px] border-t border-industrial-700/30 max-h-[200px] overflow-y-auto custom-scrollbar">
{msg.reasoning_trace.map((step, sIdx) => (
<div key={sIdx} className="space-y-1 animate-fadeIn" style={{ animationDelay: `${sIdx * 50}ms` }}>
<div className="flex items-center gap-1.5 text-gray-400">
<span className="font-mono bg-petro-900/50 text-petro-400 px-1 py-0.5 rounded text-[9px] border border-petro-800/50">{step.step}</span>
<span className="font-medium text-gray-300 truncate">{step.thought}</span>
</div>
{step.type === 'execute' && step.content && (
<div className="ml-2 pl-2 border-l border-petro-800/50">
<div className="bg-industrial-950/80 p-2 rounded border border-industrial-800/50 font-mono text-gray-500 overflow-x-auto">
<pre className="text-[9px]">{step.content}</pre>
</div>
</div>
)}
</div>
))}
</div>
</details>
</div>
)}
<div className="prose prose-invert prose-xs max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _node, href, children, ...props }) => {
// Check for citation: prefix
if (href?.startsWith('citation:')) {
const pageNum = parseInt(href.replace('citation:', ''), 10);
return (
<button
onClick={() => onNavigateToPage(pageNum)}
className="text-petro-400 hover:text-petro-300 underline underline-offset-2 transition-colors duration-200 font-medium cursor-pointer"
>
{children}
</button>
);
}
// Also catch any remaining page links that slip through
const childText = String(children);
const pageMatch = childText.match(/page\s*(\d+)/i);
if (pageMatch) {
const pageNum = parseInt(pageMatch[1], 10);
return (
<button
onClick={() => onNavigateToPage(pageNum)}
className="text-petro-400 hover:text-petro-300 underline underline-offset-2 transition-colors duration-200 font-medium cursor-pointer"
>
{children}
</button>
);
}
return <a href={href} {...props} target="_blank" rel="noopener noreferrer" className="text-petro-400 hover:text-petro-300 transition-colors">{children}</a>;
},
table: ({ node: _node, ...props }) => (
<div className="overflow-x-auto my-2 rounded border border-industrial-700/50">
<table {...props} className="min-w-full text-xs border-collapse" />
</div>
),
thead: ({ node: _node, ...props }) => <thead {...props} className="bg-industrial-800/80" />,
th: ({ node: _node, ...props }) => <th {...props} className="border border-industrial-700/50 px-2 py-1.5 text-left font-semibold text-gray-300" />,
td: ({ node: _node, ...props }) => <td {...props} className="border border-industrial-700/50 px-2 py-1.5 text-gray-400" />,
code: ({ node: _node, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
return match ? (
<code {...props} className="block bg-industrial-950/80 p-2 rounded text-xs font-mono my-2 overflow-x-auto border border-industrial-700/50" />
) : (
<code {...props} className="bg-petro-900/30 px-1 py-0.5 rounded text-xs font-mono text-petro-300 border border-petro-800/30" />
);
},
p: ({ node: _node, ...props }) => (
<p {...props} className="mb-2 last:mb-0 leading-relaxed" />
),
}}
>
{markdownContent}
</ReactMarkdown>
</div>
</div>
</div>
)}
</div>
</div>
);
})}
{loading && (
<div className="flex justify-start animate-fadeIn">
<div className="bg-industrial-800/60 backdrop-blur-sm p-3 rounded-2xl rounded-bl-md border border-industrial-700/50 flex items-center gap-2.5">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 bg-petro-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-1.5 h-1.5 bg-petro-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-1.5 h-1.5 bg-petro-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
<span className="text-xs text-gray-400">
{useRLM ? 'Deep researching...' : 'Thinking...'}
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-3 bg-gradient-to-t from-industrial-950 to-industrial-900/80 border-t border-industrial-700/50 backdrop-blur-md shrink-0">
<div className="flex gap-2 items-center bg-industrial-800/50 border border-industrial-700/50 rounded-xl p-2 focus-within:border-petro-600/50 focus-within:shadow-lg focus-within:shadow-petro-900/20 transition-all duration-300">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a question about the document..."
className="flex-1 bg-transparent text-white text-sm focus:outline-none resize-none h-[40px] custom-scrollbar placeholder-gray-500"
/>
<div className="flex items-center gap-1.5 border-l border-industrial-700/50 pl-2">
<button
onClick={() => setUseRLM(!useRLM)}
className={`p-2 rounded-lg transition-all duration-300 ${useRLM
? 'bg-petro-600/30 text-petro-400 shadow-lg shadow-petro-900/30 border border-petro-600/30'
: 'text-gray-500 hover:text-gray-300 hover:bg-industrial-700/50 border border-transparent'
}`}
title={useRLM ? "Disable Deep Research" : "Enable Deep Research (RLM)"}
>
<Icons.Database className={`w-4 h-4 ${useRLM ? 'animate-pulse' : ''}`} />
</button>
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="p-2 bg-gradient-to-r from-petro-600 to-petro-500 hover:from-petro-500 hover:to-petro-400 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 shadow-lg shadow-petro-900/40 hover:shadow-petro-800/50 disabled:shadow-none"
title="Send Message"
>
<Icons.MessageSquare className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
);
};
export default ChatInterface;