"""MiniFam — gradio.Server entry point. This serves a custom React frontend (the Dia-styled design under `frontend/`) and exposes the local agent + Markdown store as queued JSON endpoints the frontend calls with `@gradio/client`. GET / -> the React app (frontend/index.html) /app/* -> static assets (CSS, JSX) api "chat" -> one agent turn (live model + tools), with tool-call cards api "recipes" -> the recipe box as JSON api "notes" -> a member's notes as JSON api "meal_plan" -> the weekly plan + shopping list as JSON Everything runs on this machine: the model is an OpenAI-compatible endpoint (Ollama in dev, llama.cpp later) and all data lives in local Markdown files. The classic Gradio Blocks UI is preserved in app_blocks.py. """ from pathlib import Path import gradio as gr from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from minifam import agent, bootstrap, calendar_store, config, llm, serialize FRONTEND = Path(__file__).resolve().parent / "frontend" app = gr.Server(title="MiniFam") # On a Hugging Face GPU Space, stand up a local Ollama server (no Docker) so the # agent reaches it at localhost:11434 just like in dev. No-op locally — only # runs when MINIFAM_BOOTSTRAP_OLLAMA=1 is set on the Space. Returns immediately; # the model downloads in the background while the UI comes up. bootstrap.ensure_ollama() # ZeroGPU scans this, the main app module, for an @spaces.GPU function at startup. # It's already imported transitively via `agent` → `llm`, but bind it here too so # the decorated function is unmistakably present in app.py's own namespace. if llm.BACKEND == "zerogpu": from minifam.llm_zerogpu import _generate as _gpu_generate # noqa: F401 class NoCacheStaticFiles(StaticFiles): """Serve the frontend with caching off so edits show up on reload.""" async def get_response(self, path, scope): response = await super().get_response(path, scope) response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return response # --- Tool-call cards --------------------------------------------------------- # Map each real tool the agent runs to the "agent working" card the Chat screen # shows: an icon, a friendly label, and a short status verb. _TOOL_CARDS = { "add_note": ("notes", "Wrote to notes", "Saved"), "list_notes": ("notes", "Read the notes", "Read"), "add_recipe": ("recipes", "Saved a recipe", "Saved"), "import_recipe_from_url": ("recipes", "Fetched the page", "Read"), "list_recipes": ("recipes", "Checked the recipe box", "Read"), "get_recipe": ("recipes", "Looked up a recipe", "Read"), "search_recipes": ("recipes", "Searched recipes", "Read"), "save_meal_plan": ("meals", "Saved the meal plan", "Saved"), "get_meal_plan": ("meals", "Checked the meal plan", "Read"), "generate_shopping_list": ("cart", "Built the shopping list","Ready"), "add_member": ("user", "Added a family member", "Saved"), "set_food_preferences": ("user", "Updated food preferences", "Saved"), "list_members": ("user", "Checked the family", "Read"), "add_event": ("calendar","Added an event", "Saved"), "add_task": ("check", "Added a task", "Saved"), "list_calendar": ("calendar","Checked the calendar", "Read"), "complete_task": ("check", "Checked off a task", "Done"), } # Calendar tools whose card offers an "Add to Google Calendar" button. _GCAL_TOOLS = {"add_event": "Add event to Google Calendar", "add_task": "Add task to Google Calendar"} def _gcal_from_args(name: str, args: dict) -> str: """Build the Google Calendar link for a just-created event/task card.""" return calendar_store.gcal_url( { "title": args.get("title"), "date": args.get("date"), "time": args.get("time"), "end": args.get("end_time"), "members": args.get("members"), "details": args.get("details"), "type": "task" if name == "add_task" else "event", } ) def _card(event: dict) -> dict: kind, label, ok = _TOOL_CARDS.get( event["name"], ("sparkle", event["name"], "Done") ) rows = [ [str(k).replace("_", " ").title(), str(v)[:80]] for k, v in (event.get("args") or {}).items() ][:3] # `name` lets the frontend react to roster-changing turns (refresh members). card = {"name": event["name"], "kind": kind, "label": label, "ok": ok, "rows": rows} if event["name"] in _GCAL_TOOLS: card["gcalUrl"] = _gcal_from_args(event["name"], event.get("args") or {}) card["gcalLabel"] = _GCAL_TOOLS[event["name"]] return card # --- API endpoints ----------------------------------------------------------- @app.api(name="chat") def chat(message: str, member: str = "elena", history: list | None = None) -> dict: """Run one agent turn. Returns the reply, the tool cards, and updated history.""" history = history or [] convo = history + [{"role": "user", "content": message}] events: list[dict] = [] try: reply, convo = agent.run_agent(convo, member, events=events) except Exception as e: # surface model/backend issues gracefully in the UI # While the Space is still downloading/starting the model, say so # instead of the generic error (warmup_message() is None locally). reply = bootstrap.warmup_message() or ( f"I couldn't reach the local model ({e}). Is it running?" ) return {"reply": reply, "tools": [], "history": history} if not (reply or "").strip(): # Small models sometimes emit only reasoning; never show a blank bubble. reply = "Done." if events else "I'm not sure how to help with that — could you rephrase?" return {"reply": reply, "tools": [_card(e) for e in events], "history": convo} @app.api(name="recipes") def recipes() -> list: """The whole recipe box as structured cards.""" return serialize.recipes() @app.api(name="notes") def notes() -> list: """The shared family notes, projected as note cards.""" return serialize.notes() @app.api(name="meal_plan") def meal_plan() -> dict: """The latest weekly plan plus a shopping list built from its recipes.""" return serialize.meal_plan() @app.api(name="members") def members() -> list: """The family roster as avatar-ready cards.""" return serialize.members() @app.api(name="add_member") def add_member(name: str, food_preferences: str = "") -> dict: """Add a family member and return the full updated roster.""" config.add_member(name, food_preferences=food_preferences) return {"members": serialize.members()} @app.api(name="remove_member") def remove_member(member: str) -> dict: """Remove a family member by id and return the full updated roster.""" config.remove_member(member) return {"members": serialize.members()} @app.api(name="calendar") def calendar() -> list: """The family calendar (events + tasks) as UI-ready items.""" return serialize.calendar() @app.api(name="set_task_done") def set_task_done(item: str, done: bool = True) -> dict: """Check/uncheck a task by id; return the updated calendar.""" calendar_store.set_done(item, done) return {"calendar": serialize.calendar()} @app.api(name="remove_calendar_item") def remove_calendar_item(item: str) -> dict: """Remove an event/task by id; return the updated calendar.""" calendar_store.remove_item(item) return {"calendar": serialize.calendar()} # --- Static frontend --------------------------------------------------------- app.mount("/app", NoCacheStaticFiles(directory=str(FRONTEND / "app")), name="assets") @app.get("/", response_class=HTMLResponse) def index() -> str: return (FRONTEND / "index.html").read_text(encoding="utf-8") if __name__ == "__main__": app.launch(server_name="0.0.0.0", server_port=7860, show_error=True)