"""Custom Gradio Blocks UI (Off-Brand). Two surfaces: - Schedule: paste/inspect a thread (+images, +existing calendar), watch the model "think" live, then review/edit the ActionPlan, download .ics, optional GCal. - Activity: a live dashboard of what the agent and model are doing — pipeline stepper, metric tiles, event timeline, activity chart, and per-run traces. """ from __future__ import annotations import base64 import html import json import os import time from datetime import timedelta from pathlib import Path from urllib.parse import urlencode import gradio as gr import pandas as pd from dateutil import parser as dtparser from calendar_out.ics import write_ics from server import events as bus from server import impact, memory from server.imageutil import paths_to_data_uris from server.schema import ActionPlan, Event CSS = (Path(__file__).parent.parent / "static" / "app.css").read_text(encoding="utf-8") # Brand logo (robot-in-a-chat-bubble holding a calendar), inlined as a data URI # so it renders identically under every serve mode (mounted uvicorn / launch). _LOGO_URI = "data:image/png;base64," + base64.b64encode( (Path(__file__).parent.parent / "static" / "logo.png").read_bytes() ).decode("ascii") # Client-side carousel: auto-advance + arrows + dots + pause-on-hover. A # MutationObserver re-initialises when Gradio swaps the gr.HTML after an analyze. # Passed to mount_gradio_app/launch (Gradio 6 ignores js set on Blocks when mounted). CAROUSEL_JS = """ () => { function init() { document.querySelectorAll('.carousel:not([data-cz])').forEach(function (cz) { cz.setAttribute('data-cz', '1'); var slides = [].slice.call(cz.querySelectorAll('.cz-slide')); var dots = [].slice.call(cz.querySelectorAll('.cz-dot')); if (slides.length <= 1) return; var idx = 0; slides.forEach(function (s, j) { if (s.classList.contains('is-active')) idx = j; }); var ms = parseInt(cz.getAttribute('data-interval') || '4500', 10); var timer = null; function show(i) { idx = (i + slides.length) % slides.length; slides.forEach(function (s, j) { s.classList.toggle('is-active', j === idx); }); dots.forEach(function (d, j) { d.classList.toggle('is-active', j === idx); }); } function start() { if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; stop(); timer = setInterval(function () { show(idx + 1); }, ms); } function stop() { if (timer) { clearInterval(timer); timer = null; } } cz.querySelectorAll('.cz-arrow').forEach(function (a) { a.addEventListener('click', function () { show(idx + parseInt(a.getAttribute('data-dir'), 10)); start(); }); }); dots.forEach(function (d) { d.addEventListener('click', function () { show(parseInt(d.getAttribute('data-go'), 10)); start(); }); }); cz.addEventListener('mouseenter', stop); cz.addEventListener('mouseleave', start); start(); }); wireNav(); wireAnchors(); wireFaq(); wireCounter(); wireGcal(); wireFooter(); wirePasteRun(); wireModeTheme(); wirePipeClock(); } // Theme the tool card for the device's chosen workflow. The page-load part // reads localStorage (set-once: don't clobber a live switch); the listener // part recolors INSTANTLY at click time, so the theme never waits for the // server round-trip (which only swaps step content/visibility). function wireModeTheme() { var tc = document.getElementById('tool-card'); if (!tc) return; if (!tc.getAttribute('data-mode')) { var m = ''; try { m = localStorage.getItem('offgrid_mode') || ''; } catch (e) {} tc.setAttribute('data-mode', m.indexOf('Online') >= 0 ? 'online' : 'offline'); } document.querySelectorAll('#mode-toggle input[type="radio"]:not([data-mt])').forEach(function (r) { r.setAttribute('data-mt', '1'); r.addEventListener('change', function () { tc.setAttribute('data-mode', (r.value || '').indexOf('Online') >= 0 ? 'online' : 'offline'); }); }); } // Pasting IS the intent — run the analysis automatically on paste so the // user never has to click "Find the events". The button stays as fallback. function wirePasteRun() { var ta = document.querySelector('#rv-textbox textarea'); if (!ta || ta.getAttribute('data-pasterun')) return; ta.setAttribute('data-pasterun', '1'); ta.addEventListener('paste', function () { setTimeout(function () { if ((ta.value || '').trim().length < 12) return; var btn = document.querySelector('#rv-analyze button'); if (btn) btn.click(); }, 250); }); } // Fold Gradio's built-in footer (Use via API · Built with Gradio · Settings) // into the custom dark banner. appendChild MOVES the node, so Gradio's click // handlers (API docs / Settings modals) keep working; the MutationObserver // re-runs this if Gradio ever re-renders the footer. function wireFooter() { var gf = document.querySelector('.gradio-container footer:not(#site-footer)'); var host = document.querySelector('#site-footer .footer-inner'); if (gf && host && !host.contains(gf)) host.appendChild(gf); } // Plain in-page anchors ("See it in action", footer CTA): native #hash jumps // are unreliable inside the Gradio SPA, so scroll explicitly like the nav does. function wireAnchors() { document.querySelectorAll('a[href^="#"]:not([data-tab]):not([data-az])').forEach(function (a) { a.setAttribute('data-az', '1'); a.addEventListener('click', function (e) { var id = (a.getAttribute('href') || '').slice(1); if (!id) return; var el = document.getElementById(id); if (!el) return; e.preventDefault(); el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); } // Per-user Google Calendar: open the OAuth popup; persist + restore the // connection. The token JSON lives ONLY in localStorage; it carries a // refresh_token (the server refreshes at push time), so once connected the // visitor stays connected across visits — never asked again. mark() re-runs // on every init()/re-render, restoring the ✓ state on page load. function wireGcal() { function tokenInfo() { try { var raw = localStorage.getItem('gcal_token'); if (!raw) return null; var t = JSON.parse(raw); if (t.refresh_token) return t; // server can refresh if (t.expiry && new Date(t.expiry) > new Date()) return t; // still valid localStorage.removeItem('gcal_token'); // stale + unrefreshable return null; } catch (e) { return null; } } // Verification state lives on window: wireGcal re-runs with a fresh // closure on every MutationObserver tick, and mark() must not downgrade // a server-verified connection on re-render. // __gcalVerify: '' (unchecked) | 'ok' (Google answered) | 'bad' (token dead) // __gcalCheck: once-per-page-load guard for the /oauth2/check fetch // Write textContent ONLY when it actually changes: an identical-value // write still replaces the text node, which is a childList mutation — // and the body MutationObserver re-runs init() (→ mark()) on every // childList mutation, so an unconditional write here is an infinite // init↔mark feedback loop that pegs the main thread ("This page is // slowing down Firefox"). function setTxt(el, v) { if (el.textContent !== v) el.textContent = v; } function mark() { var v = window.__gcalVerify || ''; var ok = !!tokenInfo() && v !== 'bad'; var label = ok ? (v === 'ok' ? ' ✓ connected · verified' : ' ✓ connected') : ''; document.querySelectorAll('.cal-provider[data-provider="google"]').forEach(function (p) { p.classList.toggle('is-connected', ok); }); document.querySelectorAll('.gcal-state').forEach(function (s) { setTxt(s, label); }); document.querySelectorAll('.gcal-badge').forEach(function (b) { setTxt(b, ok ? 'Google:' + label : 'Google: not connected'); b.classList.toggle('is-on', ok); }); } // Round-trip the stored token to Google once per page load: the local // shape check can't see a revoked/garbage token. Only a DEFINITIVE "no" // clears the token; transient failures (env unset, network, launch-mode // Space where /oauth2/check isn't served) keep the optimistic state. function verify() { var t = null; try { t = localStorage.getItem('gcal_token'); } catch (e) {} if (!t || window.__gcalCheck) return; window.__gcalCheck = true; fetch('/oauth2/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: t }) }) .then(function (r) { if (r.status !== 200) throw 0; return r.json(); }) .then(function (d) { if (d.ok) { window.__gcalVerify = 'ok'; if (d.token) { try { localStorage.setItem('gcal_token', d.token); } catch (e) {} } } else if (!d.transient) { window.__gcalVerify = 'bad'; try { localStorage.removeItem('gcal_token'); } catch (e) {} } mark(); }) .catch(function () { /* indeterminate: keep shape-check state */ }); } mark(); verify(); document.querySelectorAll('.gcal-connect:not([data-g])').forEach(function (a) { a.setAttribute('data-g', '1'); a.addEventListener('click', function (e) { e.preventDefault(); window.open('/oauth2/start', 'gcal_oauth', 'width=520,height=680'); }); }); document.querySelectorAll('.gcal-disconnect:not([data-g])').forEach(function (a) { a.setAttribute('data-g', '1'); a.addEventListener('click', function (e) { e.preventDefault(); try { localStorage.removeItem('gcal_token'); } catch (_) {} window.__gcalVerify = ''; mark(); }); }); if (!window.__gcalMsg) { window.__gcalMsg = true; window.addEventListener('message', function (e) { if (e.origin !== location.origin || !e.data || !e.data.gcal_token) return; try { localStorage.setItem('gcal_token', e.data.gcal_token); } catch (_) {} // freshly minted token: reset and re-verify it window.__gcalCheck = false; window.__gcalVerify = ''; mark(); verify(); }); } } // Live character counter for the paste box. function wireCounter() { var ta = document.querySelector('#rv-textbox textarea'); var out = document.getElementById('rv-counter'); if (!ta || !out || ta.getAttribute('data-counter')) return; ta.setAttribute('data-counter', '1'); function upd() { out.textContent = (ta.value ? ta.value.length : 0) + ' / 12000'; } ta.addEventListener('input', upd); upd(); } // Make the grouped nav banner drive the (hidden) Gradio tabs. function wireNav() { function activate(name) { var all = [].slice.call(document.querySelectorAll('button[role="tab"], .tab-nav button')); // Prefer the real interactive buttons over Gradio's hidden measuring clones. var vis = all.filter(function (b) { return !b.closest('.visually-hidden'); }); for (var i = 0; i < vis.length; i++) { if ((vis[i].textContent || '').trim() === name) { vis[i].click(); return true; } } // Mobile fallback: a narrow viewport pushes the later tabs (Memory / Feed / // Submission, index >= 3) into Gradio's collapsed, .visually-hidden overflow // — absent from `vis`, so those nav links went dead while Home/Activity // (which stay visible) worked. Click every matching button in the FULL set: // the real (hidden) tab button switches the tab; the clones are no-ops. var hit = false; for (var j = 0; j < all.length; j++) { if ((all[j].textContent || '').trim() === name) { all[j].click(); hit = true; } } return hit; } document.querySelectorAll('#site-nav [data-tab]:not([data-nav])').forEach(function (a) { a.setAttribute('data-nav', '1'); a.addEventListener('click', function (e) { e.preventDefault(); activate(a.getAttribute('data-tab')); var anchor = a.getAttribute('data-anchor'); if (anchor) { setTimeout(function () { var el = document.querySelector(anchor); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 160); } else { setTimeout(function () { window.scrollTo({ top: 0, behavior: 'smooth' }); }, 60); } if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); }); }); } // FAQ: tab switching + client-side search filter. // While a search query is active, both panels are shown so cross-tab // matches are visible; clearing the search restores the active tab. function wireFaq() { document.querySelectorAll('.lp-faq-section:not([data-faq])').forEach(function (sec) { sec.setAttribute('data-faq', '1'); var tabs = [].slice.call(sec.querySelectorAll('.lp-faq-tab')); var lists = [].slice.call(sec.querySelectorAll('.lp-faq-list')); var empty = sec.querySelector('.lp-faq-empty'); var search = sec.querySelector('#faq-search'); var query = ''; function activeTab() { for (var i = 0; i < tabs.length; i++) { if (tabs[i].classList.contains('is-active')) return tabs[i].getAttribute('data-tab'); } return tabs[0] && tabs[0].getAttribute('data-tab'); } function render() { var active = activeTab(); var anyVisible = false; lists.forEach(function (l) { var show = query ? true : (l.getAttribute('data-panel') === active); l.classList.toggle('is-hidden', !show); if (show) { l.querySelectorAll('.lp-faq-item').forEach(function (it) { var match = !query || (it.textContent || '').toLowerCase().indexOf(query) !== -1; it.style.display = match ? '' : 'none'; if (match) anyVisible = true; if (!match && it.open) it.open = false; }); } }); if (empty) empty.classList.toggle('is-hidden', !query || anyVisible); } tabs.forEach(function (t) { t.addEventListener('click', function () { tabs.forEach(function (o) { o.classList.toggle('is-active', o === t); }); render(); }); }); if (search) { search.addEventListener('input', function (e) { query = ((e.target && e.target.value) || '').trim().toLowerCase(); render(); }); } render(); }); } // Live elapsed clock for the pipeline card. ONE global interval + a // window-level start timestamp, because Gradio replaces the trace DOM on // every generator yield — per-element timers would die each re-render. function wirePipeClock() { var running = document.querySelector('.pipe-card[data-state="running"]'); if (running) { if (!window.__pipeStart) window.__pipeStart = Date.now(); if (!window.__pipeTick) { window.__pipeTick = setInterval(function () { var s = ((Date.now() - (window.__pipeStart || Date.now())) / 1000).toFixed(1) + 's'; document.querySelectorAll('.pipe-card[data-state="running"] .pipe-clock') .forEach(function (c) { // nodeValue mutates the EXISTING text node (characterData) — // the body MutationObserver below is childList-only, so the // 100ms tick never re-fires init(). if (c.firstChild) { c.firstChild.nodeValue = s; } else { c.textContent = s; } }); }, 100); } } else { // done/error/idle: stop ticking and leave the server-rendered Speed // value alone; reset so the next run starts from zero. window.__pipeStart = null; if (window.__pipeTick) { clearInterval(window.__pipeTick); window.__pipeTick = null; } } } init(); // Coalesce re-init: Gradio replaces whole subtrees on every streamed yield, // and a raw MutationObserver(init) would run the (expensive) full re-wire // once per mutation batch — and turn any unguarded DOM write inside init() // into a tight feedback loop. One trailing 60ms timer caps re-wiring at // ~16/s no matter what mutates; newly rendered elements get wired on the // next pass, which is imperceptible. var reinitPending = false; new MutationObserver(function () { if (reinitPending) return; reinitPending = true; setTimeout(function () { reinitPending = false; init(); }, 60); }).observe(document.body, { childList: true, subtree: true }); } """ THEME = gr.themes.Base( primary_hue=gr.themes.colors.purple, secondary_hue=gr.themes.colors.cyan, neutral_hue=gr.themes.colors.slate, ) STAGE_COLORS = { "ingest": "#22d3ee", "vision": "#a78bfa", "model": "#f59e0b", "decision": "#34d399", "conflict": "#f87171", "calendar": "#8b5cf6", } # A realistic class group-chat so a first-time visitor sees value in one tap, # even in stub mode (adapted from a seed in training/make_dataset.py). SAMPLE_THREAD = ( "Room parent (3rd grade chat): Reminder — school picture day is this " "Thursday at 9am, kids wear the green class shirt!\n" "Coach Dana: Also heads up, soccer practice moves to Tuesday 5pm this week.\n" "Me: thanks! adding both so I don't forget" ) def _load_sample() -> str: return SAMPLE_THREAD # --------------------------------------------------------------------------- # # Schedule tab helpers # --------------------------------------------------------------------------- # def _events_to_rows(events: list[Event]) -> list[list]: return [ [e.title, e.start, e.end or "", e.location or "", e.reminder_minutes or ""] for e in events ] def _rows_to_events(rows) -> list[Event]: out = [] for r in rows or []: if not r or not r[0]: continue out.append( Event( title=r[0], start=r[1], end=(r[2] or None), location=(r[3] or None), reminder_minutes=int(r[4]) if str(r[4]).strip() else None, ) ) return out def _fmt_when(start: str, end: str | None = None) -> str: """Human-friendly time range from ISO strings; falls back to the raw value.""" try: s = dtparser.isoparse(start) day = s.strftime("%a, %b ") + str(s.day) t1 = s.strftime("%I:%M %p").lstrip("0") if end: e = dtparser.isoparse(end) return f"{day} · {t1} – {e.strftime('%I:%M %p').lstrip('0')}" return f"{day} · {t1}" except Exception: # noqa: BLE001 return start or "" def _plan_markdown(plan: ActionPlan) -> str: """Render the plan summary as HTML (shown in a gr.HTML). Keeps the ⚠️ marker so ``_count_conflicts`` can still tally conflicts from the rendered string.""" parts = [] if plan.reasoning: parts.append(f'

{html.escape(plan.reasoning)}

') if plan.conflicts: badges = "".join( f'⚠️ {html.escape(c.severity)} · ' f'{html.escape(c.clashes_with)}' for c in plan.conflicts ) parts.append(f'
{badges}
') if plan.proposed_times: chips = "".join( f'{html.escape(_fmt_when(t))}' for t in plan.proposed_times ) parts.append( f'
Free slots{chips}
' ) if plan.needs_clarification: parts.append(f'

❓ {html.escape(plan.needs_clarification)}

') if not parts: return '

✓ All clear — no conflicts.

' return f'
{"".join(parts)}
' def _event_meta(e: Event) -> str: """A • bullet-separated meta line (Netflix-style), location/reminder.""" bits = [] if e.location: bits.append(f"📍 {html.escape(e.location)}") if e.reminder_minutes: bits.append(f"⏰ {int(e.reminder_minutes)} min before") return f'
{" • ".join(bits)}
' if bits else "" # --------------------------------------------------------------------------- # # Rotating hero carousel (billboard slides; auto-advance via CAROUSEL_JS) # --------------------------------------------------------------------------- # def _slide(kicker: str, title: str, sub: str = "", note: str = "") -> str: sub_html = f'
{html.escape(sub)}
' if sub else "" note_html = f'
{html.escape(note)}
' if note else "" return ( '
' '
' '
' f'
{html.escape(kicker)}
' f'

{html.escape(title)}

' f"{sub_html}{note_html}" "
" ) def _event_slide(e: Event, kicker: str) -> str: note = f'
{html.escape(e.notes)}
' if e.notes else "" return ( '
' '
' '
' f'
{html.escape(kicker)}
' f'

{html.escape(e.title or "Event")}

' f'
{html.escape(_fmt_when(e.start, e.end))}
' f"{_event_meta(e)}{note}" "
" ) def _carousel_html(slides: list[str], interval: int = 9000) -> str: """Wrap slides in an auto-rotating carousel. First slide is active by default so it shows even if JS is unavailable; CAROUSEL_JS wires advance + arrows + dots.""" if not slides: return "" track = "".join( s.replace('class="cz-slide', 'class="cz-slide is-active', 1) if i == 0 else s for i, s in enumerate(slides) ) multi = len(slides) > 1 arrows = ( '' '' ) if multi else "" dots = ( '
' + "".join( f'' for i in range(len(slides)) ) + "
" ) if multi else "" return ( f'' ) def _img_slide(bg_class: str, kicker: str, title: str, sub: str) -> str: """A showcase slide with an SVG illustration background + dark scrim + white text.""" return ( f'
' '
' '
' f'
▍{html.escape(kicker)}
' f'

{html.escape(title)}

' f'
{html.escape(sub)}
' "
" ) def _idle_carousel() -> str: """Showcase slides (visible on load) — 4 image-backed cards selling impact + use.""" return _carousel_html([ _img_slide("cz-bg-chat", "Never miss a school event", "Paste the class group chat", "Every date & time becomes a calendar event — automatically."), _img_slide("cz-bg-flyer", "Snap it, don’t type it", "A flyer or screenshot works too", "It reads the date, time & place right from the image."), _img_slide("cz-bg-cal", "No more double-booking", "Checked against your calendar", "Catches clashes and suggests free times before you commit."), _img_slide("cz-bg-reply", "Reply in one tap", "A response, written for you", "RSVP and confirm without drafting a thing."), _img_slide("cz-bg-carpool", "Carpool, sorted", "Drop the carpool thread", "Pickup & drop-off times — and whose turn to drive — land on your calendar."), _img_slide("cz-bg-appt", "Don’t miss the appointment", "Forward the reminder text", "Doctor, dentist & vet visits become events — with a heads-up before."), _img_slide("cz-bg-party", "RSVP before it slips", "Drop the party invite", "The party, the RSVP deadline, and a one-tap reply — all handled."), ]) def _render_event_carousel(rows) -> str: """Rotating hero carousel of the found events — one per slide.""" events = _rows_to_events(rows) if not events: return _carousel_html([ _slide("No events", "Nothing to schedule yet", "Paste a chat with a date & time, or tap “Try a sample”.") ]) n = len(events) slides = [ _event_slide(e, "Next up" if i == 0 else f"{i + 1} of {n}") for i, e in enumerate(events) ] return _carousel_html(slides) def _provider_row(provider: str) -> str: """One row of the unified 'Connect your calendar' block. Google is the only provider with real OAuth (one click, token kept in the visitor's browser); Outlook and Apple already work with no sign-in via the per-event quick-add links and the .ics download, so their rows just say where to find that.""" if provider == "google": return ( '
' '📅 Google Calendar' '🔗 Connect' '' 'disconnect' 'one click — stays connected ' "in this browser" 'switch to ☁️ Online to ' "enable
" ) if provider == "microsoft": return ( '
' '📅 Microsoft Outlook' 'one-click “Add to Outlook” links appear on ' "every event found — no sign-in needed
" ) return ( '
' '📅 Apple Calendar' 'use “Add to Apple Calendar” on any event or the ' ".ics download — opens straight in Calendar
" ) def _gcal_badge_html() -> str: """Tiny Google connection badge for the export toolbar — wireGcal()'s mark() rewrites its text/class from the (server-verified) connection state, so the user sees whether 'Add to Google Calendar' will work BEFORE clicking it.""" return ( '
' 'Google: not connected
' ) def _connect_block_html() -> str: """The unified provider block in Step 2a (Google row mirrored on the Agent tab). wireGcal() restores the connected state from localStorage on page load, so a connected visitor is never asked to connect again.""" return ( '
' + _provider_row("google") + _provider_row("microsoft") + _provider_row("apple") + '
Your Google token stays in this browser — ' "never on our server.
" ) def _quick_add_links(e: Event) -> str: """Per-event one-click 'add to calendar' hyperlinks — THE single export surface: prefilled Google / Outlook template URLs (one click + Save, no OAuth), plus an iCal link that delivers a single-event .ics (Apple Calendar, Outlook desktop, and most calendar apps; no prefill URL exists for those).""" try: s = dtparser.isoparse(e.start) end = dtparser.isoparse(e.end) if e.end else s + timedelta(hours=1) except Exception: # noqa: BLE001 unparseable date -> no links return "" fmt = "%Y%m%dT%H%M%S" g = "https://calendar.google.com/calendar/render?" + urlencode({ "action": "TEMPLATE", "text": e.title or "Event", "dates": f"{s.strftime(fmt)}/{end.strftime(fmt)}", "details": e.notes or "", "location": e.location or "", }) o = "https://outlook.live.com/calendar/0/action/compose?" + urlencode({ "subject": e.title or "Event", "startdt": s.isoformat(), "enddt": end.isoformat(), "body": e.notes or "", "location": e.location or "", }) try: from calendar_out.ics import events_to_ics a = ("data:text/calendar;base64," + base64.b64encode(events_to_ics([e])).decode("ascii")) ical = (f' · iCal' f' · .ics') except Exception: # noqa: BLE001 ical = "" return ( '
Add to: ' f'Google · ' f'Outlook' f"{ical}
" ) def _render_event_cards(rows, mode: str = "", notes=None) -> str: """The condensed event list (plain cards, no billboard). Every card carries the quick-add hyperlinks in both modes. (``mode`` stays in the signature — all existing wiring passes it.) ``notes`` is the per-event notes list captured at analysis time (the ``found_notes`` State). Notes aren't a Dataframe column, so rows alone can't carry the arrival context ("Appointment at 10:30; arrive 15 min early") — this card is the one place the user sees it. Notes are matched to events by position and dropped if the counts diverge (a row was added or removed in the editor), so a stale note never lands on the wrong event. """ events = _rows_to_events(rows) if not events: return ( '
No events found yet — tap ' "Try a sample, or paste a chat with a date & time.
" ) notes = list(notes or []) aligned = len(notes) == len(events) cards = [] for i, e in enumerate(events): note_txt = e.notes or (notes[i] if aligned else "") or "" loc = (f'
📍 {html.escape(e.location)}
' if e.location else "") rem = (f'
⏰ Reminder {int(e.reminder_minutes)} min before
' if e.reminder_minutes else "") note = f'
📝 {html.escape(note_txt)}
' if note_txt else "" quick = _quick_add_links(e) cards.append( f'
' '' '
' f'

{html.escape(e.title or "Event")}

' f'{html.escape(_fmt_when(e.start, e.end))}' f"{loc}{rem}{note}{quick}" "
" ) n = len(events) # 2+ events: one tap for everything — an all-events .ics in the same link # style (Google/Outlook have no multi-event prefill URL, iCal does it all). all_link = "" if n > 1: try: from calendar_out.ics import events_to_ics a = ("data:text/calendar;base64," + base64.b64encode(events_to_ics(events)).decode("ascii")) all_link = (f'' f"⬇ iCal — all {n} events") except Exception: # noqa: BLE001 all_link = "" head = (f'
{n} event{"" if n == 1 else "s"} found' f"{all_link}
") return f'
{head}
{"".join(cards)}
' def _load_busy(cal_path): # Self-hosted convenience: a locally hosted agent/install can point # CAL_ICS_PATH at an exported calendar file so step 4 completes itself — # an explicit upload always takes precedence. Unset on the cloud Space. if not cal_path: cal_path = os.environ.get("CAL_ICS_PATH", "").strip() or None if not cal_path: return [] try: from calendar_out.freebusy import load_ics_busy with open(cal_path, "rb") as f: return load_ics_busy(f.read()) except Exception: # noqa: BLE001 return [] def _on_analyze(conversation: str, cal_path, image_paths, mem_json: str = ""): """Generator: run the SAME agentic workflow as the Agent tab (the MiniCPM/MCP orchestrator) behind the homepage's existing UX — the step trace streams into the "watch it think" panel, then the usual events / conflicts / reply fill in. Yields a 7-tuple: (trace, table rows, plan html, reply, status, notes, pipeline) — notes feeds the ``found_notes`` State (the 5-column table can't carry per-event notes); the trailing pipeline HTML feeds the tracker card that lives ABOVE the status line, outside the trace accordion. ``mem_json`` is the user's per-device (localStorage) memory.""" import base64 as _b64 from server.orchestrator import run_orchestrator # Calendar: explicit upload wins; CAL_ICS_PATH keeps the self-hosted # auto-calendar behavior (step 4 completes itself locally). if not cal_path: cal_path = os.environ.get("CAL_ICS_PATH", "").strip() or None ics_b64 = None if cal_path: try: ics_b64 = _b64.b64encode(Path(cal_path).read_bytes()).decode("ascii") except Exception: # noqa: BLE001 ics_b64 = None images = paths_to_data_uris(image_paths or []) memory_block = memory.facts_to_recall(_facts_load(mem_json)) or None # Pipeline-card meta: evidence reflects what's ACTUALLY read (cal_path is # post-fallback here); elapsed at the final yield is the Speed chip. t0 = time.monotonic() evidence = _evidence_label(conversation, image_paths, cal_path) _meta = lambda: {"elapsed": time.monotonic() - t0, "evidence": evidence} # noqa: E731 steps: list[dict] = [] final = None for step in run_orchestrator(conversation or "", ics_b64=ics_b64, memory_block=memory_block, images=images or None): steps.append(step) if step.get("kind") == "final": final = step yield (_render_agent_trace(steps), gr.update(), gr.update(), gr.update(), "🤖 the agent is working…", gr.update(), _render_pipeline_card(steps, _meta())) if not final: yield (_render_agent_trace(steps), gr.update(), gr.update(), gr.update(), "The agent didn't reach a final step.", gr.update(), _render_pipeline_card(steps, _meta())) return plan_dict = final.get("plan") or {} try: plan = ActionPlan(**plan_dict) except Exception: # noqa: BLE001 defensive: dict drift -> empty plan plan = ActionPlan(reasoning="", events=[], conflicts=[], proposed_times=[], reply_draft="", needs_clarification=None) # The per-event card links (the one export surface) are plain anchors — # Gradio can't hook their clicks — so a capture is recorded when the run # surfaces events, not on an export button. if plan.events: impact.record_capture(len(plan.events), conflicts_caught=len(plan.conflicts)) yield ( _render_agent_trace(steps), _events_to_rows(plan.events), _plan_markdown(plan), plan.reply_draft, f"Found {len(plan.events)} event(s) ({len(images)} image(s) read), " f"{len(plan.conflicts)} conflict(s).", [e.notes or "" for e in plan.events], _render_pipeline_card(steps, _meta()), ) def _count_conflicts(plan_md: str) -> int: """Conflicts the user just saw, read back from the rendered plan markdown (one ⚠️ per conflict in ``_plan_markdown``). Lets the export handlers record conflicts_caught without widening ``_on_analyze``'s output tuple.""" return (plan_md or "").count("⚠️") def _on_start_over(): """Clear inputs + hide results — the 'Start over' button.""" return ( "", # conversation None, # images_in None, # existing_cal gr.update(visible=False), # results "", # status "", # screenshot_status "", # trace_status _render_pipeline_card([]), # pipeline tracker back to idle ) def _screenshot_status(files) -> str: """Contextual hint shown when screenshot(s) are attached.""" n = len(files) if files else 0 if not n: return "" plural = "s" if n != 1 else "" return f'
📎 {n} screenshot{plural} attached — it’ll be read for events.
' def _on_share_trace(checked: bool, rows) -> str: """Privacy-safe trace toggle. When on, record a REDACTED trace locally (events only — never the raw chat text) and emit an activity event. Does not publish to the Hub; that stays in training/share_trace.py.""" if not checked: return "" events = _rows_to_events(rows) trace = { "events": [ {"title": e.title, "start": e.start, "end": e.end, "location": e.location} for e in events ], "n_events": len(events), } try: path = Path(os.environ.get("TRACE_PATH", "/tmp/offgrid_traces.jsonl")) with path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(trace) + "\n") except Exception: # noqa: BLE001 local logging is best-effort pass bus.emit("decision", f"privacy-safe trace recorded ({len(events)} event(s))", events=len(events)) return ('
✓ Privacy-safe trace recorded ' "(events only; raw text not stored).
") def _prep_ics(rows): """Pre-generate the .ics the moment analysis finishes so the file widget is a ready download link. Deliberately does NOT record impact — a capture is only counted on an explicit export action (_on_make_ics / gcal push).""" events = _rows_to_events(rows) if not events: return gr.update() return write_ics(events) # --------------------------------------------------------------------------- # # Agent tab: MiniCPM-planned orchestrator over the Space's own MCP tools # --------------------------------------------------------------------------- # _STEP_ICONS = {"plan": "🧠", "tool_call": "🔧", "tool_result": "📥", "final": "✅", "error": "⚠️"} # The pipeline card's six stages: (key, tile label, running caption). # Keys map onto the orchestrator's real step stream — capture/read on the plan # step, the middle three on their MCP tool calls, reply on the final step. _PIPE_STAGES = [ ("capture", "Capture", "Reading your thread…"), ("read", "Read", "Planning the work…"), ("extract", "Extract", "Extracting events…"), ("conflicts", "Conflicts", "Checking your calendar…"), ("export", "Export", "Writing your .ics…"), ("reply", "Reply", "Drafting a reply…"), ] _PIPE_TOOL_STAGE = {"extract_events": "extract", "check_conflicts": "conflicts", "make_ics": "export"} def _pipeline_stage_states(steps: list[dict]) -> tuple[dict[str, str], str]: """Derive per-stage states (todo/active/done/skip/error) plus the card state (idle/running/done/error) purely from the orchestration step stream. The LLM planner may interleave extra plan steps, "(observation)" results, and an error step followed by the scripted fallback replay — only a TRAILING error renders the error state.""" if not steps: return {k: "todo" for k, *_ in _PIPE_STAGES}, "idle" called, resulted = set(), set() seen_plan = has_final = False for s in steps: kind, tool = s.get("kind"), s.get("tool", "") if kind == "plan": seen_plan = True elif kind == "tool_call" and tool in _PIPE_TOOL_STAGE: called.add(tool) elif kind == "tool_result" and tool in _PIPE_TOOL_STAGE: resulted.add(tool) elif kind == "final": has_final = True states = {"capture": "done"} states["read"] = "done" if (called or has_final) else ( "active" if seen_plan else "todo") for tool, key in _PIPE_TOOL_STAGE.items(): if tool in resulted: states[key] = "done" elif tool in called: states[key] = "active" else: # final arrived without this tool ever running: genuinely skipped # (no calendar uploaded / no events to export) states[key] = "skip" if has_final else "todo" if has_final: states["reply"] = "done" elif "make_ics" in resulted or "extract_events" in resulted: states["reply"] = "active" else: states["reply"] = "todo" card = "done" if has_final else "running" if steps[-1].get("kind") == "error": card = "error" for key, *_ in _PIPE_STAGES: if states.get(key) not in ("done", "skip"): states[key] = "error" break return states, card def _evidence_label(conversation: str, image_paths, cal_path) -> str: """What the run actually read — the Evidence chip in the pipeline summary.""" bits = [] n_chars = len((conversation or "").strip()) if n_chars: bits.append(f"Pasted thread ({n_chars} chars)") n_img = len(image_paths or []) if n_img: bits.append(f"{n_img} screenshot{'s' if n_img != 1 else ''}") if cal_path: try: bits.append(f"+ {Path(cal_path).name}") except Exception: # noqa: BLE001 bits.append("+ calendar.ics") label = " · ".join(bits) or "No input" return label[:63] + "…" if len(label) > 64 else label _PIPE_BADGE = {"done": "✓", "skip": "–", "error": "!"} def _render_pipeline_card(steps: list[dict], meta: dict | None = None) -> str: """The elevated stepper card above the step trace: live stage tiles, a sweeping shimmer + ticking clock while running (wirePipeClock drives the 100ms tick client-side off data-state), and a permanent summary-chip row once the run completes.""" states, card_state = _pipeline_stage_states(steps) title = {"done": "Processing complete", "error": "Processing failed"}.get( card_state, "Processing Pipeline") elapsed = (meta or {}).get("elapsed") clock = f"{elapsed:.1f}s" if elapsed is not None else "—" tiles = [] for i, (key, name, _cap) in enumerate(_PIPE_STAGES): st = states.get(key, "todo") badge = _PIPE_BADGE.get(st, str(i + 1)) tiles.append( f'
' f'{badge}' f'{name}
' ) track = ''.join(tiles) caption = "" if card_state == "running": cap_txt = next((c for k, _n, c in _PIPE_STAGES if states.get(k) == "active"), "Working…") caption = f'
{html.escape(cap_txt)}
' summary = "" if card_state == "done" and meta is not None: final = next((s for s in steps if s.get("kind") == "final"), {}) plan = final.get("plan") or {} confident = not plan.get("needs_clarification") conf_cls, conf_txt = (("is-high", "HIGH") if confident else ("is-review", "NEEDS REVIEW")) counts = (f"{len(plan.get('events') or [])} event(s) · " f"{len(plan.get('conflicts') or [])} conflict(s)") evidence = html.escape(meta.get("evidence") or "—") summary = ( '
' f'Speed{clock}' f'Confidence{conf_txt}' f'Evidence{evidence}' f'Found{counts}' "
" ) return ( f'
' '
' f'{title}' f'{clock}
' f'
{track}
' f"{caption}{summary}
" ) def _render_agent_trace(steps: list[dict]) -> str: """The agent's visible work: one card per orchestration step. The pipeline card is a SEPARATE component now (above the status line, outside the accordion) — see the ``pipeline`` / ``ag_pipeline`` gr.HTMLs, fed by `_render_pipeline_card` from the same generators.""" if not steps: return '
No run yet — paste a thread and run the agent.
' cards = [] for s in steps: icon = _STEP_ICONS.get(s.get("kind"), "•") kind = s.get("kind", "") if kind == "tool_call": body = (f'{html.escape(str(s.get("tool", "")))}' f' {html.escape(json.dumps(s.get("args", {}), default=str)[:160])}') elif kind == "tool_result": body = (f'{html.escape(str(s.get("tool", "")))} → ' f'{html.escape(json.dumps(s.get("result", ""), default=str)[:200])}') elif kind == "final": summ = s.get("summary", {}) body = (f'done — {summ.get("events", 0)} event(s), ' f'{summ.get("conflicts", 0)} conflict(s), .ics ready') else: body = html.escape(str(s.get("text", ""))) cards.append(f'
{icon}' f'{body}
') return f'
{"".join(cards)}
' def _agent_plan_rows(plan: dict) -> list[list]: return [ [e.get("title"), e.get("start"), e.get("end") or "", e.get("location") or "", e.get("reminder_minutes") or ""] for e in (plan.get("events") or []) ] def _on_run_agent(thread: str, cal_path, image_paths, mem_json: str = "", mode: str = ""): """Generator: stream the orchestrator's steps into the trace panel, then surface the FULL homepage result surface — plan summary, carousel, cards, editable table, reply, and the agent-made .ics. Yields: (trace, table_rows, plan_md, cards, carousel, reply, status, conflicts, results_visible, ics_file, pipeline) """ import base64 as _b64 import tempfile from server.orchestrator import run_orchestrator _idle = (gr.update(),) * 9 if not (thread or "").strip() and not image_paths: yield (_render_agent_trace([]), *(gr.update(),) * 5, "Paste a thread or attach a screenshot first (or use the sample).", gr.update(), gr.update(), gr.update(), _render_pipeline_card([])) return # Calendar: explicit upload wins; CAL_ICS_PATH keeps parity with the # homepage's self-hosted auto-calendar behavior. if not cal_path: cal_path = os.environ.get("CAL_ICS_PATH", "").strip() or None ics_b64 = None if cal_path: try: ics_b64 = _b64.b64encode(Path(cal_path).read_bytes()).decode("ascii") except Exception: # noqa: BLE001 ics_b64 = None images = paths_to_data_uris(image_paths or []) memory_block = memory.facts_to_recall(_facts_load(mem_json)) or None # Same pipeline-card meta as the homepage run (full parity in the trace). t0 = time.monotonic() evidence = _evidence_label(thread, image_paths, cal_path) _meta = lambda: {"elapsed": time.monotonic() - t0, "evidence": evidence} # noqa: E731 steps: list[dict] = [] final = None for step in run_orchestrator(thread or "", ics_b64=ics_b64, memory_block=memory_block, images=images or None): steps.append(step) if step.get("kind") == "final": final = step yield (_render_agent_trace(steps), *(gr.update(),) * 5, "🤖 working…", gr.update(), gr.update(), gr.update(), _render_pipeline_card(steps, _meta())) if not final: yield (_render_agent_trace(steps), *(gr.update(),) * 5, "The agent didn't reach a final step.", gr.update(), gr.update(), gr.update(), _render_pipeline_card(steps, _meta())) return plan_dict = final.get("plan") or {} rows = _agent_plan_rows(plan_dict) try: plan_md = _plan_markdown(ActionPlan(**plan_dict)) except Exception: # noqa: BLE001 defensive: dict drift -> skip summary plan_md = "" ics_path = None if final.get("ics_base64"): fd, ics_path = tempfile.mkstemp(suffix=".ics", prefix="agent_events_") os.close(fd) Path(ics_path).write_bytes(_b64.b64decode(final["ics_base64"])) summ = final.get("summary", {}) # Same capture semantics as the homepage run (links can't be hooked). if rows: impact.record_capture(len(rows), conflicts_caught=int(summ.get("conflicts", 0) or 0)) yield ( _render_agent_trace(steps), rows, plan_md, _render_event_cards(rows, mode), _render_event_carousel(rows), plan_dict.get("reply_draft") or "", f"Agent finished: {summ.get('events', 0)} event(s) " f"({len(images)} image(s) read), {summ.get('conflicts', 0)} conflict(s).", int(summ.get("conflicts", 0) or 0), gr.update(visible=True), (gr.update(value=ics_path) if ics_path else gr.update()), _render_pipeline_card(steps, _meta()), ) def _on_make_ics(rows, captured_conflicts: int = 0): events = _rows_to_events(rows) if not events: return None, "No events to export." path = write_ics(events) # Exporting is the user accepting these events — record it for "This week". impact.record_capture(len(events), conflicts_caught=int(captured_conflicts or 0)) return path, f"Wrote {len(events)} event(s) to .ics." def _on_download_trace(redact: bool = True): """Export the most recent agent run as a downloadable JSON trace (Sharing is Caring). Stays local — no Hub token in the Space; publish with training/share_trace.py.""" from server.trace import export_run, write_trace # lazy, mirrors other handlers trace = export_run(redact=redact) if not trace["steps"]: return None, "No agent run to export yet — analyze a thread first." path = write_trace(trace) return path, f"Trace ready ({trace['summary']['steps']} step(s), redacted={redact})." def _on_publish_trace() -> str: """One-click 'Sharing is Caring': publish the latest run's REDACTED trace to a PUBLIC Hugging Face dataset so anyone can learn from it. Requires a Space write token (HF_WRITE_TOKEN); degrades gracefully with a clear message otherwise. Redaction is forced — raw text / titles never reach the public repo.""" token = (os.environ.get("HF_WRITE_TOKEN") or "").strip() repo = os.environ.get("TRACE_DATASET", "ParetoOptimal/offgridschedula-traces") if not token: return ("⚠️ Publishing isn't configured on this Space. Set an **`HF_WRITE_TOKEN`** " "secret (a dataset-scoped *write* token) to enable one-click publish — " "or use **⬇ Download trace** + `python training/share_trace.py`.") from server.trace import export_run # lazy trace = export_run(redact=True) # force redaction for a PUBLIC dataset if not trace["steps"]: return "Nothing to publish yet — run an analysis first, then publish its trace." import io from datetime import datetime from huggingface_hub import HfApi url = f"https://huggingface.co/datasets/{repo}" fname = f"traces/trace-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json" try: api = HfApi(token=token) api.create_repo(repo_id=repo, repo_type="dataset", private=False, exist_ok=True) api.upload_file( path_or_fileobj=io.BytesIO(json.dumps(trace, indent=2).encode("utf-8")), path_in_repo=fname, repo_id=repo, repo_type="dataset", commit_message="Add agent trace (redacted) — Sharing is Caring", ) except Exception as e: # noqa: BLE001 surface a friendly message, never crash the UI return f"Publish failed: {type(e).__name__}: {e}" bus.emit("decision", f"published redacted trace ({trace['summary']['steps']} step(s)) to the Hub") return f"✅ Shared your **redacted** trace publicly → [{repo}]({url}/blob/main/{fname})" def _on_push_gcal(rows, token_json, captured_conflicts: int = 0): """Push to the VISITOR'S own Google Calendar using their per-session OAuth token (from the Connect flow). The token lives in the browser; it's passed here only to perform the push and is never stored server-side.""" if not (token_json or "").strip(): return "Connect Google Calendar first (Step 2a → **Connect your calendar**)." events = _rows_to_events(rows) if not events: return "No events to push." try: from calendar_out.gcal import push_events_with_token links = push_events_with_token(token_json, events) # Only record once the push actually succeeded. impact.record_capture(len(events), conflicts_caught=int(captured_conflicts or 0)) return "Added to your Google Calendar:\n" + "\n".join(links) except Exception as e: # noqa: BLE001 return f"Google Calendar push failed: {e}" # --------------------------------------------------------------------------- # # Activity dashboard renderers # --------------------------------------------------------------------------- # def _stepper_html() -> str: cur = bus.current_stage() cells = [] for s in bus.STAGES: active = " active" if s == cur else "" color = STAGE_COLORS.get(s, "#888") cells.append(f'
{s}
') return '
' + "".join(cells) + "
" def _tiles_html() -> str: m = bus.metrics() defs = [ ("Messages", m["messages"]), ("Events", m["events_created"]), ("Conflicts", m["conflicts"]), ("Images", m["images_read"]), ("Model calls", m["model_calls"]), ("Avg latency", f'{m["avg_latency_ms"]} ms'), ("Errors", m["errors"]), ] cells = "".join( f'
{v}
{k}
' for k, v in defs ) return f'
{cells}
' def _timeline_html(n: int = 40) -> str: rows = bus.recent(n) if not rows: return '
No activity yet — analyze a thread or POST to /ingest.
' items = [] for e in rows: color = STAGE_COLORS.get(e["stage"], "#888") meta_bits = [] for k in ("latency_ms", "events", "conflicts", "images", "tokens"): v = e.get(k) if v: meta_bits.append(f"{k}={v}") meta = " · ".join(meta_bits) err = " err" if e.get("level") == "error" else "" items.append( f'
' f'{e["stage"]}' f'{e["message"]}' f'{meta}' f'{e["ts"][11:]}
' ) return '
' + "".join(items) + "
" def _impact_html() -> str: """Durable weekly impact (events captured, conflicts caught, time saved). Reuses the same .tiles/.tile markup as _tiles_html so the CSS styles it for free — but reads from the persisted impact log, not the ephemeral bus.""" w = impact.this_week() mins = w["minutes_saved"] saved = f"{mins // 60}h {mins % 60}m" if mins >= 60 else f"{mins}m" defs = [ ("Events captured", w["events_captured"]), ("Conflicts caught", w["conflicts_caught"]), ("Time saved", saved), ] cells = "".join( f'
{v}
{k}
' for k, v in defs ) return f'
{cells}
' def _chart_df() -> pd.DataFrame: return pd.DataFrame(bus.stage_counts()) def _traces_html(n: int = 6) -> str: runs = bus.recent_runs(n) if not runs: return '
No agent runs yet.
' blocks = [] for rid, evs in runs: lines = [] for e in evs: color = STAGE_COLORS.get(e["stage"], "#888") lat = f' ({e["latency_ms"]} ms)' if e.get("latency_ms") else "" lines.append( f'
{e["stage"]} {e["message"]}{lat}
' ) summary = f"run #{rid} — {len(evs)} step(s)" blocks.append( f'
{summary}{"".join(lines)}
' ) return "".join(blocks) def _refresh(): return ( _stepper_html(), _tiles_html(), _timeline_html(), _chart_df(), _traces_html(), _impact_html(), ) # --------------------------------------------------------------------------- # # Landing-page sections (static marketing HTML; the tool itself stays Gradio) # --------------------------------------------------------------------------- # def _nav_html() -> str: """Single grouped nav. Page links carry data-tab (the injected JS clicks the matching Gradio tab); landing links also carry data-anchor to scroll within the Schedule page. The default Gradio tab strip is hidden via CSS. (The fine-tuned-model "Powered by" line lives under the hero badges, not here.)""" return ( '' ) def _hero_copy_html() -> str: return ( '
' '
Your Calendar's Off-Grid Agent
' '

Schedule your invite
' 'before it slips away.

' '

Meetings, events, activities — buried in a noisy group chat or a ' 'screenshot. Paste it in and get calendar-ready events, conflicts caught against your ' 'calendar, and a reply written for you.

' '

No app · no account · runs locally — your messages never touch a cloud AI.

' '
' ) def _hero_visual_html() -> str: """A design play on the core function: a chat bubble morphing into a calendar event chip. Pure HTML/CSS (no JS) so it renders on every path.""" return ( '
' '
' '
Class group chat
' '
Picture day is Thursday at 9am — wear the green shirt! 📸
' '
' '
' '
' '
' '
' '
📸 Picture Day
' '
Thu · 9:00 AM
' '
Lincoln Elementary · wear green
' '
' '
' ) def _hero_examples_html() -> str: """A 2×3 grid of compact 'chat → event' example cards for the hero. The event lines are COMPUTED against today's date, following the site's date-resolution rules: weekdays resolve to the next occurrence on or after today; AM/PM is preserved exactly and inferred meridiems are flagged 'assumed'; stated times are never dropped; time-only messages inherit today; deadlines are separate entries; month-day dates already past roll to next year. (Rendered at app startup — refreshes on each restart.)""" from datetime import datetime as _dt today = _dt.now() def next_weekday(dow: int) -> _dt: # Mon=0; next occurrence ON OR AFTER today return today + timedelta(days=(dow - today.weekday()) % 7) def day_label(d: _dt) -> str: return f"{d.strftime('%a')}, {d.strftime('%b')} {d.day}" def line(title: str, when: str, time_s: str = "", assumed: bool = False) -> str: parts = [title, when] + ([time_s] if time_s else []) flag = ' assumed' if assumed else "" return html.escape(" · ".join(parts)) + flag def month_day(month: int, day: int) -> _dt: # rule 6: roll past dates to next year d = _dt(today.year, month, day) return d if d.date() >= today.date() else _dt(today.year + 1, month, day) bake = month_day(6, 12) ex = [ ("School chat", "Picture day Thursday 9am", [line("📸 Picture Day", day_label(next_weekday(3)), "9:00 AM")]), ("Coach", "Practice moved to Tue 5pm", [line("⚽ Practice", day_label(next_weekday(1)), "5:00 PM")]), ("Carpool", "I’ll grab them at 3:15", [line("🚗 Pickup", "Today", "3:15 PM", assumed=True)]), ("Doctor", "Appt confirmed Mon 10:30", [line("🩺 Checkup", day_label(next_weekday(0)), "10:30 AM", assumed=True)]), ("Party", "RSVP by Fri — party Sat 2pm", [line("📝 RSVP due", day_label(next_weekday(4))), line("🎉 Party", day_label(next_weekday(5)), "2:00 PM")]), ("Flyer", "Bake sale — June 12, 8am · drop off cookies by Thu 6pm", [line("🍪 Drop off cookies", day_label(next_weekday(3)), "6:00 PM"), line("🧁 Bake Sale", day_label(bake), "8:00 AM")]), ] cards = "".join( f'
{html.escape(frm)}
' f'
{html.escape(chat)}
' + "".join(f'
{ev}
' for ev in evs) + "
" for frm, chat, evs in ex ) return f'
{cards}
' def _hero_badges_html() -> str: """Live trust badges under the hero copy (read from env).""" f = _compliance_facts() local = "🔒 100% local" if f["local"] else "☁️ remote" return ( '
' f'{local}' '🦙 llama.cpp' '🎯 Fine-tuned' f'🪶 {html.escape(f["params"])} · ≤ 32B' '🤖 Agentic' "
" ) # --- hackathon compliance: live facts (read from env) + showcase HTML ---------- # Model: gemma-cal E4B — our calendar-native fine-tune of google/gemma-4-E4B-it # (~4B effective params, <= 32B), published as build-small-hackathon/gemma-4-cal-gguf. _SPACE_URL = "https://huggingface.co/spaces/build-small-hackathon/OffGridSchedula" _BLOB = _SPACE_URL + "/blob/main/" _MODEL_URL = "https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf" def _compliance_facts() -> dict: """Read the live model/runtime config so the badges reflect reality.""" repo = os.environ.get("MODEL_REPO", "build-small-hackathon/gemma-4-cal-gguf") fname = os.environ.get("MODEL_FILE", "gemma-cal-e4b-Q4_K_M.gguf") base = os.environ.get("INFERENCE_BASE_URL", "") local = (not base) or ("127.0.0.1" in base) or ("localhost" in base) rl = f"{repo}/{fname}".lower() if "e4b" in rl: label, params = "gemma-cal E4B (fine-tuned)", "~4B" elif "e2b" in rl: label, params = "Gemma 4 E2B", "~2B" elif "31b" in rl or "gemma-cal-q4" in rl: label, params = "gemma-cal 31B (fine-tuned)", "31B" else: label, params = repo.split("/")[-1], "≤32B" return {"repo": repo, "local": local, "label": label, "params": params} def _submission_html() -> str: """A live compliance scorecard — maps every hackathon rule to evidence.""" f = _compliance_facts() loc = "100% local — no cloud AI APIs" if f["local"] else "remote inference seam" def row(ok: bool, title: str, ev: str) -> str: cls, mark = ("sub-ok", "✓") if ok else ("sub-warn", "⚠") return ( f'
{mark}' f'
{title}
' f'
{ev}
' ) hard = ( row(True, "Small model ≤ 32B", f'{html.escape(f["label"])} · {html.escape(f["params"])} params (≤ 32B) — ' "our own fine-tune of Gemma 4 E4B, published on the Hub. Laptop-class by design: " "~4B effective params, about 5 GB at 4-bit.") + row(True, "Gradio app · Hugging Face Space", "app.py is a Gradio Blocks app, shipped as a Hugging Face Space — a " "Docker image that runs llama.cpp locally. You're reading this scorecard inside it.") + row(True, "Show, don’t tell", "So we didn't tell — we showed. A demo video of the real product running (no slides, " "just the thing doing the thing) ships with the submission, alongside the social post.") ) track = ( '

Track A — Backyard AI. A specific real person: a busy parent ' "whose kid’s school & activity events are buried in a noisy class group chat. Honest fit — " "short pasted chats and screenshots are exactly what a small, local model handles well, and the " "output (a reviewable .ics) is genuinely usable from a phone browser.

" ) bonus = ( row(True, "Off the Grid (local-first)", f"{loc} — inference runs via llama.cpp in the Space.") + row(True, "Well-Tuned", "A QLoRA fine-tune of Gemma 4 E4B, eval-gated: every retrain has to clear a " "60-example scorecard before it can ship — eight of them didn't. The one that did is " "published on the Hub.") + row(True, "Off-Brand (custom UI)", "This site — custom landing, grouped nav, dark/light hero, and bespoke CSS/JS, far past " "the default Gradio look.") + row(True, "Llama Champion", "Runs through the llama.cpp runtime (official ggml-org/llama.cpp image).") + row(True, "Sharing is Caring", "One-click ☁ Publish my trace to the Hub (Activity tab) pushes a redacted " "run to a public dataset — counts and stage names, never your chat text — so anyone can " "learn from it. (Token-free route: ⬇ Download trace and publish it yourself.)") + row(True, "Agentic (MCP tool server + agent)", "Both sides of MCP: the Space exposes " "extract_events / make_ics / check_conflicts " "as Model Context Protocol tools (schemas auto-generated) — and the " "Agent tab consumes them: an OpenBMB MiniCPM planner " "(local llama.cpp) makes a single, visible extract_events call over MCP, " "while conflict-checking and .ics rendering are finalized deterministically. " "No cloud AI APIs anywhere in the loop.") + row(True, "OpenBMB MiniCPM (sponsor)", "The Agent tab's planner is MiniCPM " "(openbmb/MiniCPM5-1B-GGUF, the ≤4B tiny variant; the 8B " "MiniCPM4.1-8B is config-selectable) served by the same local " "llama.cpp — core to the agent experience, under the 32B cap.") + row(True, "Field Notes", "By the numbers: 8 fine-tunes rejected at the eval gate, " "7 bugs in agent code that had never run, a planner trimmed " "207 s → 75 s — and still zero cloud calls.") ) return ( '
' '
Hackathon submission
' '

Built-small, by the rules.

' '

Hard constraints

' + hard + "
" '

Track

' + track + "
" '

Bonus quests

' + bonus + "
" "
" ) def _how_html() -> str: steps = [ ("01", "Paste it in", "Drop in the group chat — or a screenshot of a flyer, invite, or text."), ("02", "Review everything", "See the events, conflicts against your calendar, and a ready-to-send reply — before anything is saved."), ("03", "Add to your calendar", "Download a local .ics for any calendar, or push straight to Google or Apple Calendar."), ] cards = "".join( f'
{n}
' f'

{t}

{d}

' for n, t, d in steps ) return ( '
' '
How it works
' '

From buried message to booked — in three taps.

' f'
{cards}
' '
' ) def _uses_html() -> str: uses = [ ("🗓", "Meetings", "“Sync moved to Thursday 2pm” → on your calendar, no retyping."), ("🎒", "School & activities", "Picture day, the practice that moved, the field trip slip."), ("🚗", "Carpool & pickups", "Pickup times and whose turn to drive — sorted."), ("🩺", "Appointments", "Doctor, dentist & vet reminders, with a heads-up before."), ("🎉", "Parties & RSVPs", "The party, the RSVP deadline, and a one-tap reply."), ("📸", "Screenshots & flyers", "Snap an invite — it reads the date, time & place from the image."), ] cards = "".join( f'
{i}
' f'

{t}

{d}

' for i, t, d in uses ) return ( '
' '
Use cases
' '

Anywhere a date hides in a conversation.

' f'
{cards}
' '
' ) def _faq_html() -> str: """FAQ with two tabs (General / For developers), search filter, and native
accordion rows. Wired by the FAQ block in CAROUSEL_JS.""" general: list[tuple[str, str]] = [ ( "What problem does this actually solve?", "

School pickups, practice changes, RSVPs, doctor reminders — " "the dates that hide in noisy group chats and flyers. You paste " "the relevant chat (or a screenshot); the agent extracts the " "events, flags conflicts against your calendar, and drafts a " "reply. You go back to managing one calendar instead of " "scanning four chats.

", ), ( "Is anything sent to a cloud AI service?", "

No. Inference runs locally inside the Space via " "llama.cpp — no OpenAI / Anthropic / Google AI " "calls. The only outbound network call you might make is the " "optional push to Google Calendar, and only if you click " "it and authorise it yourself.

", ), ( "Do I need an app or an account?", "

No. It works from any phone or laptop browser. There's " "nothing to install and nothing to sign up for. The output is " "a .ics file you own — import it into Apple " "Calendar, Google Calendar, Outlook, or anything else.

", ), ( "Does it read my messages automatically?", "

No. It reads only the text you choose to paste in (and any " "screenshots you attach). It has no access to your inbox, your " "contacts, or your phone's Messages app. Nothing is read " "without your action.

", ), ( "What inputs work best?", "", ), ( "How accurate is it, really?", "

Small fine-tuned models are good at narrow tasks and worse " "at edge cases. Honestly:

" "", ), ] dev: list[tuple[str, str]] = [ ( "Can other AI agents call this Space as a tool?", "

Yes. The Space exposes a Model Context Protocol (MCP) " "server, so any MCP-aware agent (Claude Desktop, Cursor, " "etc.) can call three tools against it:

" "" "

Schemas are auto-generated from the typed wrappers in " f'server/mcp_tools.py.

', ), ( "How do I connect an MCP-aware agent to this Space?", "

Point the agent's MCP config at the Space's SSE endpoint:

" "

build-small-hackathon-offgridschedula.hf.space" "/gradio_api/mcp/sse

" "

In Claude Desktop, add an entry under " "mcpServers in " "claude_desktop_config.json with that URL. In " "Cursor, add it under Settings → MCP. The three tools " "appear automatically; the agent picks them up by docstring.

", ), ( "Where do I find the fine-tuned model?", "

Published openly on the Hugging Face Hub as " f'build-small-hackathon/gemma-4-cal-gguf' " — a QLoRA fine-tune of Gemma 4 converted to GGUF so " "llama.cpp can run it on CPU or GPU. The Space pulls the model " "weights at startup; nothing custom is bundled into this repo.

", ), ( "Can I run this off the Space, locally?", "

Yes — this Space is the repo. Its " f'Files tab is a public git ' "repository: git clone " f"{_SPACE_URL}, install requirements.txt (or " "build the Dockerfile), set MODEL_REPO + " "MODEL_FILE to point at any GGUF on the Hub, and " "run python app.py. The same Gradio UI + FastAPI " "/agent endpoint + MCP server come up on " "localhost:7860.

", ), ] def _rows(items: list[tuple[str, str]]) -> str: # Inline SVG: outer circle + horizontal line (always shown) + vertical # line (hidden when item is open) -> visual "+" toggles to "-". ico = ( '" ) return "".join( f'
' f'{q}{ico}' f'
{a}
' for q, a in items ) search_svg = ( '' '' ) return ( '
' '
' '

Frequently asked questions

' '" "
" '
' '' '' "
" f'
{_rows(general)}
' f'' '" "
" ) def _privacy_html() -> str: points = [ ("Runs locally", "Inference happens in the Space via llama.cpp — no cloud AI APIs."), ("Nothing auto-read", "It reads only what you choose to paste. No inbox, no message access."), ("No app, no account", "Works from your phone's browser. Output is a local .ics you own."), ] items = "".join( f'

✓ {t}

{d}

' for t, d in points ) return ( '
' '
Private by design
' '

Off the grid, on purpose.

' f'
{items}
' '
' ) def _tool_intro_html() -> str: return ( '
' '
' '
Try it now
' '

Paste a chat — get your events.

' ) def _tool_header_html() -> str: """Header for the elevated tool card: eyebrow + title + the "Powered by" pill (moved here from the hero — it identifies the model where you actually use it).""" return ( '
' '
' '
Try it for free
' '

Paste a chat, manage your events

' f'' '🟢 Powered by fine-tuned Gemma 4 · gemma-4-cal · llama.cpp' "
" ) def _flow_step(n: "int | str", title: str, sub: str = "") -> str: """Numbered workflow step header: round chip + title (+ muted sub note), with a dashed tail (CSS) tying the steps together down the card.""" sub_html = f'{sub}' if sub else "" return ( f'
{n}' f'{title}{sub_html}
' ) def _mode_note_html(online: bool) -> str: """Guidance line + named workflow chip inside the mode band.""" if online: chip = "ONLINE WORKFLOW" txt = ("☁️ Online — Google Calendar enabled: connect it in step 2a, " "or just continue. Everything else still runs locally.") else: chip = "OFF-GRID WORKFLOW" txt = ("🔌 Offline — everything stays on this device: upload your " "calendar in step 2a, get an .ics back.") return (f'
{chip}' f"{txt}
") def _footer_html() -> str: return ( '' ) # --------------------------------------------------------------------------- # # App # --------------------------------------------------------------------------- # def build_demo() -> gr.Blocks: # Theme/css set on the Blocks so the Off-Brand look applies no matter who # launches it (our uvicorn locally, or HF's gradio runtime on a Spaces deploy). with gr.Blocks(theme=THEME, css=CSS, title="OffGridSchedula") as demo: gr.HTML('', elem_id="status-banner-host") gr.HTML(_nav_html()) # Per-user memory lives in the visitor's browser (localStorage); this hidden # box mirrors it so handlers can read/edit it and the agent can use it. # Loaded from localStorage on page load; persisted back on every change. mem_box = gr.Textbox(visible=False, elem_id="mem-box") with gr.Tab("Schedule"): captured_conflicts = gr.State(0) # --- landing hero: dark band — copy + example-card grid --- with gr.Row(elem_id="hero"): with gr.Column(elem_id="hero-left"): gr.HTML(_hero_copy_html()) with gr.Row(elem_id="hero-cta"): hero_try_btn = gr.Button( "Try a sample", variant="primary", elem_id="hero-try", scale=0 ) gr.HTML('See it in action ↓') gr.HTML(_hero_badges_html()) with gr.Column(elem_id="hero-right"): gr.HTML(_hero_examples_html()) # --- elevated tool card (overlaps the hero); the agent up top --- with gr.Column(elem_id="tool-card"): gr.HTML(_tool_header_html()) # ── Step 1: Choose your workflow — the mode decision point ──── # gr.HTML(_flow_step(1, "Choose your workflow")) # Offline / Online mode toggle: ONE decision point that re-themes # and reconfigures the whole workflow card. Lives in a full-width # tinted "mode band"; the card carries data-mode for the CSS theme. with gr.Group(elem_id="mode-band"): mode = gr.Radio( ["🔌 Offline", "☁️ Online"], value="🔌 Offline", show_label=False, elem_id="mode-toggle", container=False, ) mode_note = gr.HTML(_mode_note_html(online=False)) # ── Step 2: Upload context — screenshot + paste, one group ──── # gr.HTML(_flow_step(2, "Upload context")) with gr.Row(elem_id="io-cols"): with gr.Column(elem_classes="io-col"): gr.HTML('
Upload a screenshot
') images_in = gr.Files( label="", file_types=["image"], type="filepath", elem_id="io-drop", ) screenshot_status = gr.HTML() with gr.Column(elem_classes="io-col"): gr.HTML('
Or paste the chat
') conversation = gr.Textbox( label="", placeholder=( "Paste the school / activity chat — e.g. “Picture day is " "Thursday at 9am, wear green”." ), lines=6, elem_id="rv-textbox", ) gr.HTML( '
Chat, SMS, or invite — English & ' 'screenshots supported' '0 / 12000
' ) # ── Step 2a: Optional — calendar (first option) · trace · personalize ── # gr.HTML(_flow_step("2a", "Optional", "(calendar · privacy trace · personalize)")) # First option: the calendar, as a dropdown (caret) holding BOTH # paths — connect a cloud provider (one unified block, all three # providers) or upload an .ics. Always rendered; offline mode # hides only the Google connect CTA via CSS (data-mode). with gr.Accordion( "🔗 Connect your calendar (+ optional .ics for conflict checks)", open=False, ) as cal_acc: gr.HTML(_connect_block_html()) existing_cal = gr.File( label="Your calendar (.ics)", file_types=[".ics"], type="filepath", ) with gr.Group(elem_classes="trace-card"): trace_checkbox = gr.Checkbox( label=("Publish privacy-safe trace — stores a redacted event summary " "for the agent-trace dataset; raw text, screenshots, and links " "are not stored."), value=False, elem_id="trace-cb", ) trace_status = gr.HTML() # Onboarding — ALWAYS on the page so the agent can be personalized # at any time (people the chat mentions, preferences, etc.). Open # for first-time visitors; collapses (never disappears) once the # device has memory or the user hits Skip. with gr.Accordion( "👋 Personalize in 10 seconds", open=True, elem_id="onboard" ) as onboard: gr.HTML( '
Tell the agent a few things — saved on your device ' "and used to read your chats better. You can skip this.
" ) ob_people = gr.Textbox( label="People (one per line — “Name = role”)", lines=2, placeholder="Dana = soccer coach\nMr. Lee = teacher", ) with gr.Row(): ob_reminder = gr.Number(label="Default reminder (min before)", value=30, precision=0) ob_decline = gr.Textbox(label="Days you usually decline", placeholder="Mondays") with gr.Row(): ob_save = gr.Button("Save to my device", variant="primary", scale=0) ob_skip = gr.Button("Skip", elem_classes="rv-secondary", scale=0) ob_status = gr.Markdown(elem_classes="rv-status") # ── Step 3: Run — the three actions nested under one step, # uniformly formatted, with "Run the agents" the highlighted # (primary) choice. ───────────────────────────────────────── # gr.HTML(_flow_step(3, "Run the agents")) with gr.Row(elem_id="rv-actions"): analyze_btn = gr.Button( "Run the agents", variant="primary", elem_id="rv-analyze", scale=0 ) start_over_btn = gr.Button( "Start over", elem_id="rv-startover", elem_classes="rv-secondary", scale=0 ) sample_btn = gr.Button( "Use the sample", elem_id="rv-sample", elem_classes="rv-secondary", scale=0 ) # The pipeline tracker sits ABOVE the status line, always # visible (idle card on landing) — the step-by-step trace # stays behind the accordion below. pipeline = gr.HTML(_render_pipeline_card([]), elem_id="rv-pipeline") status = gr.Markdown(elem_classes="rv-status") with gr.Accordion("🤖 Watch the agent work (live steps)", open=False): thinking = gr.HTML(_render_agent_trace([])) # --- results: hidden until the first analysis (progressive disclosure) with gr.Column(elem_id="rv-results", visible=False) as results: # The rotating carousel is superseded by the condensed # results card below; the component stays (hidden, still # rendered into) so every chain that targets it keeps # working unchanged. hero_carousel = gr.HTML("", elem_id="rv-hero", visible=False) # Per-event notes from the last analysis. Notes aren't a # Dataframe column, so without this State the arrival # context would be lost when events round-trip through # the editable table. found_notes = gr.State([]) # ONE condensed results card: the found events and the # export actions together, instead of separate stacked # sections. with gr.Group(elem_id="rv-resultcard"): plan_md = gr.HTML() cards = gr.HTML() with gr.Group(elem_id="rv-export"): # The per-event "Add to: Google · Outlook · iCal" # links on each card are now the ONE export # surface. This button bar duplicated it (and the # OAuth push 404s while the Space is private), so # everything here is HIDDEN — not deleted: the # mode wiring, wireGcal, and tests reference these # components, and the OAuth push returns once the # Space is public. with gr.Row(visible=False): ics_btn = gr.Button("⬇ Download .ics", variant="primary", scale=1) gcal_btn = gr.Button("Add to Google Calendar", scale=1) apple_btn = gr.Button(" Add to Apple Calendar", scale=1) gcal_link_html = gr.HTML("", visible=False) # hidden per-session token, hydrated from localStorage / the OAuth popup gcal_token = gr.Textbox(visible=False, elem_id="gcal-token") ics_file = gr.File(label="events.ics", elem_id="ics-file", visible=False) gcal_status = gr.Markdown(visible=False) gr.HTML( '
📱 On iPhone? The share-sheet ' "Shortcut adds events in two taps — " f'set it up.
' ) with gr.Accordion("✏️ Edit events", open=False): table = gr.Dataframe( headers=["title", "start", "end", "location", "reminder_min"], datatype=["str", "str", "str", "str", "number"], type="array", interactive=True, label="", ) with gr.Group(elem_classes="rv-reply"): reply = gr.Textbox(label="Suggested reply", lines=3, elem_id="rv-reply-box") copy_btn = gr.Button("Copy reply", size="sm", elem_classes="rv-copy") # --- marketing sections (now below the working tool) --- gr.HTML(_how_html()) gr.HTML(_uses_html()) gr.HTML(_privacy_html()) # --- wiring (existing handler signatures unchanged) --- analyze_inputs = [conversation, existing_cal, images_in, mem_box] analyze_outputs = [thinking, table, plan_md, reply, status, found_notes, pipeline] _reveal = lambda: gr.update(visible=True) # noqa: E731 # Client-side helpers: scroll the page (sample CTA lives way up in the # hero) and turn the generated .ics into a real browser download. _scroll_tool_js = ( "() => { var el = document.getElementById('tool-card') || " "document.getElementById('tool'); if (el) el.scrollIntoView(" "{behavior: 'smooth', block: 'start'}); }" ) _scroll_results_js = ( "() => { var el = document.getElementById('rv-results'); " "if (el) el.scrollIntoView({behavior: 'smooth', block: 'start'}); }" ) _auto_download_js = ( "(f) => { try { if (f && f.url) { var a = document.createElement('a'); " "a.href = f.url; a.download = 'events.ics'; document.body.appendChild(a); " "a.click(); a.remove(); } } catch (e) {} }" ) # Opening (not downloading) the .ics lets iOS/macOS hand it straight # to Apple Calendar ("Add All"); other platforms fall back to a download. _open_ics_js = ( "(f) => { try { if (f && f.url) window.open(f.url, '_blank'); } catch (e) {} }" ) analyze_btn.click(_on_analyze, analyze_inputs, analyze_outputs).then( _count_conflicts, plan_md, captured_conflicts ).then(_render_event_cards, [table, mode, found_notes], cards).then( _render_event_carousel, table, hero_carousel ).then(_reveal, None, results).then( _on_share_trace, [trace_checkbox, table], trace_status ).then(_prep_ics, table, ics_file) # Uploading a screenshot IS the intent too — analyze as soon as the # upload lands (the .upload event only fires with files, never on clear). images_in.upload(_on_analyze, analyze_inputs, analyze_outputs).then( _count_conflicts, plan_md, captured_conflicts ).then(_render_event_cards, [table, mode, found_notes], cards).then( _render_event_carousel, table, hero_carousel ).then(_reveal, None, results).then( None, None, None, js=_scroll_results_js ).then( _on_share_trace, [trace_checkbox, table], trace_status ).then(_prep_ics, table, ics_file) # One tap: fill the box with the sample, then run the same analysis. # The hero CTA and the in-tool "try a sample" share this flow. for _btn in (sample_btn, hero_try_btn): _btn.click(_load_sample, None, conversation).then( None, None, None, js=_scroll_tool_js ).then( _on_analyze, analyze_inputs, analyze_outputs ).then(_count_conflicts, plan_md, captured_conflicts).then( _render_event_cards, [table, mode, found_notes], cards ).then(_render_event_carousel, table, hero_carousel).then( _reveal, None, results ).then( None, None, None, js=_scroll_results_js ).then( _on_share_trace, [trace_checkbox, table], trace_status ).then(_prep_ics, table, ics_file) # Start over: clear inputs + hide results. start_over_btn.click( _on_start_over, None, [conversation, images_in, existing_cal, results, status, screenshot_status, trace_status, pipeline], ) # (Offline/Online mode wiring lives at the end of build_demo — it also # drives components on other tabs, which don't exist yet at this point.) # Contextual hint when a screenshot is attached. images_in.change(_screenshot_status, images_in, screenshot_status) # Keep cards + carousel + the pre-generated .ics in sync when the # user edits the table. table.change(_render_event_cards, [table, mode, found_notes], cards).then( _render_event_carousel, table, hero_carousel ).then(_prep_ics, table, ics_file) # Copy the reply to the clipboard (client-side, no round-trip). copy_btn.click(None, [reply], None, js="(t) => { navigator.clipboard.writeText(t || ''); }") # Download .ics: generate, then immediately save it as events.ics — # no second click on the File component needed. ics_btn.click( _on_make_ics, [table, captured_conflicts], [ics_file, status] ).then(None, ics_file, None, js=_auto_download_js) # Apple Calendar: same .ics, opened so the OS offers "Add to Calendar". apple_btn.click( _on_make_ics, [table, captured_conflicts], [ics_file, status] ).then(None, ics_file, None, js=_open_ics_js) # Pull the visitor's per-session token straight from localStorage at # click time (set by the OAuth popup), so the push uses THEIR account. gcal_btn.click( _on_push_gcal, [table, gcal_token, captured_conflicts], gcal_status, js="(rows, tok, cc) => [rows, " "(function(){try{return localStorage.getItem('gcal_token')||'';}catch(e){return '';}})(), " "cc]", ) # --- frequently-asked questions (native
accordion) --- gr.HTML(_faq_html()) # --- landing footer --- gr.HTML(_footer_html()) with gr.Tab("Agent", elem_classes="page-wrap"): ag_conflicts = gr.State(0) gr.Markdown( "### 🤖 Agent mode — the whole homepage workflow, run by an agent\n" "A small **OpenBMB MiniCPM** planner (served by the same local " "llama.cpp — no cloud AI) drives this Space's own **MCP tools** " "(`extract_events` → `check_conflicts` → `make_ics`) as a " "multi-step agent, with your device memory as context. Every " "step it takes is shown below; the results carry the full " "review-and-export surface from the Schedule page. On the free " "preview the same playbook runs scripted, so the demo always works." ) with gr.Row(): ag_images = gr.Files(label="Screenshots (optional)", file_types=["image"], type="filepath") ag_thread = gr.Textbox( label="Thread", lines=5, placeholder="Paste a chat for the agent to work end-to-end…", ) ag_cal = gr.File(label="Calendar (.ics, optional — for conflict checks)", file_types=[".ics"], type="filepath") with gr.Row(): ag_run = gr.Button("Run the agent", variant="primary", scale=0) ag_sample = gr.Button("use the sample", elem_classes="rv-linkbtn", size="sm", scale=0) # Same placement as the homepage: tracker above the status line, # step cards below. ag_pipeline = gr.HTML(_render_pipeline_card([])) ag_status = gr.Markdown(elem_classes="rv-status") gr.Markdown("#### The agent's steps") ag_trace = gr.HTML(_render_agent_trace([])) # --- results: the homepage's full surface, agent edition --- with gr.Column(visible=False) as ag_results: ag_carousel = gr.HTML("") ag_plan_md = gr.HTML() ag_cards = gr.HTML() with gr.Accordion("✏️ Edit events", open=False): ag_table = gr.Dataframe( headers=["title", "start", "end", "location", "reminder_min"], datatype=["str", "str", "str", "str", "number"], type="array", interactive=True, label="", ) with gr.Group(elem_classes="rv-reply"): ag_reply = gr.Textbox(label="Suggested reply", lines=2) ag_copy = gr.Button("Copy reply", size="sm", elem_classes="rv-copy") with gr.Group(elem_classes="ag-export"): # Hidden like the homepage bar: the per-event card links # are the one export surface (components kept for the # mode wiring + the OAuth push's return). with gr.Row(visible=False): ag_ics_btn = gr.Button("⬇ Download .ics", variant="primary", scale=1) ag_apple_btn = gr.Button(" Add to Apple Calendar", scale=1) ag_gcal_btn = gr.Button("Add to Google Calendar", scale=1, visible=False) ag_gcal_link = gr.HTML(_provider_row("google"), visible=False) ag_ics = gr.File(label="events.ics (made by the agent)", visible=False) ag_gcal_status = gr.Markdown(visible=False) ag_sample.click(_load_sample, None, ag_thread) ag_run.click( _on_run_agent, [ag_thread, ag_cal, ag_images, mem_box, mode], [ag_trace, ag_table, ag_plan_md, ag_cards, ag_carousel, ag_reply, ag_status, ag_conflicts, ag_results, ag_ics, ag_pipeline], ) # Same export semantics as the homepage (impact recorded on export). ag_copy.click(None, [ag_reply], None, js="(t) => { navigator.clipboard.writeText(t || ''); }") ag_ics_btn.click( _on_make_ics, [ag_table, ag_conflicts], [ag_ics, ag_status] ).then(None, ag_ics, None, js=_auto_download_js) ag_apple_btn.click( _on_make_ics, [ag_table, ag_conflicts], [ag_ics, ag_status] ).then(None, ag_ics, None, js=_open_ics_js) ag_gcal_btn.click( _on_push_gcal, [ag_table, gcal_token, ag_conflicts], ag_gcal_status, js="(rows, tok, cc) => [rows, " "(function(){try{return localStorage.getItem('gcal_token')||'';}catch(e){return '';}})(), " "cc]", ) # Edits keep cards + carousel + the pre-made .ics in sync (parity). ag_table.change(_render_event_cards, [ag_table, mode], ag_cards).then( _render_event_carousel, ag_table, ag_carousel ).then(_prep_ics, ag_table, ag_ics) with gr.Tab("Activity", elem_classes="page-wrap"): gr.Markdown("### This week") gr.Markdown( "What this saved you — events captured, conflicts caught, and " "estimated time saved. Persists across restarts." ) impact_panel = gr.HTML() gr.Markdown("---\nLive view of what the agent and model are doing (refreshes every 2s).") stepper = gr.HTML() tiles = gr.HTML() with gr.Row(elem_classes="act-chart-row"): timeline = gr.HTML() chart = gr.BarPlot( x="stage", y="count", title="Activity by stage", height=280, # rotate x labels so the stage names (ingest/vision/model/…) # don't crowd into garbled overlap on a narrow phone chart x_label_angle=-45, ) gr.Markdown("### Run traces") traces = gr.HTML() gr.Markdown( "**Sharing is Caring.** Publish a redacted run to a public Hugging Face " "dataset so others can learn from it — one click (needs an `HF_WRITE_TOKEN` " "secret on the Space). Or **⬇ Download** it (stays on your device) and publish " "with `python training/share_trace.py`." ) with gr.Row(): redact_chk = gr.Checkbox(label="Redact personal data", value=True) trace_dl_btn = gr.Button("⬇ Download trace (JSON)") trace_pub_btn = gr.Button("☁ Publish my trace to the Hub", variant="primary") trace_file = gr.File(label="trace.json") trace_status = gr.Markdown() trace_dl_btn.click( _on_download_trace, redact_chk, [trace_file, trace_status] ) trace_pub_btn.click(_on_publish_trace, None, trace_status) outputs = [stepper, tiles, timeline, chart, traces, impact_panel] timer = gr.Timer(2.0) timer.tick(_refresh, None, outputs) demo.load(_refresh, None, outputs) with gr.Tab("Memory", elem_classes="page-wrap"): gr.Markdown( "**What the agent knows about you.** Stored on *your device* " "(your browser's localStorage) — never on our servers — and injected " "into every extraction to personalize it. Active from your first " "message; grows as you add facts, import contacts, or use the app." ) mem_table = gr.Dataframe( headers=["id", "kind", "text", "weight"], datatype=["number", "str", "str", "number"], interactive=False, label="Memory (on this device)", ) with gr.Row(): mem_text = gr.Textbox(label="Add a fact", scale=3, placeholder="e.g. Dana is the soccer coach") mem_kind = gr.Dropdown( ["note", "contact", "preference", "location"], value="note", label="Kind" ) mem_add = gr.Button("Remember", variant="primary") with gr.Row(): mem_fid = gr.Number(label="Forget id", precision=0) mem_forget = gr.Button("Forget") # Demo seed: a curated set of people/preferences/locations that # map to the sample conversations, so visitors can see memory # personalizing a run without typing facts first. mem_samples = gr.Button("✨ Load sample memories", elem_classes="rv-secondary", scale=0) with gr.Accordion("📇 Import contacts / calendar (.vcf · CSV · .ics)", open=False): mem_file = gr.File(label="Contacts (.vcf/CSV) or calendar (.ics)", file_types=[".vcf", ".csv", ".ics"], type="filepath") mem_import = gr.Button("Import to my memory") gr.Markdown("Parsed **on your device's behalf, locally** — no cloud, no account.") # Online workflow only — hidden while the site is in Offline mode. with gr.Accordion("🗓 Import from Google Calendar (optional · cloud)", open=False, visible=False) as gcal_import_acc: gcal_ok = gr.Checkbox(label="Allow reading my Google Calendar to seed memory", value=False) gcal_import = gr.Button("Import from Google Calendar") gr.Markdown("_Opt-in cloud step. Everything else stays off-grid._") mem_status = gr.Markdown() # Every action renders the table DIRECTLY (third output) — the # mem_box.change chain below still persists to localStorage, but # the visible refresh no longer depends on that browser-side hop. _mem_out = [mem_box, mem_status, mem_table] mem_add.click(_with_rows(_mem_remember), [mem_text, mem_kind, mem_box], _mem_out) mem_forget.click(_with_rows(_mem_forget), [mem_fid, mem_box], _mem_out) mem_samples.click(_with_rows(_mem_load_samples), [mem_box], _mem_out) mem_import.click(_with_rows(_import_file), [mem_file, mem_box], _mem_out) gcal_import.click(_with_rows(_import_gcal), [gcal_ok, mem_box], _mem_out) # Single source of truth: whenever the device memory changes, re-render # the table AND persist back to the browser's localStorage. mem_box.change( lambda m: _facts_rows(_facts_load(m)), mem_box, mem_table ).then( None, mem_box, None, js="(m) => { try { localStorage.setItem('offgrid_memory', m || ''); } catch (e) {} }", ) with gr.Tab("Feed", elem_classes="page-wrap"): gr.Markdown( "Messages pushed by the Mac collector arrive at `/ingest`. " "**Privacy:** message contents only render when the deployment " "sets `EXPOSE_FEED=1` — public builds keep them sealed." ) feed = gr.JSON(label="Recent ingested messages", value=_read_feed()) gr.Button("Refresh").click(lambda: _read_feed(), None, feed) with gr.Tab("About"): gr.Markdown( "### Who it's for\n" "A busy parent whose kid's school and activity events are buried in a " "noisy class group chat — picture day Thursday, the practice that moved " "to Tuesday, the birthday-party RSVP. They read it once, mean to add it " "later, and miss it. With this, they paste the chat (or a screenshot of " "a flyer or invite) from their phone's browser and get back: the events, " "a conflict check against their calendar, and a ready-to-send reply — all " "surfaced for review before anything is saved. Output is a local .ics they " "can add to any calendar, with optional Google Calendar push.\n\n" "### Private by design\n" "No app to install and no account. It reads nothing automatically — the " "parent pastes only what they choose. Inference runs in the Space via " "llama.cpp (no cloud AI APIs), and works out of the box with no GPU " "(see Accuracy upgrade below).\n\n" "### Accuracy upgrade\n" f"**Model:** `{os.environ.get('MODEL_REPO', 'unset')}` / " f"`{os.environ.get('MODEL_FILE', 'unset')}` · " f"**mmproj:** `{os.environ.get('MMPROJ_FILE', 'unset')}`\n\n" "The free **cpu-basic** preview runs a small local model so the app works " "with no GPU. With a **GPU** enabled, the Space serves **gemma-cal E4B** — " "our calendar-native fine-tune (~5 GB, runs on a 16 GB T4) with the " "`mmproj` vision projector for screenshots and flyers. Every published " "version is eval-gated (see docs/eval-roadmap.md). All inference stays in " "the Space — no cloud AI APIs." ) with gr.Tab("Submission"): gr.HTML(_submission_html()) # --- MCP tool surface (hidden) ----------------------------------- # # Each .click() with api_name=... registers a Gradio API endpoint, and # with mcp_server=True on the launch/mount that endpoint is auto-exposed # as a Model Context Protocol tool. Type hints + docstrings on the # functions in server.mcp_tools become the MCP schema, so any MCP-aware # agent (Claude Desktop, Cursor, etc.) can call this Space as a tool. from server.mcp_tools import check_conflicts as _mcp_check_conflicts from server.mcp_tools import extract_events as _mcp_extract_events from server.mcp_tools import make_ics as _mcp_make_ics # extract_events stays text-only via a hidden component: a gr.JSON # `images` input renders an MCP schema of {"type": {}} (gradio can't type # it), which small planners (MiniCPM) fill with `{}` -> rejected. The UI's # vision path is separate (run_agent); the scripted path calls # mcp_tools.extract_events(thread, images, memory) directly. with gr.Row(visible=False): _mcp_thread = gr.Textbox(visible=False) _mcp_out_plan = gr.JSON(visible=False) _mcp_btn_extract = gr.Button(visible=False) _mcp_btn_extract.click( fn=_mcp_extract_events, inputs=[_mcp_thread], outputs=_mcp_out_plan, api_name="extract_events", ) # make_ics / check_conflicts take list[dict] `events`. As a gr.JSON # component that also renders {"type": {}}; registered via gr.api the MCP # schema is typed from the function hints ("type": "array"), so the # planner passes the real events list instead of a bare dict. gr.api(_mcp_make_ics, api_name="make_ics") gr.api(_mcp_check_conflicts, api_name="check_conflicts") # --- Offline/Online: ONE decision point that reconfigures the site ---- # Each mode is a separate guided workflow: Offline = .ics in/out, no # Google anywhere (the Step 2a connect block hides its Google CTA via # CSS on #tool-card[data-mode]); Online = one-click Google connect in # Step 2a, Google push in the export bar, Google import in Memory. # The choice persists on the device and is restored on page load. def _on_mode(m: str, rows, ag_rows, notes): """ONE atomic update per flip (visibility + both card re-renders) — chained .then steps caused visible intermediate states.""" online = "Online" in (m or "") return ( gr.update(), # cal_acc — always available (holds both paths) gr.update(), # gcal_btn — always shown (export trio is fixed) gr.update(), # gcal_link_html — hidden placeholder gr.update(visible=online), # gcal_import_acc (Memory tab) # ag_gcal_btn / ag_gcal_link stay hidden in BOTH modes while the # per-event card links are the single export surface (the OAuth # push returns when the Space is public). gr.update(), # ag_gcal_btn (Agent tab) gr.update(), # ag_gcal_link (Agent tab) _mode_note_html(online), # mode_note # cards (quick-add follows mode); pass notes through so the # arrival-context callout survives a mode flip _render_event_cards(rows, m, notes), _render_event_cards(ag_rows, m), # ag_cards (no notes State on that tab) ) # show_progress="hidden": no loading overlays flashing across the # outputs (that flash was the "glitchy" feel); the theme itself is # already recolored client-side at click time by wireModeTheme. mode.change( _on_mode, [mode, table, ag_table, found_notes], [cal_acc, gcal_btn, gcal_link_html, gcal_import_acc, ag_gcal_btn, ag_gcal_link, mode_note, cards, ag_cards], show_progress="hidden", ).then( None, mode, None, js="(m) => { try { localStorage.setItem('offgrid_mode', m || ''); } catch (e) {} " "var tc = document.getElementById('tool-card'); if (tc) tc.setAttribute(" "'data-mode', (m || '').indexOf('Online') >= 0 ? 'online' : 'offline'); }", ) # Restore the device's last choice on load. Setting the radio fires # .change (only when the stored value differs from the default), which # re-applies every visibility update above. demo.load(None, None, mode, js="() => { try { return localStorage.getItem('offgrid_mode') || '🔌 Offline'; } " "catch (e) { return '🔌 Offline'; } }") # --- onboarding actions --- # Save keeps the panel open so the confirmation is visible; Skip just # collapses it — the panel stays on the page either way, so the agent # can be personalized again at any time. ob_save.click(_on_onboard, [ob_people, ob_reminder, ob_decline, mem_box], [mem_box, onboard, ob_status]) ob_skip.click(lambda: gr.update(open=False), None, onboard) # --- load per-user memory from the browser on page load --- # Reading localStorage into mem_box triggers mem_box.change → the Memory # table renders + memory re-persists. Returning visitors (device already # has facts) get the onboarding collapsed — still there, one tap to open. demo.load(None, None, mem_box, js="() => { try { return localStorage.getItem('offgrid_memory') || ''; } " "catch (e) { return ''; } }").then( lambda m: gr.update(open=not _facts_load(m)), mem_box, onboard ) return demo # --------------------------------------------------------------------------- # # Memory tab handlers # --------------------------------------------------------------------------- # def _memory_rows() -> list[list]: return [[f["id"], f["kind"], f["text"], f.get("weight", 1)] for f in memory.list_facts()] def _on_remember(text: str, kind: str): if not (text or "").strip(): return _memory_rows(), "Type a fact first." fact = memory.remember(text, kind or "note") return _memory_rows(), f"Remembered: {fact['text']}" def _on_forget(fact_id): if fact_id is None or str(fact_id).strip() == "": return _memory_rows(), "Enter an id to forget." ok = memory.forget(int(fact_id)) return _memory_rows(), ("Forgotten." if ok else "No fact with that id.") # --- client-owned (per-user, browser localStorage) memory -------------------- def _facts_load(s) -> list[dict]: try: data = json.loads(s) if s else [] except Exception: # noqa: BLE001 return [] # Tolerate the server memory-file shape ({"facts": [...]}) — silently # treating it as empty would wipe those facts on the next save. if isinstance(data, dict): data = data.get("facts") or [] return [f for f in data if isinstance(f, dict) and f.get("text")] if isinstance(data, list) else [] def _facts_dump(facts) -> str: return json.dumps(facts or []) def _facts_rows(facts) -> list[list]: return [[f.get("id"), f.get("kind", "note"), f.get("text", ""), f.get("weight", 1)] for f in (facts or [])] def _with_rows(handler): """Memory handlers return (mem_json, status); the click ALSO renders the table directly, so the visible refresh never depends on the browser-side mem_box.change hop (the data persisted fine, but the table could stay stale until the next page load).""" def run(*args): mem_json, status = handler(*args) return mem_json, status, _facts_rows(_facts_load(mem_json)) return run def _mem_remember(text: str, kind: str, mem_json: str): facts = _facts_load(mem_json) if not (text or "").strip(): return _facts_dump(facts), "Type a fact first." facts = memory.merge_facts(facts, [(text, kind or "note")], kind or "note") return _facts_dump(facts), f"Remembered: {text.strip()}" # Showcase memories for the demo: each maps to one of the sample conversations # (Weekend Crew BBQ invite, the school-pickup juggle, the doctor-appointment # confirmation) so a visitor can SEE memory personalizing the extraction — # known people in attendees, the right reminder lead, a familiar location. _SAMPLE_FACTS = [ ("Alex hosts the Weekend Crew — get-togethers are usually in Alex's backyard", "contact"), ("Maya is in the Weekend Crew — always brings her famous potato salad, no arguments", "contact"), ("Jordan is in the Weekend Crew — will drink anything cold", "contact"), ("Party drinks: kombucha for Maya, lemonade or sparkling water for Alex", "preference"), ("RSVP to invites before the deadline — don't leave the host hanging", "preference"), ("Jenna is my partner — we trade school pickup when meetings collide", "contact"), ("School pickup is at 3:30 on weekdays", "note"), ("Primary Care of Manhattan (my doctor) is at 112A West 72nd Street, Upper West Side", "location"), ("Medical appointments: arrive 15 minutes early and remind me 60 minutes before", "preference"), ] def _mem_load_samples(mem_json: str): """Seed the device memory with the showcase facts. merge_facts dedupes by normalized text (reloading just bumps weights), so this is idempotent.""" facts = memory.merge_facts(_facts_load(mem_json), _SAMPLE_FACTS) return _facts_dump(facts), ( f"✨ Loaded {len(_SAMPLE_FACTS)} sample memories — paste the BBQ invite, " "a pickup chat, or an appointment text and watch them personalize the run." ) def _mem_forget(fact_id, mem_json: str): facts = _facts_load(mem_json) if fact_id is None or str(fact_id).strip() == "": return _facts_dump(facts), "Enter an id to forget." try: fid = int(fact_id) except Exception: # noqa: BLE001 return _facts_dump(facts), "Invalid id." new = [f for f in facts if int(f.get("id", -1)) != fid] ok = len(new) != len(facts) return _facts_dump(new), ("Forgotten." if ok else "No fact with that id.") def _on_onboard(people: str, reminder, decline: str, mem_json: str): """First-run onboarding → write structured facts to the user's device memory.""" facts = _facts_load(mem_json) new: list[tuple] = [] for line in (people or "").splitlines(): line = line.strip() if not line: continue sep = "=" if "=" in line else ("-" if " - " in line else (":" if ":" in line else None)) if sep and sep in line: name, role = line.split(sep, 1) name, role = name.strip(), role.strip() if name and role: new.append((f"{name} is the {role}", "contact")) continue new.append((f"{line} is someone you make plans with", "contact")) if reminder: try: new.append((f"Default reminder: {int(reminder)} minutes before events", "preference")) except Exception: # noqa: BLE001 pass if (decline or "").strip(): new.append((f"You usually decline events on: {decline.strip()}", "preference")) facts = memory.merge_facts(facts, new) msg = f"Saved {len(new)} fact(s) to your device." if new else "Nothing to save." # Stay open so the confirmation is visible; the panel never leaves the page. return _facts_dump(facts), gr.update(open=True), msg def _parse_memory_file(path: str) -> tuple[list[str], list[tuple]]: """Local, off-grid parse of a contacts/calendar file → (contact names, pref facts).""" names: list[str] = [] prefs: list[tuple] = [] try: raw = Path(path).read_text(encoding="utf-8", errors="ignore") except Exception: # noqa: BLE001 return names, prefs low = path.lower() if low.endswith(".vcf"): for line in raw.splitlines(): if line.upper().startswith("FN:"): nm = line[3:].strip() if nm: names.append(nm) elif low.endswith(".csv"): import csv as _csv rdr = _csv.reader(raw.splitlines()) rows = list(rdr) if rows: header = [h.strip().lower() for h in rows[0]] idx = next((i for i, h in enumerate(header) if h in ("name", "full name", "fullname", "first name", "display name")), 0) for r in rows[1:]: if len(r) > idx and r[idx].strip(): names.append(r[idx].strip()) elif low.endswith(".ics"): for line in raw.splitlines(): u = line.upper() if u.startswith("SUMMARY:"): s = line.split(":", 1)[1].strip() if s: prefs.append((f"You have an event like: {s}", "note")) elif "ATTENDEE" in u and "CN=" in u: try: cn = line.split("CN=", 1)[1].split(":", 1)[0].split(";")[0].strip() if cn: names.append(cn) except Exception: # noqa: BLE001 pass # de-dup, cap seen, uniq = set(), [] for n in names: k = n.lower() if k not in seen and len(n) <= 60: seen.add(k) uniq.append(n) return uniq[:50], prefs[:20] def _import_file(file_path, mem_json: str): facts = _facts_load(mem_json) if not file_path: return _facts_dump(facts), "Choose a .vcf, .csv, or .ics file first." names, prefs = _parse_memory_file(file_path) new = [(f"{n} is a contact you make plans with", "contact") for n in names] + prefs facts = memory.merge_facts(facts, new) return (_facts_dump(facts), f"Imported {len(names)} contact(s) and {len(prefs)} note(s) — saved to your device.") def _import_gcal(enabled: bool, mem_json: str): """Opt-in Google Calendar import → contacts/locations as facts. Cloud opt-in; degrades gracefully when Google libs/creds aren't configured on the Space.""" facts = _facts_load(mem_json) if not enabled: return _facts_dump(facts), "Tick the box to allow Google Calendar import." try: from calendar_out.gcal import read_recent_facts # lazy: google libs optional names, locs = read_recent_facts() except Exception as e: # noqa: BLE001 no creds / libs / offline -> graceful return (_facts_dump(facts), f"Google Calendar not connected on this Space ({type(e).__name__}). " "Local-only memory is unaffected.") new = [(f"{n} is a contact you make plans with", "contact") for n in names] \ + [(f"You often have events at: {loc}", "location") for loc in locs] facts = memory.merge_facts(facts, new) return (_facts_dump(facts), f"Imported {len(names)} contact(s) and {len(locs)} location(s) from Google Calendar.") _FEED_PATH = os.environ.get("FEED_PATH", "/tmp/ingest_feed.json") def _read_feed(): """The feed holds RAW private iMessages (text, senders, images). Never serve it from a public UI: gated behind EXPOSE_FEED=1, which only a trusted/local deployment should set.""" if os.environ.get("EXPOSE_FEED") != "1": return {"feed": "private", "hint": "Set EXPOSE_FEED=1 on a trusted/local deployment to view " "ingested messages here. Public builds never expose them."} try: return json.loads(Path(_FEED_PATH).read_text()) except Exception: # noqa: BLE001 return []