Spaces:
Running on Zero
Running on Zero
| """ | |
| Tutori — your personal whiteboard tutor. | |
| Speak (or type) a question. Tutori listens, gathers whatever context it needs, | |
| then teaches you out loud while sketching the idea on a whiteboard in real | |
| time. Built for the Hugging Face Build Small hackathon: every model runs on | |
| this Space itself (ZeroGPU) — about 16.9B parameters total, zero cloud APIs. | |
| """ | |
| import json | |
| import os | |
| import uuid | |
| from pathlib import Path | |
| import gradio as gr | |
| # Real models on Spaces / when forced; instant mock everywhere else so the | |
| # whole UI can be developed and demoed without a GPU. | |
| IS_SPACE = bool(os.environ.get("SPACE_ID")) or os.environ.get("TUTORI_REAL") == "1" | |
| if IS_SPACE: | |
| try: | |
| import engine as ENGINE | |
| except Exception: # e.g. Space running on CPU hardware | |
| import traceback | |
| traceback.print_exc() | |
| print("[tutori] real engine unavailable — falling back to mock") | |
| import mock_engine as ENGINE | |
| try: | |
| # ZeroGPU kills Spaces that register no @spaces.GPU function; | |
| # give it one so the mock UI stays reachable for debugging. | |
| import spaces | |
| def _zerogpu_probe(): | |
| return "ok" | |
| except Exception: | |
| pass | |
| else: | |
| import mock_engine as ENGINE | |
| ROOT = Path(__file__).parent | |
| BOARD_JS = (ROOT / "static" / "board.js").read_text() | |
| CSS = (ROOT / "static" / "style.css").read_text() | |
| FONT_FACES = (ROOT / "static" / "fonts" / "faces.css").read_text() | |
| HEAD = f""" | |
| <style>{FONT_FACES}</style> | |
| <script>{BOARD_JS}</script> | |
| """ | |
| HEADER_HTML = f""" | |
| <div id="tutori-header"> | |
| <div class="head-left"> | |
| <div class="logo"><span class="mark">✏️</span> Tutori | |
| <span class="sub">your whiteboard tutor</span> | |
| </div> | |
| <svg class="squiggle" viewBox="0 0 320 14" preserveAspectRatio="none" aria-hidden="true"> | |
| <path d="M4 9 Q 44 2, 84 8 T 164 8 T 244 9 T 316 6"/> | |
| </svg> | |
| <div class="tag">Ask anything out loud — Tutori researches it, then teaches you while sketching it live.</div> | |
| </div> | |
| <div class="badges"> | |
| <span class="badge hot">⚡ ZeroGPU · no cloud APIs</span> | |
| <span class="badge">🧠 Gemma 4 12B</span> | |
| <span class="badge">🗣️ Higgs Audio v2 TTS 3B</span> | |
| <span class="badge">🧭 MiniCPM5 1B coach</span> | |
| <span class="badge">👂 Whisper v3 turbo 0.8B</span> | |
| <span class="badge">Σ 16.9B params — Build Small ✅</span> | |
| </div> | |
| </div> | |
| """ | |
| CHIPS = [ | |
| "Why is the sky blue?", | |
| "Teach me the Pythagorean theorem", | |
| "How do neural networks learn?", | |
| "What happened in space exploration this month?", | |
| ] | |
| EMPTY_PROFILE = {} | |
| def _payload(turn_id, voice_on, status, detail, steps): | |
| # CUMULATIVE: every payload carries all steps so far (browser dedupes by | |
| # "i"). Gradio coalesces fast generator yields into latest-state — an | |
| # incremental protocol silently loses steps when that happens. | |
| n_ops = sum(len(s.get("board") or []) for s in steps) | |
| heavy = sum(1 for s in steps for op in (s.get("board") or []) | |
| if op.get("op") in ("axes", "polygon", "notes", "curve")) | |
| return json.dumps({ | |
| "turn": turn_id, | |
| "voice": bool(voice_on), | |
| "status": status, | |
| "status_detail": detail, | |
| "big": bool(n_ops >= 9 or heavy >= 4), | |
| "steps": steps, | |
| }) | |
| def _track_board(board_ops, step_board): | |
| """Mirror what the lesson drew so the next turn knows the board state.""" | |
| for op in step_board or []: | |
| if op.get("op") == "clear": | |
| board_ops = [] | |
| else: | |
| board_ops.append(op) | |
| return board_ops[-60:] | |
| N_CHIPS = len(CHIPS) | |
| def run_turn(audio_path, typed_text, snapshot, chat, convo, profile, | |
| notes, board_ops, pace, web_on, voice_on): | |
| """Bridges ENGINE.run_turn events into streaming Gradio updates.""" | |
| chat = list(chat or []) | |
| convo = list(convo or []) | |
| profile = dict(profile or {}) | |
| notes = notes or "" | |
| board_ops = list(board_ops or []) | |
| turn_id = uuid.uuid4().hex[:10] | |
| has_input = bool(audio_path or (typed_text or "").strip() or snapshot) | |
| if not has_input: | |
| yield (gr.update(), chat, convo, profile, profile, notes, board_ops, | |
| gr.update(), gr.update(), | |
| *([gr.update()] * (2 * N_CHIPS))) | |
| return | |
| user_label = (typed_text or "").strip() or ("🎙️ …" if audio_path else "🖼️ (my whiteboard)") | |
| chat.append({"role": "user", "content": user_label}) | |
| chat.append({"role": "assistant", "content": "✏️ *warming up the marker…*"}) | |
| question_for_context = user_label | |
| says = [] | |
| sent_steps = [] | |
| chip_vals = None # set when the study coach suggests follow-ups | |
| def render(status="thinking", detail=""): | |
| n = N_CHIPS | |
| if chip_vals: | |
| chips = ([gr.update(value=s) for s in chip_vals[:n]] + | |
| [gr.update()] * (n - min(n, len(chip_vals)))) | |
| states = (list(chip_vals[:n]) + | |
| [gr.update()] * (n - min(n, len(chip_vals)))) | |
| else: | |
| chips = [gr.update()] * n | |
| states = [gr.update()] * n | |
| return (_payload(turn_id, voice_on, status, detail, sent_steps), | |
| chat, convo, profile, profile, notes, board_ops, | |
| gr.update(value=None), gr.update(value=""), | |
| *chips, *states) | |
| yield render(detail="Thinking…") | |
| seq = 0 | |
| error = None | |
| print(f"[tutori] turn {turn_id}: starting engine", flush=True) | |
| try: | |
| for ev in ENGINE.run_turn(audio_path, typed_text, snapshot, convo, profile, | |
| notes, board_ops, pace, web_on, voice_on): | |
| kind = ev.get("type") | |
| if seq == 0 and kind != "step": | |
| print(f"[tutori] turn {turn_id}: event {kind}", flush=True) | |
| if kind == "status": | |
| yield render(ev.get("status", "thinking"), ev.get("detail", "")) | |
| elif kind == "transcript": | |
| question_for_context = ev["text"] | |
| chat[-2]["content"] = f"🎙️ {ev['text']}" | |
| yield render("thinking", "Heard you!") | |
| elif kind == "research": | |
| fresh = ev.get("notes", "") | |
| if fresh: | |
| notes = (notes + "\n" + fresh)[-4000:] | |
| elif kind == "step": | |
| step = dict(ev["step"], i=seq) | |
| says.append(step.get("say", "")) | |
| board_ops = _track_board(board_ops, step.get("board")) | |
| chat[-1]["content"] = "✏️ " + " ".join(s for s in says if s) | |
| sent_steps.append(step) | |
| yield render("teaching", f"Teaching — step {seq + 1}") | |
| seq += 1 | |
| elif kind == "memory": | |
| profile = dict(ev.get("profile") or profile) | |
| elif kind == "coach": | |
| chip_vals = ev.get("suggestions") or None | |
| elif kind == "final": | |
| error = ev.get("error") | |
| question_for_context = ev.get("question") or question_for_context | |
| except Exception as e: # ZeroGPU quota / GPU-time-cap errors land here | |
| msg = str(e) | |
| print(f"[tutori] turn {turn_id}: engine raised: {msg[:300]}", flush=True) | |
| if seq > 0: | |
| error = None # we already taught something — end the turn gracefully | |
| elif "quota" in msg.lower(): | |
| error = ("ZeroGPU quota reached for your session. Sign in to " | |
| "Hugging Face (free) for much more GPU time, then retry.") | |
| else: | |
| error = "The GPU hiccuped on that one — give it another try?" | |
| if error: | |
| chat[-1]["content"] = f"⚠️ {error}" | |
| yield render("error", error) | |
| return | |
| final_text = " ".join(s for s in says if s).strip() or chat[-1]["content"] | |
| chat[-1]["content"] = "✏️ " + final_text | |
| convo.append({"role": "user", "content": question_for_context}) | |
| convo.append({"role": "assistant", "content": final_text[:1200]}) | |
| yield render("done", "") | |
| def reset_session(): | |
| # chat, convo, typed text, board mirror, mic, research notes | |
| return [], [], "", [], gr.update(value=None), "" | |
| def forget_me(): | |
| return {}, {} | |
| with gr.Blocks( | |
| title="Tutori — your whiteboard tutor", | |
| theme=gr.themes.Soft( | |
| primary_hue="indigo", secondary_hue="amber", neutral_hue="stone", | |
| font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], | |
| ), | |
| css=CSS, | |
| head=HEAD, | |
| ) as demo: | |
| profile_state = gr.BrowserState(EMPTY_PROFILE, storage_key="tutori_profile_v1") | |
| convo_state = gr.State([]) | |
| notes_state = gr.State("") # web research carried across the session | |
| board_state = gr.State([]) # ops currently on the board (server mirror) | |
| payload_box = gr.Textbox( | |
| visible=True, elem_id="tutori-payload", elem_classes="payload-sink", | |
| show_label=False, container=False, | |
| ) | |
| snap_box = gr.Textbox(visible=False) | |
| gr.HTML(HEADER_HTML) | |
| with gr.Row(equal_height=False, elem_classes="studio-row"): | |
| # ---------------- whiteboard ---------------- | |
| with gr.Column(scale=15, elem_classes="board-col"): | |
| gr.HTML('<div id="tutori-board-mount"></div>') | |
| with gr.Row(elem_classes="board-actions"): | |
| ask_board_btn = gr.Button( | |
| "🖐 Ask Tutori about the board", elem_id="ask-board-btn", scale=3 | |
| ) | |
| new_btn = gr.Button("🧽 New lesson", scale=1, elem_id="new-btn") | |
| if ENGINE.MODELS_INFO["mode"] == "mock": | |
| gr.HTML( | |
| '<div id="mode-banner">🧪 <b>Mock mode</b> — running without GPU models ' | |
| "(local dev). Push to a ZeroGPU Space and the real Gemma 4 + Higgs " | |
| "full model stack switches on automatically.</div>" | |
| ) | |
| # ---------------- conversation ---------------- | |
| with gr.Column(scale=8, elem_classes="side-col"): | |
| chatbot = gr.Chatbot( | |
| type="messages", height=320, elem_id="tutori-chat", | |
| label="Lesson transcript", | |
| avatar_images=(None, None), | |
| show_copy_button=False, | |
| ) | |
| mic = gr.Audio( | |
| sources=["microphone"], type="filepath", format="wav", | |
| label="🎤 Talk to Tutori (recording stops = message sent)", | |
| elem_id="mic-box", show_download_button=False, | |
| ) | |
| with gr.Row(): | |
| text_in = gr.Textbox( | |
| placeholder="…or type your question and press Enter", | |
| show_label=False, scale=5, container=False, | |
| ) | |
| send_btn = gr.Button("Send ➤", variant="primary", scale=1, min_width=80, elem_id="send-btn") | |
| with gr.Row(elem_classes="chip-row"): | |
| chip_btns = [gr.Button(c, size="sm") for c in CHIPS] | |
| chip_states = [gr.State(c) for c in CHIPS] | |
| with gr.Accordion("⚙️ How Tutori teaches you", open=False): | |
| pace = gr.Slider( | |
| 1, 5, value=3, step=1, label="Pace & depth", | |
| info="1 = total beginner, tiny steps · 5 = expert, fast and dense", | |
| ) | |
| web_on = gr.Checkbox(True, label="🔎 Let Tutori research the web when useful") | |
| voice_on = gr.Checkbox(True, label="🔊 Voice replies (Higgs Audio)") | |
| with gr.Accordion("🧠 What Tutori remembers about you", open=False): | |
| gr.Markdown( | |
| "Tutori keeps gentle notes — your level, goals, what clicked, what " | |
| "didn't — saved **only in your browser**, and uses them to pick the " | |
| "right pace next time." | |
| ) | |
| profile_view = gr.JSON(value=EMPTY_PROFILE, label="Learner profile") | |
| forget_btn = gr.Button("🗑️ Forget everything about me", size="sm") | |
| gr.HTML( | |
| '<div id="tutori-foot">Built small for the ' | |
| '<a href="https://huggingface.co/build-small-hackathon" target="_blank">' | |
| "HF Build Small Hackathon</a> · Gemma 4 teaches · MiniCPM5 coaches · Higgs speaks · Whisper listens " | |
| "= 16.9B params, all on this Space · draw on the board with your mouse, " | |
| "Tutori can see it 👀" | |
| '<div id="made-by">Made by SSH/ProCreations</div></div>' | |
| ) | |
| # ---------------- wiring ---------------- | |
| # The `js` hook runs client-side BEFORE fn and its return replaces the | |
| # input values — we use it to inject a fresh whiteboard snapshot into | |
| # `snap_box`. (gr.State arrives as null in js; pass it through untouched.) | |
| def _snap_js(fn_name): | |
| return ( | |
| "(audio, text, snap, chat, convo, profile, notes, board, pace, web, voice) => " | |
| f"[audio, text, (window.{fn_name} ? window.{fn_name}() : ''), " | |
| "chat, convo, profile, notes, board, pace, web, voice]" | |
| ) | |
| turn_io = dict( | |
| fn=run_turn, | |
| inputs=[mic, text_in, snap_box, chatbot, convo_state, profile_state, | |
| notes_state, board_state, pace, web_on, voice_on], | |
| outputs=[payload_box, chatbot, convo_state, profile_state, profile_view, | |
| notes_state, board_state, mic, text_in, | |
| *chip_btns, *chip_states], | |
| show_progress="hidden", | |
| ) | |
| turn_events = [ | |
| mic.stop_recording(js=_snap_js("tutoriSnapshotIfInk"), **turn_io), | |
| text_in.submit(js=_snap_js("tutoriSnapshotIfInk"), **turn_io), | |
| send_btn.click(js=_snap_js("tutoriSnapshotIfInk"), **turn_io), | |
| ask_board_btn.click(js=_snap_js("tutoriSnapshot"), **turn_io), | |
| ] | |
| for btn, chip_state in zip(chip_btns, chip_states): | |
| ev = btn.click(lambda s: s, chip_state, text_in) | |
| turn_events.append(ev.then(js=_snap_js("tutoriSnapshotIfInk"), **turn_io)) | |
| payload_box.change(fn=None, inputs=payload_box, outputs=None, | |
| js="(p) => { window.tutoriOnPayload(p); }") | |
| new_btn.click( | |
| reset_session, None, | |
| [chatbot, convo_state, text_in, board_state, mic, notes_state], | |
| js="() => { window.tutoriClearAll && window.tutoriClearAll(); return []; }", | |
| cancels=turn_events, # a mid-stream turn must die with the old lesson | |
| ) | |
| forget_btn.click(forget_me, None, [profile_state, profile_view]) | |
| demo.load(lambda p: p or {}, profile_state, profile_view) | |
| if os.environ.get("TUTORI_TTS_DEMO") == "1": | |
| # dev-only narration endpoint (never set on the public Space) | |
| import spaces as _spaces | |
| def _tts_demo(text): | |
| audio_b64, dur = ENGINE.synthesize(str(text)[:300]) | |
| return json.dumps({"audio": audio_b64, "dur": dur}) | |
| _tts_in = gr.Textbox(visible=False) | |
| _tts_out = gr.Textbox(visible=False) | |
| _tts_btn = gr.Button("tts", visible=False) | |
| _tts_btn.click(_tts_demo, _tts_in, _tts_out, api_name="tts_demo") | |
| if __name__ == "__main__": | |
| # ssr_mode=False: Gradio's experimental SSR breaks ZeroGPU token | |
| # forwarding, which leaves GPU calls queued forever. | |
| demo.queue(default_concurrency_limit=4).launch( | |
| ssr_mode=False, allowed_paths=[str(ROOT / "static")]) | |