Spaces:
Sleeping
Sleeping
| """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'<p class="pl-reason">{html.escape(plan.reasoning)}</p>') | |
| if plan.conflicts: | |
| badges = "".join( | |
| f'<span class="pl-badge pl-conflict">⚠️ {html.escape(c.severity)} · ' | |
| f'{html.escape(c.clashes_with)}</span>' | |
| for c in plan.conflicts | |
| ) | |
| parts.append(f'<div class="pl-row">{badges}</div>') | |
| if plan.proposed_times: | |
| chips = "".join( | |
| f'<span class="pl-chip">{html.escape(_fmt_when(t))}</span>' | |
| for t in plan.proposed_times | |
| ) | |
| parts.append( | |
| f'<div class="pl-row"><span class="pl-label">Free slots</span>{chips}</div>' | |
| ) | |
| if plan.needs_clarification: | |
| parts.append(f'<p class="pl-clarify">❓ {html.escape(plan.needs_clarification)}</p>') | |
| if not parts: | |
| return '<div class="pl-summary"><p class="pl-clear">✓ All clear — no conflicts.</p></div>' | |
| return f'<div class="pl-summary">{"".join(parts)}</div>' | |
| 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'<div class="evx-meta">{" • ".join(bits)}</div>' 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'<div class="bb-when">{html.escape(sub)}</div>' if sub else "" | |
| note_html = f'<div class="bb-note">{html.escape(note)}</div>' if note else "" | |
| return ( | |
| '<div class="cz-slide bb">' | |
| '<div class="bb-scrim"></div>' | |
| '<div class="bb-body">' | |
| f'<div class="bb-kicker">{html.escape(kicker)}</div>' | |
| f'<h2 class="bb-title">{html.escape(title)}</h2>' | |
| f"{sub_html}{note_html}" | |
| "</div></div>" | |
| ) | |
| def _event_slide(e: Event, kicker: str) -> str: | |
| note = f'<div class="bb-note">{html.escape(e.notes)}</div>' if e.notes else "" | |
| return ( | |
| '<div class="cz-slide bb">' | |
| '<div class="bb-scrim"></div>' | |
| '<div class="bb-body">' | |
| f'<div class="bb-kicker">{html.escape(kicker)}</div>' | |
| f'<h2 class="bb-title">{html.escape(e.title or "Event")}</h2>' | |
| f'<div class="bb-when">{html.escape(_fmt_when(e.start, e.end))}</div>' | |
| f"{_event_meta(e)}{note}" | |
| "</div></div>" | |
| ) | |
| 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 = ( | |
| '<button class="cz-arrow cz-prev" data-dir="-1" aria-label="Previous">‹</button>' | |
| '<button class="cz-arrow cz-next" data-dir="1" aria-label="Next">›</button>' | |
| ) if multi else "" | |
| dots = ( | |
| '<div class="cz-dots">' | |
| + "".join( | |
| f'<button class="cz-dot{" is-active" if i == 0 else ""}" ' | |
| f'data-go="{i}" aria-label="slide {i + 1}"></button>' | |
| for i in range(len(slides)) | |
| ) | |
| + "</div>" | |
| ) if multi else "" | |
| return ( | |
| f'<div class="carousel" data-interval="{interval}">' | |
| f'<div class="cz-track">{track}</div>{arrows}{dots}</div>' | |
| ) | |
| 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'<div class="cz-slide bb bb-img {bg_class}">' | |
| '<div class="bb-scrim bb-scrim-dark"></div>' | |
| '<div class="bb-body">' | |
| f'<div class="bb-kicker">▍{html.escape(kicker)}</div>' | |
| f'<h2 class="bb-title">{html.escape(title)}</h2>' | |
| f'<div class="bb-when">{html.escape(sub)}</div>' | |
| "</div></div>" | |
| ) | |
| 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 ( | |
| '<div class="cal-provider" data-provider="google">' | |
| '<span class="cal-prov-name">📅 Google Calendar</span>' | |
| '<a class="gcal-connect" href="#" role="button">🔗 Connect</a>' | |
| '<span class="gcal-state"></span>' | |
| '<a class="gcal-disconnect" href="#" role="button">disconnect</a>' | |
| '<span class="cal-cap cal-cap-online">one click — stays connected ' | |
| "in this browser</span>" | |
| '<span class="cal-cap cal-cap-offline">switch to ☁️ Online to ' | |
| "enable</span></div>" | |
| ) | |
| if provider == "microsoft": | |
| return ( | |
| '<div class="cal-provider" data-provider="microsoft">' | |
| '<span class="cal-prov-name">📅 Microsoft Outlook</span>' | |
| '<span class="cal-cap">one-click “Add to Outlook” links appear on ' | |
| "every event found — no sign-in needed</span></div>" | |
| ) | |
| return ( | |
| '<div class="cal-provider" data-provider="apple">' | |
| '<span class="cal-prov-name">📅 Apple Calendar</span>' | |
| '<span class="cal-cap">use “Add to Apple Calendar” on any event or the ' | |
| ".ics download — opens straight in Calendar</span></div>" | |
| ) | |
| 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 ( | |
| '<div class="gcal-badge-wrap">' | |
| '<span class="gcal-badge">Google: not connected</span></div>' | |
| ) | |
| 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 ( | |
| '<div class="cal-connect" id="cal-connect">' | |
| + _provider_row("google") | |
| + _provider_row("microsoft") | |
| + _provider_row("apple") | |
| + '<div class="cal-privacy">Your Google token stays in this browser — ' | |
| "never on our server.</div></div>" | |
| ) | |
| 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' · <a href="{a}" download="event.ics" ' | |
| 'title="Downloads a .ics — Apple Calendar, Outlook desktop, ' | |
| 'and most calendar apps">iCal</a>' | |
| f' · <a href="{a}" download="event.ics" ' | |
| 'title="Download this event as a .ics file">.ics</a>') | |
| except Exception: # noqa: BLE001 | |
| ical = "" | |
| return ( | |
| '<div class="evx-add">Add to: ' | |
| f'<a href="{html.escape(g)}" target="_blank" rel="noopener">Google</a> · ' | |
| f'<a href="{html.escape(o)}" target="_blank" rel="noopener">Outlook</a>' | |
| f"{ical}</div>" | |
| ) | |
| 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 ( | |
| '<div class="evx-empty">No events found yet — tap ' | |
| "<b>Try a sample</b>, or paste a chat with a date & time.</div>" | |
| ) | |
| 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'<div class="evx-loc">📍 {html.escape(e.location)}</div>' | |
| if e.location else "") | |
| rem = (f'<div class="evx-meta">⏰ Reminder {int(e.reminder_minutes)} min before</div>' | |
| if e.reminder_minutes else "") | |
| note = f'<div class="evx-notes">📝 {html.escape(note_txt)}</div>' if note_txt else "" | |
| quick = _quick_add_links(e) | |
| cards.append( | |
| f'<article class="evx-card" style="--i:{i}">' | |
| '<span class="evx-bar"></span>' | |
| '<div class="evx-body">' | |
| f'<h3 class="evx-title">{html.escape(e.title or "Event")}</h3>' | |
| f'<span class="evx-chip">{html.escape(_fmt_when(e.start, e.end))}</span>' | |
| f"{loc}{rem}{note}{quick}" | |
| "</div></article>" | |
| ) | |
| 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'<a class="evx-add-all" href="{a}" download="events.ics" ' | |
| 'title="Downloads one .ics holding every event below">' | |
| f"⬇ iCal — all {n} events</a>") | |
| except Exception: # noqa: BLE001 | |
| all_link = "" | |
| head = (f'<div class="evx-head">{n} event{"" if n == 1 else "s"} found' | |
| f"{all_link}</div>") | |
| return f'<div class="evx-wrap">{head}<div class="evx-cards">{"".join(cards)}</div></div>' | |
| 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, | |
| use_planner=False): | |
| 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'<div class="shot-status">📎 {n} screenshot{plural} attached — it’ll be read for events.</div>' | |
| 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 ('<div class="trace-ok">✓ Privacy-safe trace recorded ' | |
| "(events only; raw text not stored).</div>") | |
| 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'<div class="pipe-stage is-{st}" title="{name}">' | |
| f'<span class="pipe-badge">{badge}</span>' | |
| f'<span class="pipe-lab">{name}</span></div>' | |
| ) | |
| track = '<span class="pipe-chev">›</span>'.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'<div class="pipe-cap">{html.escape(cap_txt)}</div>' | |
| 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 = ( | |
| '<div class="pipe-summary">' | |
| f'<span class="pipe-chip"><span class="pipe-k">Speed</span><b>{clock}</b></span>' | |
| f'<span class="pipe-chip {conf_cls}"><span class="pipe-k">Confidence</span>{conf_txt}</span>' | |
| f'<span class="pipe-chip" title="{evidence}"><span class="pipe-k">Evidence</span>{evidence}</span>' | |
| f'<span class="pipe-chip"><span class="pipe-k">Found</span>{counts}</span>' | |
| "</div>" | |
| ) | |
| return ( | |
| f'<div class="pipe-card" data-state="{card_state}">' | |
| '<div class="pipe-head">' | |
| f'<span class="pipe-title">{title}</span>' | |
| f'<span class="pipe-clock">{clock}</span></div>' | |
| f'<div class="pipe-track"><div class="pipe-shimmer"></div>{track}</div>' | |
| f"{caption}{summary}</div>" | |
| ) | |
| 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 '<div class="muted">No run yet — paste a thread and run the agent.</div>' | |
| cards = [] | |
| for s in steps: | |
| icon = _STEP_ICONS.get(s.get("kind"), "•") | |
| kind = s.get("kind", "") | |
| if kind == "tool_call": | |
| body = (f'<b>{html.escape(str(s.get("tool", "")))}</b>' | |
| f' <code>{html.escape(json.dumps(s.get("args", {}), default=str)[:160])}</code>') | |
| elif kind == "tool_result": | |
| body = (f'{html.escape(str(s.get("tool", "")))} → ' | |
| f'<code>{html.escape(json.dumps(s.get("result", ""), default=str)[:200])}</code>') | |
| elif kind == "final": | |
| summ = s.get("summary", {}) | |
| body = (f'done — <b>{summ.get("events", 0)} event(s)</b>, ' | |
| f'{summ.get("conflicts", 0)} conflict(s), .ics ready') | |
| else: | |
| body = html.escape(str(s.get("text", ""))) | |
| cards.append(f'<div class="ag-step ag-{kind}"><span class="ag-ico">{icon}</span>' | |
| f'<span class="ag-body">{body}</span></div>') | |
| return f'<div class="ag-trace">{"".join(cards)}</div>' | |
| 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'<div class="step{active}" style="--c:{color}">{s}</div>') | |
| return '<div class="stepper">' + "".join(cells) + "</div>" | |
| 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'<div class="tile"><div class="tile-v">{v}</div><div class="tile-k">{k}</div></div>' | |
| for k, v in defs | |
| ) | |
| return f'<div class="tiles">{cells}</div>' | |
| def _timeline_html(n: int = 40) -> str: | |
| rows = bus.recent(n) | |
| if not rows: | |
| return '<div class="muted">No activity yet — analyze a thread or POST to /ingest.</div>' | |
| 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'<div class="evt{err}" style="--c:{color}">' | |
| f'<span class="evt-stage">{e["stage"]}</span>' | |
| f'<span class="evt-msg">{e["message"]}</span>' | |
| f'<span class="evt-meta">{meta}</span>' | |
| f'<span class="evt-ts">{e["ts"][11:]}</span></div>' | |
| ) | |
| return '<div class="timeline">' + "".join(items) + "</div>" | |
| 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'<div class="tile"><div class="tile-v">{v}</div><div class="tile-k">{k}</div></div>' | |
| for k, v in defs | |
| ) | |
| return f'<div class="tiles">{cells}</div>' | |
| 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 '<div class="muted">No agent runs yet.</div>' | |
| blocks = [] | |
| for rid, evs in runs: | |
| lines = [] | |
| for e in evs: | |
| color = STAGE_COLORS.get(e["stage"], "#888") | |
| lat = f' <em>({e["latency_ms"]} ms)</em>' if e.get("latency_ms") else "" | |
| lines.append( | |
| f'<div class="trace-line"><span class="trace-stage" ' | |
| f'style="color:{color}">{e["stage"]}</span> {e["message"]}{lat}</div>' | |
| ) | |
| summary = f"run #{rid} — {len(evs)} step(s)" | |
| blocks.append( | |
| f'<details class="trace"><summary>{summary}</summary>{"".join(lines)}</details>' | |
| ) | |
| 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 ( | |
| '<header id="site-nav"><div class="nav-inner">' | |
| '<a class="nav-brand" data-tab="Schedule" data-anchor="#hero" href="#hero">' | |
| f'<img class="nav-logo" src="{_LOGO_URI}" alt="OffGridSchedula logo">' | |
| '<span>OffGridSchedula</span></a>' | |
| '<nav class="nav-links">' | |
| '<a class="nav-solo" data-tab="Schedule" data-anchor="#hero" href="#hero">Home</a>' | |
| '<a class="nav-solo" data-tab="Submission">Submission</a>' | |
| '<a class="nav-solo" data-tab="Activity">Activity</a>' | |
| '<a class="nav-solo" data-tab="Memory">Memory</a>' | |
| '<a class="nav-solo" data-tab="Feed">Feed</a>' | |
| '</nav></div></header>' | |
| ) | |
| def _hero_copy_html() -> str: | |
| return ( | |
| '<div class="hero-copy">' | |
| '<div class="hero-eyebrow">Your Calendar's Off-Grid Agent</div>' | |
| '<h1 class="hero-title">Schedule your invite<br>' | |
| '<span class="hero-accent">before it slips away.</span></h1>' | |
| '<p class="hero-sub">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.</p>' | |
| '<p class="hero-trust">No app · no account · runs locally — your messages never touch a cloud AI.</p>' | |
| '</div>' | |
| ) | |
| 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 ( | |
| '<div class="hero-viz">' | |
| '<div class="hv-chat">' | |
| '<div class="hv-from">Class group chat</div>' | |
| '<div class="hv-bubble">Picture day is <b>Thursday at 9am</b> — wear the green shirt! 📸</div>' | |
| '</div>' | |
| '<div class="hv-arrow">↓</div>' | |
| '<div class="hv-event">' | |
| '<div class="hv-ev-bar"></div>' | |
| '<div class="hv-ev-body">' | |
| '<div class="hv-ev-title">📸 Picture Day</div>' | |
| '<div class="hv-ev-when">Thu · 9:00 AM</div>' | |
| '<div class="hv-ev-loc">Lincoln Elementary · wear green</div>' | |
| '</div></div>' | |
| '</div>' | |
| ) | |
| 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 = ' <span class="hx-assumed">assumed</span>' 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'<div class="hx-card"><div class="hx-from">{html.escape(frm)}</div>' | |
| f'<div class="hx-chat">{html.escape(chat)}</div>' | |
| + "".join(f'<div class="hx-event">{ev}</div>' for ev in evs) | |
| + "</div>" | |
| for frm, chat, evs in ex | |
| ) | |
| return f'<div class="hx-grid">{cards}</div>' | |
| 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 ( | |
| '<div class="hero-badges">' | |
| f'<span class="hbadge">{local}</span>' | |
| '<span class="hbadge">🦙 llama.cpp</span>' | |
| '<span class="hbadge">🎯 Fine-tuned</span>' | |
| f'<span class="hbadge">🪶 {html.escape(f["params"])} · ≤ 32B</span>' | |
| '<span class="hbadge">🤖 Agentic</span>' | |
| "</div>" | |
| ) | |
| # --- 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'<div class="sub-row"><span class="sub-pill {cls}">{mark}</span>' | |
| f'<div class="sub-rt"><div class="sub-title">{title}</div>' | |
| f'<div class="sub-ev">{ev}</div></div></div>' | |
| ) | |
| hard = ( | |
| row(True, "Small model ≤ 32B", | |
| f'<b>{html.escape(f["label"])} · {html.escape(f["params"])} params</b> (≤ 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", | |
| "<code>app.py</code> 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 = ( | |
| '<p class="sub-lead"><b>Track A — Backyard AI.</b> 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 <code>.ics</code>) is genuinely usable from a phone browser.</p>" | |
| ) | |
| 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, <b>eval-gated</b>: 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 <b>llama.cpp</b> runtime (official <code>ggml-org/llama.cpp</code> image).") | |
| + row(True, "Sharing is Caring", | |
| "One-click <b>☁ Publish my trace to the Hub</b> (Activity tab) pushes a <b>redacted</b> " | |
| "run to a public dataset — counts and stage names, never your chat text — so anyone can " | |
| "learn from it. (Token-free route: <b>⬇ Download trace</b> and publish it yourself.)") | |
| + row(True, "Agentic (MCP tool server + agent)", | |
| "Both sides of MCP: the Space <b>exposes</b> " | |
| "<code>extract_events</code> / <code>make_ics</code> / <code>check_conflicts</code> " | |
| "as Model Context Protocol tools (schemas auto-generated) — and the " | |
| "<b>Agent tab</b> <b>consumes</b> them: an OpenBMB <b>MiniCPM</b> planner " | |
| "(local llama.cpp) makes a single, visible <code>extract_events</code> 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 <b>MiniCPM</b> " | |
| "(<code>openbmb/MiniCPM5-1B-GGUF</code>, the ≤4B tiny variant; the 8B " | |
| "<code>MiniCPM4.1-8B</code> 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: <b>8 fine-tunes</b> rejected at the eval gate, " | |
| "<b>7 bugs</b> in agent code that had never run, a planner trimmed " | |
| "<b>207 s → 75 s</b> — and still <b>zero cloud calls</b>.") | |
| ) | |
| return ( | |
| '<section class="sub-wrap">' | |
| '<div class="lp-eyebrow">Hackathon submission</div>' | |
| '<h2 class="lp-title">Built-small, by the rules.</h2>' | |
| '<div class="sub-group"><h3 class="sub-h">Hard constraints</h3>' + hard + "</div>" | |
| '<div class="sub-group"><h3 class="sub-h">Track</h3>' + track + "</div>" | |
| '<div class="sub-group"><h3 class="sub-h">Bonus quests</h3>' + bonus + "</div>" | |
| "</section>" | |
| ) | |
| 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 <code>.ics</code> for any calendar, or push straight to Google or Apple Calendar."), | |
| ] | |
| cards = "".join( | |
| f'<div class="lp-step"><div class="lp-step-n">{n}</div>' | |
| f'<h3 class="lp-card-t">{t}</h3><p class="lp-card-d">{d}</p></div>' | |
| for n, t, d in steps | |
| ) | |
| return ( | |
| '<section id="how" class="lp-section">' | |
| '<div class="lp-eyebrow">How it works</div>' | |
| '<h2 class="lp-title">From buried message to booked — in three taps.</h2>' | |
| f'<div class="lp-grid lp-grid-3">{cards}</div>' | |
| '</section>' | |
| ) | |
| 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'<div class="lp-card"><div class="lp-ico">{i}</div>' | |
| f'<h3 class="lp-card-t">{t}</h3><p class="lp-card-d">{d}</p></div>' | |
| for i, t, d in uses | |
| ) | |
| return ( | |
| '<section id="uses" class="lp-section">' | |
| '<div class="lp-eyebrow">Use cases</div>' | |
| '<h2 class="lp-title">Anywhere a date hides in a conversation.</h2>' | |
| f'<div class="lp-grid lp-grid-3">{cards}</div>' | |
| '</section>' | |
| ) | |
| def _faq_html() -> str: | |
| """FAQ with two tabs (General / For developers), search filter, and native | |
| <details> accordion rows. Wired by the FAQ block in CAROUSEL_JS.""" | |
| general: list[tuple[str, str]] = [ | |
| ( | |
| "What problem does this actually solve?", | |
| "<p>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 <b>one</b> calendar instead of " | |
| "scanning four chats.</p>", | |
| ), | |
| ( | |
| "Is anything sent to a cloud AI service?", | |
| "<p><b>No.</b> Inference runs locally inside the Space via " | |
| "<code>llama.cpp</code> — no OpenAI / Anthropic / Google AI " | |
| "calls. The only outbound network call you might make is the " | |
| "optional <b>push to Google Calendar</b>, and only if you click " | |
| "it and authorise it yourself.</p>", | |
| ), | |
| ( | |
| "Do I need an app or an account?", | |
| "<p>No. It works from any phone or laptop browser. There's " | |
| "nothing to install and nothing to sign up for. The output is " | |
| "a <code>.ics</code> file you own — import it into Apple " | |
| "Calendar, Google Calendar, Outlook, or anything else.</p>", | |
| ), | |
| ( | |
| "Does it read my messages automatically?", | |
| "<p>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.</p>", | |
| ), | |
| ( | |
| "What inputs work best?", | |
| "<ul>" | |
| "<li><b>Short pasted chats</b> — the last few relevant messages, " | |
| "not a six-month history.</li>" | |
| "<li><b>Screenshots</b> of flyers, invites, or texts where the " | |
| "date lives in the image.</li>" | |
| "<li><b>Your current calendar as <code>.ics</code></b> " | |
| "(optional) — used only for deterministic conflict detection, " | |
| "never sent anywhere.</li>" | |
| "</ul>", | |
| ), | |
| ( | |
| "How accurate is it, really?", | |
| "<p>Small fine-tuned models are good at narrow tasks and worse " | |
| "at edge cases. Honestly:</p>" | |
| "<ul>" | |
| "<li><b>Reliable</b> for explicit times — \"Tuesday at 3pm at " | |
| "school\".</li>" | |
| "<li><b>Decent</b> for nearby relative times — \"tomorrow\", " | |
| "\"next Monday\".</li>" | |
| "<li><b>Worse</b> for ambiguous wording — \"maybe later\". That's " | |
| "why the agent always shows you the events for review before " | |
| "anything is saved.</li>" | |
| "</ul>", | |
| ), | |
| ] | |
| dev: list[tuple[str, str]] = [ | |
| ( | |
| "Can other AI agents call this Space as a tool?", | |
| "<p>Yes. The Space exposes a <b>Model Context Protocol (MCP) " | |
| "server</b>, so any MCP-aware agent (Claude Desktop, Cursor, " | |
| "etc.) can call three tools against it:</p>" | |
| "<ul>" | |
| "<li><code>extract_events(thread, images)</code> — the headline " | |
| "tool: read a chat or screenshot, return events + conflicts + " | |
| "reply.</li>" | |
| "<li><code>make_ics(events)</code> — render events as a " | |
| "base64-encoded <code>.ics</code>.</li>" | |
| "<li><code>check_conflicts(events, ics_base64)</code> — " | |
| "deterministic free/busy check against an uploaded calendar.</li>" | |
| "</ul>" | |
| "<p>Schemas are auto-generated from the typed wrappers in " | |
| f'<a href="{_BLOB}server/mcp_tools.py">server/mcp_tools.py</a>.</p>', | |
| ), | |
| ( | |
| "How do I connect an MCP-aware agent to this Space?", | |
| "<p>Point the agent's MCP config at the Space's SSE endpoint:</p>" | |
| "<p><code>build-small-hackathon-offgridschedula.hf.space" | |
| "/gradio_api/mcp/sse</code></p>" | |
| "<p>In <b>Claude Desktop</b>, add an entry under " | |
| "<code>mcpServers</code> in " | |
| "<code>claude_desktop_config.json</code> with that URL. In " | |
| "<b>Cursor</b>, add it under Settings → MCP. The three tools " | |
| "appear automatically; the agent picks them up by docstring.</p>", | |
| ), | |
| ( | |
| "Where do I find the fine-tuned model?", | |
| "<p>Published openly on the Hugging Face Hub as " | |
| f'<a href="{_MODEL_URL}">build-small-hackathon/gemma-4-cal-gguf</a>' | |
| " — 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.</p>", | |
| ), | |
| ( | |
| "Can I run this off the Space, locally?", | |
| "<p>Yes — this Space <i>is</i> the repo. Its " | |
| f'<a href="{_SPACE_URL}/tree/main">Files tab</a> is a public git ' | |
| "repository: <code>git clone " | |
| f"{_SPACE_URL}</code>, install <code>requirements.txt</code> (or " | |
| "build the Dockerfile), set <code>MODEL_REPO</code> + " | |
| "<code>MODEL_FILE</code> to point at any GGUF on the Hub, and " | |
| "run <code>python app.py</code>. The same Gradio UI + FastAPI " | |
| "<code>/agent</code> endpoint + MCP server come up on " | |
| "<code>localhost:7860</code>.</p>", | |
| ), | |
| ] | |
| 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 = ( | |
| '<span class="lp-faq-ico" aria-hidden="true">' | |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' | |
| 'stroke-width="1.5" stroke-linecap="round">' | |
| '<circle cx="12" cy="12" r="10"/>' | |
| '<path d="M7 12h10"/>' | |
| '<path class="lp-faq-ico-v" d="M12 7v10"/>' | |
| "</svg></span>" | |
| ) | |
| return "".join( | |
| f'<details class="lp-faq-item"><summary class="lp-faq-q">' | |
| f'<span class="lp-faq-qt">{q}</span>{ico}</summary>' | |
| f'<div class="lp-faq-a">{a}</div></details>' | |
| for q, a in items | |
| ) | |
| search_svg = ( | |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' | |
| 'stroke-width="1.6" stroke-linecap="round">' | |
| '<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>' | |
| ) | |
| return ( | |
| '<section id="faq" class="lp-faq-section">' | |
| '<div class="lp-faq-head">' | |
| '<h2 class="lp-faq-h">Frequently asked questions</h2>' | |
| '<label class="lp-faq-search">' | |
| '<input id="faq-search" type="search" autocomplete="off" ' | |
| 'placeholder="Looking for something?">' | |
| f"{search_svg}" | |
| "</label>" | |
| "</div>" | |
| '<div class="lp-faq-tabs" role="tablist">' | |
| '<button type="button" class="lp-faq-tab is-active" ' | |
| 'data-tab="general" role="tab">General</button>' | |
| '<button type="button" class="lp-faq-tab" data-tab="dev" ' | |
| 'role="tab">For developers</button>' | |
| "</div>" | |
| f'<div class="lp-faq-list" data-panel="general">{_rows(general)}</div>' | |
| f'<div class="lp-faq-list is-hidden" data-panel="dev">{_rows(dev)}</div>' | |
| '<div class="lp-faq-empty is-hidden">No questions match — try ' | |
| "different words.</div>" | |
| "</section>" | |
| ) | |
| 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'<div class="lp-priv"><h3 class="lp-card-t">✓ {t}</h3><p class="lp-card-d">{d}</p></div>' | |
| for t, d in points | |
| ) | |
| return ( | |
| '<section id="privacy" class="lp-section lp-section-tint">' | |
| '<div class="lp-eyebrow">Private by design</div>' | |
| '<h2 class="lp-title">Off the grid, on purpose.</h2>' | |
| f'<div class="lp-grid lp-grid-3">{items}</div>' | |
| '</section>' | |
| ) | |
| def _tool_intro_html() -> str: | |
| return ( | |
| '<div id="tool" class="tool-anchor"></div>' | |
| '<section class="lp-section lp-tool-head">' | |
| '<div class="lp-eyebrow">Try it now</div>' | |
| '<h2 class="lp-title">Paste a chat — get your events.</h2></section>' | |
| ) | |
| 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 ( | |
| '<div id="tool" class="tool-anchor"></div>' | |
| '<div class="tc-head">' | |
| '<div><div class="tc-eyebrow">Try it for free</div>' | |
| '<h2 class="tc-title">Paste a chat, manage your events</h2></div>' | |
| f'<a class="tc-poweredby" href="{_MODEL_URL}" target="_blank" rel="noopener" ' | |
| 'title="Fine-tuned Gemma 4 — build-small-hackathon/gemma-4-cal-gguf, run via llama.cpp">' | |
| '🟢 Powered by fine-tuned Gemma 4 · <b>gemma-4-cal</b> · llama.cpp</a>' | |
| "</div>" | |
| ) | |
| 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'<span class="flow-sub">{sub}</span>' if sub else "" | |
| return ( | |
| f'<div class="io-label flow-step"><span class="step-chip">{n}</span>' | |
| f'<span class="flow-t">{title}</span>{sub_html}</div>' | |
| ) | |
| def _mode_note_html(online: bool) -> str: | |
| """Guidance line + named workflow chip inside the mode band.""" | |
| if online: | |
| chip = "ONLINE WORKFLOW" | |
| txt = ("☁️ <b>Online</b> — Google Calendar enabled: connect it in step 2a, " | |
| "or just continue. Everything else still runs locally.") | |
| else: | |
| chip = "OFF-GRID WORKFLOW" | |
| txt = ("🔌 <b>Offline</b> — everything stays on this device: upload your " | |
| "calendar in step 2a, get an <code>.ics</code> back.") | |
| return (f'<div class="mode-note"><span class="mode-chip">{chip}</span>' | |
| f"<span>{txt}</span></div>") | |
| def _footer_html() -> str: | |
| return ( | |
| '<footer id="site-footer"><div class="footer-inner">' | |
| '<h2 class="footer-cta-t">Stop missing invites.</h2>' | |
| '<a class="footer-cta" href="#tool">Try it with a sample →</a>' | |
| '<div class="footer-meta">Built off-grid · llama.cpp · no cloud AI APIs · ' | |
| f'<a href="{_SPACE_URL}">on Hugging Face</a></div>' | |
| '</div></footer>' | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # 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(_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('<a class="hero-ghost" href="#tool">See it in action ↓</a>') | |
| 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('<div class="io-label">Upload a screenshot</div>') | |
| 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('<div class="io-label">Or paste the chat</div>') | |
| 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( | |
| '<div class="rv-help"><span>Chat, SMS, or invite — English & ' | |
| 'screenshots supported</span>' | |
| '<span class="rv-counter" id="rv-counter">0 / 12000</span></div>' | |
| ) | |
| # ── 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( | |
| '<div class="ob-sub">Tell the agent a few things — saved on <b>your device</b> ' | |
| "and used to read your chats better. You can skip this.</div>" | |
| ) | |
| 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( | |
| '<div class="ship-note">📱 On iPhone? The share-sheet ' | |
| "Shortcut adds events in two taps — " | |
| f'<a href="{_BLOB}docs/automations.md" target="_blank" ' | |
| 'rel="noopener">set it up.</a></div>' | |
| ) | |
| 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 <details> 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 [] | |