import { useState, useEffect, useRef } from 'react'; import Sidebar from './components/Sidebar'; import ChatInterface from './components/ChatInterface'; import { api } from './api'; import './App.css'; function App() { const [conversations, setConversations] = useState([]); const [currentConversationId, setCurrentConversationId] = useState(null); const [currentConversation, setCurrentConversation] = useState(null); const [isLoading, setIsLoading] = useState(false); const [pendingInput, setPendingInput] = useState(null); const abortControllerRef = useRef(null); // Load conversations on mount useEffect(() => { loadConversations(); }, []); // Load conversation details when selected useEffect(() => { if (currentConversationId) { loadConversation(currentConversationId); } }, [currentConversationId]); const loadConversations = async () => { try { const convs = await api.listConversations(); setConversations(convs); } catch (error) { console.error('Failed to load conversations:', error); } }; const loadConversation = async (id) => { try { const conv = await api.getConversation(id); setCurrentConversation(conv); } catch (error) { console.error('Failed to load conversation:', error); } }; const handleNewConversation = async () => { try { const newConv = await api.createConversation(); setConversations([ { id: newConv.id, created_at: newConv.created_at, message_count: 0 }, ...conversations, ]); setCurrentConversationId(newConv.id); } catch (error) { console.error('Failed to create conversation:', error); } }; const handleSelectConversation = (id) => { setCurrentConversationId(id); }; const handleStopGeneration = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; // Recover the last user message into the input box and remove the incomplete pair setCurrentConversation((prev) => { const messages = [...prev.messages]; // Find the last user message to recover its content let recoveredContent = ''; // Remove trailing assistant message (incomplete) if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { messages.pop(); } // Remove the user message and recover its text if (messages.length > 0 && messages[messages.length - 1].role === 'user') { const userMsg = messages.pop(); recoveredContent = userMsg.content; } setPendingInput(recoveredContent); return { ...prev, messages }; }); } }; const handleSendMessage = async (content) => { if (!currentConversationId) return; const controller = new AbortController(); abortControllerRef.current = controller; setIsLoading(true); try { // Optimistically add user message to UI const userMessage = { role: 'user', content }; setCurrentConversation((prev) => ({ ...prev, messages: [...prev.messages, userMessage], })); // Create a partial assistant message that will be updated progressively const assistantMessage = { role: 'assistant', stage1: null, stage2: null, stage3: null, metadata: null, loading: { stage1: false, stage2: false, stage3: false, }, }; // Add the partial assistant message setCurrentConversation((prev) => ({ ...prev, messages: [...prev.messages, assistantMessage], })); // Send message with streaming await api.sendMessageStream(currentConversationId, content, (eventType, event) => { if (controller.signal.aborted) return; switch (eventType) { case 'stage1_start': setCurrentConversation((prev) => { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.loading.stage1 = true; return { ...prev, messages }; }); break; case 'stage1_complete': setCurrentConversation((prev) => { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.stage1 = event.data; lastMsg.loading.stage1 = false; return { ...prev, messages }; }); break; case 'stage2_start': setCurrentConversation((prev) => { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.loading.stage2 = true; return { ...prev, messages }; }); break; case 'stage2_complete': setCurrentConversation((prev) => { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.stage2 = event.data; lastMsg.metadata = event.metadata; lastMsg.loading.stage2 = false; return { ...prev, messages }; }); break; case 'stage3_start': setCurrentConversation((prev) => { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.loading.stage3 = true; return { ...prev, messages }; }); break; case 'stage3_complete': setCurrentConversation((prev) => { const messages = [...prev.messages]; const lastMsg = messages[messages.length - 1]; lastMsg.stage3 = event.data; lastMsg.loading.stage3 = false; return { ...prev, messages }; }); break; case 'title_complete': // Reload conversations to get updated title loadConversations(); break; case 'complete': // Stream complete, reload conversations list loadConversations(); setIsLoading(false); break; case 'error': console.error('Stream error:', event.message); setIsLoading(false); break; default: console.log('Unknown event type:', eventType); } }, controller.signal); } catch (error) { if (error.name === 'AbortError') { // User stopped generation — handleStopGeneration already cleaned up messages } else { console.error('Failed to send message:', error); // Remove optimistic messages on error setCurrentConversation((prev) => ({ ...prev, messages: prev.messages.slice(0, -2), })); } setIsLoading(false); abortControllerRef.current = null; } }; return (
setPendingInput(null)} />
); } export default App;