focus-buddy / app.py
pocanman's picture
add label to chat
f3cf6f4
Raw
History Blame Contribute Delete
14.5 kB
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)