File size: 18,256 Bytes
125f9c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e3734f
125f9c0
 
 
 
 
 
9700f36
 
 
 
 
 
 
 
 
 
 
125f9c0
23708e2
 
 
 
44d09c8
 
23708e2
 
44d09c8
9700f36
 
 
 
44d09c8
9700f36
 
 
 
 
 
 
 
 
 
 
44d09c8
125f9c0
 
 
 
 
 
 
 
1e3734f
 
 
 
125f9c0
 
 
 
 
 
 
 
 
 
9d08eaf
125f9c0
 
9d08eaf
125f9c0
 
9d08eaf
 
 
00b6404
 
9d08eaf
 
 
00b6404
 
 
125f9c0
9d08eaf
125f9c0
9d08eaf
 
 
125f9c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44d09c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125f9c0
 
 
 
 
1e3734f
 
 
 
 
125f9c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2679669
125f9c0
2679669
 
 
125f9c0
2679669
 
 
125f9c0
 
 
 
 
 
44d09c8
 
 
125f9c0
 
 
 
 
 
 
9700f36
 
 
125f9c0
 
 
44d09c8
125f9c0
 
 
 
44d09c8
125f9c0
536bb64
 
 
 
 
9700f36
 
125f9c0
536bb64
9700f36
536bb64
 
9700f36
536bb64
125f9c0
 
 
 
 
44d09c8
9d08eaf
44d09c8
 
 
 
 
 
 
 
125f9c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8810df6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125f9c0
8810df6
 
 
 
 
 
 
 
 
 
 
 
 
9d08eaf
 
 
125f9c0
 
 
 
 
9d08eaf
 
125f9c0
1e3734f
9d08eaf
 
 
1e3734f
 
125f9c0
 
 
 
 
 
9d08eaf
 
 
 
125f9c0
 
44d09c8
 
 
125f9c0
9d08eaf
 
 
 
 
125f9c0
 
 
 
23708e2
 
 
125f9c0
9d08eaf
 
 
 
 
125f9c0
 
 
 
 
 
 
44d09c8
125f9c0
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
"""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 &nbsp;Β·&nbsp; "
        "⏳ 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)