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
"""
# ── 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)