from __future__ import annotations import html import os import re # Disable Gradio's Node SSR sidecar BEFORE importing gradio. SSR (a separate Node proxy that # server-side-renders the page) was interfering with the dynamically-injected iframe preview; # pure client-side rendering is reliable for live HTML. On the HF Space THIS env var is what # counts โ€” the SDK launches the `app` object itself, so the launch(ssr_mode=...) at the bottom # only affects local runs. os.environ.setdefault("GRADIO_SSR_MODE", "false") import gradio as gr from instructions.toy_maker import toy_maker # Import the model layer EAGERLY at startup. HF ZeroGPU only detects @spaces.GPU functions # that are registered while the app module is importing โ€” a lazy/in-function import means the # decorated `generate` is never seen at startup ("No @spaces.GPU function detected"). The # try/except keeps app.py importable locally (no torch/llama-cpp/spaces installed); on the # Space the deps exist, the import succeeds, and ZeroGPU registers the GPU function. try: from model import generate as model_generate except Exception: # surfaced in logs below import sys import traceback print("[app] model layer not available โ€” using fallback replies. Reason:", file=sys.stderr) traceback.print_exc(file=sys.stderr) model_generate = None APP_TITLE = "Amazing Digital Pet Dentures โ€” HTML Toy Maker" APP_CSS = "" # How many recent messages to keep (and send to the model). Bounds both the context window # and the ~5 MB localStorage cap (each assistant turn carries a full HTML doc). MAX_MESSAGES = 8 WELCOME_MESSAGE = [ { "role": "assistant", "content": "Hi! I'm the dentures ๐Ÿฆท โ€” describe anything (a game, a widget, a " "visualizer, a clockโ€ฆ) and I'll build it as a live HTML toy. " "Hit ๐Ÿงน New session to start over.", } ] # ---- HTML extraction ------------------------------------------------------------------- _THINK_RE = re.compile(r".*?", re.IGNORECASE | re.DOTALL) def _strip_fences(text: str) -> str: """Remove ``` code fences but keep their contents.""" text = re.sub(r"```[a-zA-Z0-9]*\n?", "", text or "") return text.replace("```", "") def best_html(text: str | None) -> str | None: """Slice out the real HTML document: from the LAST (or ) to the LAST . The real doc is generated AFTER any reasoning, so taking the last opener avoids reasoning that merely *mentions* tags (which produced broken fragments before). """ if not text: return None low = text.lower() start = low.rfind("") if end == -1 or end <= start: return None doc = text[start:end + len("")].strip() return doc if len(doc) >= 120 else None # too-short => a mention, not a document def parse_reply(content: str, reasoning: str) -> tuple[str, str, str | None]: """Split a raw model reply into (thinking, prose, html_doc_or_None).""" content = _strip_fences(_THINK_RE.sub("", content or "")).strip() reasoning = (reasoning or "").strip() doc = best_html(content) if doc: before = content[: content.find(doc)].strip() if reasoning: # Reasoning came cleanly separated, so `before` is just the friendly sentence. thinking, prose = reasoning, (before or "Here's your toy! ๐ŸŽ‰") else: # Reasoning is mixed into content โ€” everything before the doc is thinking. thinking, prose = before, "Here's your toy! ๐ŸŽ‰" return thinking.strip(), (prose.strip() or "Here's your toy! ๐ŸŽ‰"), doc # No complete document this turn. if reasoning: return reasoning, (content or "I couldn't finish that โ€” try again?"), None return content, "I couldn't produce a complete toy that time โ€” try rephrasing?", None def answer_markdown(prose: str, doc: str | None) -> str: """The assistant chat bubble: the friendly line + the full HTML as a code block.""" if doc: return f"{prose}\n\n```html\n{doc}\n```" return prose def iframe_for(raw_html: str) -> str: srcdoc = html.escape(raw_html, quote=True) return ( '' ) def empty_preview_doc() -> str: return ( "" "" "

Your toy will appear here. ๐ŸŽช

" ) def empty_preview() -> str: return iframe_for(empty_preview_doc()) # ---- Model call ------------------------------------------------------------------------ def local_reply(message: str) -> str: """Fallback when the model layer can't be imported/run (e.g. no GPU locally).""" if not (message or "").strip(): return "Tell me what to build โ€” e.g. 'a bouncing ball that follows my mouse'." return ( "I couldn't reach the model. This runs in-process on **ZeroGPU** via " "llama-cpp-python โ€” check that the Space has ZeroGPU enabled and see the logs." ) def run_model(messages: list[dict], user_message: str) -> dict: """Always returns {"content", "reasoning"}.""" if model_generate is None: return {"content": local_reply(user_message), "reasoning": ""} try: result = model_generate(messages) if isinstance(result, dict): return {"content": result.get("content", ""), "reasoning": result.get("reasoning", "")} return {"content": str(result), "reasoning": ""} except Exception as exc: # keep the UI alive; surface the error in chat import sys import traceback traceback.print_exc(file=sys.stderr) return {"content": f"The toy maker hit a snag: {exc}", "reasoning": ""} # ---- History (no Agno; convo lives in a BrowserState) ---------------------------------- def convo_to_history(convo: list[dict]) -> list[dict]: """Rebuild the chatbot from the persisted convo on reload (thinking is live-only).""" history = [{"role": m["role"], "content": m["content"]} for m in convo if m.get("content")] return history or list(WELCOME_MESSAGE) def latest_html(convo: list[dict]) -> str | None: for m in reversed(convo): if m.get("role") == "assistant": doc = best_html(_strip_fences(m.get("content", ""))) if doc: return doc return None # ---- Event handlers -------------------------------------------------------------------- def chat_turn(message: str, history: list[dict] | None, convo: list[dict] | None): history = list(history or []) convo = list(convo or []) msg = (message or "").strip() if not msg: return "", history, convo, gr.update() history.append({"role": "user", "content": msg}) sent = [{"role": "system", "content": toy_maker}] + convo[-MAX_MESSAGES:] sent.append({"role": "user", "content": msg}) reply = run_model(sent, msg) thinking, prose, doc = parse_reply(reply["content"], reply["reasoning"]) # Thinking shown as a SEPARATE collapsible bubble (Gradio's metadata accordion). if thinking: history.append({"role": "assistant", "content": thinking, "metadata": {"title": "๐Ÿง  Thinking"}}) answer = answer_markdown(prose, doc) history.append({"role": "assistant", "content": answer}) # convo (model context + persistence) keeps the answer only โ€” NOT the thinking. convo.append({"role": "user", "content": msg}) convo.append({"role": "assistant", "content": answer}) convo = convo[-MAX_MESSAGES:] # The preview panel is always on; only swap its content when we have a new toy. view = iframe_for(doc) if doc else gr.update() return "", history, convo, view def hydrate(convo: list[dict] | None): """On page load, restore the chat + last toy from the persisted BrowserState.""" convo = list(convo or []) history = convo_to_history(convo) doc = latest_html(convo) return history, (iframe_for(doc) if doc else empty_preview()) def new_session(): """Clear chat + history + preview (panel stays on, showing the empty placeholder).""" return list(WELCOME_MESSAGE), "", [], empty_preview() def build_app() -> gr.Blocks: global APP_CSS APP_CSS = """ :root { --adpd-ink: #171717; --adpd-paper: #fff8df; --adpd-red: #ff4b4b; --adpd-blue: #42b7ff; --adpd-yellow: #ffd84d; --adpd-green: #70e06a; --adpd-purple: #bd7bff; } html, body { margin: 0; } .gradio-container { min-height: 100vh; max-width: 100% !important; padding: 0 !important; background: linear-gradient(45deg, rgba(23,23,23,.06) 25%, transparent 25%) 0 0 / 28px 28px, linear-gradient(-45deg, rgba(23,23,23,.06) 25%, transparent 25%) 0 0 / 28px 28px, var(--adpd-paper); color: var(--adpd-ink); } #adpd-shell { max-width: 100%; margin: 0; padding: 6px; gap: 6px; } #adpd-shell .gap { gap: 6px !important; } #adpd-title { border: 2px solid var(--adpd-ink); border-radius: 6px; background: var(--adpd-yellow); box-shadow: 4px 4px 0 var(--adpd-ink); padding: 6px 14px; margin-bottom: 8px; } #adpd-title h1 { font-size: clamp(1.1rem, 2vw, 1.7rem); line-height: 1.1; margin: 0; color: var(--adpd-ink); } #adpd-title p { font-size: .9rem; margin: 2px 0 0; color: var(--adpd-ink); font-weight: 700; } #chat-panel, #adventure-panel { border: 2px solid var(--adpd-ink); border-radius: 6px; background: white; box-shadow: 4px 4px 0 var(--adpd-ink); padding: 8px; } #adventure-panel { background: var(--adpd-blue); } /* Bounded viewport heights โ€” fit one screen, never grow infinitely. */ #toy-chat { height: 70vh !important; } .adventure-frame { width: 100%; height: 80vh; display: block; border: 2px solid var(--adpd-ink); border-radius: 6px; background: white; } #chat-panel textarea { min-height: 46px !important; } button, select, input, textarea { border-radius: 6px !important; } button { border: 2px solid var(--adpd-ink) !important; box-shadow: 3px 3px 0 var(--adpd-ink) !important; font-weight: 800 !important; } #adventure-toolbar { align-items: center; gap: 8px; margin-bottom: 6px; } #adventure-label h3 { margin: 0; } @media (max-width: 900px) { #toy-chat { height: 50vh !important; } .adventure-frame { height: 60vh; } } """ with gr.Blocks(title=APP_TITLE, fill_width=True) as demo: with gr.Column(elem_id="adpd-shell"): gr.Markdown( "# Amazing Digital Pet Dentures\n" "Describe anything โ€” the dentures build it as a live HTML toy.", elem_id="adpd-title", ) with gr.Row(equal_height=False, elem_id="main-row"): with gr.Column(scale=4, elem_id="chat-col"): with gr.Group(elem_id="chat-panel"): with gr.Row(elem_id="chat-toolbar"): new_session_btn = gr.Button( "๐Ÿงน New session", elem_id="new-session-btn", scale=0, min_width=150, ) chatbot = gr.Chatbot( value=list(WELCOME_MESSAGE), show_label=False, elem_id="toy-chat", height="70vh", ) with gr.Row(elem_id="chat-input-row"): message = gr.Textbox( placeholder="Describe a toy to build...", lines=1, max_lines=6, # no autofocus: it scroll-jumps to the input on load, pushing # the title off the top now that the preview makes the page tall. show_label=False, container=False, scale=8, ) send_button = gr.Button( "Send", variant="primary", scale=1, min_width=110 ) # The preview panel is always on (no open/close) โ€” it just shows the latest toy. with gr.Column(scale=7, elem_id="adventure-col"): with gr.Group(elem_id="adventure-panel"): with gr.Row(elem_id="adventure-toolbar"): gr.Markdown("### ๐ŸŽช Your toy", elem_id="adventure-label") adventure_view = gr.HTML(empty_preview(), elem_id="toy-view") # Persisted in the browser's localStorage: the model-facing conversation (user # turns + assistant answers incl. the HTML, NOT the thinking). Survives reloads; # more durable than the old ephemeral-disk SQLite. Cleared by "New session". convo = gr.BrowserState([], storage_key="adpd_convo") new_session_btn.click( new_session, inputs=None, outputs=[chatbot, message, convo, adventure_view], ) chat_io = dict( fn=chat_turn, inputs=[message, chatbot, convo], outputs=[message, chatbot, convo, adventure_view], ) message.submit(**chat_io) send_button.click(**chat_io) # On page load, restore chat + last toy from the persisted convo. demo.load(hydrate, inputs=[convo], outputs=[chatbot, adventure_view]) return demo app = build_app() if __name__ == "__main__": app.launch(css=APP_CSS, ssr_mode=False)