| """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 |
|
|
| |
| |
| 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", |
| ] |
|
|
| |
| |
| 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", |
| } |
|
|
| |
| |
| |
| _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() |
|
|
|
|
| |
| |
| |
| |
| 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: |
| |
| |
| 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(): |
| |
| |
| |
| 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 →." |
| ) |
| |
| |
| 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." |
| ) |
| |
| 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's options</h2>" |
| "<p>Pick one, cook it, then log the night with “I made this” 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), |
| {}, |
| [], |
| ) |
|
|
|
|
| |
| |
| |
| |
| CHAT_PENDING_MESSAGE = f"Thinking it over… {LOADING_COLD_START_NOTE}" |
| |
| 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"), |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| 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", |
| |
| 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", |
| |
| 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"]) |
|
|
| |
| 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"], |
| ) |
|
|
| |
| 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"] |
| ) |
| |
| |
| confirm_next_button = gr.Button( |
| "Continue →", interactive=False, elem_classes=["btn-next"] |
| ) |
|
|
| |
| 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"] |
| ) |
|
|
| |
| 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 |
| |
| nav_outputs = [stepper, step_snap, step_confirm, step_tune, step_cook] |
| nav_status_outputs = nav_outputs + [status] |
| |
| |
| |
| |
| 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, |
| ) |
|
|
| |
| |
| |
| 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, |
| ) |
|
|
| |
| |
| |
| |
| 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) |
|
|