Spaces:
Sleeping
Sleeping
| 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; | |