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> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --violet:#7c3aed; --violet-d:#6d28d9; --violet-l:#ede9fe; | |
| --emerald:#10b981; --emerald-d:#047857; | |
| --amber:#f59e0b; | |
| --ink:#0f172a; --ink2:#374151; --mut:#6b7280; --mut2:#9ca3af; | |
| --line:#e8eaee; --line2:#eef0f3; --panel:#f9fafb; --gray:#f3f4f6; | |
| --bg:#f7f8fa; --surface:#ffffff; | |
| --shadow-sm:0 1px 2px rgba(17,24,39,.04),0 2px 6px -2px rgba(17,24,39,.08); | |
| --shadow-md:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16); | |
| /* FIFA World Cup 2026 inspired palette (vibrant poster accents on dark) */ | |
| --wc-magenta:#db2777; --wc-violet:#7c3aed; --wc-blue:#2563eb; | |
| --wc-teal:#0d9488; --wc-gold:#fbbf24; --wc-pink:#f472b6; | |
| --display:'Space Grotesk',Inter,-apple-system,sans-serif; | |
| } | |
| *{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:var(--bg); -webkit-font-smoothing:antialiased; | |
| text-rendering:optimizeLegibility; | |
| } | |
| ::-webkit-scrollbar{width:10px;height:10px;} | |
| ::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:8px;border:2px solid var(--bg);} | |
| ::-webkit-scrollbar-thumb:hover{background:#b9c0c9;} | |
| /* ── Top bar ─────────────────────────────────────────── */ | |
| .topbar{ | |
| display:flex;align-items:center;gap:14px;padding:13px 22px; | |
| background:linear-gradient(100deg,#0f172a 0%,#1e293b 45%,#1e3a8a 100%);color:#fff; | |
| position:relative;z-index:5;box-shadow:0 2px 14px rgba(15,23,42,.18); | |
| } | |
| .topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.025em;display:flex;align-items:center;gap:9px;} | |
| .topbar .logo .ball{filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));} | |
| .topbar .tag{font-size:13px;opacity:.82;font-weight:500;} | |
| .topbar .tag b{color:#c4b5fd;font-weight:600;} | |
| .topbar .sp{flex:1;} | |
| .topbar .pill{font-size:11.5px;font-weight:600;background:rgba(255,255,255,.10); | |
| padding:6px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.16); | |
| display:inline-flex;align-items:center;gap:8px;} | |
| .topbar .pill b{color:#c4b5fd;} | |
| .topbar .live-dot{width:7px;height:7px;border-radius:50%;background:#34d399; | |
| box-shadow:0 0 0 0 rgba(52,211,153,.6);animation:pulse 2s infinite;} | |
| @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(52,211,153,.5);}70%{box-shadow:0 0 0 7px rgba(52,211,153,0);}100%{box-shadow:0 0 0 0 rgba(52,211,153,0);}} | |
| /* ── 3-column layout ─────────────────────────────────── */ | |
| .app{display:grid;grid-template-columns:minmax(330px,35%) minmax(0,45%) minmax(190px,21%); | |
| height:calc(100vh - 57px);} | |
| .col{display:flex;flex-direction:column;min-height:0;min-width:0;} | |
| .chat{border-right:1px solid var(--line);background:var(--bg);} | |
| .sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;} | |
| /* ── Chat column ─────────────────────────────────────── */ | |
| #chat{flex:1;overflow-y:auto;padding:20px 18px 10px;display:flex;flex-direction:column;gap:15px;} | |
| /* FIFA 2026 themed hero — real stadium imagery on a vibrant gradient fallback | |
| (looks premium even before the photo finishes loading or if it's blocked). */ | |
| .hero{position:relative;overflow:hidden;border:0;border-radius:18px;min-height:220px; | |
| background: | |
| url('https://images.unsplash.com/photo-1522778526097-ce0a22ceb253?w=1600&q=80') center/cover no-repeat, | |
| linear-gradient(135deg,#1e1b4b 0%,#6d28d9 45%,#db2777 100%); | |
| box-shadow:var(--shadow-md);display:flex;align-items:flex-end;} | |
| .hero-overlay{position:absolute;inset:0; | |
| background:linear-gradient(180deg,rgba(15,23,42,.18) 0%,rgba(15,23,42,.55) 52%,rgba(15,23,42,.93) 100%);} | |
| .hero-content{position:relative;z-index:2;padding:20px 22px 21px;color:#fff;width:100%;} | |
| .hero-badge{display:inline-block;font-size:10.5px;font-weight:800;letter-spacing:.09em;text-transform:uppercase; | |
| color:#fff;background:linear-gradient(135deg,var(--wc-magenta),var(--wc-violet)); | |
| padding:5px 11px;border-radius:999px;margin-bottom:11px;box-shadow:0 4px 12px -3px rgba(219,39,119,.5);} | |
| .hero h2{margin:0 0 8px;font-family:var(--display);font-size:25px;font-weight:700;line-height:1.08; | |
| letter-spacing:-.025em;color:#fff;} | |
| .hero h2 .hero-accent{background:linear-gradient(135deg,var(--wc-gold),var(--wc-pink)); | |
| -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;} | |
| .hero p{margin:0;font-size:13px;color:rgba(255,255,255,.92);line-height:1.55;max-width:48ch;} | |
| .hero-cold{margin-top:11px;font-size:11.5px;font-weight:600;color:#fde68a; | |
| display:inline-flex;align-items:center;gap:6px;background:rgba(0,0,0,.24); | |
| padding:5px 11px;border-radius:999px;border:1px solid rgba(251,191,36,.28);} | |
| .msg{display:flex;gap:10px;max-width:94%;animation:fade .3s ease;} | |
| .msg.user{align-self:flex-end;flex-direction:row-reverse;} | |
| .a-avatar{width:31px;height:31px;border-radius:50%;background:linear-gradient(135deg,#1e3a8a,#312e81); | |
| color:#fff;display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 31px; | |
| box-shadow:var(--shadow-sm);} | |
| .a-body{background:var(--surface);border:1px solid var(--line2);border-radius:16px; | |
| padding:11px 14px;min-width:0;box-shadow:var(--shadow-sm);} | |
| .msg.user .a-body{background:linear-gradient(135deg,var(--violet),var(--violet-d));color:#fff;border-color:transparent;box-shadow:0 6px 16px -6px rgba(124,58,237,.5);} | |
| .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:600;padding-top:3px;flex-wrap:wrap;} | |
| .spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);font-size:14px;} | |
| @keyframes spin{to{transform:rotate(360deg);}} | |
| @keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}} | |
| .elapsed{font-size:12px;color:var(--violet);font-weight:700;font-variant-numeric:tabular-nums; | |
| background:var(--violet-l);padding:1px 8px;border-radius:999px;} | |
| .green{background:linear-gradient(135deg,#ecfdf5,#d1fae5);border:1px solid #a7f3d0;color:#065f46; | |
| border-radius:12px;padding:10px 13px;font-size:14px;font-weight:700;} | |
| .clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;line-height:1.5;} | |
| .err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;line-height:1.5;} | |
| .retry-btn{margin-top:9px;background:#fff;border:1px solid var(--violet);color:var(--violet); | |
| border-radius:9px;padding:7px 14px;font:inherit;font-weight:700;font-size:13px;cursor:pointer; | |
| display:inline-flex;align-items:center;gap:6px;transition:.15s;} | |
| .retry-btn:hover{background:var(--violet-l);} | |
| .nemotron-note{font-size:11px;color:var(--mut2);margin-top:8px;font-weight:600;letter-spacing:.02em;} | |
| /* input row */ | |
| .composer{border-top:1px solid var(--line);padding:12px 16px 14px;background:var(--surface);} | |
| .composer textarea{width:100%;border:1px solid #d6d9df;border-radius:13px;padding:12px 14px; | |
| font:inherit;font-size:14px;resize:none;outline:none;min-height:50px;background:var(--bg); | |
| transition:.15s;line-height:1.45;} | |
| .composer textarea:focus{border-color:var(--violet);box-shadow:0 0 0 4px rgba(124,58,237,.13);background:#fff;} | |
| .composer .row{display:flex;align-items:center;gap:9px;margin-top:9px;} | |
| .examples{display:flex;flex-wrap:wrap;gap:7px;padding:0 18px 8px;} | |
| .chip{font-size:12px;background:var(--surface);border:1px solid var(--line2);border-radius:999px; | |
| padding:6px 12px;color:var(--ink2);cursor:pointer;transition:.15s;box-shadow:var(--shadow-sm);} | |
| .chip:hover{border-color:var(--violet);color:var(--violet);transform:translateY(-1px);} | |
| button.primary{background:linear-gradient(135deg,var(--violet),var(--violet-d));color:#fff;border:0; | |
| border-radius:11px;padding:10px 17px;font:inherit;font-weight:700;cursor:pointer; | |
| display:inline-flex;align-items:center;gap:7px;box-shadow:0 6px 16px -6px rgba(124,58,237,.55); | |
| transition:.15s;} | |
| button.primary:hover{transform:translateY(-1px);box-shadow:0 10px 22px -6px rgba(124,58,237,.6);} | |
| button.primary:disabled{opacity:.55;cursor:not-allowed;transform:none;} | |
| .hint{font-size:11.5px;color:var(--mut2);} | |
| /* ── Result column ───────────────────────────────────── */ | |
| .result{overflow-y:auto;background:var(--bg);} | |
| #result{padding:18px 20px 28px;} | |
| .empty{display:flex;flex-direction:column;align-items:center;justify-content:center; | |
| height:100%;text-align:center;padding:50px 30px; | |
| background:radial-gradient(125% 90% at 50% 0%,#1e293b 0%,#0f172a 72%);color:#cbd5e1;} | |
| .empty .big{font-size:66px;margin-bottom:16px;filter:drop-shadow(0 8px 26px rgba(56,189,248,.35));} | |
| .empty h3{margin:0 0 9px;font-family:var(--display);font-size:19px;color:#fff;font-weight:700;letter-spacing:-.015em;} | |
| .empty p{margin:0;font-size:13.5px;max-width:360px;line-height:1.6;color:#94a3b8;} | |
| /* ── "Building your trip" skeleton state (premium wait UX) ── */ | |
| /* Layla-style visible progress steps (N10): real tool activity + provenance, | |
| never raw chain-of-thought. Driven by backend `progress` events. */ | |
| .steps{display:flex;flex-direction:column;gap:5px;margin-bottom:18px; | |
| background:#fff;border:1px solid var(--line2);border-radius:14px;padding:13px 15px;box-shadow:var(--shadow-sm);} | |
| .steps-h{font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);margin-bottom:7px;} | |
| .step{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--mut2);transition:color .2s;} | |
| .step .si{width:18px;height:18px;border-radius:50%;flex:0 0 18px;display:inline-flex; | |
| align-items:center;justify-content:center;font-size:11px;background:#eef0f3;color:transparent;} | |
| .step.is-running{color:var(--ink2);font-weight:600;} | |
| .step.is-running .si{background:var(--violet);color:#fff;} | |
| .step.is-running .si::after{content:"";width:7px;height:7px;border-radius:50%;background:#fff; | |
| animation:pulse 1.1s infinite;} | |
| .step.is-done{color:var(--ink2);} | |
| .step.is-done .si{background:var(--emerald);color:#fff;} | |
| .step.is-done .si::after{content:"✓";} | |
| .step.is-fallback{color:#b45309;} | |
| .step.is-fallback .si{background:#fef3c7;color:#b45309;} | |
| .step.is-fallback .si::after{content:"!";} | |
| .step.is-unavailable{color:var(--mut2);} | |
| .step.is-unavailable .si{background:#f3f4f6;color:var(--mut2);} | |
| .step.is-unavailable .si::after{content:"–";} | |
| .step .st{flex:1;min-width:0;} | |
| .step .sd{font-size:11.5px;color:var(--mut2);font-weight:500;} | |
| .step.is-running .sd,.step.is-done .sd{color:inherit;opacity:.75;} | |
| .building{padding:2px 0;} | |
| .build-head{display:flex;align-items:center;gap:11px;background:linear-gradient(135deg,#0f172a,#1e3a8a); | |
| color:#fff;border-radius:16px;padding:14px 17px;margin-bottom:18px;box-shadow:var(--shadow-md); | |
| flex-wrap:wrap;} | |
| .build-head .spin{color:#a5b4fc;} | |
| .build-text{font-size:14px;font-weight:700;flex:1;min-width:120px;} | |
| .build-head .elapsed{background:rgba(255,255,255,.14);color:#fff;border:1px solid rgba(255,255,255,.18);} | |
| .cold-hint{font-size:12.5px;color:#c7d2fe;background:rgba(255,255,255,.08);border-radius:10px; | |
| padding:8px 12px;margin-top:8px;line-height:1.45;width:100%;border:1px solid rgba(255,255,255,.10);} | |
| @keyframes shimmer{100%{transform:translateX(100%);}} | |
| .sk{position:relative;overflow:hidden;background:#e9ecf2;border-radius:13px;} | |
| .sk::after{content:"";position:absolute;inset:0;transform:translateX(-100%); | |
| background:linear-gradient(90deg,transparent,rgba(255,255,255,.65),transparent);animation:shimmer 1.5s infinite;} | |
| .sk-h{height:13px;margin-bottom:10px;} | |
| .sk-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:15px;margin-bottom:22px;} | |
| .sk-card{background:var(--surface);border:1px solid var(--line2);border-radius:18px;padding:18px;box-shadow:var(--shadow-sm);overflow:hidden;} | |
| .sk-card .sk{margin-bottom:9px;} | |
| .sk-card .sk.big{height:30px;width:60%;margin-bottom:14px;} | |
| .sk-card .sk.line{height:12px;} | |
| .sk-card .sk.line.w80{width:80%;} | |
| .sk-card .sk.line.w60{width:60%;} | |
| .sk-map{height:300px;border-radius:18px;margin-bottom:22px;} | |
| .sk-tlrow{background:var(--surface);border:1px solid var(--line2);border-radius:16px;padding:15px; | |
| display:flex;gap:14px;align-items:center;margin-bottom:11px;box-shadow:var(--shadow-sm);overflow:hidden;} | |
| .sk-tlrow .sq{width:32px;height:32px;border-radius:10px;flex:0 0 32px;} | |
| .sk-tlrow .ln{flex:1;} | |
| .sk-tlrow .ln .sk{height:12px;margin-bottom:7px;} | |
| .sk-tlrow .ln .sk.w50{width:50%;} | |
| /* ── Sidebar ─────────────────────────────────────────── */ | |
| .sidebar .sec{padding:16px 16px 15px;border-bottom:1px solid var(--line);} | |
| .sidebar h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--mut);font-weight:800;} | |
| .brain{display:flex;gap:10px;align-items:flex-start;} | |
| .brain .b{font-size:20px;line-height:1.2;} | |
| .brain .t{font-size:12.5px;line-height:1.5;color:var(--ink2);} | |
| .brain .t b{color:var(--violet);} | |
| .legend-row{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--ink2);margin:6px 0;} | |
| .dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:0 0 9px;} | |
| .dot.live{background:var(--emerald);box-shadow:0 0 0 2px rgba(16,185,129,.2);} | |
| .dot.fallback{background:var(--amber);} | |
| .badges{display:flex;flex-wrap:wrap;gap:5px;} | |
| .badge{font-size:10.5px;font-weight:700;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe; | |
| border-radius:7px;padding:3px 8px;} | |
| .fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:5px 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:11px;padding:9px 15px;cursor:pointer;font-size:14px; | |
| box-shadow:0 6px 18px rgba(0,0,0,.25);display:none;} | |
| #md-fs-close.show{display:block;} | |
| /* ── Agent Trace / Evidence drawer (live, streamed from `trace` SSE) ─────── | |
| A persistent host above the package area. It survives skeleton/clarify/ | |
| error swaps (so a grounding refusal — a core Best-Agent proof — stays | |
| visible) and is fed by every `trace` event, so a judge watches the agent | |
| reason tool-by-tool in real time. Rules mirror render.py _CSS so the live | |
| drawer is identical to the standalone render_trace output. */ | |
| #md-live-trace{padding:16px 20px 0;} | |
| #md-live-trace:empty{display:none;} | |
| .md-pv{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:800;padding:1px 7px 1px 5px;border-radius:999px;} | |
| .md-pv::before{content:"";width:5px;height:5px;border-radius:50%;display:inline-block;} | |
| .md-pv-live{background:#ecfdf5;color:#047857;} .md-pv-live::before{background:#10b981;} | |
| .md-pv-ex{background:#fffbeb;color:#b45309;} .md-pv-ex::before{background:#f59e0b;} | |
| .md-pv-other{background:#f3f4f6;color:#4b5563;} .md-pv-other::before{background:#9ca3af;} | |
| .md-trace{margin:22px 2px 6px;background:#fff;border:1px solid #eef0f3;border-radius:18px; | |
| box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -14px rgba(17,24,39,.16);overflow:hidden;} | |
| .md-trace > summary{list-style:none;cursor:pointer;padding:15px 18px;display:flex;align-items:center; | |
| gap:11px;font-size:13.5px;font-weight:700;color:#111827;user-select:none;} | |
| .md-trace > summary::-webkit-details-marker{display:none;} | |
| .md-trace > summary .md-trace-ico{font-size:17px;} | |
| .md-trace > summary .md-trace-chev{margin-left:auto;color:#9ca3af;font-size:13px;transition:transform .2s;flex:0 0 auto;} | |
| .md-trace[open] > summary .md-trace-chev{transform:rotate(90deg);} | |
| .md-trace > summary .md-trace-sub{font-size:12px;font-weight:600;color:#6b7280;} | |
| .md-trace-body{padding:2px 18px 18px;display:grid;gap:14px;border-top:1px solid #f1f3f6;} | |
| .md-trace-meta{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:12px;color:#6b7280;font-weight:600;margin-top:13px;} | |
| .md-tag{font-size:10.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:4px 10px;border-radius:999px;} | |
| .md-tag-agent{background:#ede9fe;color:#6d28d9;} | |
| .md-tag-det{background:#fef3c7;color:#b45309;} | |
| .md-tag-clarify{background:#dbeafe;color:#1d4ed8;} | |
| .md-tag-failed{background:#fee2e2;color:#b91c1c;} | |
| .md-trace-model{color:#374151;font-weight:700;} | |
| .md-trace-sec h5{margin:0 0 8px;font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#6b7280;} | |
| .md-slots{display:flex;flex-wrap:wrap;gap:7px;} | |
| .md-slot{font-size:12px;background:#f3f4f6;border-radius:9px;padding:6px 11px;color:#374151;font-weight:600;} | |
| .md-slot b{color:#111827;font-weight:800;} | |
| .md-slot.miss{background:#fff7ed;color:#c2410c;} | |
| .md-ground{font-size:13px;line-height:1.5;color:#374151;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:11px;padding:10px 13px;} | |
| .md-ground.refused{background:#fffbeb;border-color:#fde68a;color:#92400e;} | |
| .md-ground b{color:#065f46;} .md-ground.refused b{color:#92400e;} | |
| .md-tcalls{list-style:none;margin:0;padding:0;display:grid;gap:8px;counter-reset:tc;} | |
| .md-tcall{position:relative;background:#fafbfc;border:1px solid #eef0f3;border-radius:11px;padding:10px 12px 10px 38px;font-size:12.5px;} | |
| .md-tcall::before{counter-increment:tc;content:counter(tc);position:absolute;left:11px;top:10px;width:20px;height:20px; | |
| border-radius:50%;background:#7c3aed;color:#fff;font-size:11px;font-weight:800;display:flex;align-items:center;justify-content:center;} | |
| .md-tcall.is-failed{border-color:#fecaca;background:#fef5f5;} .md-tcall.is-failed::before{background:#ef4444;} | |
| .md-tcall.is-skipped{opacity:.7;} .md-tcall.is-skipped::before{background:#9ca3af;} | |
| .md-tcall .tc-head{font-weight:800;color:#111827;font-family:'Space Grotesk',Inter,sans-serif;font-size:13px;} | |
| .md-tcall .tc-meta{color:#6b7280;margin-top:2px;} | |
| .md-tcall .tc-src{display:inline-flex;gap:5px;flex-wrap:wrap;margin-top:5px;} | |
| .md-evi{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;} | |
| .md-evi-cell{border:1px solid #eef0f3;border-radius:11px;padding:9px 11px;font-size:12px;} | |
| .md-evi-cell .ec-name{font-weight:700;color:#111827;display:flex;align-items:center;gap:6px;} | |
| .md-evi-cell .ec-stat{font-size:10.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;margin-top:3px;} | |
| .md-evi-cell.live .ec-stat{color:#047857;} .md-evi-cell.example .ec-stat{color:#b45309;} | |
| .md-evi-cell.failed .ec-stat{color:#b91c1c;} .md-evi-cell.skipped .ec-stat{color:#6b7280;} | |
| .md-formula-w{font-size:12.5px;color:#374151;margin-bottom:10px;} .md-formula-w b{color:#111827;} | |
| .md-rank-row{display:grid;grid-template-columns:22px 1fr auto;gap:10px;align-items:center;padding:7px 0;border-top:1px solid #f3f4f6;font-size:12.5px;} | |
| .md-rank-row:first-of-type{border-top:0;} | |
| .md-rank-row .rr-rk{font-weight:800;color:#9ca3af;font-family:'Space Grotesk',Inter,sans-serif;} | |
| .md-rank-row .rr-lbl{font-weight:700;color:#111827;} | |
| .md-rank-row .rr-cost{color:#6b7280;font-variant-numeric:tabular-nums;} | |
| .md-dimbar{display:flex;gap:9px;margin-top:5px;align-items:center;font-size:10.5px;color:#6b7280;} | |
| .md-dimbar .db{flex:1;height:6px;border-radius:999px;background:#eef0f3;overflow:hidden;position:relative;} | |
| .md-dimbar .db > i{position:absolute;left:0;top:0;bottom:0;background:#7c3aed;border-radius:999px;} | |
| .md-dimbar .db.b > i{background:#2563eb;} .md-dimbar .db.g > i{background:#16a34a;} | |
| .md-trace-empty{padding:14px 18px;color:#9ca3af;font-size:13px;} | |
| @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"><span class="ball">⚽</span> 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"><span class="live-dot"></span>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"> | |
| <div class="hero-overlay"></div> | |
| <div class="hero-content"> | |
| <span class="hero-badge">⚽ FIFA World Cup 2026 · Vancouver</span> | |
| <h2>Plan your <span class="hero-accent">World Cup</span> trip</h2> | |
| <p>Tell me where you're flying from, the match, your dates & budget. I'll build 3 ranked packages — cheapest flight, safest arrival, closest hotel to BC Place — with live prices & honest provenance.</p> | |
| <span class="hero-cold">⏳ First request may take a few minutes while the model warms up.</span> | |
| </div> | |
| </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 · Shift+Enter for a newline</span> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- MIDDLE: result --> | |
| <section class="col result"> | |
| <div id="md-live-trace"></div> | |
| <div id="result"> | |
| <div class="empty"> | |
| <div class="big">🏟️</div> | |
| <h3>Your Vancouver 2026 packages appear here</h3> | |
| <p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map of BC Place.</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:11px;"> | |
| <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", | |
| ]; | |
| document.getElementById("prompt").value = EXAMPLES[0]; | |
| // NOTE: status text is driven ONLY by real stream events (the backend's _pulse | |
| // sends an accurate commentary heartbeat every ~9s during any long phase). The | |
| // timer below drives the elapsed counter + cold-start hint only — it does NOT | |
| // invent phase messages, which would be inaccurate when round 1 runs long. | |
| let running = false; | |
| let resultHandled = false; // the result event repeats on `complete` — render once | |
| let elapsedTimer = null; | |
| let lastRealAt = 0; // timestamp of last real stream event (ms) | |
| let lastRealMsg = ""; | |
| 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><span class="elapsed" style="display:none">· 0:00</span></div> | |
| </div>`; | |
| chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight; | |
| return m; | |
| } | |
| function setStatus(bubble, text){ | |
| const beat = bubble.querySelector(".beat-text"); | |
| if (beat) beat.textContent = text; | |
| const bh = document.querySelector(".build-text"); | |
| if (bh) bh.textContent = text; | |
| } | |
| function setElapsed(bubble, secs){ | |
| const fmt = Math.floor(secs/60) + ":" + String(secs%60).padStart(2,"0"); | |
| const e = bubble.querySelector(".elapsed"); | |
| if (e){ e.textContent = "· " + fmt; e.style.display = "inline-block"; } | |
| const be = document.querySelector(".build-head .elapsed"); | |
| if (be) be.textContent = "· " + fmt; | |
| } | |
| 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; } | |
| // ── Skeleton "building your trip" state ──────────────── | |
| function showSkeleton(bubble){ | |
| resultEl.innerHTML = | |
| `<div class="building"> | |
| <div class="build-head"> | |
| <span class="spin">◐</span> | |
| <span class="build-text">Reading your trip request…</span> | |
| <span class="elapsed">· 0:00</span> | |
| <div class="cold-hint" style="display:none"></div> | |
| </div> | |
| <div class="steps" id="md-steps"> | |
| <div class="steps-h">Planning your trip</div> | |
| </div> | |
| <div class="sk-cards"> | |
| ${`<div class="sk-card"><div class="sk big"></div><div class="sk line w80"></div><div class="sk line w60"></div><div class="sk line"></div><div class="sk line w80"></div></div>`.repeat(3)} | |
| </div> | |
| <div class="sk sk-map"></div> | |
| ${`<div class="sk-tlrow"><div class="sk sq"></div><div class="ln"><div class="sk w50"></div><div class="sk w80"></div></div></div>`.repeat(4)} | |
| </div>`; | |
| } | |
| // ── Elapsed timer + phased progress + cold-start honesty ─ | |
| function startTimer(bubble){ | |
| stopTimer(); | |
| const t0 = performance.now(); | |
| elapsedTimer = setInterval(()=>{ | |
| const secs = Math.floor((performance.now()-t0)/1000); | |
| setElapsed(bubble, secs); | |
| // cold-start honesty after ~20s (status text itself comes from real events) | |
| const hint = document.querySelector(".cold-hint"); | |
| if (hint && secs >= 20 && hint.dataset.shown !== "1"){ | |
| hint.dataset.shown = "1"; | |
| hint.style.display = "block"; | |
| hint.textContent = "⏳ Warming up Nemotron-3-Nano-30B on Modal — the first query after idle can take ~1–2 min. It's working; the packages will stream in as soon as they're scored."; | |
| } | |
| }, 1000); | |
| } | |
| function stopTimer(){ if (elapsedTimer){ clearInterval(elapsedTimer); elapsedTimer = null; } } | |
| // ── Visible agent progress steps (N10 / Layla tripProgress) ─────────── | |
| // Ordered checklist of SAFE tool activity + provenance. Driven by real backend | |
| // `progress` events; never exposes raw chain-of-thought. Each step's status is | |
| // pending → running → done | fallback | unavailable. | |
| const STEPS = [ | |
| {key:"read", label:"Reading your request"}, | |
| {key:"extract", label:"Extracting trip details"}, | |
| {key:"flights", label:"Checking flights"}, | |
| {key:"hotels", label:"Checking hotels"}, | |
| {key:"weather", label:"Checking weather"}, | |
| {key:"nearby", label:"Checking nearby spots"}, | |
| {key:"score", label:"Scoring packages"}, | |
| {key:"itinerary", label:"Building itinerary"}, | |
| {key:"links", label:"Preparing booking & transit links"}, | |
| {key:"ready", label:"Packages ready"}, | |
| ]; | |
| let stepState = {}; | |
| function renderSteps(){ | |
| const box = document.getElementById("md-steps"); | |
| if (!box) return; | |
| let html = '<div class="steps-h">Planning your trip</div>'; | |
| for (const s of STEPS){ | |
| const st = stepState[s.key]; | |
| const status = st ? st.status : "pending"; | |
| const cls = status === "pending" ? "" : "is-" + status; | |
| const detail = (st && st.text) ? `<span class="sd"> · ${esc(st.text)}</span>` : ""; | |
| html += `<div class="step ${cls}"><span class="si"></span><span class="st">${s.label}${detail}</span></div>`; | |
| } | |
| box.innerHTML = html; | |
| } | |
| // ── Live Agent Trace / Evidence drawer ────────────────── | |
| // Mirrors render.py render_trace: extracted intent → verified-fixture grounding | |
| // → numbered tool calls → per-category live/example/failed evidence → the | |
| // deterministic ranking formula + why each package won. Fed by `trace` SSE | |
| // events; each event replaces the drawer with the accumulated state so far. | |
| const TRACE_DOTS = {live:"🟢", example:"🟠", failed:"🔴", skipped:"⚪", partial:"🟡", ok:"•"}; | |
| const TRACE_TAG = {agent:["md-tag-agent","Agent"], deterministic:["md-tag-det","Deterministic"], | |
| clarify:["md-tag-clarify","Clarify"], error:["md-tag-failed","Error"]}; | |
| const TRACE_W = {cost:"Affordable", buffer:"Safe arrival", transit:"Close", | |
| affordability:"Affordable", arrival_buffer:"Safe arrival", proximity:"Close"}; | |
| const TRACE_DIM = {affordability:["Affordable",""], arrival_buffer:["Safe arrival","b"], proximity:["Close","g"]}; | |
| const TRACE_TIER = {budget:"Budget", mid_range:"Balanced", premium:"Premium"}; | |
| const TRACE_CAT = {flights:"Flights", hotels:"Hotels", weather:"Weather", amenities:"Nearby"}; | |
| const LIVE_SRC = new Set(["serpapi","openmeteo","osm"]); | |
| function _num(g){ const n = Number(g); return Number.isFinite(n) ? String(n) : String(g); } | |
| function _range(a,b){ if(!a&&!b) return ""; if(a&&b&&a!==b) return a+" → "+b; return String(a||b||""); } | |
| function _slotV(v){ return (v===null||v===undefined||v==="") ? "—" : esc(v); } | |
| function renderTraceLive(d){ | |
| const host = document.getElementById("md-live-trace"); | |
| if (!host || !d) return; | |
| const mode = d.mode || ""; | |
| const tag = TRACE_TAG[mode] || ["md-tag-det", mode || "—"]; | |
| let meta = `<span class="md-tag ${tag[0]}">${esc(tag[1])}</span>`; | |
| if (d.model) meta += `<span class="md-trace-model">${esc(d.model)}</span>`; | |
| if (d.rounds) meta += `<span>${d.rounds} loop round${d.rounds!==1?"s":""}</span>`; | |
| meta += `<span>outcome: ${esc(d.outcome_status || "—")}</span>`; | |
| let sections = ""; | |
| // ① Extracted Trip Intent | |
| const intent = d.intent || {}, missing = d.missing_slots || []; | |
| if (intent && Object.keys(intent).length || missing.length){ | |
| const defs = [["Origin",intent.origin],["Match",intent.match_name],["Match date",intent.match_date], | |
| ["Stay",_range(intent.check_in,intent.check_out)],["Travelers",intent.travelers],["Tier",intent.budget_tier]]; | |
| let slots = defs.map(([k,v])=>`<span class="md-slot"><b>${esc(k)}:</b> ${_slotV(v)}</span>`).join(""); | |
| slots += missing.map(m=>`<span class="md-slot miss">missing: ${esc(m)}</span>`).join(""); | |
| sections += `<div class="md-trace-sec"><h5>① Extracted Trip Intent</h5><div class="md-slots">${slots}</div></div>`; | |
| } | |
| // ② Match Grounding (verified 2026 fixtures, or honest refusal) | |
| const g = d.grounding; | |
| if (g){ | |
| if (g.recognized){ | |
| const bits = [g.corrected ? "<b>Date corrected</b> to the verified 2026 fixture" | |
| : "<b>Match confirmed</b> against the verified 2026 schedule"]; | |
| if (g.kickoff) bits.push(`kickoff <b>${esc(g.kickoff)}</b>`); | |
| if (g.venue) bits.push(`at <b>${esc(g.venue)}</b>`); | |
| sections += `<div class="md-trace-sec"><h5>② Match Grounding</h5><div class="md-ground">${bits.join(" · ")}${g.note?". "+esc(g.note):""}</div></div>`; | |
| } else { | |
| const note = g.note || "Not a recognized 2026 fixture — the agent refused to invent a trip."; | |
| sections += `<div class="md-trace-sec"><h5>② Match Grounding</h5><div class="md-ground refused"><b>Refused</b> — ${esc(note)}</div></div>`; | |
| } | |
| } | |
| // ③ Tools Called (the multi-step proof) | |
| const tcalls = d.tool_calls || []; | |
| if (tcalls.length){ | |
| const rows = tcalls.map(t=>{ | |
| const st = t.status || "ok"; | |
| const cls = st==="failed"?" is-failed":(st==="skipped"?" is-skipped":""); | |
| const src = (t.sources||[]).filter(s=>s).map(s=>`<span class="md-pv md-pv-${LIVE_SRC.has(s)?"live":"ex"}">${esc(s)}</span>`).join(""); | |
| const dur = t.duration_ms ? ` · ${t.duration_ms} ms` : ""; | |
| const args = t.args || {}; | |
| const argstr = args && Object.keys(args).length ? " · "+Object.entries(args).map(([k,v])=>`${esc(k)}=${esc(v)}`).join(", ") : ""; | |
| const detail = t.detail ? `<div class="tc-meta">${esc(t.detail)}</div>` : ""; | |
| return `<li class="md-tcall${cls}"><span class="tc-head">${esc(t.name||"tool")}</span>`+ | |
| `<div class="tc-meta">${esc(st)}${dur}${argstr}</div>${detail}`+ | |
| `${src?`<span class="tc-src">${src}</span>`:""}</li>`; | |
| }).join(""); | |
| sections += `<div class="md-trace-sec"><h5>③ Tools Called</h5><ol class="md-tcalls">${rows}</ol></div>`; | |
| } | |
| // ④ Evidence per category (live vs example vs failed vs skipped) | |
| const evi = d.evidence || {}; | |
| if (evi && Object.keys(evi).length){ | |
| const cells = ["flights","hotels","weather","amenities"].map(cat=>{ | |
| const st = evi[cat] || "skipped"; | |
| return `<div class="md-evi-cell ${st}"><div class="ec-name">${TRACE_DOTS[st]||"•"} ${esc(TRACE_CAT[cat]||cat)}</div><div class="ec-stat">${esc(st)}</div></div>`; | |
| }).join(""); | |
| sections += `<div class="md-trace-sec"><h5>④ Evidence (live vs example)</h5><div class="md-evi">${cells}</div></div>`; | |
| } | |
| // ⑤ Ranking formula + why each package won | |
| const rk = d.ranking || {}; | |
| if (rk.packages && rk.packages.length){ | |
| const w = rk.weights || {}; | |
| const wline = `<div class="md-formula-w"><b>${esc(TRACE_TIER[rk.tier]||rk.tier||"Balanced")}</b> tier — `+ | |
| `composite = ${Object.entries(w).map(([k,v])=>`${TRACE_W[k]||k}×${_num(v)}`).join(" + ")}</div>`; | |
| const rows = rk.packages.map(p=>{ | |
| const sc = p.scores || {}; | |
| const bars = ["affordability","arrival_buffer","proximity"].map(k=>{ | |
| const val = Number(sc[k]||0), pct = Math.max(0,Math.min(100,Math.round(val*100))); | |
| const dm = TRACE_DIM[k]||[k,""]; | |
| return `<span class="db ${dm[1]}" title="${dm[0]} ${_num(val)}"><i style="width:${pct}%;"></i></span>`; | |
| }).join(""); | |
| const cost = Number(p.total_cost_cad||0); | |
| return `<div class="md-rank-row"><span class="rr-rk">#${p.rank}</span>`+ | |
| `<span><span class="rr-lbl">${esc(p.label||"")}</span><span class="md-dimbar">${bars}</span>${p.why?" — "+esc(p.why):""}</span>`+ | |
| `<span class="rr-cost">$${Math.round(cost).toLocaleString()} CAD</span></div>`; | |
| }).join(""); | |
| sections += `<div class="md-trace-sec"><h5>⑤ Ranking (deterministic)</h5>${wline}${rows}</div>`; | |
| } | |
| // Honesty / degradation notes | |
| const notes = d.notes || []; | |
| if (notes.length){ | |
| sections += `<div class="md-trace-sec"><h5>Notes</h5>`+ | |
| notes.map(n=>`<div class="md-rank-row" style="grid-template-columns:1fr;"><span>${esc(n)}</span></div>`).join("")+`</div>`; | |
| } | |
| const body = sections || `<div class="md-trace-empty">No trace recorded for this turn.</div>`; | |
| host.innerHTML = | |
| `<details class="md-trace" open><summary>`+ | |
| `<span class="md-trace-ico">🧭</span>`+ | |
| `<span>Agent Trace & Evidence</span>`+ | |
| `<span class="md-trace-sub">how this trip was reasoned, tool-by-tool</span>`+ | |
| `<span class="md-trace-chev">▸</span></summary>`+ | |
| `<div class="md-trace-body"><div class="md-trace-meta">${meta}</div>${body}</div></details>`; | |
| } | |
| // ── Streaming via raw SSE ─────────────────────────────── | |
| function handleEvent(ev, bubble){ | |
| if (!ev) return; | |
| // Live Agent Trace / Evidence drawer (Best-Agent proof): every `trace` event | |
| // repaints the persistent drawer, so reasoning streams tool-by-tool in real | |
| // time. Rendered into #md-live-trace (outside #result) so it survives the | |
| // skeleton→result swap AND the clarify/error clear — a grounding refusal | |
| // (agent won't invent a trip around a fake match) therefore stays visible. | |
| if (ev.type === "trace"){ | |
| renderTraceLive(ev.data); | |
| return; | |
| } | |
| if (ev.type === "progress"){ | |
| if (ev.step){ | |
| stepState[ev.step] = {status: ev.status || "running", text: ev.text || ""}; | |
| renderSteps(); | |
| if (ev.step === "ready" && ev.status === "done"){ setStatus(bubble, "Done — see your packages →"); } | |
| else if (ev.text){ setStatus(bubble, ev.text); } | |
| } | |
| return; | |
| } | |
| if (ev.type === "commentary"){ | |
| lastRealAt = performance.now(); lastRealMsg = ev.text || ""; | |
| setStatus(bubble, lastRealMsg); | |
| } else if (ev.type === "greenlight"){ | |
| lastRealAt = performance.now(); | |
| bubble.querySelector(".a-content").innerHTML = | |
| `<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`; | |
| setStatus(bubble, "✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather…"); | |
| } else if (ev.type === "clarify"){ | |
| bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`; | |
| hideBeat(bubble); stopTimer(); clearSkeleton(); | |
| } else if (ev.type === "error"){ | |
| bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`; | |
| hideBeat(bubble); stopTimer(); clearSkeleton(); | |
| } else if (ev.type === "result"){ | |
| if (resultHandled) return; // `complete` re-emits the final yield — render once | |
| resultHandled = true; | |
| lastRealAt = performance.now(); | |
| hideBeat(bubble); stopTimer(); | |
| 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:#3730a3">🤖 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 clearSkeleton(){ | |
| const b = resultEl.querySelector(".building"); | |
| if (b && !resultHandled){ | |
| resultEl.innerHTML = `<div class="empty"><div class="big">🏟️</div><h3>Your trip packages will appear here</h3><p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots.</p></div>`; | |
| } | |
| } | |
| 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); | |
| }); | |
| } | |
| function showStreamError(bubble, text, retryText){ | |
| stopTimer(); | |
| hideBeat(bubble); | |
| clearSkeleton(); | |
| const c = bubble.querySelector(".a-content"); | |
| c.innerHTML = `<div class="err">⚠️ ${esc(text||"Connection to the agent was interrupted.")}</div> | |
| <button class="retry-btn" type="button">↻ Try again</button>`; | |
| c.querySelector(".retry-btn").addEventListener("click", ()=>{ runTrip(retryText); }); | |
| } | |
| async function runTrip(forcedText){ | |
| if (running) return; | |
| const text = (forcedText == null ? promptEl.value : forcedText).trim(); | |
| if (!text) return; | |
| running = true; sendBtn.disabled = true; resultHandled = false; | |
| lastRealAt = 0; lastRealMsg = ""; | |
| stepState = {}; // reset progress steps for the new run | |
| const _traceHost = document.getElementById("md-live-trace"); | |
| if (_traceHost) _traceHost.innerHTML = ""; // reset the live trace drawer for the new run | |
| addUserBubble(text); | |
| const bubble = addAssistantBubble(); | |
| showSkeleton(bubble); | |
| startTimer(bubble); | |
| let completed = false; | |
| try{ | |
| const resp = await fetch("/gradio_api/call/plan_trip", { | |
| method: "POST", headers: {"Content-Type":"application/json"}, | |
| body: JSON.stringify({ data: [text] }), | |
| }); | |
| if (!resp.ok){ throw new Error("Server returned HTTP " + resp.status); } | |
| const j = await resp.json(); | |
| const eventId = j && j.event_id; | |
| if (!eventId){ throw new Error("No event id returned from the queue."); } | |
| 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(err){ console.warn("MatchDay: skipped a malformed stream chunk", err); } | |
| }); | |
| es.addEventListener("complete", (e)=>{ | |
| completed = true; | |
| try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){} | |
| es.close(); finish(); | |
| }); | |
| // gradio keepalive (ignored) — just mark the stream alive | |
| es.addEventListener("heartbeat", ()=>{ lastRealAt = performance.now(); }); | |
| es.addEventListener("error", ()=>{ | |
| es.close(); | |
| if (completed) return; // clean end — EventSource fires error on close | |
| if (resultHandled) return; // already rendered; nothing to do | |
| showStreamError(bubble, "Connection to the agent dropped mid-stream.", text); | |
| finish(); | |
| }); | |
| }catch(err){ | |
| showStreamError(bubble, err && err.message ? err.message : "Request failed.", text); | |
| finish(); | |
| } | |
| } | |
| function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; stopTimer(); } | |
| // ── 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> | |