| """ |
| AI Puzzle Maker — the Gradio app (Hugging Face Space entrypoint). |
| |
| A gr.Blocks page whose entire UI is the custom HTML5 canvas jigsaw game, with |
| same-origin backend routes mounted onto Gradio's FastAPI server: |
| |
| POST /quip -> MiniCPM commentary (live hints, roasts, victory lines) |
| POST /generate -> puzzle generation (MiniCPM theme pack + in-Space FLUX.2-klein art) |
| POST /publish -> share a puzzle to the community gallery |
| GET /puzzles -> gallery listing GET /puzzle/{id} -> full pack |
| POST /like -> like a puzzle |
| GET /times -> per-puzzle solve-time leaderboard POST /times -> submit a solve |
| |
| Run locally: pip install -r requirements.txt gradio && python app.py -> http://localhost:7860 |
| Deploy: push to a Hugging Face Space (SDK: gradio, app_file: app.py) |
| """ |
|
|
| import json |
| import os |
| import re |
| import urllib.request |
|
|
| import gradio as gr |
| from fastapi import FastAPI, Request |
| from fastapi.staticfiles import StaticFiles |
| from starlette.concurrency import run_in_threadpool |
|
|
| import flux_local |
| import community |
| import leaderboard |
| import moderation |
| import prompt_pipeline |
|
|
| HERE = os.path.dirname(os.path.abspath(__file__)) |
| STATIC = os.path.join(HERE, "static") |
|
|
| |
| MINICPM_URL = os.environ.get("MINICPM_URL", "") |
| MINICPM_KEY = os.environ.get("MINICPM_KEY", "") |
| MINICPM_MODEL = "MiniCPM4.1-8B" |
| _THINK = re.compile(r"<think>[\s\S]*?</think>", re.IGNORECASE) |
|
|
|
|
| def minicpm(messages, max_tokens=80, temperature=1.0): |
| if not (MINICPM_URL and MINICPM_KEY): |
| raise RuntimeError("MINICPM_URL/MINICPM_KEY not set — quips fall back to canned lines") |
| body = json.dumps({ |
| "model": MINICPM_MODEL, "messages": messages, |
| "max_tokens": max_tokens, "temperature": temperature, |
| "chat_template_kwargs": {"enable_thinking": False}, |
| }).encode() |
| req = urllib.request.Request( |
| MINICPM_URL, data=body, |
| headers={"Authorization": "Bearer " + MINICPM_KEY, "Content-Type": "application/json"}, |
| ) |
| with urllib.request.urlopen(req, timeout=20) as r: |
| data = json.loads(r.read()) |
| return _THINK.sub("", data["choices"][0]["message"]["content"]).strip() |
|
|
|
|
| def generate_pack(subject): |
| """Subject -> MiniCPM puzzle pack -> in-Space Flux artwork + mascot. Returns: |
| |
| { title, desc, voice, mascot_name, quips, victory, subject, |
| image (jpeg data-URL), thumb (jpeg data-URL), mascot (png data-URL) } |
| |
| `title`/`voice`/`quips` are always returned (themed commentary works everywhere); |
| `image`/`mascot` are filled only where FLUX.2-klein is live (the ZeroGPU Space) — |
| the client falls back to a bundled puzzle when `image` is empty. |
| """ |
| subject = (subject or "a cozy log cabin in snowy mountains at dusk").strip() |
| safe, cats = moderation.check(subject) |
| if not safe: |
| return {"error": f"blocked by content safety ({cats or 'policy violation'}) — try a different idea"} |
| try: |
| pack = prompt_pipeline.write_puzzle_pack(subject) |
| except Exception as e: |
| print(f"theme pack failed ({e}); using enforced defaults") |
| pack = prompt_pipeline._enforce({}, subject) |
| out = {"title": pack["title"], "desc": pack["desc"], "voice": pack["voice"], |
| "mascot_name": pack["mascot_name"], "quips": pack["quips"], |
| "victory": pack["victory"], "subject": subject, |
| "image": "", "thumb": "", "mascot": ""} |
| if flux_local.AVAILABLE: |
| import io |
| import sprites |
| imgs = flux_local.gen_images([(pack["prompts"]["image"], 1024, 768), |
| (pack["prompts"]["mascot"], 512, 512)]) |
| art = sprites.process_artwork(_png_bytes(imgs[0], io)) |
| out["image"] = sprites.to_jpeg_data_url(art) |
| out["thumb"] = sprites.to_jpeg_data_url(sprites.thumb(art), quality=80) |
| try: |
| out["mascot"] = sprites.to_data_url(sprites.process_mascot(_png_bytes(imgs[1], io))) |
| except Exception as e: |
| print(f"mascot processing failed (artwork still fine): {e}") |
| return out |
|
|
|
|
| def _png_bytes(img, io): |
| buf = io.BytesIO() |
| img.save(buf, "PNG") |
| return buf.getvalue() |
|
|
|
|
| |
| api = FastAPI() |
|
|
|
|
| @api.post("/quip") |
| async def quip(req: Request): |
| p = await req.json() |
| try: |
| mt = max(1, min(int(p.get("max_tokens", 80)), 220)) |
| tp = max(0.0, min(float(p.get("temperature", 1.0)), 1.5)) |
| |
| text = await run_in_threadpool(minicpm, p.get("messages", []), mt, tp) |
| return {"text": text} |
| except Exception as e: |
| return {"text": "", "error": str(e)} |
|
|
|
|
| @api.post("/generate") |
| async def generate(req: Request): |
| p = await req.json() |
| try: |
| return await run_in_threadpool(generate_pack, str(p.get("subject", ""))[:120]) |
| except Exception as e: |
| return {"error": str(e)} |
|
|
|
|
| @api.post("/publish") |
| async def publish_post(req: Request): |
| p = await req.json() |
| try: |
| wid = await run_in_threadpool(community.publish, p.get("pack"), p.get("by")) |
| return {"ok": bool(wid), "id": wid} |
| except Exception as e: |
| return {"ok": False, "error": str(e)} |
|
|
|
|
| @api.get("/puzzles") |
| async def puzzles_list(req: Request): |
| try: |
| sort = "top" if req.query_params.get("sort") == "top" else "new" |
| q = req.query_params.get("q", "") |
| return {"enabled": community.ENABLED, "rows": await run_in_threadpool(community.latest, 24, sort, q)} |
| except Exception as e: |
| return {"enabled": False, "rows": [], "error": str(e)} |
|
|
|
|
| @api.get("/puzzle/{wid}") |
| async def puzzle_get(wid: str): |
| try: |
| pack = await run_in_threadpool(community.get, wid) |
| return pack if pack else {"error": "not found"} |
| except Exception as e: |
| return {"error": str(e)} |
|
|
|
|
| @api.post("/like") |
| async def like_post(req: Request): |
| p = await req.json() |
| try: |
| likes = await run_in_threadpool(community.like, p.get("id")) |
| return {"ok": True, "likes": likes} |
| except Exception as e: |
| return {"ok": False, "error": str(e)} |
|
|
|
|
| @api.get("/times") |
| async def times_get(req: Request): |
| try: |
| pid = req.query_params.get("p", "") |
| pieces = int(req.query_params.get("n", "24")) |
| rows = await run_in_threadpool(leaderboard.top, pid, pieces, 20) |
| out = {"enabled": leaderboard.ENABLED, "rows": rows} |
| my = req.query_params.get("my") |
| if my is not None: |
| rank, total = await run_in_threadpool(leaderboard.rank_of, pid, pieces, int(my)) |
| out.update(myRank=rank, total=total) |
| return out |
| except Exception as e: |
| return {"enabled": False, "rows": [], "error": str(e)} |
|
|
|
|
| @api.post("/times") |
| async def times_post(req: Request): |
| p = await req.json() |
| try: |
| ok = await run_in_threadpool( |
| leaderboard.submit, p.get("p"), p.get("pieces", 0), p.get("name"), |
| p.get("ms", 0), p.get("moves", 0), bool(p.get("rot"))) |
| return {"ok": ok} |
| except Exception as e: |
| return {"ok": False, "error": str(e)} |
|
|
|
|
| class NoCacheStatic(StaticFiles): |
| """no-cache (NOT no-store): browsers revalidate by ETag — cheap 304s normally, |
| instantly fresh after every push. Stale game files have bitten us repeatedly.""" |
| def file_response(self, *args, **kwargs): |
| resp = super().file_response(*args, **kwargs) |
| resp.headers["Cache-Control"] = "no-cache" |
| return resp |
|
|
|
|
| api.mount("/game", NoCacheStatic(directory=STATIC, html=True), name="game") |
|
|
|
|
| def generate_puzzle(subject: str) -> dict: |
| """Puzzle generation as a GRADIO API endpoint (/gradio_api/call/generate_puzzle). |
| On ZeroGPU the @spaces.GPU call must run inside a Gradio request for correct |
| quota attribution — the game calls this path first, /generate is the fallback.""" |
| try: |
| return generate_pack(str(subject or "")[:120]) |
| except Exception as e: |
| return {"error": str(e)} |
|
|
|
|
| |
| |
| |
| |
| def _generate_puzzle_str(subject): |
| return json.dumps(generate_puzzle(subject)) |
|
|
|
|
| _BRIDGE_JS = """ |
| () => { |
| window.addEventListener('message', (ev) => { |
| const d = ev.data || {}; |
| const frame = document.getElementById('pz-frame'); |
| const reply = (msg) => { try { ((frame && frame.contentWindow) || ev.source).postMessage(msg, '*'); } catch (e) {} }; |
| if (d.type === 'ajp-ping') { reply({ type: 'ajp-pong' }); return; } // handshake: lets the game detect the bridge fast |
| if (d.type !== 'ajp-gen') return; |
| const inEl = document.querySelector('#gw-in textarea, #gw-in input'); |
| const outEl = document.querySelector('#gw-out textarea, #gw-out input'); |
| const btn = document.querySelector('#gw-btn button') || document.querySelector('#gw-btn'); |
| if (!inEl || !outEl || !btn) { reply({ type: 'ajp-gen-result', error: 'bridge components missing' }); return; } |
| const setVal = (el, v) => { |
| const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; |
| Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, v); |
| el.dispatchEvent(new Event('input', { bubbles: true })); |
| }; |
| setVal(outEl, ''); |
| setVal(inEl, String(d.subject || '')); |
| setTimeout(() => btn.click(), 80); |
| const t0 = Date.now(); |
| const iv = setInterval(() => { |
| if (outEl.value) { |
| clearInterval(iv); |
| let parsed = null; try { parsed = JSON.parse(outEl.value); } catch (e) {} |
| reply({ type: 'ajp-gen-result', data: parsed, error: parsed ? null : 'bad payload' }); |
| } else if (Date.now() - t0 > 240000) { |
| clearInterval(iv); reply({ type: 'ajp-gen-result', error: 'generation timed out' }); |
| } |
| }, 400); |
| }); |
| } |
| """ |
|
|
| |
| with gr.Blocks(title="AI Puzzle Maker", analytics_enabled=False) as demo: |
| gr.api(generate_puzzle, api_name="generate_puzzle") |
| with gr.Column(elem_id="gw-wrap"): |
| gw_in = gr.Textbox(label="subject", elem_id="gw-in") |
| gw_btn = gr.Button("generate", elem_id="gw-btn") |
| gw_out = gr.Textbox(label="result", elem_id="gw-out") |
| gw_btn.click(_generate_puzzle_str, inputs=gw_in, outputs=gw_out) |
| demo.load(fn=None, inputs=None, outputs=None, js=_BRIDGE_JS) |
| gr.HTML( |
| "<style>" |
| "footer{display:none!important}" |
| ".gradio-container{padding:0!important;margin:0!important;max-width:100%!important}" |
| ".gradio-container>.main,.gradio-container .wrap{padding:0!important;gap:0!important}" |
| "html,body,gradio-app{background:#0e0b14;margin:0}" |
| |
| |
| "#pz-frame{width:100%;height:min(860px,94vh);min-height:560px;border:0;display:block}" |
| "#gw-wrap{position:fixed!important;left:-10000px;top:0;width:10px;height:10px;overflow:hidden}" |
| "</style>" |
| '<iframe id="pz-frame" src="/game/index.html" title="AI Puzzle Maker" allow="autoplay"></iframe>' |
| ) |
|
|
| if __name__ == "__main__": |
| moderation.prewarm() |
| if os.environ.get("SPACE_ID"): |
| |
| |
| |
| |
| demo.launch(prevent_thread_lock=True, ssr_mode=False) |
| demo.app.post("/quip")(quip) |
| demo.app.post("/generate")(generate) |
| demo.app.post("/publish")(publish_post) |
| demo.app.get("/puzzles")(puzzles_list) |
| demo.app.get("/puzzle/{wid}")(puzzle_get) |
| demo.app.post("/like")(like_post) |
| demo.app.get("/times")(times_get) |
| demo.app.post("/times")(times_post) |
| demo.app.mount("/game", NoCacheStatic(directory=STATIC, html=True), name="game") |
| demo.block_thread() |
| else: |
| |
| import uvicorn |
| app = gr.mount_gradio_app(api, demo, path="/") |
| uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) |
|
|