Spaces:
Running
Running
| """DAYDREAM โ Press Your Luck, Keep Your Tiger (Gradio command deck). | |
| You take GAMBITS โ safe/bold/reckless โ through dreamlike worlds. CODE rolls the | |
| dice (visibly, seeded); a fleet of small models supplies the voices. Survive on | |
| LUCIDITY, climb PROGRESS to wake with the prize, and watch your companion Hobbes | |
| grow brave (COURAGE) because of the bets you take together. Win or lose, the run | |
| freezes into a shareable Dream Journal card. | |
| Run locally with no backend: DAYDREAM_MOCK=1 python app/app.py | |
| Against Modal endpoints: set MODAL_* env (see .env.example), python app/app.py | |
| """ | |
| import sys | |
| import pathlib | |
| sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) | |
| import concurrent.futures # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| from agents.dream import DreamEngine # noqa: E402 | |
| from agents.world import ENVIRONMENTS, LUCIDITY_START, COURAGE_MAX, TIER_TABLE # noqa: E402 | |
| from agents.vision import dream_image # noqa: E402 | |
| engine = DreamEngine() | |
| # Paint each dream beat off the main thread so the picture renders *under* the | |
| # narration (same overlap trick as the Keeper) โ near-zero added latency per turn. | |
| _IMG_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=2) | |
| _CUR_IMG = None # the in-flight image future โ only the latest beat's image matters | |
| def _submit_image(state, intent, outcome): | |
| """Start painting this beat; cancel any still-pending previous beat so a new | |
| action always supersedes the old image instead of queueing behind it.""" | |
| global _CUR_IMG | |
| if _CUR_IMG is not None: | |
| _CUR_IMG.cancel() # drops it if not yet started; a running one is ignored below | |
| _CUR_IMG = _IMG_POOL.submit(dream_image, state, intent, outcome) | |
| return _CUR_IMG | |
| # Ambient dream music: a static looping track played IN-BROWSER โ zero backend, | |
| # zero latency, zero cold-start. Activates only if a (CC0/royalty-free) file is | |
| # present, so the app never breaks without it. Drop one at assets/dream-ambient.mp3. | |
| import os as _os # noqa: E402 | |
| _ASSETS = pathlib.Path(__file__).resolve().parent.parent / "assets" | |
| MUSIC_PATH = str(_ASSETS / "dream-ambient.mp3") | |
| HAS_MUSIC = _os.path.exists(MUSIC_PATH) | |
| # Pre-generated world hero images shown the INSTANT you Begin โ so the world is | |
| # never empty while the live per-beat image generates. Returned as a PIL image | |
| # (not a path) so gr.Image embeds it directly โ a local filepath would hit | |
| # Gradio's file-serving restrictions on the Space and render blank. Cached per id. | |
| _HERO_CACHE: dict = {} | |
| def _world_hero(env_id: str): | |
| if env_id not in _HERO_CACHE: | |
| p = _ASSETS / "worlds" / f"{env_id}.png" | |
| img = None | |
| if p.exists(): | |
| try: | |
| from PIL import Image | |
| img = Image.open(p).convert("RGB") | |
| except Exception: | |
| img = None | |
| _HERO_CACHE[env_id] = img | |
| return _HERO_CACHE[env_id] | |
| SPEAKER = { | |
| "Dreamweaver": "๐ *Dreamweaver*", | |
| "Nightmare": "๐ *Nightmare*", | |
| "Hobbes": "๐ฏ **Hobbes**", | |
| } | |
| TIER_FACE = {"safe": "๐ข", "bold": "๐ก", "reckless": "๐ด"} | |
| TIER_VARIANT = {"safe": "secondary", "bold": "primary", "reckless": "stop"} | |
| MOOD_FACE = {"timid": "๐", "warming": "๐ผ", "brave": "๐บ"} | |
| # Hobbes' bond made visible turn-to-turn โ the emotional core of "Keep Your Tiger". | |
| MOOD_PHRASE = {"timid": "cowering behind you", | |
| "warming": "finding his courage", | |
| "brave": "brave โ โI've got youโ"} | |
| ENV_CHOICES = [(f"{e.emoji} {e.name}", k) for k, e in ENVIRONMENTS.items()] | |
| NBTN = 3 | |
| def _meter(filled: int, total: int, on: str, off: str = "โฌ") -> str: | |
| n = max(0, min(total, filled)) | |
| return on * n + off * (total - n) | |
| def state_md() -> str: | |
| """A focused HUD: the goal, three meters that matter, Hobbes' mood. Nothing else.""" | |
| s = engine.state | |
| if s is None: | |
| return "### ๐ No dream yet\nPick a world and press **Begin the dream**." | |
| luc = _meter(s.lucidity, LUCIDITY_START, "๐ฅ") | |
| prog = _meter(s.progress // 10, 10, "๐ฆ") | |
| cour = _meter(s.courage, COURAGE_MAX, "๐จ") | |
| parts = [ | |
| f"**๐ฏ {s.mission}**", | |
| "<sub>๐ *Race: fill Progress to wake with the prize โ before Lucidity hits 0.*</sub>", | |
| f"๐ **Progress to goal** {prog} `{s.progress}%`", | |
| f"๐ฉต **Lucidity** {luc} `{s.lucidity}/{LUCIDITY_START}`", | |
| f"{MOOD_FACE[s.mood]} **Hobbes** {cour} *{MOOD_PHRASE[s.mood]}*", | |
| ] | |
| if s.menace: | |
| parts.append(f"๐๏ธ **Nightmare** {'๐ช' * s.menace}" | |
| + (" **โ CLOSE! flee or face it**" if s.nightmare_near else "")) | |
| if s.complete: | |
| parts.append("โจ **You wake with the prize.**") | |
| elif s.lost: | |
| parts.append("๐ค **Lost in the dream.**") | |
| parts.append(f"<sub>๐ {s.location} ยท turn {s.turn} ยท seed `{s.seed}`</sub>") | |
| return "\n\n".join(parts) | |
| def card_md() -> str: | |
| """The shareable Dream Journal โ submission artifact + social post + beat-my-run.""" | |
| s = engine.state | |
| if s is None or not s.over: | |
| return "" | |
| cause = ("woke with the prize ๐" if s.complete | |
| else f"lost in the dream after {s.peak_menace} menace ๐ค") | |
| items = ", ".join(s.inventory) if s.inventory else "nothing but the dream" | |
| scars = ("; ".join(s.scars)) if s.scars else "no scars" | |
| return ( | |
| f"## {s.emoji} DREAM JOURNAL โ {s.env_name}\n" | |
| f"> *โ{engine.farewell()}โ* โ ๐ฏ Hobbes\n\n" | |
| f"- **Outcome:** {cause}\n" | |
| f"- **Turns survived:** {s.turn} โข **Progress:** {s.progress}%\n" | |
| f"- **Peak menace:** {s.peak_menace} โข **Final courage:** {s.courage}/{COURAGE_MAX} ({s.mood})\n" | |
| f"- **Carried:** {items}\n" | |
| f"- **Scars:** {scars}\n\n" | |
| f"๐ฒ *Qwen narrating ยท MiniCPM keeping the world ยท code rolling the dice*\n\n" | |
| f"**Beat my run โ seed `{s.seed}` in {s.emoji} {s.env_name}**" | |
| ) | |
| def build_story_md(history) -> str: | |
| """The player's full dream as a keepsake markdown โ every beat + the journal.""" | |
| s = engine.state | |
| title = f"# ๐ DAYDREAM โ {s.env_name if s else 'a dream'}\n" | |
| meta = (f"*Seed `{s.seed}` ยท {s.turn} turns ยท " | |
| f"dreamed by a fleet of small models*\n\n" if s else "") | |
| out = [title, meta, "---\n"] | |
| for m in (history or []): | |
| c = (m.get("content") or "").strip() | |
| if c: | |
| out.append(c + "\n") | |
| if s and s.over: | |
| out.append("\n---\n\n" + card_md()) | |
| out.append("\n\n*Made with DAYDREAM ยท small models, big dreams ๐*") | |
| return "\n".join(out) | |
| def download_story(history): | |
| """Write the story to a temp .md and hand the path to the DownloadButton.""" | |
| import tempfile | |
| seed = engine.state.seed if engine.state else "dream" | |
| path = pathlib.Path(tempfile.gettempdir()) / f"daydream-{seed}.md" | |
| path.write_text(build_story_md(history), encoding="utf-8") | |
| return str(path) | |
| def _btn_updates(): | |
| ups = [] | |
| for i in range(NBTN): | |
| if i < len(engine.gambits): | |
| label, tier = engine.gambits[i] | |
| spec = TIER_TABLE[tier] | |
| fail = int(spec["fail_chance"] * 100) | |
| reward = int(spec["progress_reward"]) | |
| cost = int(spec["lucidity_cost"]) | |
| ups.append(gr.update(value=f"{TIER_FACE[tier]} {label} ยท {fail}% fail ยท +{reward}/-{cost}", | |
| variant=TIER_VARIANT[tier], visible=True)) | |
| else: | |
| ups.append(gr.update(visible=False)) | |
| return ups | |
| def _hidden_btns(): | |
| return [gr.update(visible=False) for _ in range(NBTN)] | |
| def _card_updates(): | |
| """(card markdown, card visibility, print-button visibility).""" | |
| over = engine.state is not None and engine.state.over | |
| return gr.update(value=card_md(), visible=over), gr.update(visible=over) | |
| def _die_bubble() -> dict: | |
| """Plain-language dice: roll high to win. Shows the bands so it's learnable.""" | |
| o = engine.last_outcome | |
| f = o.fail_threshold | |
| line = {"success": "โจ **Success!**", "partial": "ใฐ **Partial** โ it half-works", | |
| "fail": "๐ฅ **Failed**"}[o.result] | |
| return {"role": "assistant", | |
| "content": (f"๐ฒ You rolled **{o.roll}** / 100 โ {line} \n" | |
| f"<sub>higher is better ยท 1โ{f} fails ยท {f+1}โ{f+20} partial ยท " | |
| f"{f+21}+ wins ยท ({o.tier} gambit)</sub>")} | |
| def _stream_turn(intent, tier, history): | |
| """Shared streaming loop -> [chatbot, state, btn1..N, intent, card, printbtn, dream].""" | |
| history = history or [] | |
| history.append({"role": "user", "content": f"๐งโ๐ {intent}"}) | |
| # Keep the gambit buttons VISIBLE and stable through the whole turn (no flicker). | |
| # Gradio queues clicks, so a tap mid-stream just runs as the next turn. | |
| yield history, state_md(), *_btn_updates(), "", *_card_updates(), gr.update() | |
| current = None | |
| die_shown = False | |
| img_future = None | |
| for speaker, delta in engine.play(intent, tier): | |
| if img_future is None: | |
| # Outcome is decided by now (None on the arrival beat). Paint this beat in | |
| # a thread (cancelling any stale previous beat) so the picture renders | |
| # *under* the prose and a new action always supersedes the old image. | |
| img_future = _submit_image(engine.state, intent, engine.last_outcome) | |
| if not die_shown and engine.last_outcome is not None: | |
| die_shown = True # reveal the seeded roll the instant it's decided | |
| history.append(_die_bubble()) | |
| yield history, gr.update(), *_btn_updates(), "", *_card_updates(), gr.update() | |
| if speaker != current: | |
| current = speaker | |
| history.append({"role": "assistant", "content": SPEAKER.get(speaker, speaker) + ": "}) | |
| history[-1]["content"] += delta | |
| yield history, gr.update(), *_btn_updates(), "", *_card_updates(), gr.update() | |
| # Show the gambit buttons the INSTANT narration + Hobbes are done โ don't make | |
| # the player wait on the slower image. (Buttons in ~8s, not ~17s.) | |
| yield history, state_md(), *_btn_updates(), "", *_card_updates(), gr.update() | |
| # Then let the dream image pop in when it's ready (often already painted under | |
| # the prose; warm FLUX is ~1s). Don't hang the turn on a slow/cold image, and | |
| # only show it if THIS beat is still the latest request โ a newer action wins. | |
| if img_future is not None: | |
| try: | |
| pic = img_future.result(timeout=30) | |
| except Exception: | |
| pic = None | |
| if pic is not None and img_future is _CUR_IMG: | |
| yield history, gr.update(), *_btn_updates(), "", *_card_updates(), gr.update(value=pic, visible=True) | |
| def begin(env_id, seed, history): | |
| engine.start(env_id, seed=(seed or "dream").strip()) | |
| env = ENVIRONMENTS[env_id] | |
| history = [{"role": "assistant", "content": f"{env.emoji} *{env.opening}*"}, | |
| {"role": "assistant", "content": f"๐ฏ **Quest:** {env.mission}"}] | |
| # Show the world's hero image + three gambit options INSTANTLY, so the world is | |
| # never empty and you always know what to do. The live narration, Hobbes' own | |
| # creative labels, and the per-beat image all layer in over the next few seconds. | |
| engine.gambits = engine._make_gambits([]) # default labels, upgraded by Hobbes | |
| hero = _world_hero(env_id) | |
| hero_up = (gr.update(value=hero, visible=True) if hero | |
| else gr.update(value=None, visible=False)) | |
| yield history, state_md(), *_btn_updates(), "", *_card_updates(), hero_up | |
| yield from _stream_turn("(You arrive and take in the scene.)", None, history) | |
| def take_turn(intent, history): | |
| if engine.state is None: | |
| yield history or [], "### ๐ Pick a world and begin first.", *_hidden_btns(), "", *_card_updates(), gr.update() | |
| return | |
| if engine.state.over or not (intent or "").strip(): | |
| yield history or [], state_md(), *_btn_updates(), "", *_card_updates(), gr.update() | |
| return | |
| yield from _stream_turn(intent.strip(), "bold", history) # typed = a bold gamble | |
| def make_choose(i): | |
| def _choose(history): | |
| if engine.state is not None and not engine.state.over and i < len(engine.gambits): | |
| label, tier = engine.gambits[i] | |
| yield from _stream_turn(label, tier, history) | |
| else: | |
| yield history or [], state_md(), *_btn_updates(), "", *_card_updates(), gr.update() | |
| return _choose | |
| CSS = """ | |
| /* --- dream atmosphere: layered nebula + slow drift, pure CSS, no JS --- */ | |
| .gradio-container { | |
| background: | |
| radial-gradient(900px 500px at 15% -10%, rgba(124,92,255,.28), transparent 60%), | |
| radial-gradient(1000px 600px at 85% 0%, rgba(56,120,200,.22), transparent 55%), | |
| radial-gradient(1200px 800px at 50% 120%, rgba(180,80,200,.16), transparent 60%), | |
| #0b0a1c; | |
| background-attachment: fixed; | |
| } | |
| .gradio-container::before { /* drifting star-dust */ | |
| content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .5; z-index: 0; | |
| background-image: | |
| radial-gradient(1.5px 1.5px at 20% 30%, #fff, transparent), | |
| radial-gradient(1.5px 1.5px at 70% 60%, #cdbcff, transparent), | |
| radial-gradient(1px 1px at 40% 80%, #fff, transparent), | |
| radial-gradient(1px 1px at 85% 25%, #bcd4ff, transparent), | |
| radial-gradient(1.5px 1.5px at 55% 15%, #fff, transparent); | |
| background-size: 600px 600px; animation: drift 90s linear infinite; | |
| } | |
| @keyframes drift { from {background-position: 0 0;} to {background-position: 600px 600px;} } | |
| h1 { letter-spacing: .5px; text-shadow: 0 2px 24px rgba(124,92,255,.5); } | |
| #deck {border-radius: 16px; backdrop-filter: blur(2px); | |
| box-shadow: 0 10px 40px rgba(0,0,0,.35), inset 0 0 0 1px rgba(124,92,255,.18);} | |
| #card {border: 1px solid #7c5cff; border-radius: 14px; padding: 8px 16px; | |
| background: rgba(40,30,80,.45);} | |
| /* the dream image is the HERO โ large, glowing, gently breathing */ | |
| #dream {border-radius: 16px; border: 1px solid rgba(124,92,255,.6); overflow: hidden; | |
| box-shadow: 0 12px 50px rgba(124,92,255,.45); animation: breathe 7s ease-in-out infinite;} | |
| #dream img {border-radius: 14px;} | |
| @keyframes breathe { 0%,100% {box-shadow: 0 12px 50px rgba(124,92,255,.35);} | |
| 50% {box-shadow: 0 16px 64px rgba(124,92,255,.6);} } | |
| /* warm-up banner shimmers so the cold-start feels like the dream forming */ | |
| #warmup {border-left: 3px solid #7c5cff; padding: 4px 14px; margin: -4px 0 6px; | |
| border-radius: 10px; font-size: .92em; position: relative; overflow: hidden; | |
| background: linear-gradient(90deg, rgba(124,92,255,.10), rgba(124,92,255,.22), rgba(124,92,255,.10)); | |
| background-size: 200% 100%; animation: shimmer 3.5s ease-in-out infinite;} | |
| @keyframes shimmer { 0% {background-position: 200% 0;} 100% {background-position: -200% 0;} } | |
| .gr-button-primary { box-shadow: 0 6px 24px rgba(124,92,255,.4); } | |
| /* fleet line โ a single subtle credit strip, not a competing banner */ | |
| #fleet {padding: 2px 8px; margin: -4px 0 2px; opacity: .72; text-align: center;} | |
| #fleet sub {font-size: .82em;} | |
| footer {visibility: hidden;} | |
| """ | |
| with gr.Blocks(title="DAYDREAM") as demo: | |
| gr.Markdown( | |
| "# ๐ DAYDREAM\n" | |
| "*A dream you **play** โ chase the quest, keep your tiger brave, press your luck.*" | |
| ) | |
| gr.Markdown( | |
| "<sub>๐ค dreamed by a fleet: ๐๐๐ฏ **Qwen3-30B-A3B** (MoE ยท 3B active) ยท " | |
| "๐บ **MiniCPM-1B** ยท ๐จ **FLUX** โ every model โค32B ยท " | |
| "โณ first turn wakes the GPUs (~1โ2 min), then ~5s each</sub>", | |
| elem_id="fleet", | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| chat = gr.Chatbot(height=440, show_label=False, elem_id="deck") | |
| with gr.Row(): | |
| btns = [gr.Button(visible=False, size="sm") for _ in range(NBTN)] | |
| with gr.Row(): | |
| intent = gr.Textbox( | |
| placeholder="โฆor type your own move โ e.g. โdig where the sand humsโ " | |
| "or โfollow the strange tracksโ (counts as a bold gamble)", | |
| scale=8, show_label=False, autofocus=True) | |
| go = gr.Button("Do it", variant="primary", scale=1) | |
| card = gr.Markdown(visible=False, elem_id="card") | |
| with gr.Row(): | |
| printbtn = gr.Button("๐ธ Freeze the dream card", visible=False) | |
| dlbtn = gr.DownloadButton("๐ฅ Download my story", size="sm") | |
| with gr.Column(scale=2): | |
| # Show the chosen world's hero image immediately (and on selection) so the | |
| # frame is never empty โ even while the first turn cold-starts. | |
| dream_img = gr.Image(value=_world_hero("candy_desert"), label="๐ The dream", | |
| height=380, visible=True, interactive=False, | |
| elem_id="dream", show_label=False) | |
| world = gr.Dropdown(ENV_CHOICES, value="candy_desert", label="World") | |
| seed = gr.Textbox(value="abc123", label="Seed (shareable)") | |
| start = gr.Button("Begin the dream ๐", variant="primary") | |
| panel = gr.Markdown(state_md()) | |
| if HAS_MUSIC: # looping ambient track; play once, it loops the session | |
| gr.Audio(MUSIC_PATH, loop=True, autoplay=False, | |
| label="๐ต Dream music", elem_id="music") | |
| # Show the picked world's hero image immediately on selection (before Begin), | |
| # so the frame previews the world and is never empty. | |
| world.change(lambda env_id: gr.update(value=_world_hero(env_id), visible=True), | |
| world, dream_img) | |
| outs = [chat, panel, *btns, intent, card, printbtn, dream_img] | |
| start.click(begin, [world, seed, chat], outs) | |
| go.click(take_turn, [intent, chat], outs) | |
| intent.submit(take_turn, [intent, chat], outs) | |
| for i, b in enumerate(btns): | |
| b.click(make_choose(i), [chat], outs) | |
| printbtn.click(lambda: gr.update(visible=True), None, card) | |
| dlbtn.click(download_story, [chat], dlbtn) # export the full dream as markdown | |
| if __name__ == "__main__": | |
| demo.launch(theme=gr.themes.Soft(primary_hue="indigo"), css=CSS) | |