""" frontend/app.py — MathMinds AI Streamlit frontend. STRUCTURE ───────── 1. CONFIG & CONSTANTS — env vars, page config, CSS 2. SESSION STATE — defaults, init, clear 3. API LAYER — all HTTP calls, no st.* calls, returns plain dicts 4. RENDER HELPERS — pure display functions, never mutate state 5. SESSION MANAGEMENT — state mutations, never call st.rerun() 6. CHAT INTERFACE — the 3-state machine (idle / processing / done) 7. MAIN ENTRY — auth gate + router STATE MACHINE (why the previous version had bugs) ────────────────────────────────────────────────── Every bug in the old version came from collapsing 3 distinct states into one render pass: add user message + call API + render answer all happened together. The fix is a strict 3-state machine: IDLE → render history + show input user submits → _add_message("user") → is_processing=True → rerun() PROCESSING → render history (user question VISIBLE above spinner) make API call → _add_message("assistant") → is_processing=False → rerun() IDLE again → render history (assistant answer now visible) Rules that make this impossible to break: - st.rerun() is ALWAYS the last statement — never inside a with-block - The API call NEVER happens inside with st.chat_message() - Render helpers never write session_state - load_messages() only called on session switch, never after an answer """ import streamlit as st import requests import base64 import logging import io import os import time import uuid from PIL import Image from streamlit_drawable_canvas import st_canvas from dotenv import load_dotenv from firebase_utils import sign_in_with_email, sign_up_with_email, send_password_reset_email logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() # ══════════════════════════════════════════════════════════════════════════════ # 1. CONFIG & CONSTANTS # ══════════════════════════════════════════════════════════════════════════════ BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") ENABLE_AUTH = os.getenv("ENABLE_AUTH", "True").lower() == "true" st.set_page_config( page_title="MathMinds AI", page_icon="🧠", layout="wide", initial_sidebar_state="expanded", ) st.markdown(""" """, unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # 2. SESSION STATE # ══════════════════════════════════════════════════════════════════════════════ _DEFAULTS = { "user": None, "current_view": "Chat", "current_mode": "solve", "chat_sessions": [], "active_session_id": None, "messages": [], "loaded_for_user": None, "loaded_for_session": None, "is_processing": False, "renaming_session_id": None, "canvas_key": "main_canvas", } for _k, _v in _DEFAULTS.items(): if _k not in st.session_state: st.session_state[_k] = _v # Dev mode bypass if not ENABLE_AUTH and st.session_state.user is None: st.session_state.user = { "email": "dev@mathminds.ai", "token": "mock_dev_token", "uid": "dev_user_123", } def _reset_state(keep_user=False): """Clear all state. Optionally preserve the user object.""" saved_user = st.session_state.user if keep_user else None for k, v in _DEFAULTS.items(): st.session_state[k] = v st.session_state.canvas_key = f"canvas_{uuid.uuid4()}" if keep_user: st.session_state.user = saved_user def _get_headers() -> dict: u = st.session_state.user return {"Authorization": f"Bearer {u['token']}"} if u and "token" in u else {} def _add_message(role: str, content, **kwargs): msg = {"role": role, "content": content, "timestamp": time.time()} msg.update(kwargs) st.session_state.messages.append(msg) def _is_blank_canvas(image_data) -> bool: if image_data is None: return True import numpy as np return image_data[:, :, 3].max() == 0 # ══════════════════════════════════════════════════════════════════════════════ # 3. API LAYER # ───────────────────────────────────────────────────────────────────────────── # Rules: # - NO st.* calls anywhere in this section # - Returns plain dict, always has "ok" key # - Never raises — exceptions are caught and returned as {"ok": False} # ══════════════════════════════════════════════════════════════════════════════ @st.cache_data(ttl=30) def api_health() -> dict: """Cached — only hits the backend once every 30 seconds, not on every rerender.""" try: r = requests.get(f"{BACKEND_URL}/health", timeout=5) return {"ok": r.status_code == 200, **r.json()} if r.status_code == 200 else {"ok": False} except Exception: return {"ok": False} def api_solve(text: str, image, session_id: str, request_id: str, mode: str = "solve") -> dict: try: r = requests.post( f"{BACKEND_URL}/{mode}", json={"text": text, "image": image, "session_id": session_id, "request_id": request_id}, headers=_get_headers(), timeout=360, ) if r.status_code == 200: return {"ok": True, **r.json()} if r.status_code == 401: return {"ok": False, "error": "AUTH_EXPIRED"} if r.status_code == 429: return {"ok": False, "error": "Daily limit reached. Please try again tomorrow."} return {"ok": False, "error": f"Backend error {r.status_code}"} except requests.exceptions.ConnectionError: return {"ok": False, "error": "Cannot reach backend. Is the server running?"} except requests.exceptions.Timeout: return {"ok": False, "error": "Request timed out."} except Exception as e: logger.error(f"api_solve: {e}") return {"ok": False, "error": "Unexpected error. Please try again."} def api_load_messages(session_id: str) -> dict: try: r = requests.get( f"{BACKEND_URL}/chat/sessions/{session_id}/messages", headers=_get_headers(), timeout=30, ) if r.status_code == 200: return {"ok": True, "messages": r.json()} if r.status_code == 404: return {"ok": False, "error": "SESSION_NOT_FOUND"} if r.status_code == 401: return {"ok": False, "error": "AUTH_EXPIRED"} return {"ok": False, "error": f"Status {r.status_code}"} except Exception as e: return {"ok": False, "error": str(e)} def api_load_sessions() -> dict: try: r = requests.get(f"{BACKEND_URL}/chat/sessions", headers=_get_headers(), timeout=10) if r.status_code == 200: return {"ok": True, "sessions": r.json()} if r.status_code == 401: return {"ok": False, "error": "AUTH_EXPIRED"} return {"ok": False, "error": f"Status {r.status_code}"} except requests.exceptions.ConnectionError: return {"ok": False, "error": "BACKEND_OFFLINE"} except Exception as e: return {"ok": False, "error": str(e)} def api_new_session() -> dict: try: r = requests.post(f"{BACKEND_URL}/chat/sessions", headers=_get_headers(), timeout=30) return {"ok": True, "session": r.json()} if r.status_code == 200 else {"ok": False, "error": f"Status {r.status_code}"} except Exception as e: return {"ok": False, "error": str(e)} def api_delete_session(sid: str) -> dict: try: r = requests.delete(f"{BACKEND_URL}/chat/sessions/{sid}", headers=_get_headers(), timeout=30) return {"ok": r.status_code == 200} except Exception as e: return {"ok": False, "error": str(e)} def api_rename_session(sid: str, title: str) -> dict: try: r = requests.patch( f"{BACKEND_URL}/chat/sessions/{sid}", json={"title": title}, headers=_get_headers(), timeout=30, ) return {"ok": r.status_code == 200} except Exception as e: return {"ok": False, "error": str(e)} # ══════════════════════════════════════════════════════════════════════════════ # 4. RENDER HELPERS # ───────────────────────────────────────────────────────────────────────────── # Rules: # - Only READ session_state, never write # - Only call st.* display functions # - Never call st.rerun() or make API calls # ══════════════════════════════════════════════════════════════════════════════ def _badge(source: str, status: str) -> str: b = "" if source == "sympy_preflight": b += '⚡ INSTANT' elif source in ("cache", "semantic_cache"): b += '💾 CACHED' elif source in ("google_adk_agent", "agent"): b += '🤖 AI' if status == "error": b += '🔴 ERROR' return b def _render_message(msg: dict): role = msg.get("role", "assistant") with st.chat_message(role, avatar="👤" if role == "user" else "🤖"): if role == "user": if msg.get("image_data"): try: st.image(base64.b64decode(msg["image_data"]), width=300) except Exception: pass st.write(msg.get("content", "")) else: meta = msg.get("metadata") or {} status = meta.get("status") or msg.get("status", "success") badges = _badge(meta.get("source", ""), status) if badges: st.markdown(badges, unsafe_allow_html=True) logic = meta.get("logic_trace") or msg.get("reasoning") if logic: steps = [s for s in (logic if isinstance(logic, list) else logic.split("\n")) if s] if steps: with st.expander("💭 Reasoning", expanded=False): for step in steps: st.caption(step) content = msg.get("content", "") if status == "error": st.error(content) elif isinstance(content, dict) and "final_answer" in content: st.markdown(content["final_answer"]) else: st.markdown(str(content)) # --- EXPORT BUTTONS --- if role == "assistant" and status != "error" and content: try: from export_utils import generate_latex, compile_pdf msg_id = msg.get("request_id") or str(int(msg.get("timestamp", 0))) pdf_state_key = f"pdf_bytes_{msg_id}" with st.expander("📥 Export Solution (LaTeX / PDF)"): tex_str = generate_latex(str(content)) c1, c2 = st.columns(2) with c1: st.download_button( label="📄 Download .tex Source", data=tex_str, file_name=f"mathminds_{msg_id[:8]}.tex", mime="text/plain", key=f"dl_tex_{msg_id}" ) with c2: if pdf_state_key not in st.session_state: if st.button("🛠️ Generate PDF", key=f"btn_compile_{msg_id}"): with st.spinner("Compiling PDF via remote TeX Live..."): pdf_data = compile_pdf(tex_str) if pdf_data: st.session_state[pdf_state_key] = pdf_data st.rerun() else: st.error("❌ Failed to compile PDF from external API.") else: st.download_button( label="✅ Click to Save PDF", data=st.session_state[pdf_state_key], file_name=f"mathminds_{msg_id[:8]}.pdf", mime="application/pdf", key=f"dl_pdf_{msg_id}" ) except Exception as e: logger.error(f"Render export error: {e}") def _render_login(): _, c, _ = st.columns([1, 2, 1]) with c: st.markdown("""
Your intelligent math assistant.
Powered by Gemini & SymPy
", unsafe_allow_html=True, ) def _render_profile(): st.title("👤 User Profile") if "profile_data" not in st.session_state: try: r = requests.get(f"{BACKEND_URL}/users/profile", headers=_get_headers(), timeout=30) st.session_state.profile_data = r.json() if r.status_code == 200 else {} except Exception: st.session_state.profile_data = {} data = st.session_state.profile_data levels = ["High School", "Undergraduate", "Graduate", "Researcher"] interests_all = ["Algebra", "Calculus", "Geometry", "Statistics", "Physics", "Computer Science", "Finance"] with st.form("profile_form"): display_name = st.text_input("Display Name", value=data.get("display_name", "")) math_level = st.selectbox( "Math Level", levels, index=levels.index(data["math_level"]) if data.get("math_level") in levels else 1, ) interests = st.multiselect( "Interests", interests_all, default=[i for i in data.get("interests", []) if i in interests_all], ) if st.form_submit_button("Save", use_container_width=True, type="primary"): payload = {"display_name": display_name, "math_level": math_level, "interests": interests} try: r = requests.post(f"{BACKEND_URL}/users/profile", json=payload, headers=_get_headers()) if r.status_code == 200: st.success("Saved!") st.session_state.profile_data = payload else: st.error(f"Failed: {r.text}") except Exception as e: st.error(str(e)) # ══════════════════════════════════════════════════════════════════════════════ # 5. SESSION MANAGEMENT # ───────────────────────────────────────────────────────────────────────────── # These functions mutate session_state but never call st.rerun(). # Callers decide when to rerun. # ══════════════════════════════════════════════════════════════════════════════ def _refresh_sessions(): result = api_load_sessions() if result["ok"]: st.session_state.chat_sessions = result["sessions"] st.session_state.loaded_for_user = st.session_state.user["uid"] elif result.get("error") == "AUTH_EXPIRED": _reset_state() elif result.get("error") == "BACKEND_OFFLINE": st.info("⌛ Backend warming up...") st.stop() def _switch_session(sid: str): """Load messages for a session. Only fetches if session actually changed.""" if st.session_state.loaded_for_session == sid: st.session_state.active_session_id = sid return result = api_load_messages(sid) if result["ok"]: st.session_state.active_session_id = sid st.session_state.messages = result["messages"] st.session_state.loaded_for_session = sid elif result.get("error") == "SESSION_NOT_FOUND": st.warning("Session not found.") st.session_state.active_session_id = None st.session_state.messages = [] elif result.get("error") == "AUTH_EXPIRED": _reset_state() def _ensure_session() -> bool: """ Make sure there's an active session. Returns True if ready, False if rerun needed. """ if st.session_state.active_session_id: return True sessions = st.session_state.chat_sessions if sessions: _switch_session(sessions[0]["session_id"]) return True # Create first session result = api_new_session() if result["ok"]: sess = result["session"] st.session_state.active_session_id = sess["session_id"] st.session_state.messages = [] st.session_state.loaded_for_session = sess["session_id"] _refresh_sessions() return False # caller must rerun return True def _render_sidebar(): with st.sidebar: st.markdown("### 🧠 MathMinds") st.caption(f"👤 {st.session_state.user['email']}") view = st.radio("Nav", ["Chat", "Profile"], index=0 if st.session_state.current_view == "Chat" else 1, label_visibility="collapsed") if view != st.session_state.current_view: st.session_state.current_view = view st.rerun() st.divider() st.markdown("#### Mode") mode_options = {"solve": "⚡ Solver", "analyze": "🔍 Analyzer", "tutor": "🎓 Tutor"} selected = st.selectbox("Select Agent", options=list(mode_options.keys()), format_func=lambda x: mode_options[x], label_visibility="collapsed") if selected != st.session_state.current_mode: st.session_state.current_mode = selected st.rerun() # Health st.divider() health = api_health() if health.get("ok"): st.markdown('