Spaces:
Running on Zero
Running on Zero
| """Custom HTML components — Off-Brand UI built on top of `gr.HTML`. | |
| Each component subclasses ``gr.HTML`` and ships its own ``html_template`` and | |
| ``css_template``. State is a Python dict; ``render(state)`` returns a complete | |
| ``<style>...</style><div>...</div>`` blob ready to drop into the page. Updates | |
| flow through normal Gradio event callbacks — call ``Component.render(...)`` in | |
| your handler and yield the new HTML string. | |
| Pattern adapted from "Gradio HTML components.md": templates substitute | |
| ``${value.X}`` tokens against the state dict, every component bundles its own | |
| CSS, and components are easy to compose because they're just `gr.HTML` under | |
| the hood. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import json | |
| import re | |
| from pathlib import Path | |
| from typing import Any, ClassVar | |
| import gradio as gr | |
| # --------------------------------------------------------------------------- | |
| # Base class: TemplatedHTML | |
| # --------------------------------------------------------------------------- | |
| TOKEN_RE = re.compile(r"\$\{([^}]+)\}") | |
| def _resolve(expr: str, scope: dict[str, Any]) -> Any: | |
| """Resolve ``value.steps[0].instruction``-style expressions against scope.""" | |
| parts = re.split(r"\.|\[|\]", expr) | |
| parts = [p for p in parts if p != ""] | |
| cur: Any = scope | |
| for p in parts: | |
| if isinstance(cur, dict) and p in cur: | |
| cur = cur[p] | |
| elif isinstance(cur, list) and p.isdigit(): | |
| cur = cur[int(p)] | |
| elif hasattr(cur, p): | |
| cur = getattr(cur, p) | |
| else: | |
| return "" | |
| return cur | |
| def substitute(template: str, state: dict[str, Any]) -> str: | |
| scope = {"value": state} | |
| def _sub(m: re.Match) -> str: | |
| out = _resolve(m.group(1), scope) | |
| if out is None: | |
| return "" | |
| return html.escape(str(out)) if not isinstance(out, str) else html.escape(out) | |
| return TOKEN_RE.sub(_sub, template) | |
| class TemplatedHTML(gr.HTML): | |
| """A gr.HTML wrapper that re-renders a template against a state dict.""" | |
| html_template: ClassVar[str] = "" | |
| css_template: ClassVar[str] = "" | |
| def __init__(self, value: dict[str, Any] | None = None, **kwargs): | |
| self._state: dict[str, Any] = value or {} | |
| super().__init__(value=self.render(self._state), **kwargs) | |
| def render(cls, state: dict[str, Any]) -> str: | |
| body = cls._render_body(state) | |
| return f"<style>{cls.css_template}</style>{body}" | |
| def _render_body(cls, state: dict[str, Any]) -> str: | |
| # Default: simple ${value.X} substitution against the state dict. | |
| return substitute(cls.html_template, state) | |
| # --------------------------------------------------------------------------- | |
| # RecipeHero — title + final-dish image + summary line | |
| # --------------------------------------------------------------------------- | |
| class RecipeHero(TemplatedHTML): | |
| css_template = """ | |
| .cwm-hero { | |
| background: #fffbf0 !important; | |
| border: 1px solid #d8c9ad; | |
| border-radius: 16px; | |
| padding: 32px; | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 28px; | |
| box-shadow: 0 10px 28px rgba(107, 74, 42, 0.10); | |
| } | |
| .cwm-hero img { | |
| width: 100%; height: 320px; object-fit: cover; border-radius: 12px; | |
| background: #efe3c8; | |
| } | |
| .cwm-hero h1 { | |
| font-family: 'Lora', serif; font-size: 38px; color: #6b4a2a !important; | |
| margin: 0 0 8px; | |
| } | |
| .cwm-hero .meta { | |
| color: #8a6a3a !important; font-size: 14px; letter-spacing: 0.04em; | |
| text-transform: uppercase; margin-bottom: 18px; | |
| } | |
| .cwm-hero .visual { | |
| font-family: 'Lora', serif; font-style: italic; color: #6b4a2a !important; | |
| font-size: 17px; line-height: 1.55; | |
| } | |
| @media (max-width: 720px) { .cwm-hero { grid-template-columns: 1fr; } } | |
| """ | |
| def _render_body(cls, state: dict[str, Any]) -> str: | |
| name = html.escape(state.get("name") or "Pick a dish to get started") | |
| cuisine = html.escape(state.get("cuisine") or "") | |
| servings = state.get("servings") or 0 | |
| time = state.get("total_time_minutes") or 0 | |
| visual = html.escape(state.get("final_dish_visual") or "") | |
| img_b64 = state.get("final_dish_image_b64") or "" | |
| img_path = state.get("final_dish_image_path") or "" | |
| if img_b64: | |
| img_tag = f'<img src="data:image/png;base64,{img_b64}" alt="final dish"/>' | |
| elif img_path: | |
| img_tag = f'<img src="/file={html.escape(img_path)}" alt="final dish"/>' | |
| else: | |
| img_tag = '<div style="background:#efe3c8;border-radius:12px;height:320px;display:flex;align-items:center;justify-content:center;color:#8a6a3a;font-family:\'Lora\',serif;font-style:italic;">Image will appear here</div>' | |
| return f""" | |
| <div class="cwm-hero"> | |
| <div>{img_tag}</div> | |
| <div> | |
| <div class="meta">{cuisine} · {servings} servings · {time} min</div> | |
| <h1>{name}</h1> | |
| <p class="visual">{visual}</p> | |
| </div> | |
| </div> | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # IngredientChips — wrap-around chips with "have / missing" tinting | |
| # --------------------------------------------------------------------------- | |
| class IngredientChips(TemplatedHTML): | |
| css_template = """ | |
| .cwm-chips { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0; } | |
| /* Forzamos el color del texto y nos aseguramos de que no se herede un color claro */ | |
| .cwm-chips .cwm-chip { | |
| background: #fbe2d2 !important; | |
| color: #6b4a2a !important; | |
| border: 1px solid #d8c9ad !important; | |
| border-radius: 999px; | |
| padding: 6px 14px; | |
| font-size: 14px; | |
| font-family: 'Inter', sans-serif; | |
| display: inline-block; | |
| } | |
| /* Forzamos el color para los chips de ingredientes faltantes */ | |
| .cwm-chips .cwm-chip.missing { | |
| background: #fbe2d2 !important; | |
| border-color: #c47a52 !important; | |
| } | |
| .cwm-chips-empty { | |
| color: #6b4a2a !important; | |
| font-style: italic; | |
| padding: 14px 0; | |
| } | |
| """ | |
| def _render_body(cls, state: dict[str, Any]) -> str: | |
| have = state.get("have") or [] | |
| missing = state.get("missing") or [] | |
| if not have and not missing: | |
| return '<div class="cwm-chips-empty">No ingredients yet — upload a fridge photo.</div>' | |
| parts = ['<div class="cwm-chips">'] | |
| for ing in have: | |
| parts.append(f'<span class="cwm-chip">{html.escape(str(ing))}</span>') | |
| for ing in missing: | |
| parts.append(f'<span class="cwm-chip missing">missing: {html.escape(str(ing))}</span>') | |
| parts.append('</div>') | |
| return "".join(parts) | |
| # --------------------------------------------------------------------------- | |
| # DishOptions — three picker buttons (rendered as plain HTML; the actual | |
| # selection is handled by a sibling Gradio Radio for state binding) | |
| # --------------------------------------------------------------------------- | |
| class DishOptions(TemplatedHTML): | |
| css_template = """ | |
| .cwm-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } | |
| .cwm-options .cwm-option { | |
| background: #fffbf0 !important; border: 1px solid #d8c9ad; border-radius: 12px; | |
| padding: 18px; text-align: left; | |
| } | |
| .cwm-options .cwm-option h3 { | |
| font-family: 'Lora', serif; font-size: 19px; color: #6b4a2a !important; | |
| margin: 0 0 6px; | |
| } | |
| .cwm-options .cwm-option p { color: #7a5a35 !important; font-size: 14px; line-height: 1.45; margin: 0; } | |
| @media (max-width: 720px) { .cwm-options { grid-template-columns: 1fr; } } | |
| """ | |
| def _render_body(cls, state: dict[str, Any]) -> str: | |
| opts = state.get("options") or [] | |
| if not opts: | |
| return "" | |
| cards = [] | |
| for o in opts[:3]: | |
| name = html.escape(str(o.get("name", ""))) | |
| why = html.escape(str(o.get("why", ""))) | |
| cards.append(f'<div class="cwm-option"><h3>{name}</h3><p>{why}</p></div>') | |
| return f'<div class="cwm-options">{"".join(cards)}</div>' | |
| # --------------------------------------------------------------------------- | |
| # StepCard — one card per step (image + instruction + duration + tip) | |
| # --------------------------------------------------------------------------- | |
| class StepCard(TemplatedHTML): | |
| css_template = """ | |
| .cwm-steps { display: flex; flex-direction: column; gap: 16px; } | |
| .cwm-steps .cwm-step { | |
| display: grid; grid-template-columns: 220px 1fr; gap: 22px; | |
| background: #fffbf0 !important; border-left: 4px solid #a85c2a; border-radius: 10px; | |
| padding: 18px 22px; | |
| } | |
| .cwm-steps .cwm-step img { | |
| width: 220px; height: 160px; object-fit: cover; border-radius: 8px; | |
| background: #efe3c8; | |
| } | |
| .cwm-steps .cwm-step .placeholder { | |
| width: 220px; height: 160px; border-radius: 8px; | |
| background: linear-gradient(135deg,#efe3c8,#dccaa3); | |
| display:flex; align-items:center; justify-content:center; | |
| color: #8a6a3a !important; font-family: 'Lora', serif; font-size: 14px; | |
| } | |
| .cwm-steps .cwm-step h3 { | |
| font-family: 'Lora', serif; color: #6b4a2a !important; margin: 0 0 6px; font-size: 22px; | |
| } | |
| .cwm-steps .cwm-step p { font-size: 16px; line-height: 1.55; color: #4a3722 !important; margin: 0 0 8px; } | |
| .cwm-steps .cwm-step .duration { | |
| display: inline-block; background: #a85c2a !important; color: #fffbf0 !important; | |
| border-radius: 999px; padding: 3px 10px; font-size: 12px; letter-spacing: 0.04em; | |
| } | |
| .cwm-steps .cwm-step .tip { | |
| margin-top: 10px; padding: 10px 12px; background: #fff3d8 !important; | |
| border-radius: 8px; font-size: 14px; color: #6b4a2a !important; | |
| } | |
| .cwm-step .tip::before { content: "💡 "; } | |
| @media (max-width: 720px) { .cwm-step { grid-template-columns: 1fr; } .cwm-step img, .cwm-step .placeholder { width: 100%; } } | |
| """ | |
| def _render_body(cls, state: dict[str, Any]) -> str: | |
| steps = state.get("steps") or [] | |
| if not steps: | |
| return '<div style="color:#b39870;font-style:italic;padding:14px 0;">No steps yet.</div>' | |
| cards = [] | |
| for s in steps: | |
| n = s.get("n", 0) | |
| instr = html.escape(s.get("instruction", "")) | |
| dur = html.escape(s.get("duration", "")) | |
| tip = s.get("tip") | |
| visual = html.escape(s.get("visual", "")) | |
| img_b64 = s.get("image_b64") or "" | |
| img_path = s.get("image_path") or "" | |
| if img_b64: | |
| img_block = f'<img src="data:image/png;base64,{img_b64}" alt="step {n}"/>' | |
| elif img_path: | |
| img_block = f'<img src="/file={html.escape(img_path)}" alt="step {n}"/>' | |
| else: | |
| img_block = f'<div class="placeholder">{visual[:80] if visual else f"Step {n}"}</div>' | |
| tip_block = f'<div class="tip">{html.escape(tip)}</div>' if tip else "" | |
| cards.append(f""" | |
| <div class="cwm-step"> | |
| {img_block} | |
| <div> | |
| <h3>Step {n}</h3> | |
| <p>{instr}</p> | |
| <span class="duration">{dur}</span> | |
| {tip_block} | |
| </div> | |
| </div> | |
| """) | |
| return f'<div class="cwm-steps">{"".join(cards)}</div>' | |
| # --------------------------------------------------------------------------- | |
| # NutritionGrid — five macro cells | |
| # --------------------------------------------------------------------------- | |
| class NutritionGrid(TemplatedHTML): | |
| css_template = """ | |
| .cwm-nutri-wrap { margin-top: 10px; } | |
| .cwm-nutri-title { | |
| font-family: 'Lora', serif; color: #6b4a2a !important; font-size: 22px; margin: 0 0 14px; | |
| } | |
| .cwm-nutri { | |
| display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; | |
| } | |
| .cwm-nutri .cwm-nutri-cell { | |
| background: #fffbf0 !important; border: 1px solid #d8c9ad; border-radius: 10px; | |
| padding: 14px 10px; text-align: center; | |
| } | |
| .cwm-nutri .cwm-nutri-cell .v { | |
| font-family: 'Lora', serif; font-size: 24px; font-weight: 700; color: #6b4a2a !important; | |
| display: block; | |
| } | |
| .cwm-nutri .cwm-nutri-cell .l { | |
| font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; | |
| color: #8a6a3a !important; margin-top: 4px; | |
| } | |
| @media (max-width: 720px) { .cwm-nutri { grid-template-columns: repeat(2, 1fr); } } | |
| """ | |
| def _render_body(cls, state: dict[str, Any]) -> str: | |
| nutri = state.get("nutrition") or {} | |
| cells = [ | |
| (nutri.get("calories", 0), "kcal"), | |
| (nutri.get("protein_g", 0), "protein"), | |
| (nutri.get("carbs_g", 0), "carbs"), | |
| (nutri.get("fat_g", 0), "fat"), | |
| (nutri.get("fiber_g", 0), "fiber"), | |
| ] | |
| cell_html = "".join( | |
| f'<div class="cwm-nutri-cell"><span class="v">{html.escape(str(v))}</span>' | |
| f'<div class="l">{html.escape(label)}</div></div>' | |
| for v, label in cells | |
| ) | |
| return f""" | |
| <div class="cwm-nutri-wrap"> | |
| <h3 class="cwm-nutri-title">Per serving</h3> | |
| <div class="cwm-nutri">{cell_html}</div> | |
| </div> | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # VerdictBadge — go / wait / fix indicator + tip | |
| # --------------------------------------------------------------------------- | |
| class VerdictBadge(TemplatedHTML): | |
| css_template = """ | |
| .cwm-verdict { | |
| display: flex; align-items: center; gap: 18px; | |
| background: #fffbf0 !important; border-radius: 12px; padding: 18px 22px; | |
| border: 1px solid #d8c9ad; | |
| } | |
| .cwm-verdict.go { border-left: 6px solid #4f8b4a; } | |
| .cwm-verdict.wait { border-left: 6px solid #d4a23c; } | |
| .cwm-verdict.fix { border-left: 6px solid #b94a3a; } | |
| .cwm-verdict-pill { | |
| font-family: 'Lora', serif; font-weight: 700; font-size: 16px; | |
| text-transform: uppercase; letter-spacing: 0.08em; | |
| padding: 8px 16px; border-radius: 999px; color: #fffbf0; | |
| } | |
| .cwm-verdict.go .cwm-verdict-pill { background: #4f8b4a; } | |
| .cwm-verdict.wait .cwm-verdict-pill { background: #d4a23c; } | |
| .cwm-verdict.fix .cwm-verdict-pill { background: #b94a3a; } | |
| .cwm-verdict-text { font-size: 16px; color: #4a3722 !important; line-height: 1.5; } | |
| .cwm-verdict-text small { color: #8a6a3a !important; display: block; margin-top: 4px; } | |
| .cwm-verdict-empty { | |
| color: #b39870; font-style: italic; padding: 14px 0; | |
| } | |
| """ | |
| def _render_body(cls, state: dict[str, Any]) -> str: | |
| v = state.get("verdict") | |
| if not v: | |
| return '<div class="cwm-verdict-empty">Upload a progress photo to get a verdict.</div>' | |
| verdict = state.get("verdict", "wait") | |
| feedback = html.escape(state.get("feedback", "")) | |
| tip = state.get("tip") | |
| tip_html = f"<small>{html.escape(tip)}</small>" if tip else "" | |
| return f""" | |
| <div class="cwm-verdict {html.escape(verdict)}"> | |
| <div class="cwm-verdict-pill">{html.escape(verdict)}</div> | |
| <div class="cwm-verdict-text">{feedback}{tip_html}</div> | |
| </div> | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Convenience helpers — ergonomic state-to-HTML for callbacks | |
| # --------------------------------------------------------------------------- | |
| def recipe_to_state(recipe: dict | object) -> dict: | |
| """Normalize a Recipe (Pydantic or dict) into the flat dict every | |
| component reads. Pydantic models are dumped via ``model_dump`` if present.""" | |
| if hasattr(recipe, "model_dump"): | |
| d = recipe.model_dump() | |
| elif isinstance(recipe, dict): | |
| d = recipe | |
| else: | |
| d = json.loads(json.dumps(recipe, default=lambda o: o.__dict__)) | |
| return d |