Spaces:
Running on Zero
Running on Zero
| 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'<script type="importmap">{_IMPORTMAP}</script>\n' | |
| f'<script>{_classicjs}</script>\n' | |
| f'<script>window.__CHARACTER_GLB_B64={json.dumps(_glb_b64)};</script>\n' | |
| f'<script type="module">{_avatarjs}</script>' | |
| ) | |
| # ββ Timer HTML (left column) ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TIMER_HTML = """ | |
| <div class="app-title">Focus Buddy <span class="app-title-sub">prototype</span></div> | |
| <div class="timer-panel" id="timer-panel"> | |
| <div class="timer-display" id="timer-display"> | |
| <span id="timer-elapsed">00:00</span><span id="timer-target" class="timer-target"> / 25:00</span> | |
| </div> | |
| <div class="timer-phase-label" id="timer-phase">βοΈ Work Session</div> | |
| <div class="mode-buttons"> | |
| <button class="mode-btn active" id="btn-pomodoro" onclick="setTimerMode('pomodoro')">π Pomodoro</button> | |
| <button class="mode-btn" id="btn-freeflow" onclick="setTimerMode('freeflow')">π Free Flow</button> | |
| </div> | |
| <div class="timer-config"> | |
| <span id="work-config"><label>Work <input type="number" id="work-dur" value="25" min="1" max="180"> min</label></span> | |
| <span id="break-config"> | |
| <label>Break <input type="number" id="break-dur" value="5" min="1" max="60"> min</label> | |
| </span> | |
| </div> | |
| <div class="timer-controls"> | |
| <button class="ctrl-btn start-btn" id="btn-start-stop" onclick="timerToggle()">βΆ Start</button> | |
| </div> | |
| </div> | |
| """ | |
| # ββ Stats bar rendering βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def render_stats_html() -> str: | |
| state = get_state() | |
| pct = state.xp_in_level # 0-99 | |
| return ( | |
| '<div class="stats-bar">' | |
| f'<div class="stat-item stat-level">βοΈ Lv {state.level}</div>' | |
| '<div class="stat-item stat-xp">' | |
| f'<span class="stat-label">XP</span>' | |
| f'<div class="xp-track"><div class="xp-fill" style="width:{pct}%"></div></div>' | |
| f'<span class="stat-value">{state.xp_in_level}/100</span>' | |
| '</div>' | |
| f'<div class="stat-item stat-coins">πͺ {state.coins}</div>' | |
| '</div>' | |
| ) | |
| # ββ 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 = """ | |
| <div class="panel-toggle-row"> | |
| <button class="panel-tab active" data-panel="chat" onclick="showPanel('chat')">π¬ Chat</button> | |
| <button class="panel-tab" data-panel="inv" onclick="showPanel('inv')">π Inventory <span class="coming-soon-badge">soon</span></button> | |
| </div> | |
| """ | |
| 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 = '<div class="inv-item-lock">π</div>' if locked else "" | |
| items_html += ( | |
| f'<div class="inv-item{lock_cls}">' | |
| f'{lock_icon}' | |
| f'<div class="inv-item-icon">{item["emoji"]}</div>' | |
| f'<div class="inv-item-name">{item["name"]}</div>' | |
| f'<div class="inv-item-cost">πͺ {item["cost"]}</div>' | |
| f'<div class="inv-item-req">Lv.{item["level_req"]}</div>' | |
| f'</div>' | |
| ) | |
| return ( | |
| '<div class="inventory-panel">' | |
| '<div class="inv-section-title">Shop β cosmetic gear</div>' | |
| f'<div class="inv-grid inv-grid-blurred">{items_html}</div>' | |
| '<div class="inv-coming-soon-overlay">' | |
| '<div class="inv-coming-soon-text">π§ Coming Soon</div>' | |
| '<div class="inv-coming-soon-sub">Earn coins to unlock gear for your buddy</div>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| # ββ 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'<div class="todo-item" id="todo-{tid}">' | |
| f'<input type="checkbox" {checked} class="todo-check"' | |
| f' data-action="complete_todo" data-id="{tid}">' | |
| f'<span class="todo-task{done_cls}">{task}</span>' | |
| f'<button class="todo-del" data-action="remove_todo" data-id="{tid}">β</button>' | |
| f'</div>' | |
| ) | |
| return ( | |
| '<div class="todo-panel" id="todo-panel">' | |
| '<h3 class="panel-title">π Quests</h3>' | |
| f'<div class="todo-list">{items}</div>' | |
| '</div>' | |
| ) | |
| # ββ 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("<div id='avatar-container' class='avatar-container'></div>") | |
| 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) | |