Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import { Send, Bot, User, ChevronLeft, LayoutGrid, GraduationCap as CapIcon, BookOpen, Upload, Settings, RefreshCw } from 'lucide-react'; | |
| const API_BASE = window.location.port === "5173" ? "http://localhost:8000/api" : "/api"; | |
| function App() { | |
| const [page, setPage] = useState('welcome'); | |
| const [curriculum, setCurriculum] = useState({}); | |
| const [selection, setSelection] = useState({ grade: '', subject: '', topic: '' }); | |
| const [messages, setMessages] = useState([]); | |
| const [inputText, setInputText] = useState(''); | |
| const [loading, setLoading] = useState(false); | |
| const [hintLevel, setHintLevel] = useState(1); | |
| const [status, setStatus] = useState('ACTIVE'); | |
| const [ingestStatus, setIngestStatus] = useState(''); | |
| const [uploadData, setUploadData] = useState({ grade: '', subject: '', file: null }); | |
| const messagesEndRef = useRef(null); | |
| useEffect(() => { | |
| fetchCurriculum(); | |
| }, []); | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages]); | |
| const fetchCurriculum = async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/curriculum`); | |
| const data = await res.json(); | |
| setCurriculum(data); | |
| } catch (err) { | |
| console.error("Failed to fetch curriculum:", err); | |
| } | |
| }; | |
| const startSession = (grade, subject, topic) => { | |
| setSelection({ grade, subject, topic }); | |
| setMessages([{ | |
| role: 'assistant', | |
| content: `Hello! I'm your AI Learner for **${grade} ${subject}**. What would you like to explore today?` | |
| }]); | |
| setPage('chat'); | |
| }; | |
| const sendMessage = async (e) => { | |
| e.preventDefault(); | |
| if (!inputText.trim() || loading) return; | |
| const userMsg = { role: 'user', content: inputText }; | |
| const newMessages = [...messages, userMsg]; | |
| setMessages(newMessages); | |
| setInputText(''); | |
| setLoading(true); | |
| try { | |
| const res = await fetch(`${API_BASE}/chat`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| messages: newMessages, | |
| grade: selection.grade, | |
| subject: selection.subject, | |
| topic: selection.topic, | |
| hint_level: hintLevel, | |
| status: status | |
| }) | |
| }); | |
| if (!res.ok) { | |
| const errorData = await res.json().catch(() => ({})); | |
| throw new Error(errorData.detail || `Server error: ${res.status}`); | |
| } | |
| const data = await res.json(); | |
| setMessages([...newMessages, data.message]); | |
| setHintLevel(data.hint_level); | |
| setStatus(data.status); | |
| } catch (err) { | |
| console.error("Chat error:", err); | |
| setMessages([...newMessages, { role: 'assistant', content: `⚠️ **Error:** ${err.message}. Please check if the backend server is running and try again.` }]); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleUpload = async (e) => { | |
| e.preventDefault(); | |
| if (!uploadData.file || !uploadData.grade || !uploadData.subject) return; | |
| setIngestStatus('Uploading...'); | |
| const formData = new FormData(); | |
| formData.append('file', uploadData.file); | |
| try { | |
| await fetch(`${API_BASE}/upload?grade=${uploadData.grade}&subject=${uploadData.subject}`, { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| setIngestStatus('Processing...'); | |
| const res = await fetch(`${API_BASE}/ingest`, { method: 'POST' }); | |
| const result = await res.json(); | |
| setIngestStatus(result.message); | |
| fetchCurriculum(); | |
| } catch (err) { | |
| setIngestStatus('Error: ' + err.message); | |
| } | |
| }; | |
| // --- RENDERERS --- | |
| if (page === 'ingest') { | |
| return ( | |
| <div className="selection-screen"> | |
| <button onClick={() => setPage('welcome')} style={{position: 'absolute', top: '2rem', left: '2rem', background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem'}}> | |
| <ChevronLeft size={20} /> Back | |
| </button> | |
| <div className="logo"><Bot size={32} /> AI Learner Admin</div> | |
| <h2>Ingest New Knowledge</h2> | |
| <form onSubmit={handleUpload} style={{width: '100%', maxWidth: '500px', display: 'flex', flexDirection: 'column', gap: '1.5rem', marginTop: '2rem'}}> | |
| <div className="input-group" style={{display: 'flex', flexDirection: 'column', gap: '0.5rem'}}> | |
| <label style={{fontSize: '0.8rem', color: '#94a3b8'}}>Grade</label> | |
| <input type="text" placeholder="e.g. Grade 10" onChange={e => setUploadData({...uploadData, grade: e.target.value})} /> | |
| </div> | |
| <div className="input-group" style={{display: 'flex', flexDirection: 'column', gap: '0.5rem'}}> | |
| <label style={{fontSize: '0.8rem', color: '#94a3b8'}}>Subject</label> | |
| <input type="text" placeholder="e.g. Science" onChange={e => setUploadData({...uploadData, subject: e.target.value})} /> | |
| </div> | |
| <div className="input-group" style={{display: 'flex', flexDirection: 'column', gap: '0.5rem'}}> | |
| <label style={{fontSize: '0.8rem', color: '#94a3b8'}}>PDF File</label> | |
| <input type="file" accept=".pdf" onChange={e => setUploadData({...uploadData, file: e.target.files[0]})} /> | |
| </div> | |
| <button type="submit" className="card" style={{width: '100%', background: 'var(--accent-primary)', color: 'white', border: 'none'}}> | |
| <Upload size={20} style={{marginBottom: '0.5rem'}} /> Upload & Process | |
| </button> | |
| {ingestStatus && <div style={{textAlign: 'center', padding: '1rem', background: 'rgba(255,255,255,0.05)', borderRadius: '0.5rem'}}>{ingestStatus}</div>} | |
| </form> | |
| </div> | |
| ); | |
| } | |
| if (page === 'welcome') { | |
| return ( | |
| <div className="selection-screen"> | |
| <button onClick={() => setPage('ingest')} style={{position: 'absolute', top: '2rem', right: '2rem', background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem'}}> | |
| <Settings size={20} /> Admin | |
| </button> | |
| <div className="logo"><Bot size={32} /> AI Learner</div> | |
| <h2>Select your grade to begin</h2> | |
| <div className="grid"> | |
| {Object.keys(curriculum).map(grade => ( | |
| <div key={grade} className="card" onClick={() => { | |
| setSelection({ ...selection, grade }); | |
| setPage('subject'); | |
| }}> | |
| <CapIcon size={40} color="#3b82f6" /> | |
| <h3 style={{marginTop: '1rem'}}>{grade}</h3> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (page === 'subject') { | |
| return ( | |
| <div className="selection-screen"> | |
| <button className="back-btn" onClick={() => setPage('welcome')} style={{position: 'absolute', top: '2rem', left: '2rem', background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem'}}> | |
| <ChevronLeft size={20} /> Back | |
| </button> | |
| <div className="logo"><Bot size={32} /> AI Learner</div> | |
| <h2>Select Subject ({selection.grade})</h2> | |
| <div className="grid"> | |
| {Object.keys(curriculum[selection.grade] || {}).map(subject => ( | |
| <div key={subject} className="card" onClick={() => { | |
| startSession(selection.grade, subject, 'General'); | |
| }}> | |
| <BookOpen size={40} color="#8b5cf6" /> | |
| <h3 style={{marginTop: '1rem'}}>{subject}</h3> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="app-container"> | |
| <div className="sidebar"> | |
| <div className="logo"><Bot size={24} /> AI Learner</div> | |
| <div style={{marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: '1rem'}}> | |
| <div style={{padding: '1rem', background: 'rgba(255,255,255,0.05)', borderRadius: '0.5rem', fontSize: '0.9rem'}}> | |
| <div style={{color: '#94a3b8', fontSize: '0.75rem', textTransform: 'uppercase', marginBottom: '0.5rem'}}>Active Session</div> | |
| <div style={{fontWeight: 'bold'}}>{selection.topic}</div> | |
| <div style={{color: '#64748b'}}>{selection.grade} • {selection.subject}</div> | |
| </div> | |
| <button | |
| onClick={() => setPage('welcome')} | |
| style={{padding: '0.75rem', background: 'transparent', border: '1px solid var(--border-color)', color: 'white', borderRadius: '0.5rem', cursor: 'pointer', transition: 'background 0.2s'}} | |
| onMouseOver={(e) => e.target.style.background = 'rgba(255,255,255,0.05)'} | |
| onMouseOut={(e) => e.target.style.background = 'transparent'} | |
| > | |
| Switch Topic | |
| </button> | |
| </div> | |
| </div> | |
| <div className="chat-main"> | |
| <div className="chat-header"> | |
| <div> | |
| <h2 style={{fontSize: '1.25rem'}}>{selection.topic}</h2> | |
| <div style={{fontSize: '0.75rem', color: '#64748b'}}>Powered by Gemini 3.1 Flash-Lite</div> | |
| </div> | |
| <div style={{display: 'flex', gap: '0.5rem'}}> | |
| <span className="status-badge hint-level">Hint Level: {hintLevel}</span> | |
| </div> | |
| </div> | |
| <div className="messages-container"> | |
| {messages.map((msg, i) => ( | |
| <div key={i} className={`message ${msg.role}`}> | |
| <div className="prose"> | |
| <ReactMarkdown>{msg.content}</ReactMarkdown> | |
| </div> | |
| </div> | |
| ))} | |
| {loading && ( | |
| <div className="message assistant" style={{fontStyle: 'italic', color: '#64748b'}}> | |
| AI Learner is thinking... | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| <div className="input-area"> | |
| <form className="input-wrapper" onSubmit={sendMessage}> | |
| <input | |
| type="text" | |
| placeholder={`Ask about ${selection.topic}...`} | |
| value={inputText} | |
| onChange={(e) => setInputText(e.target.value)} | |
| disabled={loading} | |
| /> | |
| <button type="submit" className="send-btn" disabled={loading || !inputText.trim()}> | |
| <Send size={20} /> | |
| </button> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default App; | |