VectorMind / frontend /src /App.jsx
Ash-211's picture
Fix: create conversation on upload for doc scoping
e09c55e
Raw
History Blame Contribute Delete
17.4 kB
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