Spaces:
Running on Zero
Running on Zero
| """FrogQuest — plain Gradio (gr.Blocks) app, single-page master-detail layout. | |
| Layout (one page, no onboarding gate): | |
| LEFT — hero photo (click to upload/change), hero stats, world picker + reset | |
| CENTER — the selected quest's scene image, its description, and Done / Couldn't actions | |
| RIGHT — the quest log: a selectable list of quests (selected one highlighted orange) | |
| BOTTOM — the "Frog Master" chat: one box that forges quests, adds tasks, and marks quests | |
| done / couldn't (the LLM classifies each message into an intent — see llm.route_intent) | |
| State lives in gr.BrowserState (per-browser localStorage): the resized photo (base64), the | |
| chosen world/theme, the validated adventure/quests JSON, the selected quest id, and a cache of | |
| generated scene images (JPEG data-urls). The SAME full state is also written through to a | |
| per-user JSON file server-side (store.py), keyed by a random "Hero Code" uid that lives in | |
| BrowserState — pasting the code on a new/wiped browser restores everything. No login. | |
| Images generate LAZILY: selecting a quest with no cached scene generates it on the spot; Done / | |
| Couldn't EDIT the cached initial scene into a success / failure state (never regenerate). | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import html | |
| import io | |
| import os | |
| import traceback | |
| import uuid | |
| # Speed up all HF downloads (Nemotron GGUF + FLUX weights). Must precede any huggingface_hub | |
| # import (diffusers/llama pull it in). hf-transfer is in requirements.txt. | |
| os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") | |
| import gradio as gr | |
| import spaces | |
| from PIL import Image | |
| from schema import MAX_CAMPAIGNS, THEMES, merge_quests, validate_and_clamp, validate_campaign | |
| import store | |
| # ZeroGPU scans for a @spaces.GPU function at startup; this guarantees detection regardless | |
| # of whether the heavy model modules import cleanly. | |
| def _warmup(): | |
| return "ok" | |
| # Defensive import: never let a model-import error hang the whole app. | |
| MODEL_IMPORT_ERROR = None | |
| try: | |
| from llm import generate_campaign_raw, generate_quests_raw, route_intent | |
| from images import edit_image, initial_image, initial_images, pil_to_data_url | |
| except Exception: | |
| MODEL_IMPORT_ERROR = traceback.format_exc() | |
| # ----------------------------- photo / image helpers (PIL only) ----------------------------- | |
| PHOTO_MAX_SIDE = 512 | |
| IMAGES_CAP_BYTES = 3_500_000 # soft cap on the cached-scene blob to stay under localStorage ~5MB | |
| def _resize_dataurl(pil: Image.Image, max_side: int = PHOTO_MAX_SIDE) -> str: | |
| img = pil.convert("RGB") | |
| w, h = img.size | |
| scale = min(1.0, max_side / max(w, h)) | |
| if scale < 1.0: | |
| img = img.resize((round(w * scale), round(h * scale))) | |
| buf = io.BytesIO() | |
| img.save(buf, format="JPEG", quality=80) | |
| return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode("ascii") | |
| def _dataurl_to_pil(data_url: str) -> Image.Image: | |
| s = data_url.split(",", 1)[1] if "," in data_url else data_url | |
| return Image.open(io.BytesIO(base64.b64decode(s))).convert("RGB") | |
| def _import_error_message() -> str: | |
| last = (MODEL_IMPORT_ERROR or "").strip().splitlines() | |
| return "Model failed to load: " + (last[-1] if last else "unknown error") | |
| def _trim_images(images: dict, keep_id) -> dict: | |
| """Bound the cached-scene blob so a BrowserState write stays under localStorage's ~5MB cap. | |
| Drops oldest non-selected `initial` variants first (keeping success/failure = completed | |
| progress), then whole entries if still over. Mutates and returns `images`.""" | |
| def total() -> int: | |
| return sum(len(v) for c in images.values() for v in c.values()) | |
| if total() <= IMAGES_CAP_BYTES: | |
| return images | |
| for qid in list(images.keys()): | |
| if total() <= IMAGES_CAP_BYTES: | |
| break | |
| if qid == keep_id: | |
| continue | |
| c = images[qid] | |
| if "initial" in c and ("success" in c or "failure" in c): | |
| del c["initial"] | |
| for qid in list(images.keys()): | |
| if total() <= IMAGES_CAP_BYTES: | |
| break | |
| if qid != keep_id: | |
| del images[qid] | |
| return images | |
| # ----------------------------- state plumbing ----------------------------- | |
| def _default_state() -> dict: | |
| return {"uid": None, "photo": None, "theme": "fantasy", "adventure": None, | |
| "quests": [], "campaigns": [], "selected_id": None, "images": {}, | |
| "stats": {"xp": 0, "done": 0, "retreats": 0}} | |
| def _merge(browser: dict | None, **changes) -> dict: | |
| """Return the browser dict with `changes` applied (other keys preserved for persistence).""" | |
| b = dict(browser or _default_state()) | |
| b.update(changes) | |
| return b | |
| def _find(quests: list, qid) -> dict | None: | |
| return next((q for q in (quests or []) if q.get("id") == qid), None) | |
| def _art_style(theme: str) -> str: | |
| return f"8-bit / 16-bit retro pixel art, {theme} palette, NES RPG style" | |
| def _world_for(quest: dict | None, adventure: dict | None, campaigns: list | None) -> dict | None: | |
| """The art world (title/art_style/seed) a quest renders in: its campaign's own cohesive world | |
| if it belongs to one, else the day adventure's. None when neither exists yet.""" | |
| cid = (quest or {}).get("campaign_id") | |
| if cid: | |
| c = next((c for c in (campaigns or []) if c.get("id") == cid), None) | |
| if c: | |
| return {"title": c.get("title", ""), "art_style": c.get("art_style", _art_style("fantasy")), | |
| "seed": c.get("seed", 0)} | |
| return adventure | |
| # ----------------------------- HTML builders ----------------------------- | |
| def _esc(s) -> str: | |
| return html.escape(str(s or "")) | |
| def _badges_html(q: dict) -> str: | |
| badges = [] | |
| if q.get("is_frog"): | |
| badges.append('<span class="badge frog-badge">🐸 THE FROG</span>') | |
| if q.get("type") == "bonus": | |
| badges.append('<span class="badge bonus-badge">✦ BONUS · OPTIONAL</span>') | |
| if q.get("campaign_id"): | |
| badges.append('<span class="badge group-badge">⚑ CAMPAIGN</span>') | |
| if q.get("goal_group"): | |
| badges.append(f'<span class="badge group-badge">⛓ {_esc(q["goal_group"])}</span>') | |
| return f'<div class="quest-badges">{"".join(badges)}</div>' if badges else "" | |
| def _desc_html(quest: dict | None, adventure: dict | None, hint: str | None = None) -> str: | |
| adv = "" | |
| if adventure and adventure.get("title"): | |
| adv = f'<div class="adventure-header"><h2>{_esc(adventure["title"])}</h2></div>' | |
| if not quest: | |
| body = ('<div class="fq-empty">No quest selected yet.<br>' | |
| 'Tell the Frog Master your plans below to forge your quest log.</div>') | |
| return f'<div class="fq-desc">{adv}{body}</div>' | |
| status = quest.get("status", "active") | |
| state_cls = {"success": " state-success", "failure": " state-failure"}.get(status, "") | |
| parts = [ | |
| adv, | |
| _badges_html(quest), | |
| f'<h3 class="quest-title">{_esc(quest.get("quest_title"))}</h3>', | |
| f'<p class="quest-narrative">{_esc(quest.get("narrative"))}</p>', | |
| f'<p class="quest-task">{_esc(quest.get("task"))}</p>', | |
| f'<div class="quest-foot"><span class="xp">{int(quest.get("xp", 0))} XP</span></div>', | |
| ] | |
| if status in ("success", "failure") and quest.get("result_msg"): | |
| parts.append(f'<p class="result-msg">{_esc(quest["result_msg"])}</p>') | |
| elif hint: | |
| parts.append(f'<p class="result-msg">{_esc(hint)}</p>') | |
| return f'<div class="fq-desc{state_cls}">{"".join(parts)}</div>' | |
| def _seed_stats(quests: list | None) -> dict: | |
| """One-time migration: derive lifetime counters from an old record's quest list.""" | |
| quests = quests or [] | |
| return { | |
| "done": sum(1 for q in quests if q.get("status") == "success"), | |
| "retreats": sum(1 for q in quests if q.get("status") == "failure"), | |
| "xp": sum(int(q.get("xp", 0)) for q in quests if q.get("status") == "success"), | |
| } | |
| def _stats_html(quests: list, stats: dict | None = None) -> str: | |
| """Hero stats = LIFETIME counters (stored in state; survive quest removal and day re-forges) | |
| plus a live count of currently active quests.""" | |
| s = stats or {"xp": 0, "done": 0, "retreats": 0} | |
| active = sum(1 for q in (quests or []) if q.get("status") == "active") | |
| return ( | |
| '<div class="fq-stats"><h4>HERO STATS</h4>' | |
| f'<div class="stat-row"><span>QUESTS DONE</span><b>{int(s.get("done", 0))}</b></div>' | |
| f'<div class="stat-row"><span>XP EARNED</span><b>{int(s.get("xp", 0))}</b></div>' | |
| f'<div class="stat-row"><span>RETREATS</span><b>{int(s.get("retreats", 0))}</b></div>' | |
| f'<div class="stat-row"><span>ACTIVE</span><b>{active}</b></div>' | |
| '</div>' | |
| ) | |
| # ----------------------------- core actions (reused by clicks + chat) ----------------------------- | |
| def select_quest(qid, photo, adventure, images, quests, campaigns, browser): | |
| """Select a quest: show its cached scene, or lazily generate the initial scene on first view. | |
| Campaign quests render in their campaign's world. Returns (selected_id, scene_pil_or_None, | |
| desc_html, images, browser).""" | |
| images = dict(images or {}) | |
| quest = _find(quests, qid) | |
| if quest is None: | |
| return (qid, None, _desc_html(None, adventure), images, | |
| _persist_browser(_merge(browser, selected_id=qid))) | |
| world = _world_for(quest, adventure, campaigns) | |
| cache = dict(images.get(qid) or {}) | |
| want = quest.get("image_state", "initial") | |
| data = cache.get(want) or cache.get("initial") | |
| scene = None | |
| hint = None | |
| if data is not None: | |
| scene = _dataurl_to_pil(data) | |
| else: | |
| if MODEL_IMPORT_ERROR: | |
| raise gr.Error(_import_error_message()) | |
| if not world: | |
| raise gr.Error("Forge a quest log first — tell the Frog Master your plans.") | |
| if not photo: | |
| hint = "Upload your photo (left) to draw this scene." | |
| else: | |
| pil = initial_image(_dataurl_to_pil(photo), world["art_style"], | |
| quest.get("initial_image_prompt", ""), int(world["seed"])) | |
| cache["initial"] = pil_to_data_url(pil) | |
| images[qid] = cache | |
| images = _trim_images(images, qid) | |
| scene = pil | |
| return (qid, scene, _desc_html(quest, world or adventure, hint), images, | |
| _persist_browser(_merge(browser, selected_id=qid, images=images))) | |
| def _apply_result(qid, quests, adventure, photo, images, campaigns, browser, kind, reason): | |
| """Mark a quest success/failure: EDIT its initial scene into the after-state and update text. | |
| Returns (quests, images, scene_pil, desc_html, stats_html, browser).""" | |
| if MODEL_IMPORT_ERROR: | |
| raise gr.Error(_import_error_message()) | |
| quests = [dict(q) for q in (quests or [])] | |
| quest = _find(quests, qid) | |
| if quest is None: | |
| raise gr.Error("Select a quest first.") | |
| world = _world_for(quest, adventure, campaigns) | |
| if not world: | |
| raise gr.Error("Forge a quest log first.") | |
| images = dict(images or {}) | |
| cache = dict(images.get(qid) or {}) | |
| if "initial" in cache: | |
| init_pil = _dataurl_to_pil(cache["initial"]) | |
| else: | |
| if not photo: | |
| raise gr.Error("Upload your photo (left) and view the scene first.") | |
| init_pil = initial_image(_dataurl_to_pil(photo), world["art_style"], | |
| quest.get("initial_image_prompt", ""), int(world["seed"])) | |
| cache["initial"] = pil_to_data_url(init_pil) | |
| instruction = reason or (quest["success_edit"] if kind == "success" else quest["failure_edit"]) | |
| edited = edit_image(init_pil, instruction, world["art_style"], int(world["seed"])) | |
| cache[kind] = pil_to_data_url(edited) | |
| images[qid] = cache | |
| images = _trim_images(images, qid) | |
| # Bank LIFETIME stats — but only on the FIRST resolution of this quest (a re-mark of an | |
| # already-finished quest must not double-count XP or retreats). | |
| stats = dict((browser or {}).get("stats") or {"xp": 0, "done": 0, "retreats": 0}) | |
| if quest.get("status", "active") == "active": | |
| if kind == "success": | |
| stats["done"] = int(stats.get("done", 0)) + 1 | |
| stats["xp"] = int(stats.get("xp", 0)) + int(quest.get("xp", 0)) | |
| else: | |
| stats["retreats"] = int(stats.get("retreats", 0)) + 1 | |
| quest["status"] = kind | |
| quest["image_state"] = kind | |
| if kind == "success": | |
| quest["result_msg"] = f"⚔ VICTORY! +{quest['xp']} XP — {quest['quest_title']} conquered." | |
| else: | |
| msg = "🌙 Retreat for now — you'll face it another day. No shame in resting." | |
| if reason: | |
| msg = f"{reason.strip()} · {msg}" | |
| quest["result_msg"] = msg | |
| desc = _desc_html(quest, world) | |
| return (quests, images, edited, desc, _stats_html(quests, stats), | |
| _merge(browser, quests=quests, images=images, stats=stats)) | |
| def apply_done(qid, quests, adventure, photo, images, campaigns, browser): | |
| if qid is None: | |
| raise gr.Error("Select a quest first.") | |
| return _apply_result(qid, quests, adventure, photo, images, campaigns, browser, "success", None) | |
| def apply_couldnt(qid, quests, adventure, photo, images, campaigns, browser): | |
| if qid is None: | |
| raise gr.Error("Select a quest first.") | |
| return _apply_result(qid, quests, adventure, photo, images, campaigns, browser, "failure", None) | |
| def remove_quest(qid, quests, adventure, images, selected_id, browser): | |
| """Drop a finished (done/failed) quest from the log and its cached scenes. If it was the | |
| selected quest, repoint selection to the first remaining quest (no regeneration). | |
| Returns (quests, images, selected_id, scene_pil, desc_html, stats_html, browser).""" | |
| quests = [q for q in (quests or []) if q.get("id") != qid] | |
| images = dict(images or {}) | |
| images.pop(qid, None) | |
| if selected_id == qid: | |
| selected_id = quests[0]["id"] if quests else None | |
| quest = _find(quests, selected_id) | |
| scene = None | |
| if quest: | |
| cache = images.get(quest["id"]) or {} | |
| data = cache.get(quest.get("image_state", "initial")) or cache.get("initial") | |
| if data: | |
| scene = _dataurl_to_pil(data) | |
| return (quests, images, selected_id, scene, _desc_html(quest, adventure), | |
| _stats_html(quests, (browser or {}).get("stats")), | |
| _persist_browser(_merge(browser, quests=quests, images=images, selected_id=selected_id))) | |
| # ----------------------------- chat (Frog Master, full intent routing) ----------------------------- | |
| def _chat_context(quests: list, selected_id) -> str: | |
| if not quests: | |
| return "No quest log exists yet." | |
| lines = [] | |
| for i, q in enumerate(quests): | |
| sel = " | SELECTED" if q.get("id") == selected_id else "" | |
| lines.append(f"{i + 1}. id={q.get('id')} | title={q.get('quest_title')} | " | |
| f"task={q.get('task')} | status={q.get('status')}{sel}") | |
| return "A quest log already exists:\n" + "\n".join(lines) | |
| def _resolve_target(quests: list, target: str, selected_id): | |
| t = (target or "").strip().lower() | |
| if t: | |
| for q in quests: | |
| if t in (str(q.get("id", "")).lower(), q.get("quest_title", "").lower(), | |
| q.get("task", "").lower()): | |
| return q["id"] | |
| for q in quests: # substring fallback | |
| if t in q.get("quest_title", "").lower() or t in q.get("task", "").lower(): | |
| return q["id"] | |
| return selected_id | |
| def _batch_initials(photo, art_style, seed, quests, images, keep_id): | |
| """ONE batched GPU call generating every quest's initial scene into the images cache. | |
| Mutates and returns `images`. No-op without a photo (lazy/hint path takes over).""" | |
| if not photo or not quests: | |
| return images | |
| pils = initial_images(_dataurl_to_pil(photo), art_style, | |
| [q.get("initial_image_prompt", "") for q in quests], int(seed)) | |
| for q, pil in zip(quests, pils): | |
| images.setdefault(q["id"], {})["initial"] = pil_to_data_url(pil) | |
| return _trim_images(images, keep_id) | |
| def _do_forge(message, theme, photo, quests_old, images_old, campaigns, browser): | |
| """Forge a fresh DAY log. Campaign quests (and their cached scenes) survive the re-forge — | |
| only standalone day quests are replaced. With a photo present, ALL day scenes are generated | |
| eagerly in one batched GPU call (clicking quests afterwards is instant).""" | |
| raw = generate_quests_raw(message, theme) | |
| adv = validate_and_clamp(raw, theme) | |
| day_quests, adventure = adv["quests"], adv["adventure"] | |
| keep = [q for q in (quests_old or []) if q.get("campaign_id")] | |
| keep_ids = {q["id"] for q in keep} | |
| for q in day_quests: # avoid id collisions with the kept campaign quests | |
| while q["id"] in keep_ids: | |
| q["id"] += "-d" | |
| quests = day_quests + keep | |
| images = {k: v for k, v in (images_old or {}).items() if k in keep_ids} | |
| frog_id = day_quests[0]["id"] | |
| images = _batch_initials(photo, adventure["art_style"], adventure["seed"], | |
| day_quests, images, frog_id) | |
| # Cache is warm now (or photo absent) -> this just selects the frog; no extra GPU call. | |
| _sel, scene, desc, images2, _b = select_quest(frog_id, photo, adventure, images, quests, | |
| campaigns, browser) | |
| browser2 = _merge(browser, theme=theme, adventure=adventure, quests=quests, | |
| selected_id=frog_id, images=images2) | |
| return (quests, adventure, frog_id, images2, scene, desc, | |
| _stats_html(quests, (browser or {}).get("stats")), browser2) | |
| # chat_send outputs (order): chat_input, quests_state, adventure_state, selected_id_state, | |
| # images_state, scene_image, desc_html, stats_html, browser | |
| def chat_send(message, quests, adventure, theme, photo, selected_id, images, campaigns, browser): | |
| message = (message or "").strip() | |
| nochange = (gr.update(),) * 8 # everything except the cleared input | |
| if not message: | |
| return ("",) + nochange | |
| if MODEL_IMPORT_ERROR: | |
| raise gr.Error(_import_error_message()) | |
| theme = theme if theme in THEMES else "fantasy" | |
| # Skip the (GPU) intent classifier when there's no log yet — the only sensible action is to | |
| # forge one. Saves a whole GPU reservation on the most common first message. | |
| if not quests: | |
| intent, kind = {}, "forge" | |
| else: | |
| intent = route_intent(message, _chat_context(quests, selected_id)) | |
| kind = intent.get("intent", "unknown") | |
| if kind == "forge": | |
| q, a, fid, im, sc, de, st, b2 = _do_forge(message, theme, photo, quests, images, | |
| campaigns, browser) | |
| return ("", q, a, fid, im, sc, de, st, b2) | |
| if kind == "add_tasks": | |
| raw = generate_quests_raw(message, theme) | |
| quests2 = merge_quests(quests, raw, theme) | |
| gr.Info(f"Added {len(quests2) - len(quests)} quest(s) to your log.") | |
| return ("", quests2, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), | |
| _stats_html(quests2, (browser or {}).get("stats")), _merge(browser, quests=quests2)) | |
| if kind in ("mark_done", "mark_couldnt"): | |
| qid = _resolve_target(quests, intent.get("target_task", ""), selected_id) | |
| if qid is None: | |
| gr.Info("Which quest? Select it on the right, or name it.") | |
| return ("",) + nochange | |
| result_kind = "success" if kind == "mark_done" else "failure" | |
| reason = intent.get("reason") or (message if kind == "mark_couldnt" else None) | |
| q2, im2, sc, de, st, b2 = _apply_result(qid, quests, adventure, photo, images, campaigns, | |
| browser, result_kind, reason) | |
| return ("", q2, gr.update(), qid, im2, sc, de, st, b2) | |
| gr.Info("Frog Master: tell me your plans to forge quests, what you finished, or what you couldn't do.") | |
| return ("",) + nochange | |
| # ----------------------------- other handlers ----------------------------- | |
| def upload_photo(pil, browser): | |
| if pil is None: | |
| return gr.update(), gr.update() | |
| b64 = _resize_dataurl(pil) | |
| return b64, _merge(browser, photo=b64) | |
| # forge_campaign outputs (order): campaign_goal_box, quests_state, campaigns_state, stats_html, | |
| # images_state, selected_id_state, scene_image, desc_html, browser | |
| def forge_campaign(goal, do_research, theme, photo, quests, campaigns, images, browser): | |
| """One long-term goal -> a campaign + its ordered quest chain, appended to the log. With | |
| do_research, ddgs+BS4 snippets ground the plan (research.py; degrades gracefully without it). | |
| With a photo present, ALL the campaign's scenes are generated in one batched GPU call and the | |
| first quest is shown.""" | |
| goal = (goal or "").strip() | |
| nochange = (gr.update(),) * 8 | |
| if not goal: | |
| gr.Info("Name your long-term goal first.") | |
| return (gr.update(),) + nochange | |
| if MODEL_IMPORT_ERROR: | |
| raise gr.Error(_import_error_message()) | |
| campaigns = list(campaigns or []) | |
| if len(campaigns) >= MAX_CAMPAIGNS: | |
| raise gr.Error(f"You already have {MAX_CAMPAIGNS} campaigns — conquer or clear one first.") | |
| theme = theme if theme in THEMES else "fantasy" | |
| snippets, sources = "", [] | |
| if do_research: | |
| try: | |
| from research import research_goal | |
| gr.Info("Scouting the web for your goal…") | |
| r = research_goal(goal) | |
| snippets = "\n".join(f"- {s}" for s in r.get("snippets") or []) | |
| sources = list(r.get("sources") or []) | |
| except Exception: | |
| gr.Info("Web research unavailable — forging from the model's own knowledge.") | |
| raw = generate_campaign_raw(goal, theme, snippets) | |
| result = validate_campaign( | |
| raw, theme, goal, | |
| existing_quest_ids={q.get("id") for q in (quests or [])}, | |
| existing_campaign_ids={c.get("id") for c in campaigns}, | |
| ) | |
| camp = result["campaign"] | |
| camp["sources"] = sources | |
| campaigns2 = campaigns + [camp] | |
| new_quests = result["quests"] | |
| quests2 = list(quests or []) + new_quests | |
| first = new_quests[0] | |
| images2 = _batch_initials(photo, camp["art_style"], camp["seed"], | |
| new_quests, dict(images or {}), first["id"]) | |
| cache = images2.get(first["id"]) or {} | |
| scene = _dataurl_to_pil(cache["initial"]) if cache.get("initial") else None | |
| desc = _desc_html(first, _world_for(first, None, campaigns2), | |
| None if scene else "Upload your photo (left) to draw this scene.") | |
| gr.Info(f"Campaign forged: {camp['title']} — {len(new_quests)} quests.") | |
| return ("", quests2, campaigns2, _stats_html(quests2, (browser or {}).get("stats")), images2, | |
| first["id"], scene, desc, | |
| _merge(browser, quests=quests2, campaigns=campaigns2, images=images2, | |
| selected_id=first["id"])) | |
| def change_theme(theme, adventure, images, browser): | |
| """The world picker drives image generation. Changing it with an adventure present re-themes | |
| art_style and clears the cached scenes so they regenerate in the new world on next select.""" | |
| theme = theme if theme in THEMES else "fantasy" | |
| if adventure and adventure.get("theme") != theme: | |
| adventure = dict(adventure) | |
| adventure["theme"] = theme | |
| adventure["art_style"] = _art_style(theme) | |
| desc = (f'<div class="fq-desc"><p class="fq-empty">World changed to {theme.upper()}. ' | |
| 'Reselect a quest to redraw its scene in the new style.</p></div>') | |
| return adventure, {}, None, desc, _merge(browser, theme=theme, adventure=adventure, images={}) | |
| return gr.update(), gr.update(), gr.update(), gr.update(), _merge(browser, theme=theme) | |
| def reset_all(uid): | |
| d = _default_state() | |
| d["uid"] = uid # reset clears the data, not the identity — the Hero Code stays | |
| return (d, None, None, [], [], None, None, {}, None, | |
| _desc_html(None, None), _stats_html([]), "fantasy") | |
| def persist(uid, browser): | |
| """Write-through the full state to the user's durable server record. Bound via .then(...) on | |
| TOP-LEVEL events only — never on events created inside @gr.render: a render re-run re-registers | |
| those functions under new indices, so a queued .then step 500s with KeyError.""" | |
| store.save(uid, browser) | |
| def _persist_browser(b): | |
| """In-handler persistence for @gr.render-created events (quest select/remove), where a chained | |
| .then(persist) would break (see persist's docstring). The uid travels inside the browser dict.""" | |
| store.save((b or {}).get("uid"), b) | |
| return b | |
| def _hero_code_html(uid, hf_user=None) -> str: | |
| if hf_user: | |
| return (f'<p class="hint">Synced to your Hugging Face account (<b>{_esc(hf_user)}</b>) — ' | |
| 'no code needed. Your quests follow your login on any device. Paste an old Hero ' | |
| 'Code below to import that hero into your account.</p>') | |
| return (f'<p class="hint">Your Hero Code: <b>{_esc(uid)}</b><br>Write it down — paste it on ' | |
| 'any device (or after a cleared browser) to summon this hero back. Or just sign in ' | |
| 'with Hugging Face (top right) and skip the code.</p>') | |
| def _hf_username(profile, request) -> str | None: | |
| """The logged-in HF username, or None. On Spaces with hf_oauth the identity arrives via the | |
| auto-injected gr.OAuthProfile — NOT request.username (that's only set by launch(auth=...)); | |
| reading the wrong one is exactly how cross-device sync silently broke.""" | |
| name = getattr(profile, "username", None) | |
| if not name and request is not None: | |
| name = getattr(request, "username", None) # local-dev mock / password-auth fallback | |
| return name or None | |
| def boot(browser, profile: gr.OAuthProfile | None = None, request: gr.Request = None): | |
| """Restore on page load. Identity precedence: a logged-in HF account ("hf-<username>", durable | |
| and cross-device) > the browser dict's random uid ("Hero Code") > a freshly generated one. | |
| The server-side JSON record for that identity is the source of truth and is mirrored back into | |
| BrowserState; on first sight of an identity the browser's current state migrates into it. | |
| Shows cached scenes only (never generates) so reloads are instant.""" | |
| b = dict(browser or _default_state()) | |
| hf_user = _hf_username(profile, request) | |
| if hf_user: | |
| uid = f"hf-{hf_user}" | |
| else: | |
| uid = b.get("uid") or uuid.uuid4().hex[:12] | |
| if str(uid).startswith("hf-"): | |
| # Leftover account uid after logout: never write anonymous data into the account | |
| # record — mint a fresh anonymous identity carrying the local state. | |
| uid = uuid.uuid4().hex[:12] | |
| saved = store.load(uid) | |
| if saved: | |
| b = saved | |
| b["uid"] = uid | |
| if not saved: | |
| store.save(uid, b) # first visit under this identity: adopt the browser's current state | |
| return _rehydrate(uid, b, hf_user) | |
| def restore_hero(code, browser, profile: gr.OAuthProfile | None = None, request: gr.Request = None): | |
| """Adopt a pasted Hero Code: load that uid's server record and take over this browser's | |
| identity/state with it. When LOGGED IN, the code's data is imported INTO the HF account record | |
| instead — tying it to the login so it follows the user to every device.""" | |
| code = (code or "").strip() | |
| saved = store.load(code) | |
| if not saved: | |
| raise gr.Error("No hero found for that code. Check it and try again.") | |
| hf_user = _hf_username(profile, request) | |
| uid = f"hf-{hf_user}" if hf_user else code | |
| saved["uid"] = uid | |
| if uid != code: | |
| store.save(uid, saved) | |
| gr.Info(f"Hero imported into your HF account ({hf_user}) — it now follows your login.") | |
| return _rehydrate(uid, saved, hf_user) | |
| def _rehydrate(uid, b, hf_user=None): | |
| """Build the full page state tuple (shared by boot and restore_hero).""" | |
| photo = b.get("photo") | |
| quests = b.get("quests") or [] | |
| campaigns = b.get("campaigns") or [] | |
| if "stats" not in b: # migrate pre-stats records once: bank what's currently visible | |
| b["stats"] = _seed_stats(quests) | |
| adventure = b.get("adventure") | |
| images = b.get("images") or {} | |
| theme = b.get("theme") or "fantasy" | |
| quest = _find(quests, b.get("selected_id")) or (quests[0] if quests else None) | |
| selected = quest["id"] if quest else None | |
| scene = None | |
| if quest: | |
| cache = images.get(quest["id"]) or {} | |
| data = cache.get(quest.get("image_state", "initial")) or cache.get("initial") | |
| if data: | |
| scene = _dataurl_to_pil(data) | |
| photo_pil = _dataurl_to_pil(photo) if photo else None | |
| world = _world_for(quest, adventure, campaigns) or adventure | |
| return (uid, _hero_code_html(uid, hf_user), b, photo_pil, photo, quests, campaigns, adventure, | |
| selected, images, scene, _desc_html(quest, world), _stats_html(quests, b.get("stats")), | |
| theme) | |
| # ----------------------------- UI ----------------------------- | |
| _DIR = os.path.dirname(os.path.abspath(__file__)) | |
| with open(os.path.join(_DIR, "theme.css"), "r", encoding="utf-8") as _f: | |
| THEME_CSS = _f.read() | |
| with gr.Blocks(title="FrogQuest") as demo: | |
| browser = gr.BrowserState(_default_state(), storage_key="frogquest") | |
| photo_state = gr.State(None) # resized photo as base64 data URL | |
| quests_state = gr.State([]) # drives the quest-log render | |
| adventure_state = gr.State(None) # title / art_style / seed / theme | |
| selected_id_state = gr.State(None) # currently selected quest id | |
| images_state = gr.State({}) # {quest_id: {initial/success/failure: jpeg data-url}} | |
| campaigns_state = gr.State([]) # long-term goals; each owns a chain of campaign quests | |
| campaign_filter_state = gr.State(None) # campaign id to filter the quest log by (None = all) | |
| uid_state = gr.State(None) # the "Hero Code" uid; generated by boot, lives in BrowserState | |
| with gr.Row(elem_classes=["fq-topbar"]): | |
| gr.HTML('<h1 class="fq-logo">FROG<b>QUEST</b></h1>') | |
| gr.LoginButton(elem_classes=["fq-login"]) | |
| with gr.Row(elem_classes=["fq-app-grid"]): | |
| # ---------- LEFT: hero ---------- | |
| with gr.Column(elem_classes=["fq-left"]): | |
| gr.HTML('<h2 class="panel-title">HERO</h2>') | |
| photo_image = gr.Image( | |
| type="pil", sources=["upload"], show_label=False, height=190, | |
| elem_classes=["fq-photo"], | |
| ) | |
| gr.HTML('<p class="hint">Upload your photo — used only to draw you into your scenes.</p>') | |
| stats_html = gr.HTML(_stats_html([])) | |
| gr.HTML('<h3 class="panel-subtitle">WORLD</h3>') | |
| theme_radio = gr.Radio( | |
| choices=list(THEMES), value="fantasy", show_label=False, | |
| elem_classes=["fq-theme"], | |
| ) | |
| reset_btn = gr.Button("⟲ RESET", elem_classes=["pix-btn", "small"]) | |
| with gr.Accordion("⚿ HERO CODE", open=False, elem_classes=["fq-herocode"]): | |
| herocode_html = gr.HTML("") # filled by boot | |
| restore_box = gr.Textbox(show_label=False, lines=1, elem_classes=["compose"], | |
| placeholder="paste a Hero Code...") | |
| restore_btn = gr.Button("SUMMON HERO", elem_classes=["pix-btn", "small"]) | |
| # ---------- CENTER: selected scene + actions + Frog Master chat ---------- | |
| with gr.Column(elem_classes=["fq-center"]): | |
| scene_image = gr.Image( | |
| show_label=False, interactive=False, container=False, height=380, | |
| elem_classes=["fq-scene"], | |
| ) | |
| with gr.Row(elem_classes=["fq-detail"]): | |
| with gr.Column(scale=3): | |
| desc_html = gr.HTML(_desc_html(None, None)) | |
| with gr.Column(scale=1, min_width=136, elem_classes=["fq-actions"]): | |
| done_btn = gr.Button("✓ DONE", elem_classes=["pix-btn", "btn-done"]) | |
| couldnt_btn = gr.Button("✗ COULDN'T", elem_classes=["pix-btn", "btn-couldnt"]) | |
| with gr.Row(elem_classes=["fq-chatbar"]): | |
| chat_input = gr.Textbox( | |
| show_label=False, lines=2, elem_classes=["compose", "fq-chat-input"], | |
| placeholder="Tell the Frog Master your plans, what you finished, or what you couldn't do...", | |
| ) | |
| send_btn = gr.Button("SEND ▶", elem_classes=["pix-btn", "primary", "fq-chat-send"]) | |
| gr.HTML('<p class="fq-chat-hint">e.g. "Finish the report, reply to emails, book dentist" · ' | |
| '"I finished the report" · "Couldn\'t do the gym — too tired"</p>' | |
| '<p class="fq-chat-hint">Where to Start, Where to Break and let Go,<br>' | |
| 'Where to change <br>Where to look and to grow </p>') | |
| # ---------- RIGHT: campaigns + quest log ---------- | |
| with gr.Column(elem_classes=["fq-right"]): | |
| gr.HTML('<h2 class="panel-title">CAMPAIGNS</h2>') | |
| def render_campaigns(campaigns, quests, cfilter): | |
| if not campaigns: | |
| gr.HTML('<p class="hint">No campaigns yet — forge a long-term goal below.</p>') | |
| return | |
| with gr.Column(elem_classes=["fq-tasklist"]): | |
| for c in campaigns: | |
| cid = c.get("id") | |
| cq = [q for q in (quests or []) if q.get("campaign_id") == cid] | |
| done = sum(1 for q in cq if q.get("status") == "success") | |
| classes = ["fq-task-item"] + (["selected"] if cfilter == cid else []) | |
| cb = gr.Button(f"⚑ {c.get('title', '')} — {done}/{len(cq)}", | |
| elem_classes=classes) | |
| # Click filters the quest log to this campaign; click again to clear. | |
| cb.click((lambda _cid: (lambda cur: None if cur == _cid else _cid))(cid), | |
| inputs=[campaign_filter_state], outputs=[campaign_filter_state]) | |
| if cfilter == cid and c.get("sources"): | |
| links = " · ".join( | |
| f'<a href="{_esc(u)}" target="_blank" rel="noopener">[{i + 1}]</a>' | |
| for i, u in enumerate(c["sources"][:6])) | |
| gr.HTML(f'<p class="hint">researched: {links}</p>') | |
| with gr.Accordion("⚒ NEW CAMPAIGN", open=False): | |
| campaign_goal_box = gr.Textbox( | |
| show_label=False, lines=2, elem_classes=["compose"], | |
| placeholder='A long-term goal, e.g. "score 95th percentile on the GRE"...', | |
| ) | |
| research_check = gr.Checkbox(label="research the web first", value=False) | |
| campaign_btn = gr.Button("FORGE CAMPAIGN", elem_classes=["pix-btn", "primary"]) | |
| gr.HTML('<h2 class="panel-title">QUEST LOG</h2>') | |
| def render_tasklist(quests, selected, campaigns, cfilter): | |
| quests = quests or [] | |
| if cfilter: | |
| quests = [q for q in quests if q.get("campaign_id") == cfilter] | |
| if not quests: | |
| gr.HTML('<p class="hint">No quests yet.<br>Forge them with the Frog ' | |
| 'Master chat below.</p>') | |
| return | |
| # Standalone day quests first, then each campaign's chain under its banner. | |
| titles = {c.get("id"): c.get("title", "") for c in (campaigns or [])} | |
| groups = [(None, [q for q in quests if not q.get("campaign_id")])] | |
| groups += [(c.get("id"), [q for q in quests if q.get("campaign_id") == c.get("id")]) | |
| for c in (campaigns or [])] | |
| with gr.Column(elem_classes=["fq-tasklist"]): | |
| for gid, gquests in groups: | |
| if not gquests: | |
| continue | |
| if gid: | |
| gr.HTML(f'<p class="hint">⚑ {_esc(titles.get(gid, ""))}</p>') | |
| for q in gquests: | |
| classes = ["fq-task-item"] | |
| if q.get("id") == selected: | |
| classes.append("selected") | |
| if q.get("is_frog"): | |
| classes.append("is-frog") | |
| if q.get("status") == "success": | |
| classes.append("done") | |
| elif q.get("status") == "failure": | |
| classes.append("failed") | |
| if q.get("status") == "success": | |
| mark = "✓" | |
| elif q.get("status") == "failure": | |
| mark = "✗" | |
| elif q.get("is_frog"): | |
| mark = "🐸" | |
| elif q.get("type") == "bonus": | |
| mark = "✦" | |
| else: | |
| mark = "•" | |
| finished = q.get("status") in ("success", "failure") | |
| with gr.Row(elem_classes=["fq-task-row"]): | |
| btn = gr.Button(f"{mark} {q.get('quest_title', '')}", elem_classes=classes) | |
| btn.click( | |
| (lambda qid: (lambda photo, adv, imgs, qs, cs, br: | |
| select_quest(qid, photo, adv, imgs, qs, cs, br)))(q["id"]), | |
| inputs=[photo_state, adventure_state, images_state, quests_state, | |
| campaigns_state, browser], | |
| outputs=[selected_id_state, scene_image, desc_html, images_state, browser], | |
| ) # NO .then here: render-internal chains 500 (see persist docstring) | |
| # Clear button appears only once a quest is done/failed, to remove it. | |
| if finished: | |
| clr = gr.Button("✕", elem_classes=["pix-btn", "small", "fq-task-clear"]) | |
| clr.click( | |
| (lambda qid: (lambda qs, adv, imgs, sel, br: | |
| remove_quest(qid, qs, adv, imgs, sel, br)))(q["id"]), | |
| inputs=[quests_state, adventure_state, images_state, selected_id_state, browser], | |
| outputs=[quests_state, images_state, selected_id_state, scene_image, | |
| desc_html, stats_html, browser], | |
| ) # NO .then here: render-internal chains 500 (see persist docstring) | |
| # ---------- wiring ---------- | |
| photo_image.upload(upload_photo, [photo_image, browser], [photo_state, browser] | |
| ).then(persist, [uid_state, browser]) | |
| theme_radio.change( | |
| change_theme, [theme_radio, adventure_state, images_state, browser], | |
| [adventure_state, images_state, scene_image, desc_html, browser], | |
| ).then(persist, [uid_state, browser]) | |
| reset_btn.click( | |
| reset_all, [uid_state], | |
| [browser, photo_image, photo_state, quests_state, campaigns_state, adventure_state, | |
| selected_id_state, images_state, scene_image, desc_html, stats_html, theme_radio], | |
| ).then(persist, [uid_state, browser]) | |
| done_btn.click( | |
| apply_done, [selected_id_state, quests_state, adventure_state, photo_state, images_state, | |
| campaigns_state, browser], | |
| [quests_state, images_state, scene_image, desc_html, stats_html, browser], | |
| ).then(persist, [uid_state, browser]) | |
| couldnt_btn.click( | |
| apply_couldnt, [selected_id_state, quests_state, adventure_state, photo_state, images_state, | |
| campaigns_state, browser], | |
| [quests_state, images_state, scene_image, desc_html, stats_html, browser], | |
| ).then(persist, [uid_state, browser]) | |
| campaign_btn.click( | |
| forge_campaign, [campaign_goal_box, research_check, theme_radio, photo_state, quests_state, | |
| campaigns_state, images_state, browser], | |
| [campaign_goal_box, quests_state, campaigns_state, stats_html, images_state, | |
| selected_id_state, scene_image, desc_html, browser], | |
| ).then(persist, [uid_state, browser]) | |
| _chat_inputs = [chat_input, quests_state, adventure_state, theme_radio, photo_state, | |
| selected_id_state, images_state, campaigns_state, browser] | |
| _chat_outputs = [chat_input, quests_state, adventure_state, selected_id_state, images_state, | |
| scene_image, desc_html, stats_html, browser] | |
| send_btn.click(chat_send, _chat_inputs, _chat_outputs).then(persist, [uid_state, browser]) | |
| chat_input.submit(chat_send, _chat_inputs, _chat_outputs).then(persist, [uid_state, browser]) | |
| _hydrate_outputs = [uid_state, herocode_html, browser, photo_image, photo_state, quests_state, | |
| campaigns_state, adventure_state, selected_id_state, images_state, | |
| scene_image, desc_html, stats_html, theme_radio] | |
| demo.load(boot, [browser], _hydrate_outputs) | |
| restore_btn.click(restore_hero, [restore_box, browser], _hydrate_outputs) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| css=THEME_CSS, | |
| theme=gr.themes.Base(font=[gr.themes.GoogleFont("Press Start 2P"), "monospace"]), | |
| ) | |