Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef } from 'react' | |
| import TopNav from './components/TopNav' | |
| import Sidebar from './components/Sidebar' | |
| import ChatArea from './components/ChatArea' | |
| const API_BASE = import.meta.env.VITE_API_BASE || '' | |
| function App() { | |
| const [sidebarOpen, setSidebarOpen] = useState(true) | |
| const [documents, setDocuments] = useState([]) | |
| const [messages, setMessages] = useState([]) | |
| const [isLoading, setIsLoading] = useState(false) | |
| const [error, setError] = useState(null) | |
| const [token, setToken] = useState(() => localStorage.getItem('token')) | |
| const [user, setUser] = useState(null) | |
| // Conversation state | |
| const [conversations, setConversations] = useState([]) | |
| const [activeConversationId, setActiveConversationId] = useState(null) | |
| const isFirstMessage = useRef(true) | |
| // Auth state | |
| const isAuthenticated = !!token | |
| // Fetch documents and conversations on auth | |
| useEffect(() => { | |
| if (isAuthenticated) { | |
| fetchDocuments() | |
| fetchConversations() | |
| } | |
| }, [isAuthenticated]) | |
| // --- Auth --- | |
| const handleLogin = async (username, password) => { | |
| try { | |
| setError(null) | |
| const formData = new FormData() | |
| formData.append('username', username) | |
| formData.append('password', password) | |
| const res = await fetch(`${API_BASE}/token`, { | |
| method: 'POST', | |
| body: formData, | |
| }) | |
| if (!res.ok) { | |
| const data = await res.json() | |
| throw new Error(data.detail || 'Login failed') | |
| } | |
| const data = await res.json() | |
| localStorage.setItem('token', data.access_token) | |
| setToken(data.access_token) | |
| setUser(username) | |
| // Start with a fresh chat on login | |
| setMessages([]) | |
| setActiveConversationId(null) | |
| } catch (err) { | |
| setError(err.message) | |
| throw err | |
| } | |
| } | |
| const handleRegister = async ({ username, password, first_name, last_name, email }) => { | |
| try { | |
| setError(null) | |
| const res = await fetch(`${API_BASE}/register`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username, password, first_name, last_name, email }), | |
| }) | |
| if (!res.ok) { | |
| const data = await res.json() | |
| throw new Error(data.detail || 'Registration failed') | |
| } | |
| await handleLogin(username, password) | |
| } catch (err) { | |
| setError(err.message) | |
| throw err | |
| } | |
| } | |
| const handleLogout = () => { | |
| localStorage.removeItem('token') | |
| setToken(null) | |
| setUser(null) | |
| setMessages([]) | |
| setDocuments([]) | |
| setConversations([]) | |
| setActiveConversationId(null) | |
| } | |
| // --- Conversations --- | |
| const fetchConversations = async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/conversations`, { | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }) | |
| if (res.ok) { | |
| const data = await res.json() | |
| setConversations(data.conversations) | |
| } | |
| } catch (err) { | |
| console.error('Failed to fetch conversations:', err) | |
| } | |
| } | |
| const handleNewChat = () => { | |
| setActiveConversationId(null) | |
| setMessages([]) | |
| isFirstMessage.current = true | |
| } | |
| const handleSelectConversation = async (convId) => { | |
| if (convId === activeConversationId) return | |
| setActiveConversationId(convId) | |
| setMessages([]) | |
| isFirstMessage.current = false | |
| try { | |
| const res = await fetch(`${API_BASE}/conversations/${convId}/messages`, { | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }) | |
| if (res.ok) { | |
| const data = await res.json() | |
| setMessages(data.messages.map((msg, i) => ({ | |
| id: Date.now() + i, | |
| ...msg, | |
| }))) | |
| } | |
| } catch (err) { | |
| console.error('Failed to load conversation:', err) | |
| } | |
| } | |
| const handleDeleteConversation = async (convId) => { | |
| try { | |
| await fetch(`${API_BASE}/conversations/${convId}`, { | |
| method: 'DELETE', | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }) | |
| setConversations(prev => prev.filter(c => c.id !== convId)) | |
| if (activeConversationId === convId) { | |
| handleNewChat() | |
| } | |
| } catch (err) { | |
| console.error('Failed to delete conversation:', err) | |
| } | |
| } | |
| // --- Data Management --- | |
| const handleClearData = async () => { | |
| if (!window.confirm('This will delete ALL of your documents and chat history. Are you sure? ')) | |
| return | |
| try { | |
| await fetch(`${API_BASE}/clear-data`, { | |
| method: 'POST', | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }) | |
| await fetch(`${API_BASE}/chat/history`, { | |
| method: 'DELETE', | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }) | |
| } catch (err) { | |
| console.error('Failed to clear data:', err) | |
| } | |
| setMessages([]) | |
| setDocuments([]) | |
| setConversations([]) | |
| setActiveConversationId(null) | |
| } | |
| // --- Documents --- | |
| const fetchDocuments = async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/documents`, { | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }) | |
| if (res.ok) { | |
| const data = await res.json() | |
| setDocuments(data.documents.map((doc, i) => ({ | |
| id: doc.name + i, | |
| name: doc.name, | |
| size: doc.size, | |
| status: 'ready' | |
| }))) | |
| } | |
| } catch (err) { | |
| console.error('Failed to fetch documents:', err) | |
| } | |
| } | |
| const handleUpload = async (file) => { | |
| try { | |
| setError(null) | |
| const formData = new FormData() | |
| formData.append('file', file) | |
| // Create a conversation if one doesn't exist yet | |
| let convId = activeConversationId | |
| if (!convId) { | |
| try { | |
| const convRes = await fetch(`${API_BASE}/conversations`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| }) | |
| const convData = await convRes.json() | |
| convId = convData.id | |
| setActiveConversationId(convId) | |
| setConversations(prev => [convData, ...prev]) | |
| isFirstMessage.current = true | |
| } catch (err) { | |
| console.error('Failed to create conversation for upload:', err) | |
| } | |
| } | |
| if (convId) { | |
| formData.append('conversation_id', convId) | |
| } | |
| const tempDoc = { | |
| id: Date.now(), | |
| name: file.name, | |
| status: 'uploading', | |
| progress: 0, | |
| } | |
| setDocuments(prev => [...prev, tempDoc]) | |
| const res = await fetch(`${API_BASE}/ingest`, { | |
| method: 'POST', | |
| headers: { Authorization: `Bearer ${token}` }, | |
| body: formData, | |
| }) | |
| if (!res.ok) throw new Error('Upload failed') | |
| const data = await res.json() | |
| setDocuments(prev => | |
| prev.map(doc => | |
| doc.id === tempDoc.id | |
| ? { ...doc, status: 'ready', chunks: data.chunks, images: data.images } | |
| : doc | |
| ) | |
| ) | |
| await fetchDocuments() | |
| return data | |
| } catch (err) { | |
| setError(err.message) | |
| setDocuments(prev => | |
| prev.map(doc => | |
| doc.status === 'uploading' ? { ...doc, status: 'error' } : doc | |
| ) | |
| ) | |
| throw err | |
| } | |
| } | |
| const handleDeleteDocument = async (docId, docName) => { | |
| try { | |
| const res = await fetch(`${API_BASE}/documents/${encodeURIComponent(docName)}`, { | |
| method: 'DELETE', | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }) | |
| if (res.ok) { | |
| setDocuments(prev => prev.filter(d => d.id !== docId)) | |
| } | |
| } catch (err) { | |
| console.error('Failed to delete document: ', err) | |
| } | |
| } | |
| // --- Chat --- | |
| const handleSendMessage = async (content) => { | |
| if (!content.trim() || isLoading) return | |
| // If no active conversation, create one first | |
| let convId = activeConversationId | |
| let isNew = false | |
| if (!convId) { | |
| try { | |
| const res = await fetch(`${API_BASE}/conversations`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| }) | |
| const data = await res.json() | |
| convId = data.id | |
| setActiveConversationId(convId) | |
| setConversations(prev => [data, ...prev]) | |
| isNew = true | |
| isFirstMessage.current = true | |
| } catch (err) { | |
| console.error('Failed to create conversation:', err) | |
| return | |
| } | |
| } | |
| const userMessage = { | |
| id: Date.now(), | |
| role: 'user', | |
| content: content.trim(), | |
| timestamp: new Date().toISOString(), | |
| } | |
| const aiMessageId = Date.now() + 1 | |
| setMessages(prev => [...prev, userMessage]) | |
| setIsLoading(true) | |
| setError(null) | |
| // Save user message to backend (await to ensure it's in DB) | |
| await fetch(`${API_BASE}/chat/history`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| body: JSON.stringify({ ...userMessage, conversation_id: convId }), | |
| }) | |
| try { | |
| const res = await fetch(`${API_BASE}/chat/stream`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| body: JSON.stringify({ query: content.trim(), conversation_id: convId }), | |
| }) | |
| if (!res.ok) throw new Error('Failed to get response') | |
| // Response arrived — replace typing indicator with the streaming message | |
| setIsLoading(false) | |
| setMessages(prev => [...prev, { | |
| id: aiMessageId, | |
| role: 'assistant', | |
| content: '', | |
| sources: [], | |
| images: [], | |
| timestamp: new Date().toISOString(), | |
| }]) | |
| const reader = res.body.getReader() | |
| const decoder = new TextDecoder() | |
| let buffer = '' | |
| let accumulatedContent = '' | |
| let finalSources = [] | |
| let finalImages = [] | |
| while (true) { | |
| const { done, value } = await reader.read() | |
| if (done) break | |
| buffer += decoder.decode(value, { stream: true }) | |
| const lines = buffer.split('\n') | |
| buffer = lines.pop() | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue | |
| try { | |
| const event = JSON.parse(line.slice(6)) | |
| if (event.type === 'text') { | |
| accumulatedContent += event.content | |
| setMessages(prev => prev.map(msg => | |
| msg.id === aiMessageId | |
| ? { ...msg, content: msg.content + event.content } | |
| : msg | |
| )) | |
| } else if (event.type === 'done') { | |
| finalSources = event.sources || [] | |
| finalImages = event.images || [] | |
| setMessages(prev => prev.map(msg => | |
| msg.id === aiMessageId | |
| ? { ...msg, sources: finalSources, images: finalImages } | |
| : msg | |
| )) | |
| } else if (event.type === 'error') { | |
| accumulatedContent += '\n\nError: ' + event.content | |
| setMessages(prev => prev.map(msg => | |
| msg.id === aiMessageId | |
| ? { ...msg, content: msg.content + '\n\nError: ' + event.content } | |
| : msg | |
| )) | |
| } | |
| } catch (e) { | |
| // Skip unparseable lines | |
| } | |
| } | |
| } | |
| // Save the final assistant message to backend using local variables | |
| if (accumulatedContent) { | |
| fetch(`${API_BASE}/chat/history`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| body: JSON.stringify({ | |
| conversation_id: convId, | |
| role: 'assistant', | |
| content: accumulatedContent, | |
| sources: finalSources, | |
| images: finalImages, | |
| timestamp: new Date().toISOString(), | |
| }), | |
| }) | |
| } | |
| // Generate title for new conversations | |
| if (isFirstMessage.current) { | |
| isFirstMessage.current = false | |
| fetch(`${API_BASE}/conversations/${convId}/generate-title`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| body: JSON.stringify({ message: content.trim() }), | |
| }).then(res => res.json()).then(data => { | |
| setConversations(prev => prev.map(c => | |
| c.id === convId ? { ...c, title: data.title } : c | |
| )) | |
| }).catch(() => { }) | |
| } | |
| } catch (err) { | |
| setError(err.message) | |
| setIsLoading(false) | |
| setMessages(prev => { | |
| const filtered = prev.filter(m => m.id !== aiMessageId || m.content) | |
| return [...filtered, { | |
| id: Date.now() + 2, | |
| role: 'error', | |
| content: 'Failed to get response. Please try again.', | |
| timestamp: new Date().toISOString(), | |
| }] | |
| }) | |
| } | |
| } | |
| const clearChat = () => { | |
| handleNewChat() | |
| } | |
| return ( | |
| <div className={`app-layout ${!sidebarOpen ? 'sidebar-collapsed' : ''}`}> | |
| <TopNav | |
| user={user} | |
| isAuthenticated={isAuthenticated} | |
| onLogin={handleLogin} | |
| onRegister={handleRegister} | |
| onLogout={handleLogout} | |
| onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} | |
| sidebarOpen={sidebarOpen} | |
| /> | |
| <Sidebar | |
| isOpen={sidebarOpen} | |
| isAuthenticated={isAuthenticated} | |
| documents={documents} | |
| conversations={conversations} | |
| activeConversationId={activeConversationId} | |
| onUpload={handleUpload} | |
| onDeleteDocument={(id) => { | |
| const doc = documents.find(d => d.id === id) | |
| if (doc) handleDeleteDocument(id, doc.name) | |
| }} | |
| onNewChat={handleNewChat} | |
| onSelectConversation={handleSelectConversation} | |
| onDeleteConversation={handleDeleteConversation} | |
| onClearData={handleClearData} | |
| /> | |
| <ChatArea | |
| messages={messages} | |
| isLoading={isLoading} | |
| error={error} | |
| isAuthenticated={isAuthenticated} | |
| onSendMessage={handleSendMessage} | |
| onClearChat={clearChat} | |
| onDismissError={() => setError(null)} | |
| /> | |
| </div> | |
| ) | |
| } | |
| export default App | |