| """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") |
|
|
| |
| |
| _LOGO_URI = "data:image/png;base64," + base64.b64encode( |
| (Path(__file__).parent.parent / "static" / "logo.png").read_bytes() |
| ).decode("ascii") |
|
|
| |
| |
| |
| 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", |
| } |
|
|
|
|
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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: |
| 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 "" |
|
|
|
|
| |
| |
| |
| 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: |
| 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: |
| 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) |
| |
| |
| 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: |
| 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): |
| |
| |
| |
| 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: |
| 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 |
|
|
| |
| |
| 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: |
| ics_b64 = None |
| images = paths_to_data_uris(image_paths or []) |
| memory_block = memory.facts_to_recall(_facts_load(mem_json)) or None |
|
|
| |
| |
| t0 = time.monotonic() |
| evidence = _evidence_label(conversation, image_paths, cal_path) |
| _meta = lambda: {"elapsed": time.monotonic() - t0, "evidence": evidence} |
|
|
| steps: list[dict] = [] |
| final = None |
| for step in run_orchestrator(conversation or "", ics_b64=ics_b64, |
| memory_block=memory_block, images=images or None): |
| steps.append(step) |
| if step.get("kind") == "final": |
| final = step |
| yield (_render_agent_trace(steps), gr.update(), gr.update(), gr.update(), |
| "🤖 the agent is working…", gr.update(), |
| _render_pipeline_card(steps, _meta())) |
| if not final: |
| yield (_render_agent_trace(steps), gr.update(), gr.update(), gr.update(), |
| "The agent didn't reach a final step.", gr.update(), |
| _render_pipeline_card(steps, _meta())) |
| return |
| plan_dict = final.get("plan") or {} |
| try: |
| plan = ActionPlan(**plan_dict) |
| except Exception: |
| plan = ActionPlan(reasoning="", events=[], conflicts=[], proposed_times=[], |
| reply_draft="", needs_clarification=None) |
| |
| |
| |
| 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 ( |
| "", |
| None, |
| None, |
| gr.update(visible=False), |
| "", |
| "", |
| "", |
| _render_pipeline_card([]), |
| ) |
|
|
|
|
| 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: |
| 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) |
|
|
|
|
| |
| |
| |
| _STEP_ICONS = {"plan": "🧠", "tool_call": "🔧", "tool_result": "📥", |
| "final": "✅", "error": "⚠️"} |
|
|
| |
| |
| |
| _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: |
| |
| |
| 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: |
| 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 |
| |
| |
| 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: |
| ics_b64 = None |
| images = paths_to_data_uris(image_paths or []) |
| memory_block = memory.facts_to_recall(_facts_load(mem_json)) or None |
|
|
| |
| t0 = time.monotonic() |
| evidence = _evidence_label(thread, image_paths, cal_path) |
| _meta = lambda: {"elapsed": time.monotonic() - t0, "evidence": evidence} |
|
|
| 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: |
| 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", {}) |
| |
| 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) |
| |
| 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 |
|
|
| 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 |
|
|
| trace = export_run(redact=True) |
| 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: |
| 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) |
| |
| 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: |
| return f"Google Calendar push failed: {e}" |
|
|
|
|
| |
| |
| |
| 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(), |
| ) |
|
|
|
|
| |
| |
| |
| 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: |
| 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: |
| 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>" |
| ) |
|
|
|
|
| |
| |
| |
| _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: |
| |
| |
| 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>' |
| ) |
|
|
|
|
| |
| |
| |
| def build_demo() -> gr.Blocks: |
| |
| |
| with gr.Blocks(theme=THEME, css=CSS, title="OffGridSchedula") as demo: |
| gr.HTML('<div id="status-banner" class="status-banner" role="alert"></div>', elem_id="status-banner-host") |
| gr.HTML(_nav_html()) |
|
|
| |
| |
| |
| mem_box = gr.Textbox(visible=False, elem_id="mem-box") |
|
|
| with gr.Tab("Schedule"): |
| captured_conflicts = gr.State(0) |
|
|
| |
| 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()) |
|
|
| |
| with gr.Column(elem_id="tool-card"): |
| gr.HTML(_tool_header_html()) |
|
|
| |
| gr.HTML(_flow_step(1, "Choose your workflow")) |
| |
| |
| |
| 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)) |
|
|
| |
| 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>' |
| ) |
|
|
| |
| gr.HTML(_flow_step("2a", "Optional", "(calendar · privacy trace · personalize)")) |
| |
| |
| |
| |
| 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() |
| |
| |
| |
| |
| 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") |
|
|
| |
| |
| |
| 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 |
| ) |
|
|
| |
| |
| |
| 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([])) |
|
|
| |
| with gr.Column(elem_id="rv-results", visible=False) as results: |
| |
| |
| |
| |
| hero_carousel = gr.HTML("", elem_id="rv-hero", visible=False) |
| |
| |
| |
| |
| found_notes = gr.State([]) |
| |
| |
| |
| with gr.Group(elem_id="rv-resultcard"): |
| plan_md = gr.HTML() |
| cards = gr.HTML() |
| with gr.Group(elem_id="rv-export"): |
| |
| |
| |
| |
| |
| |
| |
| |
| 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) |
| |
| 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") |
|
|
| |
| gr.HTML(_how_html()) |
| gr.HTML(_uses_html()) |
| gr.HTML(_privacy_html()) |
|
|
| |
| 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) |
| |
| |
| _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) {} }" |
| ) |
| |
| |
| _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) |
| |
| |
| 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) |
| |
| |
| 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_btn.click( |
| _on_start_over, None, |
| [conversation, images_in, existing_cal, results, status, |
| screenshot_status, trace_status, pipeline], |
| ) |
| |
| |
| |
| images_in.change(_screenshot_status, images_in, screenshot_status) |
| |
| |
| table.change(_render_event_cards, [table, mode, found_notes], cards).then( |
| _render_event_carousel, table, hero_carousel |
| ).then(_prep_ics, table, ics_file) |
| |
| copy_btn.click(None, [reply], None, js="(t) => { navigator.clipboard.writeText(t || ''); }") |
| |
| |
| ics_btn.click( |
| _on_make_ics, [table, captured_conflicts], [ics_file, status] |
| ).then(None, ics_file, None, js=_auto_download_js) |
| |
| apple_btn.click( |
| _on_make_ics, [table, captured_conflicts], [ics_file, status] |
| ).then(None, ics_file, None, js=_open_ics_js) |
| |
| |
| 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]", |
| ) |
|
|
| |
| gr.HTML(_faq_html()) |
|
|
| |
| 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) |
| |
| |
| 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([])) |
|
|
| |
| 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"): |
| |
| |
| |
| 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], |
| ) |
| |
| 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]", |
| ) |
| |
| 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, |
| |
| |
| 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") |
| |
| |
| |
| 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.") |
|
|
| |
| 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() |
|
|
| |
| |
| |
| _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) |
| |
| |
| 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()) |
|
|
| |
| |
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| |
| |
| 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", |
| ) |
| |
| |
| |
| |
| gr.api(_mcp_make_ics, api_name="make_ics") |
| gr.api(_mcp_check_conflicts, api_name="check_conflicts") |
|
|
| |
| |
| |
| |
| |
| |
| 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(), |
| gr.update(), |
| gr.update(), |
| gr.update(visible=online), |
| |
| |
| |
| gr.update(), |
| gr.update(), |
| _mode_note_html(online), |
| |
| |
| _render_event_cards(rows, m, notes), |
| _render_event_cards(ag_rows, m), |
| ) |
|
|
| |
| |
| |
| 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'); }", |
| ) |
| |
| |
| |
| demo.load(None, None, mode, |
| js="() => { try { return localStorage.getItem('offgrid_mode') || '🔌 Offline'; } " |
| "catch (e) { return '🔌 Offline'; } }") |
|
|
| |
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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.") |
|
|
|
|
| |
| def _facts_load(s) -> list[dict]: |
| try: |
| data = json.loads(s) if s else [] |
| except Exception: |
| return [] |
| |
| |
| 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()}" |
|
|
|
|
| |
| |
| |
| |
| _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: |
| 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: |
| 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." |
| |
| 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: |
| 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: |
| pass |
| |
| 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 |
|
|
| names, locs = read_recent_facts() |
| except Exception as e: |
| 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: |
| return [] |
|
|
|
|