Spaces:
Running
Running
| """The Adventures of the HF Knight -- Gradio app (Space entrypoint). | |
| Left: the parchment scroll (our own HTML, not gr.Chatbot) + input. | |
| Right: a HUD plaque -- rank ladder, stage, trial, strikes. | |
| Game logic is the same agent layer as sim.py; the per-quest loop lives in two | |
| handlers (begin / answer). | |
| """ | |
| import html | |
| import warnings | |
| import gradio as gr | |
| from agents.judge import Judge | |
| from agents.narrator import KICKOFF, Narrator | |
| from config import MAX_ATTEMPT | |
| from core.game_state import RANK_LADDER | |
| from core.validation import Validator | |
| from llm import LLM | |
| from tools import GameSession | |
| warnings.filterwarnings("ignore", message=".*moved from the Blocks constructor.*") | |
| warnings.filterwarnings("ignore", message=".*HTTP_422_UNPROCESSABLE_ENTITY.*") | |
| # gr.HTML doesn't run embedded <script>, so we inject auto-scroll into <head>: | |
| # a MutationObserver keeps the parchment scrolled to its newest line. The scroll | |
| # is deferred with requestAnimationFrame so it runs after layout (else scrollHeight | |
| # is stale and it doesn't move). | |
| HEAD_JS = """ | |
| <script> | |
| (function () { | |
| document.documentElement.lang = 'en'; // so CSS uppercase uses English rules (no dotted Δ°) | |
| const scrollTale = () => { | |
| const t = document.querySelector('#tale'); | |
| if (t) requestAnimationFrame(() => { t.scrollTop = t.scrollHeight; }); | |
| }; | |
| const start = () => new MutationObserver(scrollTale) | |
| .observe(document.body, {childList: true, subtree: true, characterData: true}); | |
| if (document.body) start(); | |
| else document.addEventListener('DOMContentLoaded', start); | |
| })(); | |
| </script> | |
| """ | |
| PARCHMENT_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;800&family=EB+Garamond:ital@0;1&display=swap'); | |
| .gradio-container { | |
| background: | |
| radial-gradient(circle at 50% 0%, #4a3520 0%, rgba(0,0,0,0) 60%), | |
| #2e2013 !important; | |
| font-family: 'EB Garamond', Georgia, serif !important; | |
| color: #e8d3a0 !important; | |
| } | |
| #title { | |
| text-align: center; margin: 0.4em 0; padding: 8px 12px; | |
| background: #f1e4c3; border: 2px solid #6b4f2c; border-radius: 8px; | |
| box-shadow: 0 5px 16px rgba(0,0,0,0.5); | |
| } | |
| #title h1 { | |
| font-family: 'EB Garamond', Georgia, serif !important; font-weight: 700; | |
| font-size: 2.5rem; color: #120c05 !important; letter-spacing: 1px; margin: 0; | |
| } | |
| /* shared parchment surface for scroll + HUD + rules */ | |
| #tale, #hud, #rules { | |
| background: | |
| radial-gradient(circle at 25% 15%, #f9f1d6 0%, rgba(249,241,214,0) 70%), | |
| radial-gradient(circle at 85% 95%, #e4d09c 0%, rgba(228,208,156,0) 70%), | |
| #f2e6c4 !important; | |
| border: 3px solid #6b4f2c; border-radius: 6px; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.5), inset 0 0 50px rgba(120,90,50,0.18); | |
| color: #3b2a18 !important; | |
| } | |
| /* lock text dark on the parchment regardless of Gradio light/dark mode */ | |
| #tale *, #hud *, #rules * { color: inherit !important; } | |
| /* the tale scroll */ | |
| #tale { | |
| padding: 26px 34px; height: 600px; overflow-y: auto; | |
| line-height: 1.6; font-size: 1.12rem; | |
| } | |
| #tale .status { | |
| text-align: center; font-family: 'Cinzel', serif; font-size: 0.8rem; | |
| letter-spacing: 1px; text-transform: uppercase; color: #7a5a30; | |
| border-bottom: 1px solid #c9b079; padding-bottom: 8px; margin-bottom: 18px; | |
| } | |
| #tale .herald { margin: 0 0 1.1em 0; } | |
| #tale .knight { margin: 0 0 1.1em 2.2em; font-style: italic; color: #5b3a1a !important; } | |
| #tale .who { | |
| display: block; font-family: 'Cinzel', serif; font-size: 0.74rem; | |
| letter-spacing: 1px; text-transform: uppercase; color: #8a6a3a !important; margin-bottom: 2px; | |
| } | |
| /* the HUD plaque */ | |
| #hud { padding: 6px 16px; } | |
| #hud .rank { | |
| font-family: 'Cinzel', serif; font-size: 1.1rem; color: #5b3a1a !important; | |
| text-align: center; margin-bottom: 1px; | |
| } | |
| #hud ul.ladder { list-style: none; padding: 0; margin: 2px 0 0 0; font-size: 0.86rem; line-height: 1.2; } | |
| #hud ul.ladder li { padding: 0 8px; color: #9a7b4a !important; border-radius: 4px; } | |
| #hud ul.ladder li.cur { color: #3b2a18 !important; font-weight: 700; background: #e4cf9a; } | |
| #hud ul.ladder li.done { color: #6b8a4a !important; } | |
| #hud .sec { | |
| font-family: 'Cinzel', serif; font-size: 0.72rem; letter-spacing: 1px; | |
| text-transform: uppercase; color: #7a5a30 !important; margin: 3px 0 0 0; | |
| border-top: 1px solid #c9b079; padding-top: 2px; | |
| } | |
| #hud .val { font-size: 0.9rem; } | |
| #hud .strikes { font-size: 1.1rem; letter-spacing: 3px; color: #8a3a2a !important; } | |
| /* the rules decree card */ | |
| /* kill Gradio's default flex gap between HUD and rules in the right column */ | |
| #rightcol { gap: 0 !important; } | |
| #rules { padding: 12px 16px; margin: 0; } | |
| #rules .sec { | |
| font-family: 'Cinzel', serif; font-size: 0.72rem; letter-spacing: 1px; | |
| text-transform: uppercase; color: #7a5a30 !important; margin: 0 0 8px 0; | |
| } | |
| #rules ul { list-style: none; padding: 0; margin: 0; font-size: 0.9rem; line-height: 1.3; } | |
| #rules li { padding: 2px 0 2px 16px; position: relative; } | |
| #rules li::before { content: "\\2756"; position: absolute; left: 0; color: #8a6a3a; font-size: 0.7rem; top: 5px; } | |
| /* input row */ | |
| #answerbox textarea { | |
| background: #f2e6c4 !important; color: #3b2a18 !important; | |
| border: 2px solid #6b4f2c !important; font-family: 'EB Garamond', serif !important; | |
| font-size: 1.05rem !important; | |
| } | |
| #sendbtn, #beginbtn { | |
| background: #6b4f2c !important; color: #f2e6c4 !important; | |
| border: 2px solid #4a3520 !important; font-family: 'Cinzel', serif !important; | |
| letter-spacing: 1px; | |
| } | |
| """ | |
| # model loaded once, lazily (keeps `gradio` hot-reload re-import fast) | |
| _narrator: Narrator | None = None | |
| def narrator() -> Narrator: | |
| global _narrator | |
| if _narrator is None: | |
| _narrator = Narrator(LLM()) | |
| return _narrator | |
| def render_tale(display) -> str: | |
| """The parchment scroll -- just the dialogue (rank/trial/strikes live in the HUD).""" | |
| rows = [] | |
| for who, text in display: | |
| body = html.escape(text).replace("\n", "<br>") | |
| label = "Herald-Mentor" if who == "herald" else "Knight" | |
| rows.append(f'<p class="{who}"><span class="who">{label}</span>{body}</p>') | |
| return f'<div id="tale">{"".join(rows)}</div>' | |
| def render_hud(session) -> str: | |
| s = session.state if session else None | |
| cur = s.rank if s else None | |
| items = [] | |
| for i, r in enumerate(RANK_LADDER): | |
| cls = "" | |
| if cur and r == cur: | |
| cls = "cur" | |
| elif s and i < s.stage_idx: | |
| cls = "done" | |
| items.append(f'<li class="{cls}">{html.escape(r)}</li>') | |
| ladder = f'<ul class="ladder">{"".join(items)}</ul>' | |
| if s and s.status == "playing": | |
| stage = session.orchestrator.bank.stage(s.stage_idx) | |
| nq = len(stage["questions"]) | |
| stage_v = f"{s.stage_idx + 1}/{len(session.orchestrator.bank)} β {stage['concept']}" | |
| trial_v = f"{s.q_idx + 1}/{nq}" | |
| strikes_v = '<span class="strikes">' + "β" * s.attempt + "β" * (MAX_ATTEMPT - s.attempt) + "</span>" | |
| elif s and s.status == "won": | |
| stage_v, trial_v, strikes_v = "All cleared", "β", "π" | |
| elif s and s.status == "lost": | |
| stage_v, trial_v, strikes_v = "Quest ended", "β", "βββ" | |
| else: | |
| stage_v, trial_v, strikes_v = "Awaiting the summons", "β", "βββ" | |
| return ( | |
| f'<div id="hud">' | |
| f'<div class="rank">{html.escape(cur or "β")}</div>' | |
| f'{ladder}' | |
| f'<div class="sec">Stage</div><div class="val">{html.escape(stage_v)}</div>' | |
| f'<div class="sec">Trial</div><div class="val">{html.escape(trial_v)}</div>' | |
| f'<div class="sec">Strikes</div><div class="val">{strikes_v}</div>' | |
| f'</div>' | |
| ) | |
| WELCOME_TALE = ('<div id="tale"><div class="status">The Realm of the Hub awaits</div>' | |
| '<p class="herald"><span class="who">Herald-Mentor</span>' | |
| 'A spirit stirs in the Realm of the Hub. Press <b>Begin thy quest</b> to be ' | |
| 'summoned, Squire, and face thy first trial.</p></div>') | |
| RULES_HTML = ( | |
| '<div id="rules"><div class="sec">The Decree of the Trials</div><ul>' | |
| '<li>Free the arcane arts β rise from Squire to <b>Grandmaster</b>.</li>' | |
| '<li>Six stages, each holding several trials.</li>' | |
| '<li>Answer the Herald\'s riddle true to advance and rise in rank.</li>' | |
| '<li>Three failed attempts end thy quest.</li>' | |
| '<li>Clear all six stages to be crowned Grandmaster.</li>' | |
| '</ul></div>') | |
| def begin(): | |
| """Summon the knight, pose the first trial, and swap Begin for the answer input.""" | |
| n = narrator() | |
| session = GameSession(validator=Validator(judge=Judge(n.llm))) | |
| messages = [{"role": "system", "content": n.system_block(session)}, | |
| {"role": "user", "content": KICKOFF}] | |
| display = [("herald", n.run(session, messages))] | |
| return (render_tale(display), session, messages, display, render_hud(session), | |
| gr.update(visible=False), gr.update(visible=True), gr.update(visible=True)) | |
| def answer(text, session, messages, display): | |
| """One player turn: judge the answer, react, pose the next trial if cleared.""" | |
| if session is None or session.state.status != "playing" or not text.strip(): | |
| tale = render_tale(display) if session else WELCOME_TALE | |
| return tale, session, messages, display, "", render_hud(session) | |
| n = narrator() | |
| before = (session.state.stage_idx, session.state.q_idx) | |
| display = display + [("knight", text)] | |
| messages.append({"role": "user", "content": text}) | |
| display = display + [("herald", n.run(session, messages))] | |
| moved = (session.state.stage_idx, session.state.q_idx) != before | |
| if session.state.status == "playing" and moved: # cleared -> pose next trial | |
| messages = [{"role": "system", "content": n.system_block(session)}, | |
| {"role": "user", "content": KICKOFF}] | |
| display = display + [("herald", n.run(session, messages))] | |
| return render_tale(display), session, messages, display, "", render_hud(session) | |
| with gr.Blocks(title="The Adventures of the HF Knight", css=PARCHMENT_CSS, head=HEAD_JS) as demo: | |
| gr.Markdown("# π‘οΈ The Adventures of the HF Knight", elem_id="title") | |
| st_session = gr.State() | |
| st_messages = gr.State() | |
| st_display = gr.State() | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| tale = gr.HTML(WELCOME_TALE) | |
| with gr.Row(): | |
| begin_btn = gr.Button("βοΈ Begin thy quest", scale=1, elem_id="beginbtn") | |
| box = gr.Textbox(placeholder="Speak thy answer, Squire...", show_label=False, | |
| scale=5, elem_id="answerbox", visible=False, max_length=200) | |
| send = gr.Button("βοΈ Answer", scale=1, elem_id="sendbtn", visible=False) | |
| with gr.Column(scale=1, elem_id="rightcol"): | |
| hud = gr.HTML(render_hud(None)) | |
| gr.HTML(RULES_HTML) | |
| begin_btn.click(begin, outputs=[tale, st_session, st_messages, st_display, hud, | |
| begin_btn, box, send]) | |
| _in = [box, st_session, st_messages, st_display] | |
| _out = [tale, st_session, st_messages, st_display, box, hud] | |
| box.submit(answer, _in, _out) | |
| send.click(answer, _in, _out) | |
| if __name__ == "__main__": | |
| demo.launch() | |