| """ |
| Discode — chat your way to a live web app. |
| |
| Left: a slim, collapsible chat rail. Talk to the AI, ask for an app, then ask |
| for changes ("make the snake green", "add a score counter"). |
| Right: the generated app, rendered live with full JavaScript. |
| |
| Theme: Frutiger Aero / skeuomorphic glass. |
| Model: Gemma 4 12B via llama.cpp. |
| Local: start `llama-server -hf ggml-org/gemma-4-12B-it-GGUF:Q4_K_M --jinja -c 4096` |
| before running this app, or install llama-cpp-python so the app can spawn it. |
| Space: the app spawns llama_cpp.server on CPU Basic unless a server is already running. |
| """ |
|
|
| import os |
| import re |
| import sys |
| import time |
| import html as html_lib |
| import subprocess |
|
|
| import gradio as gr |
| import requests |
| from agno.agent import Agent |
| from agno.models.llama_cpp import LlamaCpp |
|
|
| MODEL_REPO = os.environ.get("MODEL_REPO", "ggml-org/gemma-4-12B-it-GGUF") |
| MODEL_FILE = os.environ.get("MODEL_FILE", "gemma-4-12B-it-Q4_K_M.gguf") |
| HOST = os.environ.get("LLAMACPP_HOST", "127.0.0.1") |
| PORT = int(os.environ.get("LLAMACPP_PORT", "8080")) |
| BASE_URL = os.environ.get("LLAMACPP_BASE_URL", f"http://{HOST}:{PORT}/v1") |
| N_CTX = os.environ.get("LLAMACPP_CTX", "4096") |
| N_THREADS = os.environ.get("LLAMACPP_THREADS", "2") |
|
|
| SYSTEM_PROMPT = """You are Discode, a friendly expert front-end engineer who builds and edits ONE single-page web app for the user through conversation. |
| |
| On every turn where the user wants an app or a change: |
| 1. First write ONE short, friendly sentence (what you built or changed). |
| 2. Then output the COMPLETE, updated HTML document inside a single ```html ... ``` code block. |
| |
| The HTML must be: |
| - A full self-contained document: <!DOCTYPE html>, <html>, <head>, <body>. |
| - Inline CSS (in <style>) and JS (in <script>) only — NO external files, CDNs, or network calls. |
| - Polished, responsive, and fully functional, using full JavaScript freely (canvas, Web Audio, localStorage, timers, etc.). |
| |
| When the user asks for a change, MODIFY the current app (it will be given to you) and return the ENTIRE updated document again — never a diff or a partial snippet. |
| |
| If the user is only chatting (greeting, a question) and not asking for an app or change, reply normally with NO code block. |
| """ |
|
|
|
|
| def server_is_up() -> bool: |
| try: |
| return requests.get(f"{BASE_URL}/models", timeout=2).status_code == 200 |
| except requests.exceptions.RequestException: |
| return False |
|
|
|
|
| server_process = None |
| if server_is_up(): |
| print(f"[startup] Found llama.cpp server at {BASE_URL}", flush=True) |
| else: |
| print("[startup] No llama.cpp server found; downloading GGUF and spawning llama_cpp.server ...", flush=True) |
| from huggingface_hub import hf_hub_download |
|
|
| model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE) |
| server_process = subprocess.Popen( |
| [ |
| sys.executable, |
| "-m", |
| "llama_cpp.server", |
| "--model", |
| model_path, |
| "--host", |
| HOST, |
| "--port", |
| str(PORT), |
| "--n_ctx", |
| N_CTX, |
| "--n_threads", |
| N_THREADS, |
| ] |
| ) |
|
|
| print("[startup] Waiting for llama.cpp server ...", flush=True) |
| for _ in range(360): |
| if server_is_up(): |
| print("[startup] llama.cpp server is ready.", flush=True) |
| break |
| time.sleep(1) |
| else: |
| raise RuntimeError("llama.cpp server did not start in time") |
|
|
|
|
| agent = Agent( |
| model=LlamaCpp(id=MODEL_FILE, base_url=BASE_URL, api_key="sk-no-key-needed"), |
| instructions=SYSTEM_PROMPT, |
| markdown=False, |
| debug_mode=True, |
| ) |
|
|
|
|
| |
| def _extract_doc(text: str) -> str: |
| lower = text.lower() |
| start = lower.find("<!doctype") |
| if start == -1: |
| start = lower.find("<html") |
| end = lower.rfind("</html>") |
| if start != -1 and end != -1: |
| return text[start:end + len("</html>")] |
| return text.strip() |
|
|
|
|
| def parse_response(text: str): |
| """Split the model output into (chat_message, html_or_None).""" |
| text = (text or "").strip() |
|
|
| fence = re.search(r"```(?:html)?\s*(.*?)```", text, re.DOTALL | re.IGNORECASE) |
| if fence: |
| html = _extract_doc(fence.group(1).strip()) |
| chat = (text[:fence.start()] + text[fence.end():]).strip() |
| return (chat or "Here's your app! ✨"), html |
|
|
| |
| if "<html" in text.lower() and "</html>" in text.lower(): |
| return "Here's your app! ✨", _extract_doc(text) |
|
|
| |
| return text, None |
|
|
|
|
| def render_iframe(site_html: str) -> str: |
| """Embed the generated site with full JS capability (no restrictive sandbox).""" |
| srcdoc = html_lib.escape(site_html or "", quote=True) |
| return ( |
| f'<iframe srcdoc="{srcdoc}" ' |
| 'allow="autoplay; fullscreen; clipboard-write; gamepad; accelerometer; gyroscope" ' |
| 'class="aero-frame"></iframe>' |
| ) |
|
|
|
|
| WELCOME = """ |
| <div class="aero-welcome"> |
| <div class="bubble"></div> |
| <h2>✨ Your app appears here</h2> |
| <p>Ask me in the chat to build something — a game, a tool, a toy.</p> |
| </div> |
| """ |
|
|
|
|
| |
| def on_send(user_msg, messages, current_html): |
| messages = messages or [] |
| if not user_msg or not user_msg.strip(): |
| return messages, gr.update(), current_html, "" |
|
|
| messages.append({"role": "user", "content": user_msg}) |
|
|
| if current_html: |
| prompt = ( |
| "The current app HTML is:\n```html\n" + current_html + "\n```\n\n" |
| "User request: " + user_msg |
| ) |
| else: |
| prompt = user_msg |
|
|
| started = time.time() |
| result = agent.run(prompt) |
| elapsed = time.time() - started |
| chat_text, new_html = parse_response(result.content) |
| chat_text = f"{chat_text}\n\n_responded in {elapsed:.1f}s via Gemma 4 12B Q4 on llama.cpp_" |
|
|
| messages.append({"role": "assistant", "content": chat_text}) |
|
|
| if new_html: |
| return messages, render_iframe(new_html), new_html, "" |
| return messages, gr.update(), current_html, "" |
|
|
|
|
| def toggle_chat(is_visible): |
| is_visible = not is_visible |
| label = "◀ Hide chat" if is_visible else "Chat ▶" |
| return gr.update(visible=is_visible), label, is_visible |
|
|
|
|
| |
| AERO_CSS = """ |
| .gradio-container { |
| background: linear-gradient(180deg,#5db4e0 0%,#9fe0ef 30%,#cdf3d4 70%,#9bd86f 100%) fixed !important; |
| font-family: 'Segoe UI','Frutiger','Myriad Pro',sans-serif !important; |
| } |
| /* floating bubbles overlay */ |
| .gradio-container::before { |
| content:""; position:fixed; inset:0; pointer-events:none; z-index:0; |
| background: |
| radial-gradient(circle at 12% 80%, rgba(255,255,255,0.5) 0 8px, transparent 9px), |
| radial-gradient(circle at 22% 60%, rgba(255,255,255,0.35) 0 14px, transparent 15px), |
| radial-gradient(circle at 85% 75%, rgba(255,255,255,0.4) 0 20px, transparent 21px), |
| radial-gradient(circle at 70% 30%, rgba(255,255,255,0.3) 0 10px, transparent 11px); |
| } |
| #aero-title { text-align:center; } |
| #aero-title h1 { |
| color:#fff; font-weight:800; letter-spacing:.5px; |
| text-shadow: 0 1px 0 rgba(255,255,255,.5), 0 2px 6px rgba(0,70,110,.6); |
| } |
| /* glassy panels */ |
| #chat-col, #preview-col { |
| background: rgba(255,255,255,0.30) !important; |
| border: 1px solid rgba(255,255,255,0.75) !important; |
| border-radius: 20px !important; |
| box-shadow: 0 10px 34px rgba(0,60,90,0.30), inset 0 1px 0 rgba(255,255,255,0.95) !important; |
| backdrop-filter: blur(14px) saturate(170%); |
| -webkit-backdrop-filter: blur(14px) saturate(170%); |
| padding: 12px !important; |
| } |
| /* glossy buttons */ |
| .aero-btn, button.primary { |
| background: linear-gradient(180deg,#c8f99a 0%,#86d943 47%,#5cb52a 53%,#9ae866 100%) !important; |
| border: 1px solid #4e9c1f !important; |
| border-radius: 13px !important; |
| color: #133f08 !important; font-weight: 700 !important; |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.85), 0 3px 9px rgba(0,0,0,0.22) !important; |
| text-shadow: 0 1px 0 rgba(255,255,255,0.6) !important; |
| } |
| .aero-btn:hover, button.primary:hover { filter: brightness(1.07); } |
| /* inputs / chatbot glassy */ |
| #chat-col textarea, #chat-col input { |
| background: rgba(255,255,255,0.7) !important; |
| border: 1px solid rgba(255,255,255,0.9) !important; |
| border-radius: 12px !important; |
| box-shadow: inset 0 2px 5px rgba(0,60,90,0.15) !important; |
| } |
| #chatbox { background: transparent !important; border: none !important; } |
| /* live preview frame */ |
| .aero-frame { |
| width:100%; height:78vh; border:none; border-radius:14px; background:#fff; |
| box-shadow: inset 0 0 0 1px rgba(255,255,255,.8), 0 6px 18px rgba(0,50,80,.25); |
| } |
| .aero-welcome { |
| height:78vh; display:flex; flex-direction:column; align-items:center; justify-content:center; |
| color:#0a3a52; text-align:center; border-radius:14px; |
| background: linear-gradient(180deg, rgba(255,255,255,.55), rgba(255,255,255,.25)); |
| box-shadow: inset 0 1px 0 rgba(255,255,255,.9); |
| } |
| .aero-welcome h2 { text-shadow: 0 1px 0 rgba(255,255,255,.7); } |
| """ |
|
|
|
|
| |
| with gr.Blocks(css=AERO_CSS, theme=gr.themes.Soft(), title="Discode") as demo: |
| chat_visible = gr.State(True) |
| current_html = gr.State("") |
|
|
| gr.Markdown("# 🏃 Discode", elem_id="aero-title") |
|
|
| with gr.Row(): |
| |
| with gr.Column(scale=2, min_width=280, elem_id="chat-col") as chat_col: |
| chatbot = gr.Chatbot( |
| height="62vh", |
| elem_id="chatbox", |
| show_label=False, |
| avatar_images=(None, None), |
| ) |
| prompt = gr.Textbox( |
| placeholder="Build me a neon snake game…", |
| show_label=False, |
| lines=2, |
| ) |
| send = gr.Button("Send ✨", variant="primary", elem_classes=["aero-btn"]) |
|
|
| |
| with gr.Column(scale=7, elem_id="preview-col"): |
| with gr.Row(): |
| toggle = gr.Button("◀ Hide chat", elem_classes=["aero-btn"], scale=0) |
| preview = gr.HTML(WELCOME) |
|
|
| |
| send.click(on_send, [prompt, chatbot, current_html], [chatbot, preview, current_html, prompt]) |
| prompt.submit(on_send, [prompt, chatbot, current_html], [chatbot, preview, current_html, prompt]) |
| toggle.click(toggle_chat, [chat_visible], [chat_col, toggle, chat_visible]) |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|