import base64 import json import pathlib import gradio as gr from src.llm import chat, MODEL_ID from src.game_state import get_state _ROOT = pathlib.Path(__file__).parent # ── Load static assets ──────────────────────────────────────────────────────── def _read(path: str) -> str: return (_ROOT / path).read_text(encoding="utf-8") _THREE_VER = "0.160.0" _IMPORTMAP = json.dumps({ "imports": { "three": f"https://cdn.jsdelivr.net/npm/three@{_THREE_VER}/build/three.module.js", "three/addons/": f"https://cdn.jsdelivr.net/npm/three@{_THREE_VER}/examples/jsm/", } }) # Inline the GLB as base64 so the avatar needs no static-file serving / CORS setup. _glb_b64 = base64.b64encode((_ROOT / "static/models/character.glb").read_bytes()).decode() _css = _read("static/css/style.css") _classicjs = _read("static/js/bridge.js") + "\n" + _read("static/js/timer.js") _avatarjs = _read("static/js/avatar.js") _head = ( f'\n' f'\n' f'\n' f'' ) # ── Timer HTML (left column) ────────────────────────────────────────────────── TIMER_HTML = """
Focus Buddy prototype
00:00 / 25:00
⚔️ Work Session
""" # ── Stats bar rendering ─────────────────────────────────────────────────────── def render_stats_html() -> str: state = get_state() pct = state.xp_in_level # 0-99 return ( '
' f'
⚔️ Lv {state.level}
' '
' f'XP' f'
' f'{state.xp_in_level}/100' '
' f'
🪙 {state.coins}
' '
' ) # ── Inventory rendering ─────────────────────────────────────────────────────── _INVENTORY_ITEMS = [ {"id": "hat_red", "name": "Red Hat", "cost": 10, "level_req": 1, "emoji": "🎩"}, {"id": "cape_blue", "name": "Blue Cape", "cost": 25, "level_req": 2, "emoji": "🧣"}, {"id": "crown", "name": "Gold Crown", "cost": 50, "level_req": 3, "emoji": "👑"}, {"id": "aura_dark", "name": "Dark Aura", "cost": 80, "level_req": 4, "emoji": "✨"}, ] PANEL_TABS_HTML = """
""" def render_inventory_html() -> str: state = get_state() items_html = "" for item in _INVENTORY_ITEMS: locked = state.level < item["level_req"] lock_cls = " item-locked" if locked else "" lock_icon = '
🔒
' if locked else "" items_html += ( f'
' f'{lock_icon}' f'
{item["emoji"]}
' f'
{item["name"]}
' f'
🪙 {item["cost"]}
' f'
Lv.{item["level_req"]}
' f'
' ) return ( '
' '
Shop — cosmetic gear
' f'
{items_html}
' '
' '
🚧 Coming Soon
' '
Earn coins to unlock gear for your buddy
' '
' '
' ) # ── Todo rendering ──────────────────────────────────────────────────────────── def _escape(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">") def render_todo_html(todos: list[dict]) -> str: sorted_todos = todos items = "" for t in sorted_todos: tid = t["id"] task = _escape(t["task"]) checked = "checked" if t["done"] else "" done_cls = " todo-done" if t["done"] else "" items += ( f'
' f'' f'{task}' f'' f'
' ) return ( '
' '

📋 Quests

' f'
{items}
' '
' ) # ── Helpers ─────────────────────────────────────────────────────────────────── def _extract_text(content) -> str: if isinstance(content, list): return " ".join( part["text"] for part in content if isinstance(part, dict) and "text" in part ) return str(content) def respond(message: str, history: list[dict]): state = get_state() messages = [{"role": m["role"], "content": _extract_text(m["content"])} for m in history] messages.append({"role": "user", "content": message}) reply, js_cmd = chat(messages, timer_state=state.timer_state, todos=state.todos) history = history + [ {"role": "user", "content": message}, {"role": "assistant", "content": reply}, ] # Re-render todos so any LLM-driven add/complete/remove shows up in the UI. return "", history, js_cmd or "", render_todo_html(state.todos), render_stats_html() def add_todo_handler(task: str): if task.strip(): get_state().add_todo(task.strip()) return render_todo_html(get_state().todos), "" def reset_state(): from src import game_state as _gs _gs._state = _gs.GameState() if _gs._SAVE_FILE.exists(): _gs._SAVE_FILE.unlink() return render_todo_html(_gs._state.todos), render_stats_html() def on_bridge_event(raw: str): """Handle JS→Python bridge events. Returns (bridge_reset, todo_html, stats_html).""" no_op = ("", gr.update(), gr.update()) if not raw: return no_op try: event = json.loads(raw) except json.JSONDecodeError: return no_op event_type = event.get("type", "") state = get_state() if event_type == "timer_complete": phase = event.get("phase", "work") mode = event.get("mode", "pomodoro") if phase == "work": state.add_xp(25) state.add_coins(10) print(f"[bridge] timer_complete work ({mode}) → +25 XP, +10 coins") else: print(f"[bridge] timer_complete break ({mode})") return "", gr.update(), render_stats_html() if event_type == "timer_stop": phase = event.get("phase", "work") mode = event.get("mode", "pomodoro") seconds_elapsed = event.get("seconds_elapsed", 0) if phase == "work" and seconds_elapsed > 0: if mode == "pomodoro": target_secs = event.get("target_secs", 25 * 60) xp = max(0, round(25 * seconds_elapsed / target_secs)) coins = max(0, round(10 * seconds_elapsed / target_secs)) else: # freeflow: 1 XP per minute, 1 coin per 2 minutes minutes = seconds_elapsed // 60 xp = minutes coins = minutes // 2 if xp > 0: state.add_xp(xp) state.add_coins(coins) print(f"[bridge] timer_stop {mode}/{phase} {seconds_elapsed}s → +{xp} XP, +{coins} coins") return "", gr.update(), render_stats_html() if event_type == "complete_todo": todo_id = event.get("id") if todo_id is not None: state.complete_todo(int(todo_id)) return "", render_todo_html(state.todos), render_stats_html() if event_type == "remove_todo": todo_id = event.get("id") if todo_id is not None: state.remove_todo(int(todo_id)) return "", render_todo_html(state.todos), gr.update() return no_op # ── Layout ──────────────────────────────────────────────────────────────────── with gr.Blocks(title="Focus Buddy") as demo: with gr.Row(): # ── Left column ─────────────────────────────────────────────────────── with gr.Column(scale=1): gr.HTML(TIMER_HTML) # Todo section todo_html = gr.HTML(render_todo_html(get_state().todos)) with gr.Row(): new_todo_input = gr.Textbox( placeholder="Add a quest...", show_label=False, container=False, scale=5, ) add_btn = gr.Button("+", scale=1) add_btn.click(add_todo_handler, inputs=[new_todo_input], outputs=[todo_html, new_todo_input]) new_todo_input.submit(add_todo_handler, inputs=[new_todo_input], outputs=[todo_html, new_todo_input]) # ── Right column ────────────────────────────────────────────────────── with gr.Column(scale=1): gr.HTML("
") stats_html = gr.HTML(render_stats_html()) gr.HTML(PANEL_TABS_HTML) gr.HTML(render_inventory_html(), elem_id="inventory-panel-wrap") _GREETING = ( "⚔️ Hail, adventurer! I'm **Pip**, your trusty RPG companion! Here's what I can do for you:\n\n" "🍅 **Timer** — start, stop, or reset your Pomodoro or Free Flow focus session\n" "📋 **Quests** — add, complete, or remove tasks from your quest log\n" "🪙 **Rewards** — earn XP & Focus Coins for every session you finish\n\n" "Just tell me what you need — *\"Start a 25-minute focus session\"* or *\"Add 'write report' to my quests\"* — and I'll handle the rest. Ready when you are! 🗡️" ) chatbot = gr.Chatbot( label=f"Pip — your RPG Buddy ({MODEL_ID})", height=400, elem_id="chatbot-wrap", value=[{"role": "assistant", "content": _GREETING}], ) msg = gr.Textbox( placeholder="Talk to Pip...", show_label=False, container=False, elem_id="chat-input-wrap", ) with gr.Accordion("🛠 Debug", open=False, elem_id="debug-section"): with gr.Row(): add5_btn = gr.Button("+5 min elapsed", variant="secondary", size="sm") add15_btn = gr.Button("+15 min elapsed", variant="secondary", size="sm") reset_btn = gr.Button("Reset saved state", variant="stop", size="sm") reset_status = gr.Markdown("") add5_btn.click( fn=None, js="() => { timerAddMinutes(5); }") add15_btn.click(fn=None, js="() => { timerAddMinutes(15); }") def _do_reset(): todo_h, stats_h = reset_state() return todo_h, stats_h, "✅ State reset — XP, coins, and quests cleared." reset_btn.click(_do_reset, outputs=[todo_html, stats_html, reset_status]) # Hidden bridge textbox — JS writes here, Python reads via .change() bridge_input = gr.Textbox(visible="hidden", elem_id="bridge_input") bridge_input.change( on_bridge_event, inputs=[bridge_input], outputs=[bridge_input, todo_html, stats_html], ) # JS command channel — hidden textbox so the value is available to the JS-only .then() js_cmd_box = gr.Textbox(visible="hidden", elem_id="js_cmd_out") msg.submit(respond, [msg, chatbot], [msg, chatbot, js_cmd_box, todo_html, stats_html]).then( fn=None, inputs=[js_cmd_box], js="(cmd) => { runPyCmd(cmd); }", ) demo.load( fn=lambda: (render_todo_html(get_state().todos), render_stats_html()), outputs=[todo_html, stats_html], ) if __name__ == "__main__": demo.launch(css=_css, head=_head)