mini-fam / app.py
eloigil6's picture
Auto-select ZeroGPU backend on the Space; register @spaces.GPU in app.py
6dda138
Raw
History Blame Contribute Delete
8.26 kB
"""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)