diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-08 03:24:36 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-08 03:24:36 -0700 |
| commit | 3de7c5eed5ba262abf0d746211e33800db6d66df (patch) | |
| tree | 6fddb5381fb178423eac34894add5b611babe300 | |
| parent | f361e7599d9a11ad3397b7b6bffee151ab9bdde9 (diff) | |
Add recipe suggestions, chat tab, and major workflow improvements
- Replace 7-day grid menu with browsable recipe suggestion cards (swap, remove, make this)
- Add Chat tab: conversational AI with full pantry/menu/grocery context
- Grocery list works without a menu (pantry-only mode)
- Individual grocery checkboxes auto-add items to pantry
- Swap modal with optional preference input
- User notes textarea on menu and grocery generation
- Clear button for menu and grocery list
- LLM notes/summary displayed after generation, persisted to DB
- Favicon linked in HTML
- Category dropdown styled for dark theme
- System prompt configurable via SYSTEM_PROMPT in .env
- Fix startup error (ollama_timeout default), DB migration for menu_plans.notes
- Simplify: batch N+1 queries, extract _current_monday(), merge chat sync fns,
asyncio.get_running_loop(), fix currentGroceryId bug, cap chat history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | .env.example | 1 | ||||
| -rw-r--r-- | CLAUDE.md | 22 | ||||
| -rw-r--r-- | README.md | 25 | ||||
| -rw-r--r-- | config.py | 1 | ||||
| -rw-r--r-- | main.py | 6 | ||||
| -rw-r--r-- | models.py | 1 | ||||
| -rw-r--r-- | routers/grocery.py | 135 | ||||
| -rw-r--r-- | routers/menus.py | 191 | ||||
| -rw-r--r-- | schemas.py | 1 | ||||
| -rw-r--r-- | services/ai_service.py | 201 | ||||
| -rw-r--r-- | static/app.js | 335 | ||||
| -rw-r--r-- | static/favicon.svg | 34 | ||||
| -rw-r--r-- | static/index.html | 35 | ||||
| -rw-r--r-- | static/style.css | 286 |
14 files changed, 997 insertions, 277 deletions
diff --git a/.env.example b/.env.example index d3cb4e2..04b33a6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ OLLAMA_HOST=http://localhost:11434 MODEL_NAME=gemma4:latest DATABASE_URL=sqlite:///./chef.db +SYSTEM_PROMPT=You are a professional chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. You prioritize:\n1. Using ingredients that expire soonest\n2. Nutritional variety across the week\n3. Avoiding meals eaten in the past 7 days\n4. Practical home recipes under 60 minutes @@ -18,7 +18,7 @@ uvicorn main:app --reload --port 8080 open http://localhost:8000/docs ``` -There are no tests. The SQLite database (`chef.db`) is auto-created on first startup via `Base.metadata.create_all()` in the lifespan handler — no migrations needed. +There are no tests. The SQLite database (`chef.db`) is auto-created on first startup via `Base.metadata.create_all()` in the lifespan handler — no migrations needed for new installs. The lifespan handler also runs a one-time `ALTER TABLE menu_plans ADD COLUMN notes TEXT` migration for existing databases that predate that column. ## Architecture @@ -27,17 +27,31 @@ Single-process FastAPI app. All state lives in SQLite. Ollama runs as a separate **Request flow for AI endpoints:** 1. Router calls `pantry_service.build_pantry_context(db)` to snapshot the current pantry + recent meal history into a plain dict 2. That dict is passed to an `ai_service` function which builds a prompt and calls Ollama synchronously via `run_in_executor` (keeps the async event loop unblocked during the 15–120s generation) -3. Ollama always returns JSON (`format="json"` is set on every call); the response is `json.loads()`'d and returned directly — no intermediate parsing layer +3. Structured AI endpoints (menu, grocery, swap) use `format="json"` and return parsed dicts. The chat endpoint uses plain text — no JSON parsing. 4. The router saves AI output into the DB and returns both the DB record and the raw AI response to the frontend **Route registration order matters:** `app.include_router(...)` calls happen before `app.mount("/", StaticFiles(...))`. Reversing this breaks all API routes — the static catch-all intercepts them first. **JSON columns:** `recipes.ingredients`, `menu_plans.plan`, and `grocery_lists.items` are stored as JSON strings in `Text` columns. Always `json.dumps()` before saving and `json.loads()` before using. -**`menu_plans.plan` structure:** `{"monday": {"breakfast": <recipe_id>, "lunch": <recipe_id>, "dinner": <recipe_id>}, ...}`. The `POST /api/menus/generate` response also includes `week_plan` with full recipe details (name, ingredients, instructions) — the frontend uses this for display without a second fetch. On page reload only the plan with IDs is available, so `GET /api/recipes` is needed to look up names. +**`menu_plans.plan` structure:** A flat JSON array of recipe IDs — `[1, 4, 7, ...]`. The `POST /api/menus/generate` response also includes `recipes` with full recipe details (name, ingredients, instructions) — the frontend uses this for immediate display. On page reload only the plan IDs are available, so `GET /api/recipes` is needed to hydrate names and details. **`meal_ingredients.ingredient_name` is denormalized** — it stores a copy of the name string rather than a FK to `ingredients`. This preserves meal history when pantry items are deleted. **`grocery.py` exports two routers:** `router` (prefix `/api/grocery`) and `ai_router` (prefix `/api/ai`). Both are included in `main.py`. -**Config** is loaded from `.env` via `pydantic-settings`. Key vars: `OLLAMA_HOST`, `MODEL_NAME`, `DATABASE_URL`. Change `MODEL_NAME` to switch Ollama models (e.g. `mistral`, `llama3.1`). +**`_current_monday()` helper** is defined in both `routers/menus.py` and `routers/grocery.py`. It returns the ISO date of the current week's Monday and is used by every endpoint that scopes data to the current week. + +**`ai_service._chat_sync(messages, json_format=True)`** is the single sync Ollama call. Pass `json_format=False` for the chat endpoint which returns plain text. All async public functions call `run_in_executor` wrapping this to avoid blocking the event loop. + +**Config** is loaded from `.env` via `pydantic-settings`. Key vars: + +| Var | Default | Purpose | +|-----|---------|---------| +| `OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL | +| `MODEL_NAME` | — | Which Ollama model to use (e.g. `llama3.1`, `mistral`) | +| `DATABASE_URL` | `sqlite:///./chef.db` | SQLAlchemy DB URL | +| `OLLAMA_TIMEOUT` | `120` | Seconds before Ollama call times out | +| `SYSTEM_PROMPT` | *(see config.py)* | System prompt prepended to all structured AI calls | + +Change `MODEL_NAME` to switch models. Edit `SYSTEM_PROMPT` to change the AI's persona and priorities for menu/grocery/swap generation (does not affect the chat tab's system prompt, which is built dynamically with kitchen context). @@ -1,17 +1,17 @@ # Commis -An AI-powered personal chef that tracks your pantry, logs what you've eaten, and generates a rotating weekly menu and grocery list — all running locally via Ollama. +An AI-powered personal chef that tracks your pantry, logs what you've eaten, and generates recipe suggestions and grocery lists — all running locally via Ollama. ## Requirements - Python 3.11+ -- [Ollama](https://ollama.com) running locally with a model pulled (e.g. `ollama pull llama3`) +- [Ollama](https://ollama.com) running locally with a model pulled (e.g. `ollama pull llama3.1`) ## Setup ```bash pip install -r requirements.txt -cp .env.example .env # or edit .env directly +cp .env.example .env # then edit .env uvicorn main:app --reload ``` @@ -21,23 +21,28 @@ Open [http://localhost:8000](http://localhost:8000). ``` OLLAMA_HOST=http://localhost:11434 -MODEL_NAME=llama3 +MODEL_NAME=llama3.1 DATABASE_URL=sqlite:///./chef.db +OLLAMA_TIMEOUT=120 +SYSTEM_PROMPT=You are a professional chef assistant... ``` -Change `MODEL_NAME` to any model you have pulled locally (`mistral`, `llama3.1`, etc.). +`MODEL_NAME` can be any model you have pulled locally. `SYSTEM_PROMPT` controls the AI's priorities for recipe and grocery generation — edit it to change the style, dietary focus, or constraints. The chat tab uses its own dynamic prompt built from your kitchen context. ## Features -**Pantry** — Add ingredients with quantities, units, categories, and expiry dates. Items expiring within 7 days are highlighted; within 2 days flagged red. +**Pantry** — Add ingredients with quantities, units, categories, and expiry dates. Items expiring within 7 days are highlighted. -**Meal Log** — Record what you eat. Commis tracks the last 14 days of history so the AI knows what to avoid repeating. +**Meal Log** — Record what you eat with optional ingredient tracking. Commis uses the last 14 days of history to avoid recommending things you've recently had. -**Weekly Menu** — Hit "Generate Menu" and Ollama builds a 7-day breakfast/lunch/dinner plan based on what's in your pantry, prioritizing ingredients expiring soon and avoiding recent repeats. +**Recipe Suggestions** — "Generate Menu" asks Ollama for 8–12 diverse recipe ideas based on your pantry and meal history. Recipes use a type badge (breakfast / lunch / dinner), show time and serving estimates, and display full ingredients and instructions. Each card has: +- **Make This** — jumps to the meal log pre-filled with the recipe +- **Swap** — opens a prompt where you can optionally describe what you want, then replaces that card with a new AI suggestion +- **Remove** — removes the recipe from this week's suggestions -**Grocery List** — After generating a menu, "Generate List" diffs the menu's ingredient needs against your pantry and produces a cost-estimated shopping list grouped by store section. "Mark All Purchased" auto-updates your pantry. +**Grocery List** — "Generate List" produces a cost-estimated shopping list grouped by store section (produce, dairy, protein, etc.). If a weekly menu exists, the list is scoped to those recipes and subtracts what you already have. Without a menu it suggests general pantry staples. Checking off an individual item immediately adds it to your pantry. "Mark All Purchased" does the same for everything at once. -**Quick Recipe** — `POST /api/ai/suggest-recipe` returns one recipe you can make right now from what's on hand. +**Chat** — A persistent chat tab where you can talk to Commis directly. It has full visibility into your current pantry, this week's recipes, and your grocery list, and maintains conversation history for the duration of the browser session. ## API @@ -6,6 +6,7 @@ class Settings(BaseSettings): model_name: str database_url: str ollama_timeout: int = 120 + system_prompt: str = "You are a professional chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. You prioritize:\n1. Using ingredients that expire soonest\n2. Nutritional variety across the week\n3. Avoiding meals eaten in the past 7 days\n4. Practical home recipes under 60 minutes" class Config: env_file = ".env" @@ -2,6 +2,7 @@ from contextlib import asynccontextmanager import asyncio from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from sqlalchemy import text from database import engine, Base from config import settings from routers import pantry, meals, recipes, menus, grocery @@ -11,6 +12,11 @@ from routers import pantry, meals, recipes, menus, grocery async def lifespan(app: FastAPI): # Startup Base.metadata.create_all(bind=engine) + with engine.connect() as conn: + result = conn.execute(text("PRAGMA table_info(menu_plans)")) + if 'notes' not in [row[1] for row in result]: + conn.execute(text("ALTER TABLE menu_plans ADD COLUMN notes TEXT")) + conn.commit() yield # Shutdown pass @@ -61,6 +61,7 @@ class MenuPlan(Base): plan = Column(Text, nullable=False) generated_at = Column(DateTime, default=datetime.utcnow) model_used = Column(String, nullable=True) + notes = Column(Text, nullable=True) class GroceryList(Base): diff --git a/routers/grocery.py b/routers/grocery.py index cde2068..17215d2 100644 --- a/routers/grocery.py +++ b/routers/grocery.py @@ -12,6 +12,11 @@ router = APIRouter(prefix="/api/grocery", tags=["grocery"]) ai_router = APIRouter(prefix="/api/ai", tags=["ai"]) +def _current_monday(): + today = datetime.utcnow().date() + return today - timedelta(days=today.weekday()) + + @router.get("") async def list_grocery_lists(db: Session = Depends(get_db)): """List all grocery lists.""" @@ -22,8 +27,7 @@ async def list_grocery_lists(db: Session = Depends(get_db)): @router.get("/current") async def get_current_grocery_list(db: Session = Depends(get_db)): """Get grocery list for current week.""" - today = datetime.utcnow().date() - monday = today - timedelta(days=today.weekday()) + monday = _current_monday() grocery_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first() if not grocery_list: @@ -41,57 +45,49 @@ async def get_grocery_list(id: int, db: Session = Depends(get_db)): @router.post("/generate") -async def generate_grocery_list(db: Session = Depends(get_db)): +async def generate_grocery_list(request: dict = Body(default={}), db: Session = Depends(get_db)): """Generate a grocery list for the current week.""" + user_notes = request.get("user_notes") if request else None + # Get current week's Monday - today = datetime.utcnow().date() - monday = today - timedelta(days=today.weekday()) + monday = _current_monday() # Fetch current MenuPlan menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() - if not menu_plan: - raise HTTPException(status_code=404, detail="No menu plan for current week. Generate a menu first.") - # Parse the plan JSON - try: - plan_dict = json.loads(menu_plan.plan) - except json.JSONDecodeError: - raise HTTPException(status_code=500, detail="Invalid menu plan data") + pantry_context = pantry_service.build_pantry_context(db) - # Collect all recipe IDs and fetch recipes - recipe_ids = set() - for day_meals in plan_dict.values(): - if isinstance(day_meals, dict): - for recipe_id in day_meals.values(): - if isinstance(recipe_id, int): - recipe_ids.add(recipe_id) + try: + if menu_plan: + # Parse the plan JSON (now a flat array of recipe IDs) + try: + recipe_ids = json.loads(menu_plan.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") - recipes = db.query(Recipe).filter(Recipe.id.in_(recipe_ids)).all() if recipe_ids else [] - recipe_map = {r.id: r for r in recipes} + recipes = db.query(Recipe).filter(Recipe.id.in_(recipe_ids)).all() if recipe_ids else [] + recipe_map = {r.id: r for r in recipes} - # Build menu_plan_for_ai - menu_plan_for_ai = {} - for day, day_meals in plan_dict.items(): - menu_plan_for_ai[day] = {} - for meal_type, recipe_id in day_meals.items(): - recipe = recipe_map.get(recipe_id) - if recipe: - try: - ingredients = json.loads(recipe.ingredients) - except (json.JSONDecodeError, TypeError): - ingredients = [] - menu_plan_for_ai[day][meal_type] = { - "name": recipe.name, - "ingredients": ingredients, - } + recipes_for_ai = [] + for recipe_id in recipe_ids: + recipe = recipe_map.get(recipe_id) + if recipe: + try: + ingredients = json.loads(recipe.ingredients) + except (json.JSONDecodeError, TypeError): + ingredients = [] + recipes_for_ai.append({ + "name": recipe.name, + "meal_type": recipe.meal_type, + "ingredients": ingredients, + }) - # Build pantry context and generate grocery list - try: - pantry_context = pantry_service.build_pantry_context(db) - ai_result = await ai_service.generate_grocery_list(menu_plan_for_ai, pantry_context) + ai_result = await ai_service.generate_grocery_list(recipes_for_ai, pantry_context, user_notes) + else: + ai_result = await ai_service.generate_grocery_list_from_pantry(pantry_context, user_notes) except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) - except (ConnectionError, Exception) as e: + except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") # Upsert GroceryList @@ -122,6 +118,18 @@ async def generate_grocery_list(db: Session = Depends(get_db)): } +@router.delete("/current") +async def delete_current_grocery_list(db: Session = Depends(get_db)): + """Delete the grocery list for the current week.""" + monday = _current_monday() + grocery_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first() + if not grocery_list: + raise HTTPException(status_code=404, detail="No grocery list for current week") + db.delete(grocery_list) + db.commit() + return {"status": "deleted"} + + @router.put("/{id}/purchased") async def mark_purchased(id: int, db: Session = Depends(get_db)): """Mark a grocery list as purchased and update pantry.""" @@ -175,7 +183,7 @@ async def suggest_recipe_endpoint(db: Session = Depends(get_db)): return result except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) - except (ConnectionError, Exception) as e: + except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") @@ -184,3 +192,46 @@ async def list_models(): """List available Ollama models.""" models = await ai_service.get_available_models() return {"models": models} + + +@ai_router.post("/chat") +async def chat_endpoint(request: dict = Body(default={}), db: Session = Depends(get_db)): + """Chat with Commis using current kitchen context.""" + message = request.get("message", "").strip() + if not message: + raise HTTPException(status_code=400, detail="Message is required") + + history = request.get("history", []) + + # Build pantry context + pantry_context = pantry_service.build_pantry_context(db) + + # Get current week's menu recipes + monday = _current_monday() + + menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + menu_context = [] + if menu_plan: + try: + plan_ids = json.loads(menu_plan.plan) + recipes = db.query(Recipe).filter(Recipe.id.in_(plan_ids)).all() if plan_ids else [] + menu_context = [{"name": r.name, "meal_type": r.meal_type} for r in recipes] + except Exception: + menu_context = [] + + # Get current grocery list items + grocery_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first() + grocery_context = [] + if grocery_list: + try: + grocery_context = json.loads(grocery_list.items) + except Exception: + grocery_context = [] + + try: + response = await ai_service.chat_with_commis(message, history, pantry_context, menu_context, grocery_context) + return {"response": response} + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") diff --git a/routers/menus.py b/routers/menus.py index 49c4654..9ec1d1d 100644 --- a/routers/menus.py +++ b/routers/menus.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Body from sqlalchemy.orm import Session from datetime import datetime, timedelta, date import json @@ -12,11 +12,15 @@ from config import settings router = APIRouter(prefix="/api/menus", tags=["menus"]) +def _current_monday(): + today = datetime.utcnow().date() + return today - timedelta(days=today.weekday()) + + @router.get("/current") async def get_current_menu(db: Session = Depends(get_db)): """Get menu plan for current week.""" - today = datetime.utcnow().date() - monday = today - timedelta(days=today.weekday()) + monday = _current_monday() menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() if not menu: @@ -37,67 +41,64 @@ async def generate_menu(request: dict = None, db: Session = Depends(get_db)): try: if isinstance(week_start, str): week_start = datetime.fromisoformat(week_start).date() - else: - week_start = week_start except Exception: raise HTTPException(status_code=400, detail="Invalid week_start date") else: - today = datetime.utcnow().date() - week_start = today - timedelta(days=today.weekday()) + week_start = _current_monday() # Build pantry context and generate menu try: pantry_context = pantry_service.build_pantry_context(db) - ai_result = await ai_service.generate_weekly_menu(pantry_context) + user_notes = request.get("user_notes") if request and isinstance(request, dict) else None + ai_result = await ai_service.generate_weekly_menu(pantry_context, user_notes) except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) - except (ConnectionError, Exception) as e: + except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") - # Save recipes and build plan dict - week_plan = ai_result.get("week_plan", {}) - plan_dict = {} - - for day, meals in week_plan.items(): - plan_dict[day] = {} - for meal_type, meal_data in meals.items(): - if isinstance(meal_data, dict) and "name" in meal_data: - meal_name = meal_data["name"] + # Save recipes and build plan list (flat array of IDs) + recipes_ai = ai_result.get("recipes", []) + plan_ids = [] - # Check if recipe exists by name - existing_recipe = db.query(Recipe).filter(Recipe.name == meal_name).first() - if existing_recipe: - recipe_id = existing_recipe.id - else: - # Create new recipe - new_recipe = Recipe( - name=meal_name, - meal_type=meal_type, - ingredients=json.dumps(meal_data.get("ingredients", [])), - instructions=meal_data.get("instructions", ""), - estimated_time_minutes=meal_data.get("time_minutes", 30), - servings=meal_data.get("serves", 2), - source="ai", - ) - db.add(new_recipe) - db.flush() - recipe_id = new_recipe.id + valid_recipes = [r for r in recipes_ai if isinstance(r, dict) and "name" in r] + all_names = [r["name"] for r in valid_recipes] + existing = db.query(Recipe).filter(Recipe.name.in_(all_names)).all() + existing_by_name = {r.name: r for r in existing} - plan_dict[day][meal_type] = recipe_id + for recipe_data in valid_recipes: + meal_name = recipe_data["name"] + if meal_name in existing_by_name: + recipe_id = existing_by_name[meal_name].id + else: + new_recipe = Recipe( + name=meal_name, + meal_type=recipe_data.get("meal_type", "dinner"), + ingredients=json.dumps(recipe_data.get("ingredients", [])), + instructions=recipe_data.get("instructions", ""), + estimated_time_minutes=recipe_data.get("time_minutes", 30), + servings=recipe_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + recipe_id = new_recipe.id + plan_ids.append(recipe_id) - # Upsert MenuPlan + ai_notes = ai_result.get("notes", "") existing_plan = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first() if existing_plan: - existing_plan.plan = json.dumps(plan_dict) + existing_plan.plan = json.dumps(plan_ids) existing_plan.generated_at = datetime.utcnow() existing_plan.model_used = settings.model_name + existing_plan.notes = ai_notes menu = existing_plan else: menu = MenuPlan( week_start=week_start, - plan=json.dumps(plan_dict), + plan=json.dumps(plan_ids), generated_at=datetime.utcnow(), model_used=settings.model_name, + notes=ai_notes, ) db.add(menu) @@ -106,8 +107,8 @@ async def generate_menu(request: dict = None, db: Session = Depends(get_db)): return { "menu_plan": MenuPlanRead.from_orm(menu), - "week_plan": week_plan, - "notes": ai_result.get("notes", ""), + "recipes": recipes_ai, + "notes": menu.notes or "", } @@ -127,6 +128,112 @@ async def get_menu(week_start: date, db: Session = Depends(get_db)): return MenuPlanRead.from_orm(menu) +@router.delete("/current") +async def delete_current_menu(db: Session = Depends(get_db)): + """Delete the menu plan for the current week.""" + monday = _current_monday() + menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if not menu: + raise HTTPException(status_code=404, detail="No menu plan for current week") + db.delete(menu) + db.commit() + return {"status": "deleted"} + + +@router.delete("/current/recipes/{recipe_id}") +async def remove_recipe_from_menu(recipe_id: int, db: Session = Depends(get_db)): + """Remove a single recipe from the current week's plan.""" + monday = _current_monday() + menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if not menu: + raise HTTPException(status_code=404, detail="No menu plan for current week") + + try: + plan_ids = json.loads(menu.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") + + if recipe_id not in plan_ids: + raise HTTPException(status_code=404, detail="Recipe not in current menu plan") + + plan_ids.remove(recipe_id) + menu.plan = json.dumps(plan_ids) + db.commit() + return {"status": "removed", "plan": plan_ids} + + +@router.post("/current/recipes/{recipe_id}/swap") +async def swap_recipe_in_menu(recipe_id: int, request: dict = Body(default={}), db: Session = Depends(get_db)): + """Swap a single recipe in the current week's plan with a new AI suggestion.""" + monday = _current_monday() + menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if not menu: + raise HTTPException(status_code=404, detail="No menu plan for current week") + + try: + plan_ids = json.loads(menu.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") + + # Find the recipe being swapped + old_recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first() + if not old_recipe: + raise HTTPException(status_code=404, detail="Recipe not found") + + meal_type = old_recipe.meal_type + + # Get names of all other recipes in the plan to avoid repeats + other_ids = [rid for rid in plan_ids if rid != recipe_id] + other_recipes = db.query(Recipe).filter(Recipe.id.in_(other_ids)).all() if other_ids else [] + existing_names = [r.name for r in other_recipes] + + # Generate replacement + user_notes = request.get("user_notes") if request else None + try: + pantry_context = pantry_service.build_pantry_context(db) + ai_result = await ai_service.generate_replacement_recipe(meal_type, existing_names, pantry_context, user_notes) + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") + + recipe_data = ai_result.get("recipe", {}) + if not recipe_data or "name" not in recipe_data: + raise HTTPException(status_code=503, detail="AI returned invalid recipe data") + + # Save new recipe + new_recipe = Recipe( + name=recipe_data["name"], + meal_type=recipe_data.get("meal_type", meal_type), + ingredients=json.dumps(recipe_data.get("ingredients", [])), + instructions=recipe_data.get("instructions", ""), + estimated_time_minutes=recipe_data.get("time_minutes", 30), + servings=recipe_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + + # Swap the ID in the plan + idx = plan_ids.index(recipe_id) + plan_ids[idx] = new_recipe.id + menu.plan = json.dumps(plan_ids) + db.commit() + db.refresh(new_recipe) + + return { + "recipe": { + "id": new_recipe.id, + "name": new_recipe.name, + "meal_type": new_recipe.meal_type, + "ingredients": recipe_data.get("ingredients", []), + "instructions": new_recipe.instructions, + "estimated_time_minutes": new_recipe.estimated_time_minutes, + "servings": new_recipe.servings, + } + } + + @router.delete("/{id}") async def delete_menu(id: int, db: Session = Depends(get_db)): """Delete a menu plan.""" @@ -134,6 +134,7 @@ class MenuPlanRead(BaseModel): plan: str generated_at: datetime model_used: Optional[str] + notes: Optional[str] = None model_config = ConfigDict(from_attributes=True) diff --git a/services/ai_service.py b/services/ai_service.py index 2efd38f..0abad09 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -3,30 +3,24 @@ import json import asyncio from config import settings -SYSTEM_PROMPT = """You are a professional chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. You prioritize: -1. Using ingredients that expire soonest -2. Nutritional variety across the week -3. Avoiding meals eaten in the past 7 days -4. Practical home recipes under 60 minutes""" - - def _get_client(): return ollama.Client(host=settings.ollama_host) -def _chat_sync(messages: list) -> str: +def _chat_sync(messages: list, json_format: bool = True) -> str: client = _get_client() + kwargs = {"temperature": 0.7, "num_predict": 4096 if json_format else 1024} response = client.chat( model=settings.model_name, messages=messages, - format="json", - options={"temperature": 0.7, "num_predict": 4096}, + **({"format": "json"} if json_format else {}), + options=kwargs, ) return response["message"]["content"] async def _chat(messages: list) -> dict: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() raw = await loop.run_in_executor(None, lambda: _chat_sync(messages)) try: return json.loads(raw) @@ -34,63 +28,63 @@ async def _chat(messages: list) -> dict: raise ValueError(f"Ollama returned invalid JSON: {e}. Raw: {raw[:200]}") -async def generate_weekly_menu(pantry_context: dict) -> dict: - """Generate a 7-day rotating meal plan based on pantry state.""" - user_message = f"""Given the following pantry and recent meal history, generate a 7-day rotating meal plan. +async def generate_weekly_menu(pantry_context: dict, user_notes: str | None = None) -> dict: + """Suggest 8-12 diverse recipes for the week, prioritizing expiring pantry items.""" + user_message = f"""Given my current pantry and recent meal history, suggest 8-12 diverse recipes I could make this week. PANTRY STATE: {json.dumps(pantry_context, indent=2)} -RULES: -- Each day needs breakfast, lunch, and dinner -- No meal should repeat within the same week -- No meal should repeat a meal eaten in the last 7 days (see recent_meals) -- Prioritize ingredients expiring within 3 days (see expiring_soon) -- Each recipe should only use pantry ingredients OR common staples (salt, pepper, oil, etc.) -- Keep each meal practical — under 60 minutes +GUIDELINES: +- Suggest genuinely good, practical dishes — do NOT limit yourself to only pantry ingredients +- Prioritize using ingredients expiring soon (see expiring_soon), but feel free to include recipes that require buying additional items +- Avoid meals eaten in the last 7 days (see recent_meals) +- Include a mix of breakfast, lunch, and dinner options +- Keep each recipe under 60 minutes +- Include full ingredient lists with quantities and units Respond ONLY with this JSON structure: {{ - "week_plan": {{ - "monday": {{ - "breakfast": {{"name": "...", "ingredients": ["item1", "item2"], "instructions": "...", "time_minutes": 20}}, - "lunch": {{"name": "...", "ingredients": [...], "instructions": "...", "time_minutes": 30}}, - "dinner": {{"name": "...", "ingredients": [...], "instructions": "...", "time_minutes": 45}} - }}, - "tuesday": {{ ... }}, - "wednesday": {{ ... }}, - "thursday": {{ ... }}, - "friday": {{ ... }}, - "saturday": {{ ... }}, - "sunday": {{ ... }} - }}, + "recipes": [ + {{ + "name": "...", + "meal_type": "breakfast", + "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], + "instructions": "Step 1... Step 2...", + "time_minutes": 20, + "serves": 2 + }} + ], "notes": "brief explanation of choices" }}""" + if user_notes: + user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" + messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": settings.system_prompt}, {"role": "user", "content": user_message}, ] return await _chat(messages) -async def generate_grocery_list(menu_plan: dict, pantry_context: dict) -> dict: - """Generate a minimal grocery list based on the menu plan and current pantry.""" - user_message = f"""Given the weekly meal plan and current pantry, generate a minimal grocery list. +async def generate_grocery_list(recipes: list, pantry_context: dict, user_notes: str | None = None) -> dict: + """Generate a minimal grocery list based on a list of recipes and current pantry.""" + user_message = f"""Given these recipes and current pantry, generate a minimal grocery list. -MEAL PLAN: -{json.dumps(menu_plan, indent=2)} +RECIPES: +{json.dumps(recipes, indent=2)} CURRENT PANTRY: {json.dumps(pantry_context["available_ingredients"], indent=2)} RULES: - Only list ingredients NOT already sufficiently in the pantry -- Consolidate duplicate ingredients across meals (buy once for the week) +- Consolidate duplicate ingredients across recipes (buy once) - Group items by store section (produce, dairy, protein, pantry, frozen, bakery) - Estimate realistic retail costs in USD -- Prefer bulk when ingredient appears in 3+ meals +- Prefer bulk when ingredient appears in 3+ recipes Respond ONLY with this JSON: {{ @@ -101,7 +95,7 @@ Respond ONLY with this JSON: "unit": "lbs", "store_section": "produce", "estimated_cost": 3.50, - "used_in_meals": ["Chicken Stir Fry", "Chicken Soup"], + "used_in_meals": ["Recipe A", "Recipe B"], "reason": "not in pantry" }} ], @@ -109,8 +103,55 @@ Respond ONLY with this JSON: "shopping_notes": "..." }}""" + if user_notes: + user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" + + messages = [ + {"role": "system", "content": settings.system_prompt}, + {"role": "user", "content": user_message}, + ] + + return await _chat(messages) + + +async def generate_grocery_list_from_pantry(pantry_context: dict, user_notes: str | None = None) -> dict: + """Generate a grocery list based on pantry state alone, without a menu plan.""" + user_message = f"""Given my current pantry, generate a practical grocery list to stock up for the week. + +CURRENT PANTRY: +{json.dumps(pantry_context["available_ingredients"], indent=2)} + +EXPIRING SOON: +{json.dumps(pantry_context.get("expiring_soon", []), indent=2)} + +RULES: +- Suggest ingredients that would complement what's already in the pantry +- Group items by store section (produce, dairy, protein, pantry, frozen, bakery) +- Estimate realistic retail costs in USD +- Focus on versatile staples that enable multiple meals + +Respond ONLY with this JSON: +{{ + "items": [ + {{ + "name": "...", + "quantity": 2.0, + "unit": "lbs", + "store_section": "produce", + "estimated_cost": 3.50, + "used_in_meals": [], + "reason": "versatile staple" + }} + ], + "total_estimate": 45.00, + "shopping_notes": "..." +}}""" + + if user_notes: + user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" + messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": settings.system_prompt}, {"role": "user", "content": user_message}, ] @@ -137,7 +178,46 @@ Respond ONLY with this JSON: }}""" messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": settings.system_prompt}, + {"role": "user", "content": user_message}, + ] + + return await _chat(messages) + + +async def generate_replacement_recipe(meal_type: str, existing_names: list, pantry_context: dict, user_notes: str | None = None) -> dict: + """Generate a single replacement recipe of the given meal type.""" + user_message = f"""Suggest ONE {meal_type} recipe to replace a dish I don't want. + +CURRENT PANTRY: +{json.dumps(pantry_context["available_ingredients"], indent=2)} + +ALREADY IN PLAN (do not suggest these): +{json.dumps(existing_names, indent=2)} + +GUIDELINES: +- Suggest a genuinely good, practical {meal_type} dish +- Do NOT limit yourself to only pantry ingredients +- Under 60 minutes +- Must not be any of the dishes already in the plan + +Respond ONLY with this JSON: +{{ + "recipe": {{ + "name": "...", + "meal_type": "{meal_type}", + "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], + "instructions": "Step 1... Step 2...", + "time_minutes": 30, + "serves": 2 + }} +}}""" + + if user_notes: + user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" + + messages = [ + {"role": "system", "content": settings.system_prompt}, {"role": "user", "content": user_message}, ] @@ -154,5 +234,34 @@ async def get_available_models() -> list: except Exception: return [] - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await loop.run_in_executor(None, _list_models_sync) + + +async def chat_with_commis(message: str, history: list, pantry_context: dict, menu_context: list, grocery_context: list) -> str: + """Have a conversation with Commis using full kitchen context.""" + system_prompt = f"""You are Commis, a friendly and knowledgeable personal chef assistant. You have full visibility into the user's kitchen — their pantry, this week's recipe suggestions, and their grocery list. Use this context to give personalized, practical cooking advice. + +PANTRY (ingredients on hand): +{json.dumps(pantry_context.get("available_ingredients", []), indent=2)} + +EXPIRING SOON: +{json.dumps(pantry_context.get("expiring_soon", []), indent=2)} + +RECENT MEALS (last 14 days): +{json.dumps(pantry_context.get("recent_meals", []), indent=2)} + +THIS WEEK'S RECIPE SUGGESTIONS: +{json.dumps(menu_context, indent=2)} + +GROCERY LIST: +{json.dumps(grocery_context, indent=2)} + +Be conversational, helpful, and concise. Reference specific ingredients and recipes by name when relevant. You do not need to respond with JSON.""" + + messages = [{"role": "system", "content": system_prompt}] + messages.extend(history) + messages.append({"role": "user", "content": message}) + + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: _chat_sync(messages, json_format=False)) diff --git a/static/app.js b/static/app.js index 678b59b..8f39fb8 100644 --- a/static/app.js +++ b/static/app.js @@ -9,8 +9,11 @@ const state = { ollamaStatus: 'unknown', currentGroceryId: null, editingIngredientId: null, + chatHistory: [], }; +let pendingSwap = null; + // ── API Utilities ────────────────────────────────────────────────────────── async function api(method, path, body = null) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; @@ -103,6 +106,7 @@ function switchTab(tabName) { else if (tabName === 'meals') loadMeals(); else if (tabName === 'menu') loadMenu(); else if (tabName === 'grocery') loadGrocery(); + else if (tabName === 'chat') loadChat(); } // ── Pantry Tab ───────────────────────────────────────────────────────────── @@ -380,92 +384,139 @@ async function loadMenu() { }); if (!res) { - document.getElementById('menu-grid').innerHTML = ''; + document.getElementById('recipe-list').innerHTML = ''; document.getElementById('menu-empty').classList.remove('hidden'); document.getElementById('menu-notes').classList.add('hidden'); + document.getElementById('btn-clear-menu').classList.add('hidden'); return; } - state.currentMenu = res; + let planIds = []; + try { + const parsed = typeof res.plan === 'string' ? JSON.parse(res.plan) : res.plan; + planIds = Array.isArray(parsed) ? parsed : []; + } catch { planIds = []; } + + const allRecipes = await api('GET', '/recipes'); + const recipeMap = Object.fromEntries(allRecipes.map(r => [r.id, r])); + const planRecipes = planIds.map(id => recipeMap[id]).filter(Boolean); + + state.currentMenu = { ...res, recipes: planRecipes }; document.getElementById('menu-empty').classList.add('hidden'); + document.getElementById('btn-clear-menu').classList.remove('hidden'); if (res.notes) { document.getElementById('menu-notes').textContent = res.notes; document.getElementById('menu-notes').classList.remove('hidden'); + } else { + document.getElementById('menu-notes').classList.add('hidden'); } - renderMenuGrid(); + renderRecipeList(planRecipes); } catch (err) { showToast(err.message, 'error'); } } -function renderMenuGrid() { - const grid = document.getElementById('menu-grid'); - if (!state.currentMenu) return; - - const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; - const mealTypes = ['breakfast', 'lunch', 'dinner']; - - let plan = state.currentMenu.plan; - if (typeof plan === 'string') { - try { - plan = JSON.parse(plan); - } catch { - plan = {}; - } +function renderRecipeList(recipes) { + const list = document.getElementById('recipe-list'); + if (!recipes || recipes.length === 0) { + list.innerHTML = ''; + return; } - let weekPlan = state.currentMenu.week_plan; - if (typeof weekPlan === 'string') { - try { - weekPlan = JSON.parse(weekPlan); - } catch { - weekPlan = {}; + list.innerHTML = recipes.map(recipe => { + let ingredients = recipe.ingredients || []; + if (typeof ingredients === 'string') { + try { ingredients = JSON.parse(ingredients); } catch { ingredients = []; } } - } - // Header row - let html = '<div class="menu-grid-header"></div>'; - days.forEach(day => { - html += `<div class="menu-grid-header">${day}</div>`; - }); + const ingredientText = Array.isArray(ingredients) + ? ingredients.map(i => { + if (typeof i === 'string') return i; + return [i.quantity, i.unit, i.name].filter(Boolean).join(' '); + }).join(', ') + : ''; - // Meal type rows - mealTypes.forEach(mealType => { - days.forEach(day => { - const dayKey = day.toLowerCase(); - const dayPlan = plan[dayKey] || {}; - const recipeId = dayPlan[mealType]; + const mealType = recipe.meal_type || 'dinner'; + const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); + const time = recipe.time_minutes || recipe.estimated_time_minutes; + const serves = recipe.serves || recipe.servings; + const meta = [time ? `${time} min` : '', serves ? `serves ${serves}` : ''].filter(Boolean).join(' · '); - let recipeName = '—'; - let recipeTime = ''; - let repeatWarning = false; + return ` + <div class="recipe-card"> + <div class="recipe-card-header"> + <div class="recipe-card-title">${recipe.name}</div> + <span class="recipe-type-badge ${mealType}">${typeDisplay}</span> + </div> + ${meta ? `<div class="recipe-card-meta">${meta}</div>` : ''} + ${ingredientText ? `<div class="recipe-card-ingredients"><strong>Ingredients:</strong> ${ingredientText}</div>` : ''} + ${recipe.instructions ? `<div class="recipe-card-instructions">${recipe.instructions}</div>` : ''} + <div class="recipe-card-actions"> + <button class="btn btn-sm btn-primary" onclick="makeThisMeal('${recipe.name.replace(/'/g, "\\'")}', '${mealType}')">Make This</button> + <button class="btn btn-sm btn-secondary" onclick="swapRecipe(${recipe.id}, '${mealType}', this)">Swap</button> + <button class="btn btn-sm btn-danger" onclick="removeRecipe(${recipe.id}, this)">Remove</button> + </div> + </div> + `; + }).join(''); +} - if (weekPlan && weekPlan[dayKey] && weekPlan[dayKey][mealType]) { - const recipe = weekPlan[dayKey][mealType]; - recipeName = recipe.name || recipe.recipe_name || '—'; - recipeTime = recipe.time_minutes ? `${recipe.time_minutes} min` : ''; - repeatWarning = recipe.repeat_warning || false; - } +async function removeRecipe(recipeId, btn) { + btn.disabled = true; + try { + await api('DELETE', `/menus/current/recipes/${recipeId}`, null); + state.currentMenu.recipes = state.currentMenu.recipes.filter(r => r.id !== recipeId); + renderRecipeList(state.currentMenu.recipes); + if (state.currentMenu.recipes.length === 0) { + document.getElementById('menu-empty').classList.remove('hidden'); + document.getElementById('btn-clear-menu').classList.add('hidden'); + } + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; + } +} - const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); - const warningClass = repeatWarning ? 'repeat-warning' : ''; +function swapRecipe(recipeId, mealType, btn) { + pendingSwap = { recipeId, mealType, btn }; + const recipe = state.currentMenu.recipes.find(r => r.id === recipeId); + document.getElementById('swap-modal-subtitle').textContent = recipe ? `Replacing: ${recipe.name}` : ''; + document.getElementById('swap-notes').value = ''; + document.getElementById('swap-modal-overlay').classList.remove('hidden'); + document.getElementById('swap-notes').focus(); +} - html += ` - <div class="menu-grid-cell ${warningClass}"> - <div> - <div class="menu-meal-type">${typeDisplay}</div> - <div class="menu-meal-name">${recipeName}</div> - ${recipeTime ? `<div class="menu-time">${recipeTime}</div>` : ''} - </div> - ${recipeName !== '—' ? `<button class="btn btn-sm btn-primary" onclick="makeThisMeal('${recipeName.replace(/'/g, "\\'")}', '${mealType}')">Make This</button>` : ''} - </div> - `; - }); - }); +function cancelSwap() { + document.getElementById('swap-modal-overlay').classList.add('hidden'); + pendingSwap = null; +} - grid.innerHTML = html; +async function confirmSwap() { + if (!pendingSwap) return; + const { recipeId, btn } = pendingSwap; + const userNotes = document.getElementById('swap-notes').value.trim(); + + document.getElementById('swap-modal-overlay').classList.add('hidden'); + btn.disabled = true; + btn.textContent = '...'; + pendingSwap = null; + + try { + const res = await api('POST', `/menus/current/recipes/${recipeId}/swap`, + userNotes ? { user_notes: userNotes } : {} + ); + const newRecipe = res.recipe; + state.currentMenu.recipes = state.currentMenu.recipes.map(r => + r.id === recipeId ? newRecipe : r + ); + renderRecipeList(state.currentMenu.recipes); + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; + btn.textContent = 'Swap'; + } } async function generateMenu() { @@ -476,16 +527,18 @@ async function generateMenu() { spinner.classList.remove('hidden'); try { - const res = await api('POST', '/menus/generate', {}); - state.currentMenu = res; + const userNotes = document.getElementById('menu-user-notes').value.trim(); + const res = await api('POST', '/menus/generate', userNotes ? { user_notes: userNotes } : {}); + state.currentMenu = { ...res.menu_plan, recipes: res.recipes }; document.getElementById('menu-empty').classList.add('hidden'); + document.getElementById('btn-clear-menu').classList.remove('hidden'); if (res.notes) { document.getElementById('menu-notes').textContent = res.notes; document.getElementById('menu-notes').classList.remove('hidden'); } - renderMenuGrid(); + renderRecipeList(res.recipes); showToast('Menu generated!'); } catch (err) { if (err.message.includes('503')) { @@ -499,6 +552,20 @@ async function generateMenu() { } } +async function clearMenu() { + try { + await api('DELETE', '/menus/current', null); + state.currentMenu = null; + document.getElementById('recipe-list').innerHTML = ''; + document.getElementById('menu-empty').classList.remove('hidden'); + document.getElementById('menu-notes').classList.add('hidden'); + document.getElementById('btn-clear-menu').classList.add('hidden'); + showToast('Menu cleared'); + } catch (err) { + showToast(err.message, 'error'); + } +} + async function makeThisMeal(mealName, mealType) { try { const now = new Date(); @@ -528,12 +595,21 @@ async function loadGrocery() { document.getElementById('grocery-total').classList.add('hidden'); document.getElementById('grocery-empty').classList.remove('hidden'); document.getElementById('btn-mark-purchased').classList.add('hidden'); + document.getElementById('btn-clear-grocery').classList.add('hidden'); return; } state.currentGrocery = res; state.currentGroceryId = res.id; document.getElementById('grocery-empty').classList.add('hidden'); + document.getElementById('btn-clear-grocery').classList.remove('hidden'); + + if (res.notes) { + document.getElementById('grocery-notes').textContent = res.notes; + document.getElementById('grocery-notes').classList.remove('hidden'); + } else { + document.getElementById('grocery-notes').classList.add('hidden'); + } if (!res.is_purchased) { document.getElementById('btn-mark-purchased').classList.remove('hidden'); @@ -582,7 +658,12 @@ function renderGroceryList() { const usedIn = item.used_in ? item.used_in.join(', ') : ''; return ` <div class="grocery-item"> - <input type="checkbox"> + <input type="checkbox" + data-name="${item.name.replace(/"/g, '"')}" + data-quantity="${item.quantity || 0}" + data-unit="${(item.unit || '').replace(/"/g, '"')}" + data-category="${(item.store_section || '').replace(/"/g, '"')}" + onchange="checkGroceryItem(this)"> <div class="grocery-item-details"> <div class="grocery-item-name">${item.name}</div> <div class="grocery-item-meta"> @@ -610,6 +691,25 @@ function renderGroceryList() { } } +async function checkGroceryItem(checkbox) { + if (!checkbox.checked) return; + checkbox.disabled = true; + + try { + await api('POST', '/pantry', { + name: checkbox.dataset.name, + quantity: parseFloat(checkbox.dataset.quantity) || 1, + unit: checkbox.dataset.unit, + category: checkbox.dataset.category || null, + }); + checkbox.closest('.grocery-item').classList.add('grocery-item-checked'); + } catch (err) { + showToast('Failed to add to pantry: ' + err.message, 'error'); + checkbox.checked = false; + checkbox.disabled = false; + } +} + async function generateGrocery() { const btn = document.getElementById('btn-generate-grocery'); const spinner = document.getElementById('grocery-spinner'); @@ -618,17 +718,23 @@ async function generateGrocery() { spinner.classList.remove('hidden'); try { - const res = await api('POST', '/grocery/generate', {}); + const userNotes = document.getElementById('grocery-user-notes').value.trim(); + const res = await api('POST', '/grocery/generate', userNotes ? { user_notes: userNotes } : {}); state.currentGrocery = res; - state.currentGroceryId = res.id; + state.currentGroceryId = res.grocery_list.id; document.getElementById('grocery-empty').classList.add('hidden'); document.getElementById('btn-mark-purchased').classList.remove('hidden'); + document.getElementById('btn-clear-grocery').classList.remove('hidden'); + + if (res.shopping_notes) { + document.getElementById('grocery-notes').textContent = res.shopping_notes; + document.getElementById('grocery-notes').classList.remove('hidden'); + } + renderGroceryList(); showToast('Grocery list generated!'); } catch (err) { - if (err.message.includes('not found') || err.message.includes('No menu')) { - showToast('Generate a weekly menu first', 'error'); - } else if (err.message.includes('503')) { + if (err.message.includes('503')) { showToast('Ollama is offline or not responding', 'error'); } else { showToast(err.message, 'error'); @@ -639,6 +745,23 @@ async function generateGrocery() { } } +async function clearGrocery() { + try { + await api('DELETE', '/grocery/current', null); + state.currentGrocery = null; + state.currentGroceryId = null; + document.getElementById('grocery-sections').innerHTML = ''; + document.getElementById('grocery-total').classList.add('hidden'); + document.getElementById('grocery-empty').classList.remove('hidden'); + document.getElementById('grocery-notes').classList.add('hidden'); + document.getElementById('btn-mark-purchased').classList.add('hidden'); + document.getElementById('btn-clear-grocery').classList.add('hidden'); + showToast('Grocery list cleared'); + } catch (err) { + showToast(err.message, 'error'); + } +} + async function markPurchased() { try { await api('PUT', `/grocery/${state.currentGroceryId}/purchased`, {}); @@ -650,6 +773,71 @@ async function markPurchased() { } } +// ── Chat Tab ─────────────────────────────────────────────────────────────── +function loadChat() { + renderChat(); +} + +function renderChat() { + const messagesDiv = document.getElementById('chat-messages'); + if (state.chatHistory.length === 0) { + messagesDiv.innerHTML = '<div class="chat-welcome">Hi! I\'m Commis, your personal chef assistant. Ask me anything about your pantry, recipes, or meal planning.</div>'; + return; + } + + messagesDiv.innerHTML = state.chatHistory.map(msg => ` + <div class="chat-message chat-message-${msg.role}"> + <div class="chat-bubble">${msg.content.replace(/\n/g, '<br>')}</div> + </div> + `).join(''); + + messagesDiv.scrollTop = messagesDiv.scrollHeight; +} + +async function sendChatMessage() { + const input = document.getElementById('chat-input'); + const btn = document.getElementById('btn-send-chat'); + const message = input.value.trim(); + if (!message) return; + + input.value = ''; + input.disabled = true; + btn.disabled = true; + + state.chatHistory.push({ role: 'user', content: message }); + renderChat(); + + // Show typing indicator + const messagesDiv = document.getElementById('chat-messages'); + const typingEl = document.createElement('div'); + typingEl.className = 'chat-message chat-message-assistant'; + typingEl.id = 'chat-typing'; + typingEl.innerHTML = '<div class="chat-bubble chat-typing-indicator">...</div>'; + messagesDiv.appendChild(typingEl); + messagesDiv.scrollTop = messagesDiv.scrollHeight; + + try { + const res = await api('POST', '/ai/chat', { + message, + history: state.chatHistory.slice(-20).slice(0, -1), + }); + + document.getElementById('chat-typing')?.remove(); + state.chatHistory.push({ role: 'assistant', content: res.response }); + renderChat(); + } catch (err) { + document.getElementById('chat-typing')?.remove(); + state.chatHistory.pop(); + showToast(err.message, 'error'); + input.value = message; + renderChat(); + } finally { + input.disabled = false; + btn.disabled = false; + input.focus(); + } +} + // ── Initialization ───────────────────────────────────────────────────────── async function init() { // Check Ollama status @@ -745,11 +933,22 @@ async function init() { // Set up menu form document.getElementById('btn-generate-menu').addEventListener('click', generateMenu); + document.getElementById('btn-clear-menu').addEventListener('click', clearMenu); // Set up grocery form document.getElementById('btn-generate-grocery').addEventListener('click', generateGrocery); + document.getElementById('btn-clear-grocery').addEventListener('click', clearGrocery); document.getElementById('btn-mark-purchased').addEventListener('click', markPurchased); + // Set up chat + document.getElementById('btn-send-chat').addEventListener('click', sendChatMessage); + document.getElementById('chat-input').addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendChatMessage(); + } + }); + // Load initial tab await loadPantry(); } diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..7122059 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,34 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> + <!-- Background circle --> + <circle cx="32" cy="32" r="32" fill="#FF6B35"/> + + <!-- Chef hat (white puff top) --> + <ellipse cx="32" cy="18" rx="13" ry="11" fill="white"/> + <!-- Hat brim --> + <rect x="19" y="24" width="26" height="7" rx="3" fill="white"/> + + <!-- Hat shine --> + <ellipse cx="27" cy="14" rx="4" ry="3" fill="rgba(255,255,255,0.4)"/> + + <!-- Face --> + <circle cx="32" cy="42" r="13" fill="#FDDBB4"/> + + <!-- Rosy cheeks --> + <circle cx="24" cy="44" r="3.5" fill="#FFB3A7" opacity="0.7"/> + <circle cx="40" cy="44" r="3.5" fill="#FFB3A7" opacity="0.7"/> + + <!-- Eyes (cute closed/happy) --> + <path d="M27 40 Q28.5 38 30 40" stroke="#5C3317" stroke-width="1.8" fill="none" stroke-linecap="round"/> + <path d="M34 40 Q35.5 38 37 40" stroke="#5C3317" stroke-width="1.8" fill="none" stroke-linecap="round"/> + + <!-- Smile --> + <path d="M27 46 Q32 51 37 46" stroke="#5C3317" stroke-width="1.8" fill="none" stroke-linecap="round"/> + + <!-- Little nose dot --> + <circle cx="32" cy="43.5" r="1" fill="#C68642"/> + + <!-- Tiny sparkle top right --> + <g fill="white" opacity="0.9"> + <polygon points="56,10 57,13 60,13 57.5,15 58.5,18 56,16 53.5,18 54.5,15 52,13 55,13" transform="scale(0.6) translate(38, 4)"/> + </g> +</svg> diff --git a/static/index.html b/static/index.html index a9ae018..d0e0243 100644 --- a/static/index.html +++ b/static/index.html @@ -5,6 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Commis</title> <link rel="stylesheet" href="style.css"> + <link rel="icon" href="favicon.svg" type="image/svg+xml"> </head> <body> <header> @@ -17,6 +18,7 @@ <button class="tab-btn" data-tab="meals">Meals</button> <button class="tab-btn" data-tab="menu">This Week's Menu</button> <button class="tab-btn" data-tab="grocery">Grocery List</button> + <button class="tab-btn" data-tab="chat">Chat</button> </nav> <main> @@ -96,12 +98,14 @@ <div class="tab-header"> <h2>This Week's Menu</h2> <div class="header-actions"> + <textarea id="menu-user-notes" class="ai-notes-input" placeholder="Any preferences or instructions for the AI (e.g. vegetarian, 2 people, quick breakfasts)..." rows="2"></textarea> <button class="btn btn-primary" id="btn-generate-menu">✨ Generate Menu</button> + <button class="btn btn-danger hidden" id="btn-clear-menu">Clear</button> <div id="menu-spinner" class="spinner hidden"></div> </div> </div> <div id="menu-notes" class="info-banner hidden"></div> - <div id="menu-grid" class="menu-grid"></div> + <div id="recipe-list" class="recipe-list"></div> <div id="menu-empty" class="empty-state hidden">No menu generated yet. Click "Generate Menu" to create one.</div> </div> @@ -110,17 +114,44 @@ <div class="tab-header"> <h2>Grocery List</h2> <div class="header-actions"> + <textarea id="grocery-user-notes" class="ai-notes-input" placeholder="Any preferences or instructions for the AI (e.g. budget $50, no red meat, stock up on breakfast items)..." rows="2"></textarea> <button class="btn btn-primary" id="btn-generate-grocery">✨ Generate List</button> + <button class="btn btn-danger hidden" id="btn-clear-grocery">Clear</button> <button class="btn btn-success hidden" id="btn-mark-purchased">✓ Mark All Purchased</button> <div id="grocery-spinner" class="spinner hidden"></div> </div> </div> + <div id="grocery-notes" class="info-banner hidden"></div> <div id="grocery-total" class="hidden"></div> <div id="grocery-sections"></div> - <div id="grocery-empty" class="empty-state hidden">No grocery list yet. Generate a menu first, then generate the grocery list.</div> + <div id="grocery-empty" class="empty-state hidden">No grocery list yet. Click "Generate List" to create one.</div> + </div> + + <!-- Chat Tab --> + <div id="tab-chat" class="tab-content hidden"> + <div class="chat-container"> + <div id="chat-messages" class="chat-messages"></div> + <div class="chat-input-area"> + <textarea id="chat-input" class="chat-input" placeholder="Ask Commis anything about your kitchen..." rows="2"></textarea> + <button class="btn btn-primary" id="btn-send-chat">Send</button> + </div> + </div> </div> </main> + <!-- Swap Recipe Modal --> + <div id="swap-modal-overlay" class="modal-overlay hidden" onclick="cancelSwap()"> + <div class="modal" onclick="event.stopPropagation()"> + <div class="modal-title">Swap Recipe</div> + <div id="swap-modal-subtitle" class="modal-subtitle"></div> + <textarea id="swap-notes" class="ai-notes-input" placeholder="Any preferences? e.g. vegetarian, something with chicken, quick and easy... (optional)" rows="3"></textarea> + <div class="modal-actions"> + <button class="btn btn-secondary" onclick="cancelSwap()">Cancel</button> + <button class="btn btn-primary" onclick="confirmSwap()">Find Replacement</button> + </div> + </div> + </div> + <div id="toast-container"></div> <script src="app.js"></script> </body> diff --git a/static/style.css b/static/style.css index 9d90898..4cc033b 100644 --- a/static/style.css +++ b/static/style.css @@ -270,6 +270,16 @@ main { border-color: #16a34a; } +.btn-secondary { + background-color: var(--surface2); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--text-muted); +} + .btn-sm { padding: 0.5rem 1rem; font-size: 0.8rem; @@ -354,97 +364,87 @@ tbody tr.expiry-warn { font-size: 0.9rem; } -/* ── Menu Grid ──────────────────────────────────────────────────────────── */ -.menu-grid { +/* ── Recipe List ───────────────────────────────────────────────────────── */ +.recipe-list { display: grid; - grid-template-columns: repeat(8, 1fr); - gap: 0.5rem; - margin-bottom: 2rem; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; + padding: 1rem 0; } -.menu-grid-cell { +.recipe-card { background-color: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; - padding: 1rem; - min-height: 120px; + padding: 1.25rem; display: flex; flex-direction: column; - justify-content: space-between; - transition: all 0.2s ease; + gap: 0.5rem; + transition: border-color 0.2s ease; } -.menu-grid-cell:hover { +.recipe-card:hover { border-color: var(--accent); } -.menu-grid-cell.repeat-warning { - border: 2px solid var(--warning); +.recipe-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; } -.menu-grid-header { - background-color: var(--surface2); +.recipe-card-title { font-weight: 600; - text-align: center; - padding: 1rem; - border-radius: 0.5rem; - border: 1px solid var(--border); + font-size: 1rem; + color: var(--text); } -.menu-meal-type { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - color: var(--text-muted); - margin-bottom: 0.5rem; +.recipe-type-badge { + font-size: 0.7rem; + padding: 0.2rem 0.6rem; + border-radius: 9999px; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; } -.menu-meal-name { - font-weight: 600; - margin-bottom: 0.5rem; - word-break: break-word; -} +.recipe-type-badge.breakfast { background-color: rgba(251, 191, 36, 0.2); color: #fbbf24; } +.recipe-type-badge.lunch { background-color: rgba(52, 211, 153, 0.2); color: #34d399; } +.recipe-type-badge.dinner { background-color: rgba(139, 92, 246, 0.2); color: #8b5cf6; } +.recipe-type-badge.snack { background-color: rgba(251, 146, 60, 0.2); color: #fb923c; } -.menu-time { - display: inline-block; - font-size: 0.75rem; - background-color: var(--surface2); - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - margin-bottom: 0.75rem; +.recipe-card-meta { + font-size: 0.8rem; color: var(--text-muted); } -.menu-actions { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; +.recipe-card-ingredients { + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.5; } -@media (max-width: 768px) { - .menu-grid { - grid-template-columns: 1fr; - } - - .menu-grid-header { - display: none; - } +.recipe-card-instructions { + font-size: 0.85rem; + color: var(--text); + line-height: 1.6; + border-top: 1px solid var(--border); + padding-top: 0.5rem; + margin-top: 0.25rem; + white-space: pre-line; +} - .menu-grid-cell { - position: relative; - } +.recipe-card .btn { + align-self: flex-start; + margin-top: 0.5rem; +} - .menu-grid-cell::before { - content: attr(data-day); - position: absolute; - top: 0.5rem; - right: 0.5rem; - font-size: 0.75rem; - color: var(--text-muted); - background-color: var(--surface2); - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - } +.recipe-card-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; } /* ── Grocery List ───────────────────────────────────────────────────────── */ @@ -826,3 +826,163 @@ tbody tr.expiry-warn { min-width: unset; } } + +.ai-notes-input { + width: 100%; + padding: 0.6rem 0.75rem; + background-color: var(--surface2); + border: 1px solid var(--border); + border-radius: 0.375rem; + color: var(--text); + font-size: 0.85rem; + resize: vertical; + font-family: inherit; + margin-bottom: 0.5rem; +} + +.ai-notes-input:focus { + outline: none; + border-color: var(--accent); +} + +.ai-notes-input::placeholder { + color: var(--text-muted); +} + +.grocery-item-checked { + opacity: 0.45; +} + +.grocery-item-checked .grocery-item-name { + text-decoration: line-through; +} + +.modal-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 0.75rem; + padding: 1.5rem; + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text); +} + +.modal-subtitle { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: -0.5rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.chat-container { + display: flex; + flex-direction: column; + height: calc(100vh - 180px); +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 1rem 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.chat-welcome { + color: var(--text-muted); + font-style: italic; + text-align: center; + padding: 2rem; +} + +.chat-message { + display: flex; +} + +.chat-message-user { + justify-content: flex-end; +} + +.chat-message-assistant { + justify-content: flex-start; +} + +.chat-bubble { + max-width: 75%; + padding: 0.75rem 1rem; + border-radius: 1rem; + font-size: 0.9rem; + line-height: 1.6; + word-break: break-word; +} + +.chat-message-user .chat-bubble { + background-color: var(--accent); + color: white; + border-bottom-right-radius: 0.25rem; +} + +.chat-message-assistant .chat-bubble { + background-color: var(--surface2); + color: var(--text); + border-bottom-left-radius: 0.25rem; +} + +.chat-typing-indicator { + color: var(--text-muted); + font-style: italic; +} + +.chat-input-area { + display: flex; + gap: 0.75rem; + align-items: flex-end; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.chat-input { + flex: 1; + padding: 0.75rem; + background-color: var(--surface2); + border: 1px solid var(--border); + border-radius: 0.5rem; + color: var(--text); + font-size: 0.9rem; + resize: none; + font-family: inherit; + line-height: 1.5; +} + +.chat-input:focus { + outline: none; + border-color: var(--accent); +} + +.chat-input::placeholder { + color: var(--text-muted); +} |
