fridge-to-fork / app.py
hcho22's picture
Upload app.py with huggingface_hub
92ffefd verified
Raw
History Blame Contribute Delete
75.4 kB
"""Fridge to Fork Gradio app.
The Space runs on CPU and calls the authenticated Modal backend for both model
jobs. Recipe generation is deliberately gated behind editable ingredient fields:
`/generate` receives only the current confirmed field values, never the raw
vision extraction state.
The look is a warm, kitchen-table redesign (cream paper, Spectral serif
headlines over Hanken Grotesque, a leafy garden green hero with a clementine
citrus pop). Because the product must stay a Gradio app, that design is
recreated here with Gradio theming + CSS + custom-HTML cards rather than a
separate frontend framework.
"""
import contextlib
import html
import json
import mimetypes
import os
import re
from pathlib import Path
from typing import Any
import gradio as gr
import requests
try:
from dotenv import load_dotenv
# Local dev: pull F2F_BACKEND_URL / F2F_AUTH_TOKEN from a .env. On the HF Space
# these are injected as real env vars and no .env exists, so this no-ops.
load_dotenv()
except ModuleNotFoundError:
pass
BACKEND_URL_ENV = "F2F_BACKEND_URL"
AUTH_TOKEN_ENV = "F2F_AUTH_TOKEN"
MAX_IMAGES = 3
MAX_INGREDIENT_SLOTS = 40
BACKEND_TIMEOUT_SECONDS = 90
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"]
LOADING_COLD_START_NOTE = (
"First call this session can take up to about 1 minute while the kitchen warms up."
)
CUISINE_CHOICES = [
"No preference",
"American",
"Italian",
"Mexican",
"Chinese",
"Japanese",
"Korean",
"Indian",
"Mediterranean",
"Thai",
]
# Tap-to-add exclusions that sit beside the free-text kid-notes field. Selected
# presets are merged with whatever the cook types before going to /generate.
KID_NOTE_PRESETS = [
"no spicy",
"no visible veg",
"no seafood",
]
FREE_STAPLE_NAMES = {
"salt",
"kosher salt",
"sea salt",
"table salt",
"pepper",
"black pepper",
"ground pepper",
"freshly ground pepper",
"freshly ground black pepper",
"salt and pepper",
"water",
"tap water",
"cooking water",
"oil",
"cooking oil",
"neutral oil",
"vegetable oil",
"canola oil",
"olive oil",
"avocado oil",
"cooking spray",
}
STAPLE_ROOT_TOKENS = {"salt", "pepper", "oil", "water", "spray"}
QUANTITY_OR_USAGE_TOKENS = {
"a",
"an",
"and",
"as",
"boiling",
"can",
"cans",
"carton",
"cartons",
"clove",
"cloves",
"cold",
"cup",
"cups",
"dash",
"drizzle",
"enough",
"for",
"frying",
"g",
"gram",
"grams",
"handful",
"handfuls",
"hot",
"kg",
"large",
"lb",
"lbs",
"liter",
"liters",
"litre",
"litres",
"medium",
"ml",
"more",
"needed",
"of",
"optional",
"or",
"ounce",
"ounces",
"oz",
"package",
"packages",
"pan",
"pinch",
"pint",
"pints",
"plus",
"pound",
"pounds",
"quart",
"quarts",
"skillet",
"slice",
"slices",
"small",
"splash",
"tablespoon",
"tablespoons",
"taste",
"tbsp",
"tbsps",
"teaspoon",
"teaspoons",
"the",
"to",
"tsp",
"tsps",
"warm",
}
# Inline icons (stroke = currentColor), mirrored from the design's icon set so
# the recipe cards carry the same leaf / cart / clock / people detailing. Sizes
# are set in CSS so one constant can be reused at any scale.
_SVG_ATTRS = (
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" '
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'
)
ICON_CLOCK = f'<svg {_SVG_ATTRS}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
ICON_USERS = (
f'<svg {_SVG_ATTRS}><path d="M16 19v-1a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v1"/>'
'<circle cx="9" cy="7" r="3.2"/><path d="M22 19v-1a4 4 0 0 0-3-3.85"/>'
'<path d="M16 3.6A4 4 0 0 1 16 11"/></svg>'
)
ICON_LEAF = (
f'<svg {_SVG_ATTRS}><path d="M4 20s.5-7 5-11.5S20 4 20 4s.5 6.5-4 11-12 5-12 5Z"/>'
'<path d="M4 20c4-6 8-8 12-9"/></svg>'
)
ICON_CART = (
f'<svg {_SVG_ATTRS}><circle cx="9" cy="20" r="1.4"/><circle cx="17" cy="20" r="1.4"/>'
'<path d="M2 3h2.2l2.1 11.4a1.5 1.5 0 0 0 1.5 1.2h8.4a1.5 1.5 0 0 0 1.5-1.2L20 7H5.3"/></svg>'
)
ICON_SPARKLE = (
f'<svg {_SVG_ATTRS}><path d="M12 3l1.8 4.9L18.7 9.7 13.8 11.5 12 16.4 10.2 11.5 5.3 9.7 10.2 7.9z"/>'
'<path d="M19 14.5l.7 1.9 1.9.7-1.9.7-.7 1.9-.7-1.9-1.9-.7 1.9-.7z"/></svg>'
)
class FriendlyError(Exception):
"""An error safe to show in the Gradio UI."""
def _loading_html(label: str) -> str:
safe_label = html.escape(" ".join(str(label).split()) or "Working...")
safe_note = html.escape(LOADING_COLD_START_NOTE)
return f"""
<div class="kitchen-loading" role="status" aria-live="polite">
<span class="kitchen-loading__spinner" aria-hidden="true"></span>
<div class="kitchen-loading__copy">
<p class="kitchen-loading__label">{safe_label}</p>
<p class="kitchen-loading__note">{safe_note}</p>
</div>
</div>
""".strip()
# Guided flow: four steps the user walks through one screen at a time. The
# labels drive the progress rail; the numbers are the source of truth for which
# gr.Column is visible. _show_step() returns updates for
# [stepper, step_snap, step_confirm, step_tune, step_cook] in that order.
WIZARD_STEPS = (
("Snap", "Your fridge"),
("Confirm", "What we spotted"),
("Tune", "A couple of dials"),
("Cook", "Tonight's dinners"),
)
def _stepper_html(active: int) -> str:
"""Progress rail markup for the guided flow; `active` is 1-based."""
nodes = []
for number, (label, _) in enumerate(WIZARD_STEPS, start=1):
if number < active:
state = "done"
elif number == active:
state = "active"
else:
state = "todo"
nodes.append(
f'<li class="wizard-step is-{state}">'
f'<span class="wizard-step__dot">{number}</span>'
f'<span class="wizard-step__label">{html.escape(label)}</span>'
"</li>"
)
return (
'<ol class="wizard-progress" aria-label="Progress through the steps">'
+ "".join(nodes)
+ "</ol>"
)
def _show_step(active: int) -> list[Any]:
"""Reveal exactly one step.
Returns updates for [stepper, step_snap, step_confirm, step_tune, step_cook];
callers append this to a handler's outputs to navigate the wizard.
"""
updates: list[Any] = [gr.update(value=_stepper_html(active))]
updates += [
gr.update(visible=(number == active))
for number in range(1, len(WIZARD_STEPS) + 1)
]
return updates
def _clear_status_update() -> Any:
return gr.update(value="", visible=False)
def _show_step_and_clear_status(active: int) -> list[Any]:
return _show_step(active) + [_clear_status_update()]
def _backend_base_url() -> str:
raw_url = os.environ.get(BACKEND_URL_ENV, "").strip()
if not raw_url:
raise FriendlyError(f"Set {BACKEND_URL_ENV} before using the app.")
return raw_url.rstrip("/")
def _auth_headers() -> dict[str, str]:
token = os.environ.get(AUTH_TOKEN_ENV, "").strip()
if not token:
raise FriendlyError(f"Set {AUTH_TOKEN_ENV} before using the app.")
return {"Authorization": f"Bearer {token}"}
def _backend_endpoint(path: str) -> str:
return f"{_backend_base_url()}/{path.lstrip('/')}"
def _friendly_backend_error(response: requests.Response) -> FriendlyError:
if response.status_code == 401:
return FriendlyError("Backend authentication failed. Check F2F_AUTH_TOKEN.")
try:
payload = response.json()
except ValueError:
payload = {}
detail = payload.get("detail") if isinstance(payload, dict) else None
if response.status_code in {413, 415, 422} and isinstance(detail, str):
return FriendlyError(detail)
if response.status_code >= 500:
return FriendlyError(
"The backend had trouble processing that request. Try again in a moment."
)
if isinstance(detail, str) and detail:
return FriendlyError(detail)
return FriendlyError("The backend rejected the request.")
def _post_multipart(path: str, image_paths: list[Path]) -> dict[str, Any]:
files: list[tuple[str, tuple[str, Any, str]]] = []
with contextlib.ExitStack() as stack:
for image_path in image_paths:
mime_type = (
mimetypes.guess_type(image_path.name)[0] or "application/octet-stream"
)
handle = stack.enter_context(image_path.open("rb"))
files.append(("images", (image_path.name, handle, mime_type)))
response = requests.post(
_backend_endpoint(path),
headers=_auth_headers(),
files=files,
timeout=BACKEND_TIMEOUT_SECONDS,
)
if response.status_code >= 400:
raise _friendly_backend_error(response)
try:
return response.json()
except ValueError as exc:
raise FriendlyError("Backend returned something other than JSON.") from exc
def _post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
response = requests.post(
_backend_endpoint(path),
headers=_auth_headers(),
json=payload,
timeout=BACKEND_TIMEOUT_SECONDS,
)
if response.status_code >= 400:
raise _friendly_backend_error(response)
try:
return response.json()
except ValueError as exc:
raise FriendlyError("Backend returned something other than JSON.") from exc
def _post_form_with_optional_image(
path: str,
data: dict[str, str],
image_path: Path | None,
) -> dict[str, Any]:
files: list[tuple[str, tuple[str, Any, str]]] | None = None
with contextlib.ExitStack() as stack:
if image_path is not None:
mime_type = (
mimetypes.guess_type(image_path.name)[0] or "application/octet-stream"
)
handle = stack.enter_context(image_path.open("rb"))
files = [("plate_photo", (image_path.name, handle, mime_type))]
response = requests.post(
_backend_endpoint(path),
headers=_auth_headers(),
data=data,
files=files,
timeout=BACKEND_TIMEOUT_SECONDS,
)
if response.status_code >= 400:
raise _friendly_backend_error(response)
try:
return response.json()
except ValueError as exc:
raise FriendlyError("Backend returned something other than JSON.") from exc
def _upload_paths(files: Any) -> list[Path]:
if files is None:
raise FriendlyError("Choose 1-3 fridge, pantry, or counter photos first.")
if not isinstance(files, list):
files = [files]
paths: list[Path] = []
for file_obj in files:
if isinstance(file_obj, (str, Path)):
paths.append(Path(file_obj))
elif hasattr(file_obj, "name"):
paths.append(Path(file_obj.name))
elif isinstance(file_obj, dict) and file_obj.get("path"):
paths.append(Path(file_obj["path"]))
paths = [path for path in paths if path.exists()]
if not paths:
raise FriendlyError("Choose 1-3 fridge, pantry, or counter photos first.")
if len(paths) > MAX_IMAGES:
raise FriendlyError(f"Use at most {MAX_IMAGES} photos for one extraction.")
return paths
def _optional_upload_path(file_obj: Any) -> Path | None:
if file_obj is None:
return None
if isinstance(file_obj, list):
file_obj = file_obj[0] if file_obj else None
if file_obj is None:
return None
path: Path | None = None
if isinstance(file_obj, (str, Path)):
path = Path(file_obj)
elif hasattr(file_obj, "name"):
path = Path(file_obj.name)
elif isinstance(file_obj, dict) and file_obj.get("path"):
path = Path(file_obj["path"])
if path is None or not path.exists():
raise FriendlyError("Choose a valid plate photo or clear the upload.")
return path
def _clean_ingredient_name(value: Any) -> str:
if not isinstance(value, str):
return ""
return " ".join(value.split())
def _normalize_food_name(value: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", " ", value.casefold())
return " ".join(normalized.split())
FREE_STAPLE_TOKENS = {
token
for staple_name in FREE_STAPLE_NAMES
for token in _normalize_food_name(staple_name).split()
}
def _is_quantity_token(value: str) -> bool:
return bool(re.fullmatch(r"\d+(?:[./]\d+)?|half|quarter", value))
def _food_tokens(value: str) -> set[str]:
return {
token
for token in _normalize_food_name(value).split()
if token not in QUANTITY_OR_USAGE_TOKENS and not _is_quantity_token(token)
}
def _is_free_staple(value: str) -> bool:
tokens = _food_tokens(value)
if not tokens:
return False
normalized = " ".join(tokens)
if normalized in FREE_STAPLE_NAMES:
return True
return bool(tokens & STAPLE_ROOT_TOKENS) and tokens.issubset(FREE_STAPLE_TOKENS)
def _renderable_ingredient_items(items: Any) -> list[str]:
if not isinstance(items, list):
return []
renderable: list[str] = []
seen: set[str] = set()
for item in items:
# Dinner items are {"name", "qty"} objects; bare strings are tolerated
# so a stale backend during a deploy window still renders.
if isinstance(item, dict):
ingredient = _clean_ingredient_name(item.get("name"))
qty = _clean_ingredient_name(item.get("qty"))
else:
ingredient = _clean_ingredient_name(item)
qty = ""
key = _normalize_food_name(ingredient)
if not ingredient or not key or key in seen or _is_free_staple(ingredient):
continue
renderable.append(f"{ingredient}{qty}" if qty else ingredient)
seen.add(key)
return renderable
def _ingredient_label(item: dict[str, Any]) -> str:
name = _clean_ingredient_name(item.get("name"))
qty = _clean_ingredient_name(item.get("qty"))
if qty:
return f"{qty} {name}".strip()
return name
def _ingredients_from_extract(payload: dict[str, Any]) -> list[str]:
items = payload.get("items", [])
if not isinstance(items, list):
raise FriendlyError(
"Backend returned an ingredient payload in an unexpected shape."
)
ingredients: list[str] = []
seen: set[str] = set()
for item in items:
if not isinstance(item, dict):
continue
ingredient = _ingredient_label(item)
key = ingredient.casefold()
if ingredient and key not in seen:
ingredients.append(ingredient)
seen.add(key)
return ingredients[:MAX_INGREDIENT_SLOTS]
def _merge_kid_notes(preset_values: Any, free_text: Any) -> str:
"""Combine the selected preset chips with the typed notes into one string.
The backend treats `kid_notes` as a free-text list of exclusions, so we just
concatenate the active presets and the cook's own text (deduped, comma-joined).
"""
parts: list[str] = []
seen: set[str] = set()
candidates: list[Any] = []
if isinstance(preset_values, list):
candidates.extend(preset_values)
elif preset_values:
candidates.append(preset_values)
candidates.append(free_text)
for candidate in candidates:
cleaned = _clean_ingredient_name(candidate)
key = cleaned.casefold()
if cleaned and key not in seen:
parts.append(cleaned)
seen.add(key)
return ", ".join(parts)
def _confirmed_pantry_from_slots(slot_values: tuple[Any, ...]) -> list[str]:
confirmed: list[str] = []
seen: set[str] = set()
for value in slot_values:
ingredient = _clean_ingredient_name(value)
key = ingredient.casefold()
if ingredient and key not in seen:
confirmed.append(ingredient)
seen.add(key)
return confirmed
def _slot_component_updates(ingredients: list[str]) -> list[Any]:
ingredients = ingredients[:MAX_INGREDIENT_SLOTS]
group_updates = [
gr.update(visible=index < len(ingredients))
for index in range(MAX_INGREDIENT_SLOTS)
]
textbox_updates = [
gr.update(value=ingredients[index] if index < len(ingredients) else "")
for index in range(MAX_INGREDIENT_SLOTS)
]
return group_updates + textbox_updates
def _make_button_update(ingredients: list[str]) -> Any:
return gr.update(interactive=bool(ingredients))
def _ingredient_textboxes_changed(*slot_values):
ingredients = _confirmed_pantry_from_slots(slot_values)
return _make_button_update(ingredients)
def _confirm_next_clicked(*slot_values):
if _confirmed_pantry_from_slots(slot_values):
return _show_step_and_clear_status(3)
return _show_step(2) + [
gr.update(value="Add at least one ingredient before continuing.", visible=True)
]
def _reset_cook_outputs():
# 13 outputs (9 cook results + 4 chat components). NEVER let an inputs=0
# dep over these hit exactly 10 outputs: it would duplicate the harness's
# make-loading fingerprint (outputs==10 ∧ make_button ∧ dinners_html).
return (
"",
gr.update(interactive=True, value="Make dinner"),
gr.update(visible=False),
gr.update(choices=[], value=None, visible=False, interactive=False),
gr.update(value=None, visible=False),
gr.update(visible=False, interactive=False, value="I made this"),
gr.update(value="", visible=False),
{},
[],
gr.update(visible=False),
[],
gr.update(value=""),
gr.update(interactive=True, value="Ask"),
)
def _preserve_cook_outputs():
return tuple(gr.update() for _ in range(len(_reset_cook_outputs())))
def _pantry_changed_since_generation(generated_pantry, slot_values):
if not isinstance(generated_pantry, list):
return False
previous_pantry = _confirmed_pantry_from_slots(tuple(generated_pantry))
if not previous_pantry:
return False
return _confirmed_pantry_from_slots(slot_values) != previous_pantry
def _reset_cook_outputs_if_pantry_changed(generated_pantry, *slot_values):
if _pantry_changed_since_generation(generated_pantry, slot_values):
return _reset_cook_outputs()
return _preserve_cook_outputs()
def _reset_generation_outputs_if_pantry_changed(generated_pantry, *slot_values):
if _pantry_changed_since_generation(generated_pantry, slot_values):
return (gr.update(value="", visible=False),) + _reset_cook_outputs()
return (gr.update(),) + _preserve_cook_outputs()
def _has_generated_dinner_options(dinners_payload, generated_pantry):
if not isinstance(generated_pantry, list) or not generated_pantry:
return False
if not isinstance(dinners_payload, dict):
return False
dinners = dinners_payload.get("dinners")
return isinstance(dinners, list) and bool(dinners)
def _reset_generation_outputs_if_dinner_generated(dinners_payload, generated_pantry):
if _has_generated_dinner_options(dinners_payload, generated_pantry):
return (
(gr.update(value="", visible=False),)
+ _reset_cook_outputs()
+ (gr.update(),)
)
return (gr.update(),) + _preserve_cook_outputs() + (gr.update(),)
def _extract_loading():
return (
gr.update(value=_loading_html("Reading your fridge…"), visible=True),
gr.update(interactive=False, value="Looking inside…"),
)
def _extract_clicked(files):
try:
paths = _upload_paths(files)
payload = _post_multipart("/extract", paths)
ingredients = _ingredients_from_extract(payload)
status = (
"Review the ingredients, make any edits, then press Continue →."
if ingredients
else "No ingredients were detected. Add ingredients manually, then press Continue →."
)
# Success: hand the user to Step 2 (Confirm) — even with nothing detected,
# so they can add ingredients manually before Tune/Cook.
return (
_slot_component_updates(ingredients)
+ [
gr.update(value=status, visible=True),
gr.update(visible=True),
_make_button_update(ingredients),
payload,
gr.update(value="", visible=False),
gr.update(interactive=True, value="Scan again"),
]
+ _show_step(2)
)
except (FriendlyError, requests.RequestException) as exc:
message = (
str(exc)
if isinstance(exc, FriendlyError)
else "Could not reach the backend."
)
# Failure: stay on Step 1 so the user can retry the scan.
return (
_slot_component_updates([])
+ [
gr.update(value=message, visible=True),
gr.update(visible=False),
gr.update(interactive=False),
{},
gr.update(value="", visible=False),
gr.update(interactive=True, value="Find ingredients"),
]
+ _show_step(1)
)
def _add_ingredient_clicked(new_ingredient, *slot_values):
ingredients = _confirmed_pantry_from_slots(slot_values)
ingredient = _clean_ingredient_name(new_ingredient)
if not ingredient:
status = "Type an ingredient to add."
elif len(ingredients) >= MAX_INGREDIENT_SLOTS:
status = f"Ingredient list is capped at {MAX_INGREDIENT_SLOTS} items."
elif ingredient.casefold() in {item.casefold() for item in ingredients}:
status = f"{ingredient} is already on the list."
else:
ingredients.append(ingredient)
status = "Ingredient added."
return _slot_component_updates(ingredients) + [
gr.update(value=""),
gr.update(value=status, visible=True),
_make_button_update(ingredients),
]
def _delete_ingredient_clicked(index, *slot_values):
updated_slots = list(slot_values)
if index < len(updated_slots):
updated_slots[index] = ""
ingredients = _confirmed_pantry_from_slots(tuple(updated_slots))
status = (
"Ingredient removed."
if ingredients
else "Ingredient removed. Add at least one ingredient before making dinner."
)
return _slot_component_updates(ingredients) + [
gr.update(value=status, visible=True),
_make_button_update(ingredients),
]
def _render_list(items: Any, list_class: str, empty_text: str = "None") -> str:
renderable_items = _renderable_ingredient_items(items)
if not renderable_items:
return f'<p class="empty-list">{html.escape(empty_text)}</p>'
return (
f'<ul class="ingredient-tags {list_class}">'
+ "".join(f"<li>{html.escape(item)}</li>" for item in renderable_items)
+ "</ul>"
)
def _render_dinners(payload: dict[str, Any]) -> str:
dinners = payload.get("dinners", [])
if not isinstance(dinners, list) or not dinners:
return ""
time_cap_note = payload.get("time_cap_note")
note_markup = ""
if isinstance(time_cap_note, str) and time_cap_note.strip():
note_markup = (
'<div class="time-cap-note">'
f"{html.escape(time_cap_note.strip())}"
"</div>"
)
cards: list[str] = []
for dinner in dinners:
if not isinstance(dinner, dict):
continue
title = html.escape(str(dinner.get("title", "Dinner option")))
time_min = html.escape(str(dinner.get("time_min", "?")))
serves = html.escape(str(dinner.get("serves", "?")))
steps = dinner.get("steps", [])
if isinstance(steps, list):
step_markup = (
"<ol>"
+ "".join(f"<li>{html.escape(str(step))}</li>" for step in steps)
+ "</ol>"
)
else:
step_markup = ""
card_number = len(cards) + 1
cards.append(
f"""
<article class="recipe-card">
<div class="recipe-card__topline">
<span class="recipe-card__eyebrow">Dinner {card_number}</span>
<div class="recipe-card__meta">
<span>{ICON_CLOCK} about {time_min} min</span>
<span>{ICON_USERS} serves ~{serves}</span>
</div>
</div>
<div class="recipe-card__header">
<h3>{title}</h3>
</div>
<div class="recipe-card__lists">
<section class="recipe-card__fridge">
<h4>{ICON_LEAF} From your fridge</h4>
{_render_list(dinner.get("from_fridge"), "from-fridge")}
</section>
<section class="recipe-card__need">
<h4>{ICON_CART} You'll also need</h4>
{_render_list(dinner.get("also_need"), "also-need", "Nothing extra needed")}
</section>
</div>
<section class="recipe-card__steps">
<h4>Steps</h4>
{step_markup}
</section>
</article>
"""
)
results_header = (
'<header class="results-header">'
f'<span class="results-eyebrow">{ICON_SPARKLE} Step 4 · Cook</span>'
"<h2>Tonight&#39;s options</h2>"
"<p>Pick one, cook it, then log the night with &ldquo;I made this&rdquo; below.</p>"
"</header>"
)
return (
results_header
+ note_markup
+ '<div class="recipe-grid">'
+ "".join(cards)
+ "</div>"
)
def _dinner_choice_options(payload: dict[str, Any]) -> list[tuple[str, str]]:
dinners = payload.get("dinners", [])
if not isinstance(dinners, list):
return []
options: list[tuple[str, str]] = []
for index, dinner in enumerate(dinners):
if not isinstance(dinner, dict):
continue
title = _clean_ingredient_name(str(dinner.get("title", "")))
if not title:
title = f"Dinner option {index + 1}"
options.append((f"Dinner {index + 1}: {title}", str(index)))
return options
def _selected_dinner(
selected_value: Any,
payload: dict[str, Any],
) -> dict[str, Any]:
if not isinstance(selected_value, str) or not selected_value:
raise FriendlyError("Choose which dinner you made.")
try:
selected_index = int(selected_value)
except ValueError as exc:
raise FriendlyError("Choose which dinner you made.") from exc
dinners = payload.get("dinners", [])
if (
not isinstance(dinners, list)
or selected_index < 0
or selected_index >= len(dinners)
):
raise FriendlyError("Generate dinner options before logging one.")
dinner = dinners[selected_index]
if not isinstance(dinner, dict):
raise FriendlyError("Choose which dinner you made.")
return dinner
def _make_dinner_loading():
"""Immediate feedback before the (slower) backend call runs.
Clears any prior cards and locks the button so a confident night still
reads as "working," not "nothing happened."
"""
return (
gr.update(value="Finding dinners from your confirmed list…", visible=True),
_loading_html("Finding dinners from your confirmed list…"),
gr.update(interactive=False, value="Cooking up ideas…"),
gr.update(visible=False),
gr.update(choices=[], value=None, visible=False, interactive=False),
gr.update(value=None, visible=False),
gr.update(visible=False, interactive=False),
gr.update(value="", visible=False),
{},
[],
)
def _make_dinner_clicked(
cuisine,
headcount,
time_cap_min,
healthier,
kid_notes,
kid_note_presets,
*slot_values,
):
confirmed_pantry = _confirmed_pantry_from_slots(slot_values)
if not confirmed_pantry:
return (
gr.update(
value="Confirm at least one ingredient before making dinner.",
visible=True,
),
"",
gr.update(interactive=False, value="Make dinner"),
gr.update(visible=False),
gr.update(choices=[], value=None, visible=False, interactive=False),
gr.update(value=None, visible=False),
gr.update(visible=False, interactive=False),
gr.update(value="", visible=False),
{},
[],
)
payload = {
"confirmed_pantry": confirmed_pantry,
"cuisine": cuisine or "No preference",
"headcount": int(headcount),
"time_cap_min": int(time_cap_min),
"healthier": bool(healthier),
"kid_notes": _merge_kid_notes(kid_note_presets, kid_notes),
}
try:
dinners = _post_json("/generate", payload)
dinner_choices = _dinner_choice_options(dinners)
return (
gr.update(
value=(
"Dinner options are based on your confirmed list: "
+ ", ".join(confirmed_pantry)
),
visible=True,
),
_render_dinners(dinners),
gr.update(interactive=True, value="Make dinner again"),
gr.update(visible=bool(dinner_choices)),
gr.update(
choices=dinner_choices,
value=dinner_choices[0][1] if dinner_choices else None,
visible=bool(dinner_choices),
interactive=bool(dinner_choices),
),
gr.update(value=None, visible=bool(dinner_choices)),
gr.update(visible=bool(dinner_choices), interactive=bool(dinner_choices)),
gr.update(value="", visible=False),
dinners,
confirmed_pantry,
)
except (FriendlyError, requests.RequestException) as exc:
message = (
str(exc)
if isinstance(exc, FriendlyError)
else "Could not reach the backend."
)
return (
gr.update(value=message, visible=True),
"",
gr.update(interactive=True, value="Make dinner"),
gr.update(visible=False),
gr.update(choices=[], value=None, visible=False, interactive=False),
gr.update(value=None, visible=False),
gr.update(visible=False, interactive=False),
gr.update(value="", visible=False),
{},
[],
)
# gr.Chatbot sanitizes HTML inside messages (gradio 5.9.1 default), so the
# pending placeholder is plain text carrying the shared cold-start note rather
# than the .kitchen-loading markup. It doubles as the sentinel the final step
# replaces with the real reply.
CHAT_PENDING_MESSAGE = f"Thinking it over… {LOADING_COLD_START_NOTE}"
# Mirrors the backend's CHAT_MAX_HISTORY_MESSAGES; the server re-caps anyway.
CHAT_HISTORY_LIMIT = 16
def _chat_messages(history) -> list[dict[str, str]]:
"""Normalize a gr.Chatbot(type="messages") value to wire-shape dicts."""
messages: list[dict[str, str]] = []
for message in history if isinstance(history, list) else []:
if isinstance(message, dict):
role = message.get("role")
content = message.get("content")
else:
role = getattr(message, "role", None)
content = getattr(message, "content", None)
if role in ("user", "assistant") and isinstance(content, str):
messages.append({"role": role, "content": content})
return messages
def _ask_loading(history, question):
"""Append the question + a pending placeholder and lock the Ask button.
An empty/whitespace question is a no-op: nothing is appended, and the
untouched history tells _ask_final to skip the backend call too.
"""
question = " ".join(str(question or "").split())
if not question:
return gr.update(), gr.update(), gr.update()
messages = _chat_messages(history)
messages.append({"role": "user", "content": question})
messages.append({"role": "assistant", "content": CHAT_PENDING_MESSAGE})
return (
messages,
gr.update(value=""),
gr.update(interactive=False, value="Asking…"),
)
def _ask_final(history, dinners_payload, confirmed_pantry, kid_note_presets, kid_notes):
"""Swap the pending placeholder for the /chat reply (or a friendly error)."""
messages = _chat_messages(history)
if (
not messages
or messages[-1]["role"] != "assistant"
or messages[-1]["content"] != CHAT_PENDING_MESSAGE
):
return gr.update(), gr.update()
payload = {
"messages": messages[:-1][-CHAT_HISTORY_LIMIT:],
"dinners": dinners_payload if isinstance(dinners_payload, dict) else {},
"confirmed_pantry": (
confirmed_pantry if isinstance(confirmed_pantry, list) else []
),
"kid_notes": _merge_kid_notes(kid_note_presets, kid_notes),
}
try:
reply = _post_json("/chat", payload).get("reply")
if isinstance(reply, str) and reply.strip():
messages[-1] = {"role": "assistant", "content": reply.strip()}
else:
messages[-1] = {
"role": "assistant",
"content": "The kitchen came back empty — try asking that again.",
}
except (FriendlyError, requests.RequestException) as exc:
message = (
str(exc)
if isinstance(exc, FriendlyError)
else "Could not reach the backend."
)
messages[-1] = {"role": "assistant", "content": message}
return messages, gr.update(interactive=True, value="Ask")
def _reveal_chat_panel(dinners_payload):
"""Show a fresh, empty chat panel once dinners exist (hide it otherwise).
Runs as a `.then` AFTER make-final so the locked make-dinner handler shapes
(loading: 10 outputs, final: >40 inputs / 2 state outputs) never change.
Arity is inputs=1/outputs=4, which collides with no harness fingerprint.
"""
has_dinners = isinstance(dinners_payload, dict) and bool(
dinners_payload.get("dinners")
)
return (
gr.update(visible=has_dinners),
[],
gr.update(value=""),
gr.update(interactive=True, value="Ask"),
)
def _made_this_loading():
return (
gr.update(value=_loading_html("Saving to the kitchen log…"), visible=True),
gr.update(interactive=False, value="Logging…"),
)
def _made_this_clicked(
selected_dinner,
plate_photo,
dinners_payload,
confirmed_pantry,
):
try:
if not isinstance(dinners_payload, dict):
raise FriendlyError("Generate dinner options before logging one.")
if not isinstance(confirmed_pantry, list) or not confirmed_pantry:
raise FriendlyError("Generate dinner options before logging one.")
dinner = _selected_dinner(selected_dinner, dinners_payload)
dish_title = _clean_ingredient_name(str(dinner.get("title", "")))
if not dish_title:
raise FriendlyError("Choose which dinner you made.")
image_path = _optional_upload_path(plate_photo)
response = _post_form_with_optional_image(
"/made-this",
{
"dish_title": dish_title,
"confirmed_pantry": json.dumps(confirmed_pantry),
},
image_path,
)
record = response.get("record", {})
made_at = record.get("made_at") if isinstance(record, dict) else None
suffix = f" ({made_at})" if isinstance(made_at, str) and made_at else ""
return (
gr.update(
value=f'Saved "{dish_title}" to the usage log{suffix}.',
visible=True,
),
gr.update(interactive=True, value="I made this"),
)
except (FriendlyError, requests.RequestException) as exc:
message = (
str(exc)
if isinstance(exc, FriendlyError)
else "Could not reach the backend."
)
return (
gr.update(value=message, visible=True),
gr.update(interactive=True, value="I made this"),
)
# Warm "kitchen-table" design system. Base tokens (cream paper, leafy green,
# clementine pop) live on :root; the green/pop tints are derived with color-mix
# on .gradio-container so every custom-HTML card inside it can use them. The
# class names below are the contract with build_app() and the HTML renderers —
# keep them in sync if either side changes.
APP_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,500;0,600;0,700;1,500&family=Hanken+Grotesque:wght@400;500;600;700&display=swap');
:root {
--paper: #F4EFE4;
--paper-2: #EFE9DB;
--card: #FFFDF8;
--ink: #211F1A;
--ink-soft: #3a372f;
--muted: #736D60;
--faint: #9b9486;
--line: #E6DECF;
--line-2: #DBD2C0;
--green: #2E7D52;
--pop: #E8742A;
--r: 16px;
--font-display: 'Spectral', Georgia, 'Times New Roman', serif;
--shadow: 0 1px 2px rgba(60, 50, 30, 0.05), 0 10px 26px -14px rgba(60, 50, 30, 0.22);
--shadow-sm: 0 1px 2px rgba(60, 50, 30, 0.05), 0 4px 12px -8px rgba(60, 50, 30, 0.2);
}
.gradio-container {
--green-soft: color-mix(in srgb, var(--green) 11%, var(--card));
--green-line: color-mix(in srgb, var(--green) 26%, white);
--green-ink: color-mix(in srgb, var(--green) 78%, black);
--pop-soft: color-mix(in srgb, var(--pop) 12%, var(--card));
--pop-line: color-mix(in srgb, var(--pop) 34%, white);
--pop-ink: color-mix(in srgb, var(--pop) 70%, black);
min-height: 100vh;
color: var(--ink);
overflow-x: hidden;
background:
radial-gradient(1200px 700px at 78% -8%, color-mix(in srgb, var(--paper) 40%, white), transparent 60%),
var(--paper);
}
@media (prefers-color-scheme: dark) {
body,
.gradio-container {
background: var(--paper) !important;
color: var(--ink) !important;
}
}
.gradio-container,
.gradio-container * {
letter-spacing: 0 !important;
}
#app-shell {
max-width: 1180px;
margin: 0 auto;
padding: 28px 22px 90px;
}
/* ---------------- header ---------------- */
#brand-header {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 14px;
padding: 6px 0 22px;
margin-bottom: 24px;
border-bottom: 1px solid var(--line);
}
.brand-mark {
width: 54px;
height: 54px;
display: grid;
place-items: center;
border-radius: 15px;
background: var(--green);
color: #fff;
font-family: var(--font-display);
font-weight: 700;
font-size: 21px;
letter-spacing: -0.02em !important;
box-shadow: var(--shadow-sm);
}
.brand-copy {
min-width: 0;
}
.brand-copy h1,
.brand-copy p {
margin: 0;
}
.brand-copy h1 {
font-family: var(--font-display);
font-weight: 700;
font-size: 30px;
line-height: 1;
letter-spacing: -0.015em !important;
color: var(--ink);
}
.brand-copy p {
margin-top: 6px;
color: var(--muted);
font-size: 14.5px;
max-width: 42rem;
overflow-wrap: anywhere;
white-space: normal !important;
}
/* ---------------- guided wizard ---------------- */
/* Progress rail: numbered dots joined by connectors, one panel shown at a time. */
.wizard-progress {
display: flex;
align-items: center;
gap: 8px;
list-style: none;
margin: 0 auto 26px;
padding: 0;
max-width: 660px;
}
.wizard-step {
display: flex;
align-items: center;
gap: 9px;
flex: 1 1 0;
min-width: 0;
color: var(--faint);
font-size: 13px;
font-weight: 600;
}
.wizard-step:last-child {
flex: 0 0 auto;
}
.wizard-step::after {
content: "";
flex: 1;
height: 2px;
border-radius: 2px;
background: var(--line);
}
.wizard-step:last-child::after {
display: none;
}
.wizard-step__dot {
display: grid;
place-items: center;
width: 26px;
height: 26px;
flex: none;
border-radius: 50%;
background: var(--card);
border: 1.5px solid var(--line-2);
color: var(--faint);
font-size: 13px;
font-weight: 700;
}
.wizard-step__label {
white-space: nowrap;
}
.wizard-step.is-active {
color: var(--green-ink);
}
.wizard-step.is-active .wizard-step__dot {
background: var(--green);
border-color: var(--green);
color: #fff;
}
.wizard-step.is-done {
color: var(--ink-soft);
}
.wizard-step.is-done .wizard-step__dot {
background: var(--green-soft);
border-color: var(--green-line);
color: var(--green-ink);
}
.wizard-step.is-done::after {
background: var(--green-line);
}
.wizard-panel {
max-width: 640px;
margin: 0 auto;
width: 100%;
}
.wizard-nav {
margin-top: 20px;
gap: 12px;
}
.settings-panel,
.ingredient-editor,
.made-this-panel {
border: 1px solid var(--line) !important;
border-radius: var(--r) !important;
background: var(--card) !important;
box-shadow: var(--shadow);
}
.settings-panel,
.made-this-panel {
margin-top: 16px;
}
/* ---------------- section headings ---------------- */
.section-heading {
margin: 4px 2px 12px;
}
.section-heading span {
display: inline-block;
margin-bottom: 5px;
color: var(--green);
font-size: 11.5px;
font-weight: 800;
letter-spacing: 0.13em !important;
text-transform: uppercase;
}
.section-heading.tone-pop span {
color: var(--pop-ink);
}
.section-heading h2 {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 24px;
line-height: 1.1;
letter-spacing: -0.01em !important;
color: var(--ink);
}
.section-heading .note-line {
margin: 8px 0 0;
color: var(--muted);
font-size: 13.5px;
line-height: 1.45;
max-width: 46ch;
}
/* ---------------- status line ---------------- */
.status-line {
margin-top: 14px;
padding: 11px 14px;
border: 1px solid var(--line);
border-left: 4px solid var(--green);
border-radius: 12px;
background: var(--card);
color: var(--ink-soft);
}
.status-line p,
.allergy-disclaimer p {
margin: 0 !important;
}
/* ---------------- loading (markup matches validate_loading.py) ---------------- */
@keyframes f2f-spin {
to {
transform: rotate(360deg);
}
}
.kitchen-loading {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
margin: 14px 0;
padding: 14px 15px;
border: 1px solid var(--green-line);
border-left: 4px solid var(--green);
border-radius: 12px;
background: var(--green-soft);
color: var(--ink);
}
.kitchen-loading__spinner {
width: 26px;
height: 26px;
flex: 0 0 26px;
border: 3px solid color-mix(in srgb, var(--pop) 28%, white);
border-top-color: var(--pop);
border-radius: 50%;
animation: f2f-spin 0.7s linear infinite;
}
.kitchen-loading__copy {
min-width: 0;
}
.kitchen-loading__label,
.kitchen-loading__note {
margin: 0 !important;
}
.kitchen-loading__label {
color: var(--ink);
font-weight: 800;
line-height: 1.3;
}
.kitchen-loading__note {
margin-top: 3px !important;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.38;
}
/* ---------------- allergy disclaimer ---------------- */
.allergy-disclaimer {
margin: 6px 0 2px !important;
}
.allergy-disclaimer p {
display: flex;
gap: 10px;
padding: 13px 15px;
border: 1px solid var(--pop-line);
border-radius: 12px;
background: color-mix(in srgb, var(--pop) 7%, var(--card));
color: var(--ink-soft);
font-size: 13px;
line-height: 1.45;
}
.allergy-disclaimer p::before {
content: "";
flex: none;
width: 9px;
height: 9px;
margin-top: 5px;
border-radius: 999px;
background: var(--pop);
}
/* ---------------- recipe chat ---------------- */
#recipe-chat {
background: var(--card) !important;
}
#recipe-chat .message.user,
#recipe-chat .message-wrap.user-row .message,
#recipe-chat .message-wrap.user .message,
#recipe-chat .user-row .message,
#recipe-chat .user-message {
background: var(--green-soft) !important;
border: 1px solid var(--green-line) !important;
color: var(--ink) !important;
}
#recipe-chat .message.user *,
#recipe-chat .message-wrap.user-row .message *,
#recipe-chat .message-wrap.user .message *,
#recipe-chat .user-row .message *,
#recipe-chat .user-message * {
color: var(--ink) !important;
}
/* ---------------- buttons ---------------- */
.primary-action {
margin-top: 16px;
}
/* "Find ingredients" — citrus accent */
.btn-accent,
.btn-accent button {
background: var(--pop) !important;
border-color: var(--pop) !important;
color: #fff !important;
}
.btn-accent:hover,
.btn-accent button:hover {
filter: brightness(1.06);
}
/* "Add" ingredient — soft green */
.btn-add,
.btn-add button {
background: var(--green-soft) !important;
border: 1.4px solid var(--green-line) !important;
color: var(--green-ink) !important;
}
.btn-add:hover,
.btn-add button:hover {
background: var(--green) !important;
color: #fff !important;
}
/* "I made this" — outlined green */
.btn-cook,
.btn-cook button {
background: transparent !important;
border: 1.5px solid var(--green) !important;
color: var(--green-ink) !important;
}
.btn-cook:hover,
.btn-cook button:hover {
background: var(--green) !important;
color: #fff !important;
}
/* ---------------- upload dropzone ---------------- */
.gradio-container button:has(input[data-testid="file-upload"]) {
background: var(--green-soft) !important;
border: 1.6px dashed var(--green-line) !important;
border-radius: 13px !important;
color: var(--green-ink) !important;
}
.gradio-container button:has(input[data-testid="file-upload"]) * {
color: var(--green-ink) !important;
}
.gradio-container button:has(input[data-testid="file-upload"]) svg {
stroke: var(--green) !important;
}
.gradio-container button:has(input[data-testid="file-upload"]):hover {
background: color-mix(in srgb, var(--green) 16%, white) !important;
}
/* ---------------- ingredient editor rows ---------------- */
.ingredient-row {
align-items: center;
gap: 8px;
margin: 6px 0 !important;
}
.ingredient-row textarea,
.ingredient-row input {
min-height: 42px !important;
border-radius: 11px !important;
}
.kid-note-presets label {
border-radius: 999px !important;
}
.delete-chip {
min-width: 42px !important;
max-width: 42px !important;
height: 42px !important;
border-radius: 11px !important;
}
/* ---------------- results ---------------- */
#dinner-results {
margin-top: 18px;
}
.results-header {
margin: 6px 0 18px;
}
.results-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
color: var(--pop-ink);
font-size: 11.5px;
font-weight: 800;
letter-spacing: 0.13em !important;
text-transform: uppercase;
}
.results-eyebrow svg {
width: 13px;
height: 13px;
color: var(--pop);
}
.results-header h2 {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.01em !important;
color: var(--ink);
}
.results-header p {
margin: 7px 0 0;
color: var(--muted);
font-size: 14.5px;
}
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.time-cap-note {
margin: 0 0 16px;
padding: 12px 14px;
border: 1px solid var(--pop-line);
border-radius: 12px;
background: color-mix(in srgb, var(--pop) 7%, var(--card));
color: var(--pop-ink);
font-size: 0.92rem;
}
/* ---------------- dinner cards ---------------- */
.recipe-card {
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 22px;
box-shadow: var(--shadow);
}
.recipe-card h3,
.recipe-card h4 {
margin: 0;
}
.recipe-card h3 {
font-family: var(--font-display);
font-weight: 600;
font-size: 22px;
line-height: 1.16;
color: var(--ink);
}
.recipe-card h4 {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.05em !important;
text-transform: uppercase;
}
.recipe-card__topline {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.recipe-card__eyebrow {
border: 1px solid var(--pop-line);
border-radius: 999px;
background: var(--pop-soft);
color: var(--pop-ink);
padding: 4px 10px;
font-size: 11.5px;
font-weight: 800;
letter-spacing: 0.1em !important;
text-transform: uppercase;
}
.recipe-card__meta {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 7px;
}
.recipe-card__meta span {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
border-radius: 999px;
background: var(--paper-2);
color: var(--muted);
padding: 5px 10px;
font-size: 12.5px;
font-weight: 600;
}
.recipe-card__meta svg {
width: 14px;
height: 14px;
color: var(--green);
}
.recipe-card__header {
margin-top: 2px;
}
.recipe-card__lists {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 11px;
align-items: start;
}
.recipe-card__fridge {
background: var(--green-soft);
border: 1px solid var(--green-line);
border-radius: 12px;
padding: 13px;
}
.recipe-card__need {
background: color-mix(in srgb, var(--pop) 7%, var(--card));
border: 1px dashed var(--pop-line);
border-radius: 12px;
padding: 13px;
}
.recipe-card__fridge h4,
.recipe-card__need h4 {
display: flex;
align-items: center;
gap: 6px;
}
.recipe-card__fridge h4 {
color: var(--green-ink);
}
.recipe-card__fridge h4 svg {
color: var(--green);
}
.recipe-card__need h4 {
color: var(--pop-ink);
}
.recipe-card__need h4 svg {
color: var(--pop);
}
.recipe-card__fridge h4 svg,
.recipe-card__need h4 svg {
width: 14px;
height: 14px;
}
.recipe-card__lists ul.ingredient-tags {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.recipe-card__lists ul.ingredient-tags li {
margin: 0;
padding: 0 0 0 14px;
position: relative;
background: none !important;
border: none !important;
color: var(--ink-soft);
font-weight: 600;
font-size: 13px;
line-height: 1.4;
}
.recipe-card__lists ul.ingredient-tags li::before {
content: "";
position: absolute;
left: 0;
top: 7px;
width: 5px;
height: 5px;
border-radius: 999px;
}
.from-fridge li::before {
background: var(--green);
}
.also-need li::before {
background: var(--pop);
}
.empty-list {
margin: 8px 0 0;
color: var(--muted);
font-size: 13px;
}
.recipe-card__steps {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.recipe-card__steps h4 {
color: var(--muted);
letter-spacing: 0.1em !important;
margin-bottom: 10px;
}
.recipe-card__steps ol {
margin: 0;
padding: 0;
list-style: none;
counter-reset: s;
display: flex;
flex-direction: column;
gap: 10px;
}
.recipe-card__steps ol li {
counter-increment: s;
position: relative;
margin: 0;
padding-left: 32px;
font-size: 14px;
line-height: 1.45;
color: var(--ink-soft);
}
.recipe-card__steps ol li::before {
content: counter(s);
position: absolute;
left: 0;
top: -1px;
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--green-soft);
border: 1px solid var(--green-line);
color: var(--green-ink);
font-size: 12px;
font-weight: 800;
display: grid;
place-items: center;
}
/* ---------------- chrome ---------------- */
footer,
.footer,
#footer,
.built-with,
.show-api {
display: none !important;
visibility: hidden !important;
}
@media (max-width: 720px) {
#app-shell {
padding: 18px 14px 28px;
}
.brand-copy h1 {
font-size: 24px;
}
.wizard-step__label {
display: none;
}
.recipe-grid {
grid-template-columns: 1fr;
}
.recipe-card__lists {
grid-template-columns: 1fr;
}
.recipe-card__meta {
justify-content: flex-start;
}
}
"""
def build_app() -> gr.Blocks:
theme = gr.themes.Soft(
primary_hue="green",
secondary_hue="orange",
neutral_hue="stone",
font=[
gr.themes.GoogleFont("Hanken Grotesque"),
"system-ui",
"-apple-system",
"sans-serif",
],
font_mono=["SFMono-Regular", "Consolas", "monospace"],
).set(
body_background_fill="#F4EFE4",
body_background_fill_dark="#F4EFE4",
body_text_color="#211F1A",
body_text_color_dark="#211F1A",
body_text_color_subdued="#736D60",
body_text_color_subdued_dark="#736D60",
background_fill_primary="#FFFDF8",
background_fill_primary_dark="#FFFDF8",
background_fill_secondary="#EFE9DB",
background_fill_secondary_dark="#EFE9DB",
block_background_fill="#FFFDF8",
block_background_fill_dark="#FFFDF8",
block_border_color="#E6DECF",
block_border_color_dark="#E6DECF",
block_border_width="1px",
block_radius="16px",
block_shadow="0 1px 2px rgba(60, 50, 30, 0.05), 0 10px 26px -14px rgba(60, 50, 30, 0.22)",
block_shadow_dark="0 1px 2px rgba(60, 50, 30, 0.05), 0 10px 26px -14px rgba(60, 50, 30, 0.22)",
input_background_fill="#FFFDF8",
input_background_fill_dark="#FFFDF8",
input_border_color="#DBD2C0",
input_border_color_dark="#DBD2C0",
input_border_color_focus="#2E7D52",
input_border_color_focus_dark="#2E7D52",
input_radius="11px",
button_large_radius="12px",
button_small_radius="12px",
button_primary_background_fill="#2E7D52",
button_primary_background_fill_dark="#2E7D52",
button_primary_background_fill_hover="#256B49",
button_primary_background_fill_hover_dark="#256B49",
button_primary_border_color="#2E7D52",
button_primary_border_color_dark="#2E7D52",
button_primary_text_color="#ffffff",
button_primary_text_color_dark="#ffffff",
button_secondary_background_fill="#EFE9DB",
button_secondary_background_fill_dark="#EFE9DB",
button_secondary_background_fill_hover="#E6DECF",
button_secondary_background_fill_hover_dark="#E6DECF",
button_secondary_border_color="#DBD2C0",
button_secondary_border_color_dark="#DBD2C0",
button_secondary_text_color="#3a372f",
button_secondary_text_color_dark="#3a372f",
checkbox_label_background_fill="#FFFDF8",
checkbox_label_background_fill_dark="#FFFDF8",
checkbox_label_background_fill_selected="#e7f1ea",
checkbox_label_background_fill_selected_dark="#e7f1ea",
checkbox_label_border_color="#DBD2C0",
checkbox_label_border_color_dark="#DBD2C0",
checkbox_label_border_color_selected="#2E7D52",
checkbox_label_border_color_selected_dark="#2E7D52",
checkbox_label_text_color_selected="#225e3d",
checkbox_label_text_color_selected_dark="#225e3d",
# Component labels: subtle muted text on the card, not heavy green chips.
block_label_background_fill="#FFFDF8",
block_label_background_fill_dark="#FFFDF8",
block_label_text_color="#736D60",
block_label_text_color_dark="#736D60",
block_label_border_width="0px",
block_label_border_width_dark="0px",
block_title_text_color="#3a372f",
block_title_text_color_dark="#3a372f",
# Checkboxes: cream box with a leafy-green check when selected.
checkbox_background_color="#FFFDF8",
checkbox_background_color_dark="#FFFDF8",
checkbox_background_color_selected="#2E7D52",
checkbox_background_color_selected_dark="#2E7D52",
checkbox_border_color="#DBD2C0",
checkbox_border_color_dark="#DBD2C0",
checkbox_border_color_focus="#2E7D52",
checkbox_border_color_focus_dark="#2E7D52",
checkbox_border_color_selected="#2E7D52",
checkbox_border_color_selected_dark="#2E7D52",
)
with gr.Blocks(title="Fridge to Fork", theme=theme, css=APP_CSS) as demo:
raw_extraction_state = gr.State({})
dinners_state = gr.State({})
confirmed_pantry_state = gr.State([])
with gr.Column(elem_id="app-shell"):
gr.HTML(
"""
<header id="brand-header">
<div class="brand-mark">F2F</div>
<div class="brand-copy">
<h1>Fridge to Fork</h1>
<p>Dinner from what's already waiting.</p>
</div>
</header>
"""
)
stepper = gr.HTML(_stepper_html(1), elem_id="wizard-progress")
status = gr.Markdown(visible=False, elem_classes=["status-line"])
# ---------------- Step 1 · Snap ----------------
with gr.Column(elem_classes=["wizard-panel"]) as step_snap:
gr.HTML(
"""
<div class="section-heading">
<span>Step 1 · Snap</span>
<h2>Your fridge</h2>
<p class="note-line">Add a photo or two of the open fridge — we'll read what's inside.</p>
</div>
"""
)
photos = gr.File(
file_count="multiple",
file_types=IMAGE_EXTENSIONS,
type="filepath",
label="Fridge photos",
)
extract_loading = gr.HTML(visible=False)
extract_button = gr.Button(
"Find ingredients",
variant="primary",
elem_classes=["primary-action"],
)
# ---------------- Step 2 · Confirm ----------------
with gr.Column(
visible=False, elem_classes=["wizard-panel"]
) as step_confirm:
gr.HTML(
"""
<div class="section-heading">
<span>Step 2 · Confirm</span>
<h2>What we spotted</h2>
<p class="note-line">Fix anything off — recipes are only built from this list, never a raw guess.</p>
</div>
"""
)
with gr.Group(elem_classes=["ingredient-editor"]) as ingredient_editor:
ingredient_groups: list[Any] = []
ingredient_textboxes: list[Any] = []
ingredient_delete_buttons: list[Any] = []
for index in range(MAX_INGREDIENT_SLOTS):
with gr.Row(
visible=False, elem_classes=["ingredient-row"]
) as row:
textbox = gr.Textbox(
show_label=False,
placeholder="Ingredient",
container=False,
scale=8,
)
delete_button = gr.Button(
"x",
min_width=42,
scale=0,
elem_classes=["delete-chip"],
)
ingredient_groups.append(row)
ingredient_textboxes.append(textbox)
ingredient_delete_buttons.append(delete_button)
with gr.Row():
new_ingredient = gr.Textbox(
show_label=False,
placeholder="Add an ingredient",
container=False,
scale=5,
)
add_ingredient_button = gr.Button(
"Add", scale=1, elem_classes=["btn-add"]
)
with gr.Row(elem_classes=["wizard-nav"]):
back_to_snap_button = gr.Button(
"← Back", elem_classes=["btn-back"]
)
# Gated: stays disabled until at least one ingredient is
# confirmed, so Tune/Cook can't run on an empty pantry.
confirm_next_button = gr.Button(
"Continue →", interactive=False, elem_classes=["btn-next"]
)
# ---------------- Step 3 · Tune ----------------
with gr.Column(
visible=False, elem_classes=["wizard-panel"]
) as step_tune:
gr.HTML(
"""
<div class="section-heading">
<span>Step 3 · Tune</span>
<h2>A couple of dials</h2>
<p class="note-line">Optional — the defaults are fine. Nudge anything to taste.</p>
</div>
"""
)
with gr.Group(elem_classes=["settings-panel"]):
cuisine = gr.Dropdown(
CUISINE_CHOICES,
value="No preference",
label="Cuisine",
)
with gr.Row():
headcount = gr.Number(
value=4,
precision=0,
minimum=1,
maximum=12,
label="People",
)
time_cap = gr.Number(
value=45,
precision=0,
minimum=10,
maximum=180,
label="Minutes",
)
healthier = gr.Checkbox(value=False, label="Make it healthier")
kid_notes = gr.Textbox(
label="Kid notes",
placeholder="Anything to avoid, e.g. no mushrooms, no olives",
lines=2,
)
gr.Markdown(
"Preferences only — not allergy-safe. Give every recipe a once-over yourself.",
elem_classes=["allergy-disclaimer"],
)
kid_note_presets = gr.CheckboxGroup(
KID_NOTE_PRESETS,
label="Quick exclusions",
elem_classes=["kid-note-presets"],
)
with gr.Row(elem_classes=["wizard-nav"]):
back_to_confirm_button = gr.Button(
"← Back", elem_classes=["btn-back"]
)
tune_next_button = gr.Button(
"Continue →", elem_classes=["btn-next"]
)
# ---------------- Step 4 · Cook ----------------
with gr.Column(
visible=False, elem_classes=["wizard-panel"]
) as step_cook:
gr.HTML(
"""
<div class="section-heading">
<span>Step 4 · Cook</span>
<h2>Tonight's dinners</h2>
<p class="note-line">Build 3–4 dinners from your confirmed list, then log the one you cook.</p>
</div>
"""
)
make_dinner_button = gr.Button(
"Make dinner",
variant="primary",
elem_classes=["primary-action"],
)
dinners_html = gr.HTML(elem_id="dinner-results")
with gr.Group(
visible=False, elem_classes=["made-this-panel"]
) as chat_panel:
gr.HTML(
"""
<div class="section-heading">
<span>Chat</span>
<h2>Ask about these dinners</h2>
</div>
"""
)
chatbot = gr.Chatbot(
type="messages",
elem_id="recipe-chat",
height=320,
show_label=False,
)
gr.Markdown(
"Substitution ideas are preferences only — not allergy-safe; "
"double-check anything that matters.",
elem_classes=["allergy-disclaimer"],
)
with gr.Row():
chat_input = gr.Textbox(
placeholder="What can I use instead of gochujang?",
show_label=False,
scale=4,
)
ask_button = gr.Button(
"Ask",
variant="secondary",
elem_classes=["btn-cook"],
scale=1,
)
with gr.Group(
visible=False, elem_classes=["made-this-panel"]
) as made_this_panel:
gr.HTML(
"""
<div class="section-heading tone-pop">
<span>Log</span>
<h2>I made this</h2>
</div>
"""
)
dinner_choice = gr.Radio(
choices=[],
label="Dinner cooked",
interactive=False,
)
plate_photo = gr.File(
file_count="single",
file_types=IMAGE_EXTENSIONS,
type="filepath",
label="Finished plate photo (optional)",
)
made_this_button = gr.Button(
"I made this",
variant="secondary",
interactive=False,
elem_classes=["btn-cook"],
)
made_this_status = gr.Markdown(
visible=False,
elem_classes=["status-line"],
)
with gr.Row(elem_classes=["wizard-nav"]):
back_to_tune_button = gr.Button(
"← Back", elem_classes=["btn-back"]
)
start_over_button = gr.Button(
"Start over", elem_classes=["btn-back"]
)
slot_outputs = ingredient_groups + ingredient_textboxes
# Order matches _show_step(): [stepper, step1, step2, step3, step4].
nav_outputs = [stepper, step_snap, step_confirm, step_tune, step_cook]
nav_status_outputs = nav_outputs + [status]
# 13 outputs — the 4 chat components ride along so every invalidation
# path (extract success, pantry edits, tune changes, start-over) clears
# and hides the chat with the cook results. Keep this length away from
# 10 (the make-loading harness fingerprint).
cook_reset_outputs = [
dinners_html,
make_dinner_button,
made_this_panel,
dinner_choice,
plate_photo,
made_this_button,
made_this_status,
dinners_state,
confirmed_pantry_state,
chat_panel,
chatbot,
chat_input,
ask_button,
]
extract_button.click(
_extract_loading,
inputs=None,
outputs=[extract_loading, extract_button],
api_name=False,
).then(
_extract_clicked,
inputs=[photos],
outputs=slot_outputs
+ [
status,
ingredient_editor,
confirm_next_button,
raw_extraction_state,
extract_loading,
extract_button,
]
+ nav_outputs,
api_name=False,
).then(
_reset_cook_outputs,
inputs=None,
outputs=cook_reset_outputs,
api_name=False,
)
add_ingredient_button.click(
_add_ingredient_clicked,
inputs=[new_ingredient] + ingredient_textboxes,
outputs=slot_outputs
+ [
new_ingredient,
status,
confirm_next_button,
],
api_name=False,
).then(
_reset_cook_outputs_if_pantry_changed,
inputs=[confirmed_pantry_state] + ingredient_textboxes,
outputs=cook_reset_outputs,
api_name=False,
)
for index, delete_button in enumerate(ingredient_delete_buttons):
delete_button.click(
lambda *values, index=index: _delete_ingredient_clicked(index, *values),
inputs=ingredient_textboxes,
outputs=slot_outputs
+ [
status,
confirm_next_button,
],
api_name=False,
).then(
_reset_cook_outputs_if_pantry_changed,
inputs=[confirmed_pantry_state] + ingredient_textboxes,
outputs=cook_reset_outputs,
api_name=False,
)
for textbox in ingredient_textboxes:
textbox.change(
_ingredient_textboxes_changed,
inputs=ingredient_textboxes,
outputs=[confirm_next_button],
api_name=False,
)
textbox.input(
_reset_generation_outputs_if_pantry_changed,
inputs=[confirmed_pantry_state] + ingredient_textboxes,
outputs=[status] + cook_reset_outputs,
api_name=False,
)
for tune_control in [
cuisine,
headcount,
time_cap,
healthier,
kid_notes,
kid_note_presets,
]:
tune_control.change(
_reset_generation_outputs_if_dinner_generated,
inputs=[dinners_state, confirmed_pantry_state],
outputs=[status] + cook_reset_outputs + [tune_next_button],
api_name=False,
)
kid_notes.input(
_reset_generation_outputs_if_dinner_generated,
inputs=[dinners_state, confirmed_pantry_state],
outputs=[status] + cook_reset_outputs + [tune_next_button],
api_name=False,
)
make_dinner_button.click(
_make_dinner_loading,
inputs=None,
outputs=[
status,
dinners_html,
make_dinner_button,
made_this_panel,
dinner_choice,
plate_photo,
made_this_button,
made_this_status,
dinners_state,
confirmed_pantry_state,
],
api_name=False,
).then(
_make_dinner_clicked,
inputs=[
cuisine,
headcount,
time_cap,
healthier,
kid_notes,
kid_note_presets,
]
+ ingredient_textboxes,
outputs=[
status,
dinners_html,
make_dinner_button,
made_this_panel,
dinner_choice,
plate_photo,
made_this_button,
made_this_status,
dinners_state,
confirmed_pantry_state,
],
api_name=False,
).then(
_reveal_chat_panel,
inputs=[dinners_state],
outputs=[chat_panel, chatbot, chat_input, ask_button],
api_name=False,
)
# Ask loop: button click and Enter-in-textbox share the same two-step
# loading + final chain. Arities (2/3 and 5/2) collide with none of the
# six locked harness fingerprints.
for ask_trigger in (ask_button.click, chat_input.submit):
ask_trigger(
_ask_loading,
inputs=[chatbot, chat_input],
outputs=[chatbot, chat_input, ask_button],
api_name=False,
).then(
_ask_final,
inputs=[
chatbot,
dinners_state,
confirmed_pantry_state,
kid_note_presets,
kid_notes,
],
outputs=[chatbot, ask_button],
api_name=False,
)
made_this_button.click(
_made_this_loading,
inputs=None,
outputs=[made_this_status, made_this_button],
api_name=False,
).then(
_made_this_clicked,
inputs=[
dinner_choice,
plate_photo,
dinners_state,
confirmed_pantry_state,
],
outputs=[made_this_status, made_this_button],
api_name=False,
)
# Pure navigation between steps. These carry no backend work — each just
# reveals one step and repaints the progress rail. (The Snap → Confirm
# and Cook results advances are folded into _extract_clicked / the cook
# step itself, so they aren't here.)
back_to_snap_button.click(
lambda: _show_step_and_clear_status(1),
inputs=None,
outputs=nav_status_outputs,
api_name=False,
)
confirm_next_button.click(
_confirm_next_clicked,
inputs=ingredient_textboxes,
outputs=nav_status_outputs,
api_name=False,
)
back_to_confirm_button.click(
lambda: _show_step_and_clear_status(2),
inputs=None,
outputs=nav_status_outputs,
api_name=False,
)
tune_next_button.click(
lambda: _show_step_and_clear_status(4),
inputs=None,
outputs=nav_status_outputs,
api_name=False,
)
back_to_tune_button.click(
lambda: _show_step_and_clear_status(3),
inputs=None,
outputs=nav_status_outputs,
api_name=False,
)
start_over_button.click(
lambda: _show_step_and_clear_status(1),
inputs=None,
outputs=nav_status_outputs,
api_name=False,
).then(
_reset_cook_outputs,
inputs=None,
outputs=cook_reset_outputs,
api_name=False,
)
return demo
demo = build_app()
if __name__ == "__main__":
demo.launch(show_api=False)