Spaces:
Runtime error
Runtime error
| import { useState, useRef, useEffect } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import remarkGfm from 'remark-gfm'; | |
| import { sendChatMessage } from '../services/api'; | |
| export default function ChatWindow({ hasDocuments }) { | |
| const [messages, setMessages] = useState([]); | |
| const [input, setInput] = useState(''); | |
| const [isStreaming, setIsStreaming] = useState(false); | |
| const messagesEndRef = useRef(null); | |
| const inputRef = useRef(null); | |
| const scrollToBottom = () => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }; | |
| useEffect(scrollToBottom, [messages]); | |
| const handleSend = async () => { | |
| const question = input.trim(); | |
| if (!question || isStreaming) return; | |
| const userMessage = { role: 'user', content: question }; | |
| setMessages((prev) => [...prev, userMessage]); | |
| setInput(''); | |
| setIsStreaming(true); | |
| // Add empty assistant message that we'll fill via streaming | |
| const assistantMessage = { role: 'assistant', content: '', sources: [] }; | |
| setMessages((prev) => [...prev, assistantMessage]); | |
| const chatHistory = messages.map((m) => ({ | |
| role: m.role, | |
| content: m.content, | |
| })); | |
| await sendChatMessage( | |
| question, | |
| chatHistory, | |
| // onChunk | |
| (chunk) => { | |
| setMessages((prev) => { | |
| const updated = [...prev]; | |
| const last = { ...updated[updated.length - 1] }; | |
| last.content += chunk; | |
| updated[updated.length - 1] = last; | |
| return updated; | |
| }); | |
| }, | |
| // onSources | |
| (sources) => { | |
| setMessages((prev) => { | |
| const updated = [...prev]; | |
| const last = { ...updated[updated.length - 1] }; | |
| last.sources = sources; | |
| updated[updated.length - 1] = last; | |
| return updated; | |
| }); | |
| }, | |
| // onDone | |
| () => setIsStreaming(false), | |
| // onError | |
| (error) => { | |
| setMessages((prev) => { | |
| const updated = [...prev]; | |
| const last = updated[updated.length - 1]; | |
| last.content = `❌ Error: ${error}`; | |
| last.isError = true; | |
| return [...updated]; | |
| }); | |
| setIsStreaming(false); | |
| } | |
| ); | |
| }; | |
| const handleKeyDown = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }; | |
| return ( | |
| <div className="chat-window"> | |
| <div className="chat-messages"> | |
| {messages.length === 0 ? ( | |
| <div className="chat-empty"> | |
| <div className="chat-empty-icon"> | |
| <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"> | |
| <circle cx="12" cy="12" r="10" /> | |
| <path d="M12 16v-4" /> | |
| <path d="M12 8h.01" /> | |
| </svg> | |
| </div> | |
| <h2>RAG Assistant</h2> | |
| <p>Upload a PDF document and ask questions about its content.</p> | |
| <p className="hint">Your answers will be grounded in the uploaded documents.</p> | |
| </div> | |
| ) : ( | |
| messages.map((msg, i) => ( | |
| <div key={i} className={`message ${msg.role} ${msg.isError ? 'error' : ''}`}> | |
| <div className="message-avatar"> | |
| {msg.role === 'user' ? ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> | |
| <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> | |
| <circle cx="12" cy="7" r="4" /> | |
| </svg> | |
| ) : ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> | |
| <path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z" /> | |
| <path d="M6 10a6 6 0 0 0 12 0" /> | |
| <rect x="9" y="14" width="6" height="4" rx="1" /> | |
| <path d="M8 18h8v2a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2z" /> | |
| </svg> | |
| )} | |
| </div> | |
| <div className="message-content"> | |
| <div className="message-role"> | |
| {msg.role === 'user' ? 'You' : 'Assistant'} | |
| </div> | |
| <div className="message-text markdown-body"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}> | |
| {msg.content} | |
| </ReactMarkdown> | |
| {isStreaming && i === messages.length - 1 && msg.role === 'assistant' && ( | |
| <span className="cursor-blink">▊</span> | |
| )} | |
| </div> | |
| {msg.sources && msg.sources.length > 0 && ( | |
| <div className="message-sources"> | |
| <span className="sources-label">Sources:</span> | |
| {msg.sources.map((src, j) => ( | |
| <span key={j} className="source-tag"> | |
| 📄 {src.filename} | |
| <span className="relevance">({(src.relevance * 100).toFixed(0)}%)</span> | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| <div className="chat-input-area"> | |
| {!hasDocuments && ( | |
| <div className="no-docs-warning"> | |
| ⚠️ Upload a document first to get answers based on your content | |
| </div> | |
| )} | |
| <div className="chat-input-wrapper"> | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={hasDocuments ? 'Ask a question about your documents...' : 'Upload a document first...'} | |
| disabled={isStreaming} | |
| rows={1} | |
| className="chat-input" | |
| /> | |
| <button | |
| className="send-btn" | |
| onClick={handleSend} | |
| disabled={!input.trim() || isStreaming} | |
| > | |
| {isStreaming ? ( | |
| <div className="send-spinner"></div> | |
| ) : ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> | |
| <line x1="22" y1="2" x2="11" y2="13" /> | |
| <polygon points="22 2 15 22 11 13 2 9 22 2" /> | |
| </svg> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |