import streamlit as st import requests import pandas as pd import plotly.express as px import time import streamlit.components.v1 as components try: from streamlit_calendar import calendar as st_calendar _HAS_CALENDAR = True except ImportError: _HAS_CALENDAR = False st_calendar = None from datetime import date import os # ----------------------------------------------------------------------------- # 1. CONFIG & CSS # ----------------------------------------------------------------------------- st.set_page_config( page_title="FocusFlow", page_icon="đŸŽ¯", layout="wide", initial_sidebar_state="expanded" ) st.markdown(""" """, unsafe_allow_html=True) st.markdown(""" """, unsafe_allow_html=True) # Backend URL API_URL = "http://localhost:8000" # INVISIBLE WIPE TRIGGER (temporarily bypass UI caching issues) if st.query_params.get("wipe") == "true": try: requests.delete(f"{API_URL}/admin/clear_all_data?secret=focusflow_clear", timeout=10) st.success("✅ Master Admin Wipe Complete! All shared data deleted. Remove ?wipe=true from URL and refresh.") except Exception as e: st.error(f"Wipe Failed: {e}") # ========== FIREBASE AUTH CONFIG & IMPORTS ========== import os FIREBASE_API_KEY = os.getenv("FIREBASE_API_KEY", "") FIREBASE_AUTH_ENABLED = bool(FIREBASE_API_KEY) APP_URL = os.getenv("APP_URL", "http://localhost:8501").rstrip("/") from backend.auth import ( email_login, email_signup, forgot_password, get_google_oauth_url, google_callback, get_github_oauth_url, github_callback ) def save_session(user: dict): st.session_state["uid"] = user["uid"] st.session_state["user_email"] = user["email"] st.session_state["user_name"] = user["name"] st.session_state["user_avatar"] = user["avatar"] st.session_state["firebase_token"] = user["token"] # Added for get_headers st.session_state["logged_in"] = True # Sync profile to Supabase try: requests.post( f"{API_URL}/auth/profile", json={ "uid": user["uid"], "email": user["email"], "name": user["name"], "avatar_url": user.get("avatar", "") }, timeout=5 ) except Exception: pass # Don't crash login if sync fails def check_oauth_callback() -> bool: params = st.query_params code = params.get("code", "") state = params.get("state", "") if not code: return False if state == "google": with st.spinner("Completing Google sign in..."): try: user = google_callback(code, APP_URL) save_session(user) st.query_params.clear() st.rerun() except Exception as e: st.error(f"❌ Google login failed: {e}") st.query_params.clear() return True if state == "github": with st.spinner("Completing GitHub sign in..."): try: user = github_callback(code, APP_URL) save_session(user) st.query_params.clear() st.rerun() except Exception as e: st.error(f"❌ GitHub login failed: {e}") st.query_params.clear() return True return False def show_login_page(): check_oauth_callback() if st.session_state.get("logged_in") or st.session_state.get("local_bypass"): return # Center layout _, col, _ = st.columns([1, 1.2, 1]) with col: st.markdown("""
đŸŽ¯

FocusFlow

AI-Powered Study Companion

""", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) if FIREBASE_AUTH_ENABLED: # Google button g_url = get_google_oauth_url(APP_URL) st.markdown( f"""
Continue with Google
""", unsafe_allow_html=True ) # GitHub button gh_url = get_github_oauth_url(APP_URL) st.markdown( f"""
âšĢ Continue with GitHub
""", unsafe_allow_html=True ) st.markdown( "

— or —

", unsafe_allow_html=True ) tab_login, tab_signup = st.tabs(["🔑 Login", "✨ Sign Up"]) with tab_login: email = st.text_input( "Email", key="li_email", placeholder="you@example.com" ) password = st.text_input( "Password", key="li_pass", type="password", placeholder="Your password" ) c1, c2 = st.columns(2) with c1: if st.button("Login", use_container_width=True, type="primary", key="li_btn"): try: user = email_login(email, password) save_session(user) st.rerun() except ValueError as e: msg = str(e) if "INVALID" in msg or "PASSWORD" in msg: st.error("❌ Wrong email or password") elif "NOT_FOUND" in msg: st.error("❌ Email not registered") else: st.error(f"❌ {msg}") with c2: if st.button("Forgot?", use_container_width=True, key="fp_btn"): try: forgot_password(email) st.success("✅ Reset email sent!") except Exception as e: st.error(f"❌ {e}") with tab_signup: name = st.text_input( "Full Name", key="su_name", placeholder="Your name" ) email2 = st.text_input( "Email", key="su_email", placeholder="you@example.com" ) pass2 = st.text_input( "Password", key="su_pass", type="password", placeholder="Min 6 characters" ) if st.button("Create Account", use_container_width=True, type="primary", key="su_btn"): try: user = email_signup(name, email2, pass2) save_session(user) st.success("✅ Welcome to FocusFlow!") st.rerun() except ValueError as e: msg = str(e) if "EMAIL_EXISTS" in msg: st.error("❌ Email already registered") elif "WEAK_PASSWORD" in msg: st.error("❌ Password too weak (min 6 chars)") else: st.error(f"❌ {msg}") else: st.info("â„šī¸ Firebase not configured. Running in local mode.") if st.button("â–ļ Continue without login", type="primary", use_container_width=True): st.session_state["local_bypass"] = True st.rerun() st.markdown( "

" "Made for students, by students â¤ī¸

", unsafe_allow_html=True ) # ========== AUTH GATE ========== if not st.session_state.get("logged_in") and not st.session_state.get("local_bypass"): show_login_page() st.stop() # Show profile and sign out in sidebar with st.sidebar: # User profile card uid = st.session_state.get("uid", "") name = st.session_state.get("user_name", "Student") email = st.session_state.get("user_email", "") avatar = st.session_state.get("user_avatar", "") st.markdown("---") col1, col2 = st.columns([1, 3]) with col1: if avatar: st.image(avatar, width=45) else: # Show colored initial circle initial = name[0].upper() if name else "S" st.markdown( f"""
{initial}
""", unsafe_allow_html=True ) with col2: st.markdown(f"**{name}**") st.caption(email[:25] + "..." if len(email) > 25 else email) if st.button("đŸšĒ Sign Out", use_container_width=True): for k in ["uid", "user_email", "user_name", "user_avatar", "logged_in", "firebase_token", "local_bypass", "user_info", "profile_loaded", "study_plan", "topic_scores", "mastery_data", "chat_history"]: st.session_state.pop(k, None) st.rerun() st.markdown("---") # Session State if "timer_running" not in st.session_state: st.session_state.timer_running = False if "expiry_time" not in st.session_state: st.session_state.expiry_time = None if "time_left_m" not in st.session_state: st.session_state.time_left_m = 0 if "time_left_s" not in st.session_state: st.session_state.time_left_s = 0 if "chat_history" not in st.session_state: st.session_state.chat_history = [] if "mastery_data" not in st.session_state: st.session_state.mastery_data = {"S1": 0, "S2": 0, "S3": 0, "S4": 0} if "expanded_topics" not in st.session_state: st.session_state.expanded_topics = set() if "show_analytics" not in st.session_state: st.session_state.show_analytics = False if "topic_scores" not in st.session_state: st.session_state.topic_scores = {} # Track quiz performance by topic_id if "app_config" not in st.session_state: try: resp = requests.get(f"{API_URL}/config", timeout=5) if resp.status_code == 200: st.session_state.app_config = resp.json() else: st.session_state.app_config = {"youtube_enabled": True} except Exception: st.session_state.app_config = {"youtube_enabled": True} # Helper function to add auth headers to all API requests def get_headers(): """Get auth headers for API requests — uses Firebase token if available.""" headers = {} if FIREBASE_AUTH_ENABLED and "firebase_token" in st.session_state: headers["Authorization"] = f"Bearer {st.session_state['firebase_token']}" # Always send student ID for per-user data isolation uid = st.session_state.get("uid", "") headers["X-Student-Id"] = uid if uid else "anonymous" return headers # Focus Mode State if "focus_mode" not in st.session_state: st.session_state.focus_mode = False if "active_topic" not in st.session_state: st.session_state.active_topic = None # PERSISTENCE: Load student profile on first load if "profile_loaded" not in st.session_state: st.session_state.profile_loaded = True try: resp = requests.get(f"{API_URL}/student/profile", headers=get_headers(), timeout=5) if resp.status_code == 200: profile = resp.json() # DEBUG: Show what we got plan_topics = profile.get("study_plan", {}).get("topics", []) quiz_history = profile.get("quiz_history", []) # Restore study plan if exists if plan_topics: st.session_state.study_plan = plan_topics st.toast(f"📚 Restored {len(plan_topics)} topics from previous session", icon="✅") else: st.session_state.study_plan = [] # Don't show message for first-time users # Restore quiz scores if quiz_history: for quiz_record in quiz_history: st.session_state.topic_scores[quiz_record["topic_id"]] = { "topic_title": quiz_record.get("topic_title"), "score": quiz_record["score"], "total": quiz_record["total"], "percentage": quiz_record["percentage"] } st.toast(f"📊 Restored {len(quiz_history)} quiz results", icon="✅") # Restore mastery data if profile.get("mastery_tracker"): st.session_state.mastery_data = profile["mastery_tracker"] # ========== DATE-AWARE DAY PROGRESSION ========== from datetime import date today = date.today() today_str = today.strftime("%Y-%m-%d") # Get stored current day and last access date current_study_day = profile.get("current_study_day", 1) last_access_date = profile.get("last_access_date", today_str) # Check if it's a new calendar day if last_access_date != today_str and st.session_state.study_plan: # Calculate how many days have passed from datetime import datetime last_date_obj = datetime.strptime(last_access_date, "%Y-%m-%d").date() days_passed = (today - last_date_obj).days if days_passed > 0: # Advance to next day current_study_day += days_passed max_day = max([t.get("day", 1) for t in st.session_state.study_plan]) if st.session_state.study_plan else 1 current_study_day = min(current_study_day, max_day) # Cap at max day # Auto-unlock topics for the new day for topic in st.session_state.study_plan: if topic.get("day") == current_study_day and topic.get("status") != "completed": topic["status"] = "unlocked" # Update profile with new day and date try: requests.post(f"{API_URL}/student/save_progress", json={ "current_study_day": current_study_day, "last_access_date": today_str }, headers=get_headers(), timeout=5) st.toast(f"📅 Advanced to Day {current_study_day}! New topics unlocked", icon="đŸŽ¯") except: pass # Store current day in session state st.session_state.current_study_day = current_study_day else: st.session_state.study_plan = [] st.error(f"Could not load profile: {resp.status_code}") except Exception as e: st.session_state.study_plan = [] st.error(f"Could not connect to backend: {e}") else: # Ensure study_plan exists even if profile load was skipped if "study_plan" not in st.session_state: st.session_state.study_plan = [] # Default to day 1 if no profile loaded if "current_study_day" not in st.session_state: st.session_state.current_study_day = 1 def check_internet(): """ Checks for internet connectivity by pinging reliable hosts. try multiple to be sure. """ keywords = ["google.com", "cloudflare.com", "github.com"] for host in keywords: try: requests.get(f"http://{host}", timeout=3) return True except: continue return False # ----------------------------------------------------------------------------- # 2. ANALYTICS MODAL (Rendered at top if active) # ----------------------------------------------------------------------------- if st.session_state.show_analytics: with st.container(): st.markdown("""

Performance Analytics

""", unsafe_allow_html=True) # We can't easily put Streamlit widgets INSIDE that pure HTML string div above. # But we can simulate a modal by clearing the main area or using st.dialog (New in 1.34+) # If st.dialog is available (it was in the previous app.py), we should use it. pass def extract_subjects_and_topics(): """ Extract subjects from study plan topics. Returns: {subject_name: [topic_data_with_scores]} """ import re subjects = {} for topic in st.session_state.study_plan: title = topic.get("title", "") # Remove "Day X:" prefix if present title_cleaned = re.sub(r'^Day\s+\d+:\s*', '', title) # Try to extract subject from remaining text # Look for patterns like "OOPS:" or "Manufacturing:" or just use first few words if ":" in title_cleaned: # Get first part before colon as subject subject = title_cleaned.split(":")[0].strip() elif " - " in title_cleaned: # Alternative separator subject = title_cleaned.split(" - ")[0].strip() else: # Use first 2-3 capitalized words as subject words = title_cleaned.split() # Take first 1-2 capitalized words as subject name subject_words = [] for word in words[:3]: if word[0].isupper() or word.isupper(): subject_words.append(word) else: break subject = " ".join(subject_words) if subject_words else "General" # Clean up subject name subject = subject.strip() if not subject or subject.startswith("Day"): subject = "General" if subject not in subjects: subjects[subject] = [] # Add topic with its score data topic_data = { "title": title, "id": topic.get("id"), "status": topic.get("status", "locked"), "quiz_passed": topic.get("quiz_passed", False) } # Add score if available if topic.get("id") in st.session_state.topic_scores: topic_data["score_data"] = st.session_state.topic_scores[topic.get("id")] subjects[subject].append(topic_data) return subjects @st.dialog("📊 Analytics Overview", width="large") def show_analytics_dialog(): subjects_data = extract_subjects_and_topics() if not subjects_data: st.info("📚 No subjects found. Create a study plan to see analytics.") return # Create dynamic tabs subject_names = list(subjects_data.keys()) tabs = st.tabs(subject_names) for idx, subject_name in enumerate(subject_names): with tabs[idx]: topics = subjects_data[subject_name] # Calculate subject mastery completed_topics = [t for t in topics if t.get("status") == "completed"] total_topics = len(topics) completion_pct = (len(completed_topics) / total_topics * 100) if total_topics > 0 else 0 # Calculate average score for topics with quiz data topics_with_scores = [t for t in topics if "score_data" in t] if topics_with_scores: avg_score = sum(t["score_data"]["percentage"] for t in topics_with_scores) / len(topics_with_scores) else: avg_score = 0 # Display mastery header st.markdown(f"""

{avg_score:.1f}%

Overall Mastery

""", unsafe_allow_html=True) # Progress metrics col1, col2 = st.columns(2) with col1: st.metric("Topics Completed", f"{len(completed_topics)}/{total_topics}") st.progress(completion_pct / 100) with col2: st.metric("Quizzes Taken", f"{len(topics_with_scores)}/{total_topics}") quiz_completion = (len(topics_with_scores) / total_topics * 100) if total_topics > 0 else 0 st.progress(quiz_completion / 100) st.markdown("---") st.markdown("### 📈 Performance Breakdown") # Classify topics by performance strong = [t for t in topics_with_scores if t["score_data"]["percentage"] >= 75] moderate = [t for t in topics_with_scores if 50 <= t["score_data"]["percentage"] < 75] needs_work = [t for t in topics_with_scores if t["score_data"]["percentage"] < 50] # Display classifications col1, col2, col3 = st.columns(3) with col1: st.markdown("#### 💚 Strong Topics") st.caption(f"{len(strong)} topic(s)") if strong: for t in strong: score_pct = t["score_data"]["percentage"] score_str = f"{t['score_data']['score']}/{t['score_data']['total']}" st.success(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})") else: st.info("No strong topics yet. Keep studying!") with col2: st.markdown("#### 🟡 Moderate Topics") st.caption(f"{len(moderate)} topic(s)") if moderate: for t in moderate: score_pct = t["score_data"]["percentage"] score_str = f"{t['score_data']['score']}/{t['score_data']['total']}" st.warning(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})") else: st.info("No moderate topics yet") with col3: st.markdown("#### 🔴 Needs Work") st.caption(f"{len(needs_work)} topic(s)") if needs_work: for t in needs_work: score_pct = t["score_data"]["percentage"] score_str = f"{t['score_data']['score']}/{t['score_data']['total']}" st.error(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})") else: st.info("Great! No topics need extra work") # ----------------------------------------------------------------------------- # 3. QUIZ TO UNLOCK (Dialog) # ----------------------------------------------------------------------------- @st.dialog("Topic Mastery Quiz") def show_quiz_dialog(topic_id, topic_name): st.markdown(f"### Quiz for: {topic_name}") st.write("Complete this short quiz to prove mastery and unlock the next topic.") with st.form(key=f"quiz_form_{topic_id}"): # Quiz Questions (Demo) st.markdown("**1. What is the First Law of Thermodynamics?**") q1 = st.radio("Select the best answer:", [ "Energy cannot be created or destroyed, only transformed.", "Entropy always increases.", "Heat flows from cold to hot.", "F = ma" ], key=f"q1_{topic_id}") st.markdown("**2. Which system type allows energy but not matter transfer?**") q2 = st.radio("Select the best answer:", [ "Open System", "Closed System", "Isolated System", "Solar System" ], key=f"q2_{topic_id}") if st.form_submit_button("Submit Quiz"): score = 0 if q1.startswith("Energy cannot"): score += 50 if q2 == "Closed System": score += 50 # Call backend try: payload = {"topic_id": topic_id, "quiz_score": score} resp = requests.post(f"{API_URL}/unlock_topic", json=payload, headers=get_headers()) if resp.status_code == 200: data = resp.json() if data.get("success"): if data.get("next_topic_unlocked"): st.balloons() st.success(f"Score: {score}% - PASSED! Next topic unlocked.") else: st.warning(f"Score: {score}% - Keep studying! You need >60% to pass.") time.sleep(2) st.rerun() else: st.error(data.get("message")) else: st.error(f"Error: {resp.status_code}") except Exception as e: st.error(f"Connection failed: {e}") st.markdown("**P** N/A", unsafe_allow_html=True) st.markdown("

--

", unsafe_allow_html=True) # ----------------------------------------------------------------------------- # 3. QUIZ TO UNLOCK (Dialog) # ----------------------------------------------------------------------------- @st.dialog("Topic Mastery Quiz") def show_quiz_dialog(topic_id, topic_name): st.markdown(f"**Topic:** {topic_name}") st.markdown("To unlock the next topic, you must pass this quiz.") # Mock Question st.info("Question: What is the primary concept of this topic?") ans = st.radio("Select Answer:", ["Energy Conservation", "Wrong Answer 1", "Wrong Answer 2"], key=f"q_radio_{topic_id}") if st.button("Submit Answer", type="primary"): if ans == "Energy Conservation": st.balloons() st.success("Correct! Next topic unlocked.") # Update Mock State for i, t in enumerate(st.session_state.study_plan): if t["id"] == topic_id: t["quiz_passed"] = True # Unlock next if i + 1 < len(st.session_state.study_plan): st.session_state.study_plan[i+1]["status"] = "unlocked" break time.sleep(1.5) st.rerun() else: st.error("Incorrect. Try again.") # (The previous tool usage showed show_quiz_dialog was inserted. I will target the end of it to insert Flashcards) # Actually, let's just insert it before MAIN LAYOUT, which is clearer. # ----------------------------------------------------------------------------- # 4. FLASHCARDS (Dialog) # ----------------------------------------------------------------------------- @st.dialog("Topic Flashcards", width="large") def show_flashcard_dialog(topic_id, topic_name): st.markdown(f"### Flashcards: {topic_name}") # Flashcard Data (Demo) flashcards = [ {"q": "What is the First Law of Thermodynamics?", "a": "Energy cannot be created or destroyed, only transformed."}, {"q": "Define Entropy.", "a": "A measure of the disorder or randomness in a system."}, {"q": "What is an Isolated System?", "a": "A system that exchanges neither matter nor energy with its surroundings."} ] # Session State for this dialog interaction # Note: st.dialog reruns the function body on interaction. if "fc_index" not in st.session_state: st.session_state.fc_index = 0 if "fc_flipped" not in st.session_state: st.session_state.fc_flipped = False # Navigation Limits current_idx = st.session_state.fc_index total = len(flashcards) if current_idx >= total: st.success("You've reviewed all cards!") if st.button("Restart"): st.session_state.fc_index = 0 st.session_state.fc_flipped = False st.rerun() return card = flashcards[current_idx] # Progress st.progress((current_idx + 1) / total) st.caption(f"Card {current_idx + 1} of {total}") # Card UI content = card["a"] if st.session_state.fc_flipped else card["q"] bg_color = "#EFF6FF" if not st.session_state.fc_flipped else "#F0FDF4" # Blue (Question) -> Green (Answer) border_color = "#DBEAFE" if not st.session_state.fc_flipped else "#BBF7D0" label = "QUESTION" if not st.session_state.fc_flipped else "ANSWER" st.markdown(f"""
{label}
{content}
""", unsafe_allow_html=True) # Controls c1, c2 = st.columns(2) with c1: btn_text = "Show Answer" if not st.session_state.fc_flipped else "Show Question" if st.button(btn_text, use_container_width=True): st.session_state.fc_flipped = not st.session_state.fc_flipped st.rerun() with c2: if st.session_state.fc_flipped: if st.button("Next Card →", type="primary", use_container_width=True): st.session_state.fc_index += 1 st.session_state.fc_flipped = False st.rerun() else: st.button("Next Card →", disabled=True, use_container_width=True) # Lock next until flipped? Or allow skipping. Let's lock to encourage reading. # --- LAYOUT SWITCHER --- if not st.session_state.focus_mode: # Standard 3-Column Layout left_col, mid_col, right_col = st.columns([0.25, 0.50, 0.25], gap="medium") else: # Focus Mode Layout (2 Columns: Chat + Content) left_col, mid_col = st.columns([0.30, 0.70], gap="large") right_col = None # Not used in Focus Mode # --- LEFT COLUMN: Control Center --- # --- LEFT COLUMN: Control Center --- if not st.session_state.focus_mode: with left_col: # ====== USER INFO & SIGN-OUT REMOVED (now in sidebar) ====== st.markdown("""

🎮 Control Center

""", unsafe_allow_html=True) # Timer Widget with st.container(border=True): st.markdown("""

âąī¸ Study Timer

""", unsafe_allow_html=True) # Timer Logic total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s if st.session_state.timer_running: # Check if time is up remaining = st.session_state.expiry_time - time.time() if remaining <= 0: st.session_state.timer_running = False st.session_state.expiry_time = None st.session_state.time_left_m, st.session_state.time_left_s = 0, 0 st.balloons() st.rerun() else: # Render JS Timer (Non-blocking) # We need to inject the SAME styles to match the look. # Since components run in iframe, we copy the CSS. m, s = divmod(int(remaining), 60) html_code = f"""
{m:02d}
{s:02d}
""" components.html(html_code, height=120) # Show ONLY Stop Button if st.button("STOP", use_container_width=True, type="secondary"): st.session_state.timer_running = False st.session_state.expiry_time = None st.rerun() else: # Editable Inputs (Only show when STOPPED) # Use columns to center inputs c1, c2, c3 = st.columns([0.45, 0.1, 0.45]) with c1: st.number_input("Min", min_value=0, max_value=999, label_visibility="collapsed", key="time_left_m") with c2: st.markdown("
:
", unsafe_allow_html=True) with c3: st.number_input("Sec", min_value=0, max_value=59, label_visibility="collapsed", key="time_left_s") st.write("") # Spacer # Start Button if st.button("START", use_container_width=True, type="primary"): total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s if total_seconds > 0: st.session_state.timer_running = True st.session_state.expiry_time = time.time() + total_seconds st.rerun() # Sources Widget with st.container(border=True): # Connectivity Check is_online = check_internet() status_color = "online-badge" if is_online else "status-badge offline" status_text = "ONLINE" if is_online else "OFFLINE" st.markdown(f"""
Sources {status_text}
""", unsafe_allow_html=True) # Tabs # Tabs Removed - Unified View # tab_offline, tab_online = st.tabs(["Offline Sources", "Online Sources"]) # Helper to fetch sources sources_list = [] try: s_resp = requests.get(f"{API_URL}/sources", headers=get_headers()) if s_resp.status_code == 200: sources_list = s_resp.json() except: pass if sources_list: for src in sources_list: # Icon Logic icon = "📄" if src['type'] == 'url': icon = "🌐" elif src['type'] == 'youtube': icon = "đŸ“ē" c1, c2 = st.columns([0.85, 0.15]) with c1: short = src['filename'][:25] + "..." if len(src['filename']) > 25 else src['filename'] st.markdown( f"""
{icon} {short}
""", unsafe_allow_html=True ) with c2: if st.button("đŸ—‘ī¸", key=f"del_{src['id']}", help="Delete source", type="tertiary"): try: # Optimistically update UI by removing from list or just rerun requests.delete(f"{API_URL}/sources/{src['id']}", headers=get_headers()) time.sleep(0.1) # Small delay for DB prop st.rerun() except Exception as e: st.error(f"Error: {e}") else: st.markdown("""
📂

No sources added yet

""", unsafe_allow_html=True) # --- Add Source Section --- st.markdown("
", unsafe_allow_html=True) # PDF Upload with st.expander("+ Add PDF / Document"): uploaded = st.file_uploader("Upload PDF", type=["pdf", "txt"], label_visibility="collapsed") if uploaded: # Check duplication in session state to prevent infinite rerun loop if "processed_files" not in st.session_state: st.session_state.processed_files = set() if uploaded.name not in st.session_state.processed_files: try: # Send to backend files = {"file": (uploaded.name, uploaded, uploaded.type)} with st.spinner("Uploading & Indexing..."): resp = requests.post(f"{API_URL}/upload", files=files, headers=get_headers()) if resp.status_code == 200: st.session_state.processed_files.add(uploaded.name) st.success(f"Successfully uploaded: {uploaded.name}") time.sleep(1) st.rerun() else: # Parse error for user-friendly message try: error_detail = resp.json().get("detail", resp.text) except Exception: error_detail = resp.text if "OCR" in str(error_detail) or "scan" in str(error_detail).lower(): st.error(f"📸 {error_detail}") elif "No readable text" in str(error_detail): st.error("📄 This PDF appears to be scanned/image-only. OCR could not extract text. Please try a clearer scan or a text-based PDF.") else: st.error(f"Upload failed: {error_detail}") except Exception as e: st.error(f"Error: {e}") # URL Input youtube_enabled = st.session_state.get("app_config", {}).get("youtube_enabled", True) if youtube_enabled: with st.expander("â–ļī¸ Add URL / YouTube"): url_input = st.text_input("URL", placeholder="https://youtube.com/... or any webpage", label_visibility="collapsed") if st.button("Process URL", use_container_width=True): if not url_input: st.warning("Please enter a URL") else: import re as re_mod is_youtube = "youtube.com" in url_input or "youtu.be" in url_input if is_youtube: # Extract video ID vid_match = re_mod.search(r'(?:v=|youtu\.be/|shorts/|embed/)([a-zA-Z0-9_-]{11})', url_input) if not vid_match: st.error("❌ Invalid YouTube URL format. Supported: youtube.com/watch?v=ID, youtu.be/ID, or youtube.com/shorts/ID") else: video_id = vid_match.group(1) with st.spinner("âŗ Fetching transcript via Invidious..."): try: resp = requests.post(f"{API_URL}/ingest_youtube", json={"video_id": video_id}, headers=get_headers(), timeout=120) if resp.status_code == 200: st.success("✅ YouTube transcript processed successfully!") time.sleep(1) st.rerun() else: error_detail = resp.json().get('detail', resp.text) if "No captions available" in str(error_detail): st.error("❌ No captions found. Try a video with CC enabled.") elif "Could not reach any transcript" in str(error_detail): st.error("âš ī¸ Transcript service unavailable. Try again later.") else: st.error(f"Failed: {error_detail}") except requests.Timeout: st.error("âąī¸ Request timed out. Please try again.") except Exception as e: st.error(f"Error: {str(e)}") else: # Non-YouTube URL: use server-side ingestion with st.spinner("Fetching content..."): try: resp = requests.post(f"{API_URL}/ingest_url", json={"url": url_input}, headers=get_headers(), timeout=120) if resp.status_code == 200: st.success(f"✅ {resp.json().get('message', 'Content added!')}") time.sleep(1) st.rerun() else: error_detail = resp.json().get('detail', resp.text) st.error(f"Failed: {error_detail}") except requests.Timeout: st.error("âąī¸ Request timed out. Please try again.") except Exception as e: st.error(f"Error: {str(e)}") else: with st.expander("â–ļī¸ Add YouTube Video — Local Only"): st.info( "âš ī¸ **YouTube is not available in cloud mode.**\n\n" "HuggingFace Spaces blocks outbound network requests.\n\n" "**To use YouTube sources:**\n" "đŸ’ģ Run FocusFlow locally with Ollama\n\n" "**Right now you can:**\n" "📄 Upload a PDF of your notes\n" "📋 Paste text directly below" ) # Paste Text Input with st.expander("📋 Paste Text / Notes"): paste_label = st.text_input( "Source name (optional)", placeholder="e.g. Chapter 3 Notes, Lecture Summary...", key="paste_label_input" ) paste_text = st.text_area( "Paste your text here", placeholder=( "Paste any text here — lecture notes, " "article content, copied webpage text, " "study notes, anything you want to learn from..." ), height=200, key="paste_text_input" ) word_count = len(paste_text.split()) if paste_text else 0 if paste_text: st.caption(f"📝 {word_count} words") col1, col2 = st.columns([2, 1]) with col1: process_btn = st.button( "➕ Add as Source", key="process_paste_btn", disabled=len(paste_text.strip()) < 50, use_container_width=True ) with col2: st.caption("Min 50 chars") if process_btn and paste_text.strip(): source_name = paste_label.strip() if paste_label.strip() \ else f"Pasted Text ({word_count} words)" with st.spinner("Processing your text..."): try: # Use API_URL which is the global backend URL configured in app.py response = requests.post( f"{API_URL}/ingest_text", json={ "text": paste_text.strip(), "source_name": source_name, "source_type": "paste" }, headers=get_headers() ) if response.status_code == 200: st.success(f"✅ '{source_name}' added successfully!") # Remove keys from session_state instead of setting to "" due to Streamlit unchangeable key rules when bound to a widget if "paste_text_input" in st.session_state: del st.session_state["paste_text_input"] if "paste_label_input" in st.session_state: del st.session_state["paste_label_input"] time.sleep(1) st.rerun() else: error = response.json().get("detail", "Unknown error") st.error(f"❌ Failed to add text: {error}") except Exception as e: st.error(f"❌ Error: {str(e)}") if len(paste_text.strip()) > 0 and len(paste_text.strip()) < 50: st.warning("âš ī¸ Please paste at least 50 characters.") # --- FOCUS MODE UI --- if st.session_state.focus_mode: # FOCUS: LEFT COLUMN (CHAT) with left_col: st.markdown("### đŸ’Ŧ Study Assistant") # Fixed-height chat container to keep messages inside messages = st.container(height=600, border=True) with messages: for msg in st.session_state.chat_history: with st.chat_message(msg["role"]): st.write(msg["content"]) # Show sources for assistant messages if msg["role"] == "assistant" and msg.get("sources"): with st.expander("📚 Sources", expanded=False): for idx, s in enumerate(msg["sources"], 1): if isinstance(s, dict): filename = s.get("source", "").split("/")[-1] page = s.get("page", "N/A") st.caption(f"{idx}. 📄 {filename}, p.{page}") # Chat input at bottom - messages will appear in container above if prompt := st.chat_input(f"Ask about {st.session_state.active_topic}..."): st.session_state.chat_history.append({"role": "user", "content": prompt}) # Call AI with st.spinner("Thinking..."): try: # Prepare history history = [{"role": m["role"], "content": m["content"]} for m in st.session_state.chat_history[:-1][-5:]] resp = requests.post(f"{API_URL}/query", json={"question": prompt, "history": history}, headers=get_headers()) if resp.status_code == 200: data = resp.json() ans = data.get("answer", "No answer.") srcs = data.get("sources", []) # Include sources if available if srcs: st.session_state.chat_history.append({"role": "assistant", "content": ans, "sources": srcs}) else: st.session_state.chat_history.append({"role": "assistant", "content": ans}) else: st.session_state.chat_history.append({"role": "assistant", "content": "Error processing request."}) except Exception as e: st.session_state.chat_history.append({"role": "assistant", "content": f"Connection Error: {e}"}) st.rerun() # FOCUS: RIGHT COLUMN (LESSON CONTENT) - Scrollable Document Viewer with mid_col: topic_title = st.session_state.active_topic # Handle case where active_topic is dict or string if isinstance(topic_title, dict): topic_title = topic_title.get('title', 'Unknown Topic') st.markdown(f"### 📖 {topic_title}") st.markdown("---") # Unique key for this topic's content t_id = st.session_state.active_topic['id'] if isinstance(st.session_state.active_topic, dict) else hash(topic_title) content_key = f"content_{t_id}" # 1. Fetch Content if missing if content_key not in st.session_state: with st.spinner(f"🤖 AI is writing a lesson for '{topic_title}'..."): try: resp = requests.post(f"{API_URL}/generate_lesson", json={"topic": topic_title}, headers=get_headers(), timeout=300) if resp.status_code == 200: st.session_state[content_key] = resp.json()["content"] else: st.session_state[content_key] = f"âš ī¸ Server Error: {resp.text}" except Exception as e: st.session_state[content_key] = f"âš ī¸ Connection Error: {e}" # 2. Render Content in Scrollable Container (like a document viewer) lesson_container = st.container(height=650, border=True) with lesson_container: st.markdown(st.session_state[content_key]) # 3. Exit Button (stays fixed below the scrollable content) if st.button("âŦ… Finish & Return", use_container_width=True): st.session_state.focus_mode = False st.rerun() # --- MIDDLE COLUMN: Intelligent Workspace --- # --- MIDDLE COLUMN: Intelligent Workspace --- if not st.session_state.focus_mode: with mid_col: # Header h_col1, h_col2 = st.columns([0.8, 0.2]) with h_col1: st.markdown("""

🧠 Intelligent Workspace

""", unsafe_allow_html=True) with h_col2: if st.button("📊 Analytics"): show_analytics_dialog() # Reading Content / Chat Area # Use native container with border to replace "custom-card" and fix "small box" issue with st.container(border=True): # 1. Chat History / Content (Scrollable Container) # using height=500 to create a scrolling area like a real chat app chat_container = st.container(height=500) with chat_container: if not st.session_state.chat_history: # Welcome Content st.markdown("""
📚

Welcome to FocusFlow

Your AI-powered study companion

📄 Upload a PDF
📋 Paste your notes
đŸ—“ī¸ Generate a study plan
""", unsafe_allow_html=True) else: # Chat Messages # Chat Messages for i, msg in enumerate(st.session_state.chat_history): with st.chat_message(msg["role"]): st.markdown(msg["content"]) # Source Display Logic (MUST BE INSIDE THE LOOP) if msg["role"] == "assistant" and msg.get("sources"): with st.expander("📚 View Sources", expanded=False): st.caption("Information retrieved from:") for idx, s in enumerate(msg["sources"], 1): # Crash Proof Check: Handle string vs dict if isinstance(s, str): st.markdown(f"**{idx}.** {s[:100]}...") else: # It is a dictionary src = s.get("source", "Document") # Extract filename from path filename = src.split("/")[-1] if "/" in src else src page_num = s.get("page", "N/A") # Display with nice formatting st.markdown(f"**{idx}.** 📄 `{filename}` â€ĸ Page {page_num}") # 2. Input Area (Pinned to bottom of the visible card by being outside scroll container) with st.form(key="chat_form", clear_on_submit=True): cols = st.columns([0.85, 0.15]) with cols[0]: user_input = st.text_input("Ask a question...", placeholder="Ask a question about your documents...", label_visibility="collapsed", key="chat_input_widget") with cols[1]: submit_button = st.form_submit_button("Send", use_container_width=True) if submit_button and user_input: st.session_state.chat_history.append({"role": "user", "content": user_input}) try: with st.spinner("Thinking..."): # Prepare history (exclude sources for cleanliness) history = [ {"role": msg["role"], "content": msg["content"]} for msg in st.session_state.chat_history[:-1][-5:] # Last 5 valid history items before current question ] resp = requests.post(f"{API_URL}/query", json={"question": user_input, "history": history}, headers=get_headers()) if resp.status_code == 200: try: data = resp.json() ans = data.get("answer", "No answer.") srcs = data.get("sources", []) if srcs: st.session_state.chat_history.append({"role": "assistant", "content": ans, "sources": srcs}) else: st.session_state.chat_history.append({"role": "assistant", "content": ans}) except Exception as e: st.session_state.chat_history.append({"role": "assistant", "content": f"Error parsing response: {e}\\n\\nRaw text: {resp.text}"}) else: st.session_state.chat_history.append({"role": "assistant", "content": "Error."}) except Exception as e: st.session_state.chat_history.append({"role": "assistant", "content": f"Connection Error: {e}"}) st.rerun() # --- RIGHT COLUMN: Scheduler --- # --- RIGHT COLUMN: Scheduler --- # --- RIGHT COLUMN: Scheduler --- if right_col: with right_col: # --- CALENDAR WIDGET --- st.markdown("""

📅 Study Calendar

""", unsafe_allow_html=True) today = date.today() selected_date = st.date_input("Select Date", value=today) # --- LOGIC: Show plan for selected date --- # If user selects a future date, show its plan inline if selected_date != today: delta = selected_date - today day_offset = delta.days + 1 st.markdown(f"### 📋 Plan for {selected_date}") # Filter plan for this hypothetical day day_tasks = [t for t in st.session_state.study_plan if t.get("day") == day_offset] if day_tasks: for t in day_tasks: st.markdown(f"- **{t['title']}**") else: st.info("No plan generated for this specific date yet.") st.markdown("---") # --- B. TALK TO CALENDAR --- with st.form("calendar_chat_form", clear_on_submit=True): plan_query = st.text_input("Talk to Calendar...", placeholder="e.g., 'Make a 3 day plan'") submitted = st.form_submit_button("🚀 Generate Plan") if submitted and plan_query: with st.spinner("🤖 AI (1B) is thinking..."): try: # Increased timeout to 300s for safety resp = requests.post(f"{API_URL}/generate_plan", json={"request_text": plan_query}, headers=get_headers(), timeout=300) if resp.status_code == 200: plan_data = resp.json() raw_plan = plan_data.get("days", []) # ROBUST SANITIZATION LOOP for index, task in enumerate(raw_plan): # 1. FORCE UNLOCK DAY 1 (The Fix) if index == 0: task["status"] = "unlocked" task["locked"] = False else: # Default logic for others: Trust 'status' or default to 'locked' # We ignore the 'locked' boolean fallback to be stricter, # ensuring only Day 1 is open initially if not specified. task["status"] = task.get("status", "locked") # 2. Fix IDs & Keys if "id" not in task: task["id"] = index + 1 task["quiz_passed"] = task.get("quiz_passed", False) task["title"] = task.get("topic", f"Topic {task['id']}") # Fallback title st.session_state.study_plan = raw_plan # AUTO-SAVE: Persist the new plan try: num_days = max([t.get("day", 1) for t in raw_plan]) if raw_plan else 0 save_resp = requests.post(f"{API_URL}/student/save_plan", json={ "topics": raw_plan, "num_days": num_days }, headers=get_headers(), timeout=5) if save_resp.status_code == 200: st.toast(f"💾 Progress saved: {len(raw_plan)} topics", icon="✅") else: st.warning(f"Could not save progress: {save_resp.text}") except Exception as e: st.warning(f"Could not save progress: {e}") st.success("📅 Plan Created! Check Today's Topics.") st.rerun() else: st.error(f"Failed: {resp.text}") except Exception as e: st.error(f"Error: {e}") # --- TODAY'S TOPICS (FILTERED BY CURRENT STUDY DAY) --- current_day = st.session_state.get("current_study_day", 1) st.markdown(f"### Today's Topics (Day {current_day})") # FILTER: Show tasks for current study day todays_tasks = [t for t in st.session_state.study_plan if t.get("day") == current_day] if not todays_tasks: st.caption("No tasks for today. Ask the calendar to make a plan!") else: # Group tasks by subject if multiple topics per day if len(todays_tasks) > 1: st.caption(f"📚 {len(todays_tasks)} topics to cover today") for i, task in enumerate(todays_tasks): # Display subject badge if available subject_badge = "" if "subject" in task and task["subject"]: subject_badge = f"**{task['subject']}** â€ĸ " # 1. COMPLETED if task["status"] == "completed": st.success(f"✅ {subject_badge}{task['title']}") # (Flashcards button REMOVED as requested) # 2. ACTIVE / UNLOCKED elif task["status"] == "unlocked": with st.container(border=True): # Show subject badge prominently if "subject" in task and task["subject"]: st.caption(f"📘 {task['subject']}") st.markdown(f"**{task['title']}**") # The Focus Mode Button if st.button(f"🚀 Start Learning", key=f"start_{task['id']}"): st.session_state.focus_mode = True st.session_state.active_topic = task['title'] st.rerun() # 1. THE QUIZ BUTTON if st.button(f"📝 Take Quiz (Unlock Next)", key=f"quiz_btn_{task['id']}"): st.session_state[f"show_quiz_{task['id']}"] = True st.rerun() # 2. THE QUIZ (Inline - no dialog to avoid Streamlit error) if st.session_state.get(f"show_quiz_{task['id']}", False): st.markdown("---") st.write("### 🧠 Knowledge Check") # 1. FETCH QUIZ DATA (Dynamic) quiz_key = f"quiz_data_{task['id']}" if quiz_key not in st.session_state: with st.spinner(f"🤖 Generating quiz for '{task['title']}'..."): try: resp = requests.post(f"{API_URL}/generate_quiz", json={"topic": task['title']}, headers=get_headers(), timeout=120) if resp.status_code == 200: st.session_state[quiz_key] = resp.json().get("quiz", []) else: st.error("Failed to generate quiz.") except Exception as e: st.error(f"Connection error: {e}") quiz_data = st.session_state.get(quiz_key, []) if quiz_data: st.caption("Answer the questions below. Next topic unlocks automatically.") score = 0 user_answers = {} # 2. RENDER QUESTIONS for i, q in enumerate(quiz_data): st.markdown(f"**Q{i+1}: {q['question']}**") user_answers[i] = st.radio( "Select one:", q['options'], key=f"q_{task['id']}_{i}" ) st.markdown("---") col1, col2 = st.columns([1, 1]) with col1: if st.button("🚀 Submit Quiz", key=f"submit_{task['id']}", use_container_width=True): # GRADING LOGIC for i, q in enumerate(quiz_data): if user_answers[i] == q['answer']: score += 1 # STORE SCORE FOR ANALYTICS st.session_state.topic_scores[task['id']] = { "topic_title": task['title'], "score": score, "total": len(quiz_data), "percentage": (score / len(quiz_data)) * 100 } st.info(f"📊 Your Score: {score}/{len(quiz_data)}") # ALWAYS UNLOCK NEXT TOPIC st.balloons() # --- ADAPTIVE LOGIC (Optional based on score) --- if score == 3: st.toast("🚀 Perfect Score! Accelerating future plan...", icon="⚡") for future_task in st.session_state.study_plan: if future_task["id"] > task["id"]: if "Advanced" not in future_task["title"]: future_task["title"] = f"Advanced: {future_task['title']}" future_task["details"] = "Deep dive with complex examples. (AI Adjusted)" elif score == 2: st.toast("âš ī¸ Good effort! Adding revision steps...", icon="đŸ›Ąī¸") for future_task in st.session_state.study_plan: if future_task["id"] > task["id"]: if "Review" not in future_task["title"]: future_task["title"] = f"Review & {future_task['title']}" future_task["details"] = "Includes recap of previous concepts. (AI Adjusted)" st.success(f"✅ Quiz completed! Unlocking next topic...") time.sleep(1) # UNLOCK NEXT TOPIC task["status"] = "completed" task["quiz_passed"] = True current_id = task["id"] for next_task in st.session_state.study_plan: if next_task["id"] == current_id + 1: next_task["status"] = "unlocked" next_task["locked"] = False break # AUTO-SAVE: Persist quiz score and completion try: subject = task.get("subject", "General") requests.post(f"{API_URL}/student/quiz_complete", json={ "topic_id": task["id"], "topic_title": task["title"], "subject": subject, "score": score, "total": len(quiz_data), "time_taken": 0 }, headers=get_headers(), timeout=5) except Exception: pass # Silent fail for auto-save # Close Quiz st.session_state[f"show_quiz_{task['id']}"] = False st.rerun() with col2: if st.button("✕ Cancel", key=f"cancel_{task['id']}", use_container_width=True): st.session_state[f"show_quiz_{task['id']}"] = False st.rerun() # 3. LOCKED else: with st.container(border=True): st.markdown(f"🔒 {task['title']}", unsafe_allow_html=True)