matchday-server-test / index.html
mzidan000's picture
Upload folder using huggingface_hub
04e80a8 verified
Raw
History Blame Contribute Delete
18 kB
<!DOCTYPE html>
<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!important;inset:0!important;z-index:99998!important;
height:100vh!important;width:100vw!important;border-radius:0!important;}
#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>