Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>MatchDay — AI trip planner for the 2026 FIFA World Cup, Vancouver</title> | |
| <!-- Leaflet preloaded in <head> so the injected map's inline init runs instantly (N1). --> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |
| <style> | |
| :root{ | |
| --violet:#7c3aed; --violet-d:#6d28d9; --emerald:#10b981; --amber:#f59e0b; | |
| --ink:#111827; --ink2:#374151; --mut:#6b7280; --mut2:#9ca3af; | |
| --line:#e5e7eb; --panel:#f9fafb; --gray:#f3f4f6; | |
| } | |
| *{box-sizing:border-box;} | |
| html,body{margin:0;height:100%;} | |
| body{ | |
| font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; | |
| color:var(--ink); background:#fff; -webkit-font-smoothing:antialiased; | |
| } | |
| ::-webkit-scrollbar{width:9px;height:9px;} | |
| ::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:6px;} | |
| /* ── Top bar ─────────────────────────────────────────── */ | |
| .topbar{ | |
| display:flex;align-items:center;gap:14px;padding:12px 20px; | |
| border-bottom:1px solid var(--line); | |
| background:linear-gradient(100deg,#0f172a,#1e3a8a);color:#fff;position:relative;z-index:5; | |
| } | |
| .topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.02em;display:flex;align-items:center;gap:8px;} | |
| .topbar .tag{font-size:13px;opacity:.85;font-weight:500;} | |
| .topbar .sp{flex:1;} | |
| .topbar .pill{font-size:11px;font-weight:600;background:rgba(255,255,255,.14); | |
| padding:4px 10px;border-radius:999px;border:1px solid rgba(255,255,255,.22);} | |
| .topbar .pill b{color:#c4b5fd;} | |
| /* ── 3-column layout ─────────────────────────────────── */ | |
| .app{display:grid;grid-template-columns:minmax(320px,35%) minmax(0,45%) minmax(180px,20%); | |
| height:calc(100vh - 53px);} | |
| .col{display:flex;flex-direction:column;min-height:0;min-width:0;} | |
| .chat{border-right:1px solid var(--line);} | |
| .sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;} | |
| /* ── Chat column ─────────────────────────────────────── */ | |
| #chat{flex:1;overflow-y:auto;padding:18px 18px 8px;display:flex;flex-direction:column;gap:14px;} | |
| .hero{background:var(--gray);border:1px solid var(--line);border-radius:14px;padding:16px;} | |
| .hero h2{margin:0 0 6px;font-size:15px;} | |
| .hero p{margin:0;font-size:13px;color:var(--mut);line-height:1.5;} | |
| .msg{display:flex;gap:10px;max-width:92%;animation:fade .25s ease;} | |
| .msg.user{align-self:flex-end;flex-direction:row-reverse;} | |
| .a-avatar{width:30px;height:30px;border-radius:50%;background:#1e3a8a;color:#fff; | |
| display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 30px;} | |
| .a-body{background:var(--gray);border:1px solid var(--line);border-radius:14px; | |
| padding:11px 14px;min-width:0;} | |
| .msg.user .a-body{background:var(--violet);color:#fff;border-color:var(--violet-d);} | |
| .a-content{font-size:14px;line-height:1.5;} | |
| .a-beat{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink2); | |
| font-weight:500;padding-top:2px;} | |
| .spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);} | |
| @keyframes spin{to{transform:rotate(360deg);}} | |
| @keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}} | |
| .green{background:#f0fdf4;border:1px solid #bbf7d0;color:#166534;border-radius:12px; | |
| padding:10px 13px;font-size:14px;font-weight:600;} | |
| .clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;} | |
| .err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;} | |
| .nemotron-note{font-size:11px;color:var(--mut2);margin-top:7px;font-weight:600;letter-spacing:.02em;} | |
| .msg.user .nemotron-note,.msg.user .a-beat{color:rgba(255,255,255,.92);} | |
| /* input row */ | |
| .composer{border-top:1px solid var(--line);padding:12px 14px;background:#fff;} | |
| .composer textarea{width:100%;border:1px solid #d1d5db;border-radius:12px;padding:11px 13px; | |
| font:inherit;font-size:14px;resize:none;outline:none;min-height:48px;} | |
| .composer textarea:focus{border-color:var(--violet);box-shadow:0 0 0 3px rgba(124,58,237,.15);} | |
| .composer .row{display:flex;align-items:center;gap:8px;margin-top:8px;} | |
| .examples{display:flex;flex-wrap:wrap;gap:6px;padding:0 18px 10px;} | |
| .chip{font-size:12px;background:#fff;border:1px solid var(--line);border-radius:999px; | |
| padding:5px 11px;color:var(--ink2);cursor:pointer;transition:.15s;} | |
| .chip:hover{border-color:var(--violet);color:var(--violet);} | |
| button.primary{background:var(--violet);color:#fff;border:0;border-radius:10px;padding:9px 16px; | |
| font:inherit;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;} | |
| button.primary:hover{background:var(--violet-d);} | |
| button.primary:disabled{opacity:.55;cursor:not-allowed;} | |
| .hint{font-size:12px;color:var(--mut);} | |
| /* ── Result column ───────────────────────────────────── */ | |
| .result{overflow-y:auto;} | |
| #result{padding:16px 18px 24px;} | |
| .empty{display:flex;flex-direction:column;align-items:center;justify-content:center; | |
| height:100%;text-align:center;color:var(--mut2);padding:30px;} | |
| .empty .big{font-size:46px;margin-bottom:10px;} | |
| .empty h3{margin:0 0 6px;font-size:16px;color:var(--ink2);} | |
| .empty p{margin:0;font-size:13px;max-width:320px;line-height:1.5;} | |
| /* ── Sidebar ─────────────────────────────────────────── */ | |
| .sidebar .sec{padding:16px 16px 14px;border-bottom:1px solid var(--line);} | |
| .sidebar h4{margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);} | |
| .brain{display:flex;gap:10px;align-items:flex-start;} | |
| .brain .b{font-size:20px;} | |
| .brain .t{font-size:12.5px;line-height:1.45;color:var(--ink2);} | |
| .brain .t b{color:var(--violet);} | |
| .legend-row{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink2);margin:5px 0;} | |
| .dot{width:9px;height:9px;border-radius:50%;display:inline-block;} | |
| .dot.live{background:var(--emerald);} | |
| .dot.fallback{background:var(--amber);} | |
| .badges{display:flex;flex-wrap:wrap;gap:5px;} | |
| .badge{font-size:10.5px;font-weight:600;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe; | |
| border-radius:6px;padding:3px 7px;} | |
| .fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:4px 0;} | |
| .fact b{color:var(--ink);} | |
| /* full-screen map */ | |
| #matchday-map.fs{position:fixed;inset:0;z-index:99998; | |
| height:100vh;width:100vw;border-radius:0;} | |
| #md-fs-close{position:fixed;top:16px;right:18px;z-index:99999;background:#fff;color:var(--ink); | |
| font-weight:700;border:0;border-radius:10px;padding:9px 15px;cursor:pointer;font-size:14px; | |
| box-shadow:0 4px 14px rgba(0,0,0,.25);display:none;} | |
| #md-fs-close.show{display:block;} | |
| @media (max-width:980px){ | |
| .app{grid-template-columns:1fr;height:auto;} | |
| .chat{border-right:0;border-bottom:1px solid var(--line);} | |
| .sidebar{border-left:0;border-top:1px solid var(--line);} | |
| .result{min-height:60vh;} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="topbar"> | |
| <div class="logo">⚽ MatchDay</div> | |
| <div class="tag">Your AI trip planner for the <b>2026 FIFA World Cup · Vancouver</b></div> | |
| <div class="sp"></div> | |
| <span class="pill">Brain <b>Nemotron-3-Nano-30B</b> · Hands <b>Python</b> · <b>gradio.Server</b></span> | |
| </header> | |
| <div class="app"> | |
| <!-- LEFT: conversation --> | |
| <section class="col chat"> | |
| <div id="chat"> | |
| <div class="hero"> | |
| <h2>👋 Plan your World Cup trip</h2> | |
| <p>Tell me where you're flying from, the match you want, dates and budget. I'll build 3 ranked packages — cheapest flight, safest arrival, closest hotel to BC Place — with live prices and honest provenance.</p> | |
| </div> | |
| </div> | |
| <div class="examples" id="examples"> | |
| <span class="chip">Flying from Montreal, Canada vs Qatar, mid-range, June 26-29, just me</span> | |
| <span class="chip">Toronto to see Brazil vs Germany, premium, July 12, 2 adults</span> | |
| <span class="chip">From Halifax, Canada vs Morocco June 18, couple, luxury</span> | |
| </div> | |
| <div class="composer"> | |
| <textarea id="prompt" rows="2" placeholder="e.g. Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me" | |
| aria-label="Describe your trip"></textarea> | |
| <div class="row"> | |
| <button class="primary" id="send" onclick="runTrip()">🏈 Plan my trip</button> | |
| <span class="hint">Enter to send · Enter+Shift for newline</span> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- MIDDLE: result --> | |
| <section class="col result"> | |
| <div id="result"> | |
| <div class="empty"> | |
| <div class="big">🏟️</div> | |
| <h3>Your trip packages will appear here</h3> | |
| <p>Once you send your request, Nemotron picks the tools and Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map.</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- RIGHT: sidebar --> | |
| <aside class="col sidebar"> | |
| <div class="sec"> | |
| <h4>How it works</h4> | |
| <div class="brain"> | |
| <div class="b">🧠</div> | |
| <div class="t"><b>Brain:</b> Nemotron-3-Nano-30B (3B-active MoE) on Modal A100. It chooses the tools and writes the comparisons. It <b>never</b> calls an API or names a price.</div> | |
| </div> | |
| <div class="brain" style="margin-top:10px;"> | |
| <div class="b">🤲</div> | |
| <div class="t"><b>Hands:</b> deterministic Python calls SerpApi / Open-Meteo / OpenStreetMap, scores packages with a fixed formula, and tags every value with provenance.</div> | |
| </div> | |
| </div> | |
| <div class="sec"> | |
| <h4>Provenance — no hallucinated prices</h4> | |
| <div class="legend-row"><span class="dot live"></span> <b>● live</b> — real SerpApi / Open-Meteo / OSM reading</div> | |
| <div class="legend-row"><span class="dot fallback"></span> <b>example</b> — fixture (demo fallback)</div> | |
| </div> | |
| <div class="sec"> | |
| <h4>Built for Build Small</h4> | |
| <div class="badges"> | |
| <span class="badge">🎨 Off-Brand</span> | |
| <span class="badge">🤖 Best Agent</span> | |
| <span class="badge">🟩 Nemotron</span> | |
| <span class="badge">🟢 Modal</span> | |
| <span class="badge">📡 Trace</span> | |
| </div> | |
| </div> | |
| <div class="sec"> | |
| <h4>The match</h4> | |
| <div class="fact">🏟️ <b>BC Place Stadium</b>, Vancouver</div> | |
| <div class="fact">⏰ ~7:00 PM PT kickoff</div> | |
| <div class="fact">📍 Downtown — walkable from central hotels</div> | |
| </div> | |
| </aside> | |
| </div> | |
| <button id="md-fs-close" onclick="matchdayFullscreen(false)" aria-label="Exit full screen">✕ Close</button> | |
| <script> | |
| const EXAMPLES = [ | |
| "Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me", | |
| "I want to fly from Toronto to see Brazil vs Germany, premium, July 12, 2 adults", | |
| "Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget", | |
| "From Halifax, Canada vs Morocco June 18, couple, luxury", | |
| ]; | |
| // Pre-fill the first example for instant demo. | |
| document.getElementById("prompt").value = EXAMPLES[0]; | |
| let running = false; | |
| let resultHandled = false; // the result event repeats on `complete` — render it once | |
| const chatEl = document.getElementById("chat"); | |
| const resultEl = document.getElementById("result"); | |
| const promptEl = document.getElementById("prompt"); | |
| const sendBtn = document.getElementById("send"); | |
| // ── DOM helpers ──────────────────────────────────────── | |
| function addUserBubble(text){ | |
| const m = document.createElement("div"); | |
| m.className = "msg user"; | |
| m.innerHTML = `<div class="a-avatar">🧑</div><div class="a-body"><div class="a-content"></div></div>`; | |
| m.querySelector(".a-content").textContent = text; | |
| chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight; | |
| return m; | |
| } | |
| function addAssistantBubble(){ | |
| const m = document.createElement("div"); | |
| m.className = "msg assistant"; | |
| m.innerHTML = | |
| `<div class="a-avatar">🤖</div> | |
| <div class="a-body"> | |
| <div class="a-content"></div> | |
| <div class="a-beat"><span class="spin">◐</span><span class="beat-text">Reading your trip request…</span></div> | |
| </div>`; | |
| chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight; | |
| return m; | |
| } | |
| function setBeat(bubble, text){ | |
| const beat = bubble.querySelector(".a-beat"); | |
| if (!beat) return; | |
| beat.style.display = "flex"; | |
| beat.querySelector(".beat-text").textContent = text; | |
| } | |
| function hideBeat(bubble){ | |
| const beat = bubble.querySelector(".a-beat"); | |
| if (beat) beat.style.display = "none"; | |
| } | |
| function esc(s){ const d=document.createElement("div"); d.textContent=s==null?"":String(s); return d.innerHTML; } | |
| // ── Streaming via raw SSE (verified wire format) ─────── | |
| function handleEvent(ev, bubble){ | |
| if (!ev) return; | |
| if (ev.type === "commentary"){ | |
| setBeat(bubble, ev.text); | |
| } else if (ev.type === "greenlight"){ | |
| bubble.querySelector(".a-content").innerHTML = | |
| `<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`; | |
| } else if (ev.type === "clarify"){ | |
| bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`; | |
| hideBeat(bubble); | |
| } else if (ev.type === "error"){ | |
| bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`; | |
| hideBeat(bubble); | |
| } else if (ev.type === "result"){ | |
| if (resultHandled) return; // `complete` re-emits the final yield — render once | |
| resultHandled = true; | |
| hideBeat(bubble); | |
| bubble.querySelector(".a-content").innerHTML = | |
| `<div class="green">✅ Built 3 ranked packages → see them on the map</div>`; | |
| resultEl.innerHTML = ev.html; | |
| activateScripts(resultEl); // re-init the injected Leaflet map | |
| if (ev.explanation && ev.explanation.trim()){ | |
| const nb = document.createElement("div"); | |
| nb.className = "msg assistant"; | |
| const safe = esc(ev.explanation).replace(/\n/g,"<br>"); | |
| nb.innerHTML = | |
| `<div class="a-avatar">🤖</div><div class="a-body"> | |
| <div class="a-content"><b style="color:#1e3a8a">🤖 Nemotron compares your options</b><br>${safe} | |
| <div class="nemotron-note">written by Nemotron-3-Nano-30B · grounded in the scored data</div> | |
| </div></div>`; | |
| chatEl.appendChild(nb); | |
| } | |
| chatEl.scrollTop = chatEl.scrollHeight; | |
| } | |
| } | |
| function activateScripts(root){ | |
| // innerHTML-inserted <script> tags don't auto-run — re-create them so the | |
| // Leaflet init (and any other inline script in the result fragment) executes. | |
| root.querySelectorAll("script").forEach(old=>{ | |
| const s = document.createElement("script"); | |
| if (old.src){ s.src = old.src; } else { s.textContent = old.textContent; } | |
| old.replaceWith(s); | |
| }); | |
| } | |
| async function runTrip(forcedText){ | |
| if (running) return; | |
| const text = (forcedText == null ? promptEl.value : forcedText).trim(); | |
| if (!text) return; | |
| running = true; sendBtn.disabled = true; | |
| addUserBubble(text); | |
| const bubble = addAssistantBubble(); | |
| try{ | |
| const resp = await fetch("/gradio_api/call/plan_trip", { | |
| method: "POST", headers: {"Content-Type":"application/json"}, | |
| body: JSON.stringify({ data: [text] }), | |
| }); | |
| const j = await resp.json(); | |
| const eventId = j && j.event_id; | |
| if (!eventId){ bubble.querySelector(".a-content").innerHTML = `<div class="err">No event id returned from the queue.</div>`; hideBeat(bubble); return; } | |
| const es = new EventSource("/gradio_api/call/plan_trip/" + eventId); | |
| es.addEventListener("generating", (e)=>{ | |
| try{ const arr = JSON.parse(e.data); handleEvent(JSON.parse(arr[0]), bubble); }catch(_){} | |
| }); | |
| es.addEventListener("complete", (e)=>{ | |
| try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){} | |
| es.close(); finish(); | |
| }); | |
| es.addEventListener("error", ()=>{ es.close(); finish(); }); | |
| }catch(err){ | |
| bubble.querySelector(".a-content").innerHTML = `<div class="err">⚠️ ${esc(err.message)}</div>`; | |
| hideBeat(bubble); finish(); | |
| } | |
| } | |
| function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; } | |
| // ── Full-screen map (Layla frame 24) ─────────────────── | |
| function matchdayFullscreen(on){ | |
| const mapEl = document.getElementById("matchday-map"); | |
| const closeBtn = document.getElementById("md-fs-close"); | |
| if (!mapEl) return; | |
| if (on === undefined) on = !mapEl.classList.contains("fs"); | |
| mapEl.classList.toggle("fs", on); | |
| closeBtn.classList.toggle("show", on); | |
| setTimeout(()=>{ if (window._matchdayMap){ window._matchdayMap.invalidateSize(); } }, 120); | |
| } | |
| window.matchdayFullscreen = matchdayFullscreen; | |
| // ── Examples + keyboard ──────────────────────────────── | |
| document.getElementById("examples").addEventListener("click", (e)=>{ | |
| const chip = e.target.closest(".chip"); | |
| if (!chip) return; | |
| promptEl.value = chip.textContent; | |
| runTrip(chip.textContent); | |
| }); | |
| promptEl.addEventListener("keydown", (e)=>{ | |
| if (e.key === "Enter" && !e.shiftKey){ e.preventDefault(); runTrip(); } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |