Spaces:
Running on Zero
Running on Zero
| """MetroLLM-Bench demo v3 — Gradio + ZeroGPU rebuild. | |
| Build is incremental and verified at each step: | |
| 1. UI shell: scenarios, custom tab, result chatbot. No model. | |
| 2. Model load + one-round @spaces.GPU generate. | |
| 3. (this commit) Multi-round agent loop with in-process tool dispatch. | |
| 4. Streaming reasoning + structured tool-call rendering. | |
| 5. Leaflet map with route polyline. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| import gradio as gr | |
| # Monorepo: harness/, data/, dashboard/ live at HERE.parent. | |
| # Space deploy: pack_for_space.sh copies them next to app.py, so HERE itself. | |
| HERE = Path(__file__).resolve().parent | |
| REPO_ROOT = HERE if (HERE / "harness").is_dir() else HERE.parent | |
| if str(HERE) not in sys.path: | |
| sys.path.insert(0, str(HERE)) | |
| import prompts # type: ignore # noqa: E402 | |
| # --- Spaces decorator stub for local dev ----------------------------------- | |
| # The `spaces` package only exists on HF infra. On local dev we use a no-op | |
| # decorator so the module imports cleanly without it. | |
| try: | |
| import spaces # type: ignore | |
| GPU = spaces.GPU | |
| except Exception: | |
| class _NoopGPU: | |
| def __call__(self, *args, **kwargs): | |
| if args and callable(args[0]): | |
| return args[0] | |
| def deco(fn): | |
| return fn | |
| return deco | |
| GPU = _NoopGPU() | |
| DEFAULT_SYSTEM = os.environ.get("SYSTEM_NAME", "bart") | |
| prompts.validate_slug(DEFAULT_SYSTEM) | |
| KNOWN_SYSTEMS = prompts.known_systems() | |
| SYSTEM_INFO = { | |
| # bg/fg are the active-card fill colour and the readable foreground on | |
| # top of it. bg comes from each system's own lines.json (one line picked | |
| # per system to give distinct colours across the row, not all-red). | |
| "marta": {"city": "Atlanta", "country": "USA", "system": "MARTA", | |
| "bg": "#0060A9", "fg": "#fff"}, # Blue Line | |
| "bart": {"city": "San Francisco", "country": "USA", "system": "BART", | |
| "bg": "#F58220", "fg": "#fff"}, # Orange Line | |
| "cta": {"city": "Chicago", "country": "USA", "system": "CTA", | |
| "bg": "#C60C30", "fg": "#fff"}, # Red Line | |
| "doha": {"city": "Doha", "country": "Qatar", "system": "Metro", | |
| "bg": "#FFD700", "fg": "#000"}, # Gold Line (bright → dark text) | |
| "taipei": {"city": "Taipei", "country": "Taiwan", "system": "MRT", | |
| "bg": "#0070BD", "fg": "#fff"}, # Bannan Line | |
| "beijing": {"city": "Beijing", "country": "China", "system": "Subway", | |
| "bg": "#A4343A", "fg": "#fff"}, # Line 1 | |
| } | |
| # Single-line label kept for places that still want a one-liner (page title, …). | |
| SYSTEM_LABELS = {s: f"{i['city']}: {i['system']}" for s, i in SYSTEM_INFO.items()} | |
| def _system_card_label(slug: str) -> str: | |
| """Two-line label for system-card buttons. CSS ::first-line styles | |
| the system name; line 2 (city, country) inherits the smaller mono.""" | |
| info = SYSTEM_INFO.get(slug) or {"city": slug.title(), "country": "", "system": slug.title()} | |
| loc = ", ".join(p for p in (info.get("city"), info.get("country")) if p) | |
| return f"{info['system']}\n{loc}" | |
| def _load_geo(system: str) -> dict: | |
| """Slice of dashboard/verify_data.json for the active system.""" | |
| p = REPO_ROOT / "dashboard" / "verify_data.json" | |
| if not p.exists(): | |
| return {"stations": [], "edges": [], "lines": []} | |
| raw = (__import__("json").loads(p.read_text())).get(system) or {} | |
| return { | |
| "stations": [ | |
| {"id": s["id"], "name": s.get("name", s["id"]), | |
| "lat": s.get("lat"), "lon": s.get("lon"), | |
| "lines": s.get("lines", [])} | |
| for s in raw.get("stations", []) | |
| ], | |
| "edges": [ | |
| {"from": e["from"], "to": e["to"], "line": e.get("line", "")} | |
| for e in raw.get("edges", []) | |
| ], | |
| "lines": raw.get("lines", []), | |
| } | |
| import model as mdl # noqa: E402 (heavy import; lands once at module load) | |
| import tools # noqa: E402 | |
| # --- Site header ------------------------------------------------------------ | |
| REPO_URL = os.environ.get("REPO_URL", "") | |
| PAPER_URL = os.environ.get("PAPER_URL", "") | |
| # Mean Tier-1 across 6 systems, n=2 seeds, v23. Source: research_v3 / memory. | |
| _T1_BY_SIZE = { | |
| "2B": "82.4", | |
| "4B": "91.0", | |
| "9B": "92.5", | |
| "27B": "90.4", | |
| } | |
| def _model_size(model_id: str) -> str: | |
| m = re.search(r"-(\d+(?:\.\d+)?)B\b", model_id) | |
| return f"{m.group(1)}B" if m else "—" | |
| def _build_site_header() -> str: | |
| base = mdl.MODEL_ID | |
| adapter = mdl.ADAPTER_ID or "" | |
| size = _model_size(base) | |
| t1 = _T1_BY_SIZE.get(size, "—") | |
| model_link = f"https://huggingface.co/{adapter}" if adapter else "#" | |
| paper_attr = "" if PAPER_URL else ' data-placeholder="true"' | |
| repo_attr = "" if REPO_URL else ' data-placeholder="true"' | |
| return f""" | |
| <div id="site-header"> | |
| <div class="site-title">MetroLLM-Bench</div> | |
| <div class="site-subtitle"> | |
| Interactive transit-kiosk companion to the MetroLLM-Bench paper. Pick a | |
| metro and a scenario, then watch a fine-tuned LLM call tools to plan | |
| your trip, across six real systems, reasoning with disruptions and | |
| edge cases. | |
| </div> | |
| <div class="site-meta"> | |
| <a class="meta-link" href="{PAPER_URL or '#'}"{paper_attr}>paper</a> | |
| <a class="meta-link" href="{REPO_URL or '#'}"{repo_attr}>repository</a> | |
| <a class="meta-link" href="{model_link}" target="_blank" rel="noopener">model</a> | |
| </div> | |
| <div class="site-specs"> | |
| <span class="spec"><span class="spec-key">base</span>{base}</span> | |
| <span class="spec"><span class="spec-key">params</span>{size}</span> | |
| <span class="spec"><span class="spec-key">peft</span>LoRA r=16, v23</span> | |
| <span class="spec"><span class="spec-key">bench T1</span>{t1}%</span> | |
| </div> | |
| </div> | |
| """ | |
| SITE_HEADER_HTML = _build_site_header() | |
| def _build_site_footer() -> str: | |
| base = mdl.MODEL_ID | |
| adapter = mdl.ADAPTER_ID or "" | |
| size = _model_size(base) | |
| model_link = f"https://huggingface.co/{adapter}" if adapter else "" | |
| paper_attr = "" if PAPER_URL else ' data-placeholder="true"' | |
| repo_attr = "" if REPO_URL else ' data-placeholder="true"' | |
| if model_link: | |
| attribution = ( | |
| f'<a class="footer-link footer-attribution-link" ' | |
| f'href="{model_link}" target="_blank" rel="noopener">' | |
| f'Qwen3.5-{size} + LoRA v23</a>' | |
| ) | |
| else: | |
| attribution = f"Qwen3.5-{size} + LoRA v23" | |
| return f""" | |
| <div id="site-footer"> | |
| <div class="footer-meta"> | |
| <a class="footer-link" href="{PAPER_URL or '#'}"{paper_attr}>paper</a> | |
| <a class="footer-link" href="{REPO_URL or '#'}"{repo_attr}>repository</a> | |
| <span class="footer-attribution">Powered by {attribution}</span> | |
| </div> | |
| <div class="footer-credit">MetroLLM-Bench · interactive demo · 2026</div> | |
| </div> | |
| """ | |
| SITE_FOOTER_HTML = _build_site_footer() | |
| def _system_bundle(system: str) -> dict: | |
| """All UI-relevant data for a system: stations, lines, events, | |
| scenarios, geo, system prompt, and a station_id lookup.""" | |
| geo = _load_geo(system) | |
| return { | |
| "slug": system, | |
| "name": SYSTEM_LABELS.get(system, system.title()), | |
| "stations": prompts.load_stations(system), | |
| "lines": prompts.load_lines(system), | |
| "events": prompts.load_events(system), | |
| "scenarios": prompts.load_scenarios(system), | |
| "geo": geo, | |
| "station_by_id": {s["id"]: s for s in geo["stations"]}, | |
| } | |
| # Pre-load every available system at startup so switching is just a state | |
| # update — no repeat file reads, no re-tokenization. | |
| SYSTEMS = {s: _system_bundle(s) for s in KNOWN_SYSTEMS} | |
| print(f"[app] loaded systems: {', '.join(SYSTEMS.keys())}", flush=True) | |
| print(f"[app] mock_server in-process: {'ok' if tools.health() else 'unreachable'}", | |
| flush=True) | |
| MAX_ROUNDS = int(os.environ.get("MAX_ROUNDS", "10")) | |
| # Animated three-dot placeholder shown while the model is generating but | |
| # narrative content hasn't surfaced yet (e.g. inside a <think> block). | |
| # CSS in CUSTOM_CSS animates the .loading-dots span via opacity pulse so | |
| # the user sees a living "thinking" cue instead of a static "…". | |
| _LOADING_DOTS = '<span class="loading-dots">…</span>' | |
| # Pure-function rendering helpers — extracted to render.py so the capture | |
| # tooling (scripts/capture_replay.py --llama-server …) can format tool | |
| # cards + route stops without importing app.py (which triggers model | |
| # load). Aliased back to the original _-prefixed names so all call-sites | |
| # below remain unchanged. | |
| from render import ( # noqa: E402 | |
| KNOWN_TOOLS, | |
| strip_think_block as _strip_think_block, | |
| tool_status as _tool_status, | |
| summarise_result as _summarise_result, | |
| summarise_args as _summarise_args, | |
| format_tool_card as _format_tool_card, | |
| route_with_coords as _route_with_coords, | |
| ) | |
| def _generate_one_round(messages: list[dict]) -> str: | |
| """Blocking single-round generate. Kept as a fallback path.""" | |
| return mdl.generate_one_round(messages) | |
| def _stream_one_round(messages: list[dict]): | |
| """Generator: yields (chunk, accumulated_full_text) per token batch. | |
| The @GPU decorator holds the GPU for the generator's lifetime — fine for | |
| a single round (~30s on A100). Multi-round budgeting still happens in | |
| the outer agent loop where each round is its own GPU call.""" | |
| yield from mdl.stream_one_round(messages) | |
| # --- Map JS ---------------------------------------------------------------- | |
| # Leaflet is loaded via the `head=` injection on launch. Init runs once on | |
| # page load (we poll for the #metro-map div to exist because Gradio's Svelte | |
| # runtime mounts it after demo.load fires). The route is pushed from Python | |
| # via a hidden `gr.JSON()` state — its .change handler redraws the polyline. | |
| _DEAD_INIT_JS = """ | |
| () => { | |
| const init = () => { | |
| const el = document.getElementById('metro-map'); | |
| if (!el) { setTimeout(init, 100); return; } | |
| if (el._leafletReady) return; | |
| if (typeof L === 'undefined') { setTimeout(init, 100); return; } | |
| el._leafletReady = true; | |
| const map = L.map('metro-map', { zoomControl: true, scrollWheelZoom: false }); | |
| L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { | |
| attribution: '© OSM © CARTO', maxZoom: 18, | |
| }).addTo(map); | |
| const geo = window.METRO_GEO || {stations: [], edges: [], lines: []}; | |
| const sById = {}; | |
| for (const s of geo.stations) sById[s.id] = s; | |
| const lineColor = {}; | |
| for (const l of geo.lines) lineColor[l.id || l.name?.toLowerCase()] = l.color || '#999'; | |
| // Network background — semi-transparent line segments | |
| for (const e of geo.edges) { | |
| const a = sById[e.from], b = sById[e.to]; | |
| if (!a || !b || a.lat == null || b.lat == null) continue; | |
| const c = lineColor[(e.line || '').toLowerCase()] || '#999'; | |
| L.polyline([[a.lat, a.lon], [b.lat, b.lon]], { | |
| color: c, weight: 3, opacity: 0.45, | |
| }).addTo(map); | |
| } | |
| for (const s of geo.stations) { | |
| if (s.lat == null) continue; | |
| L.circleMarker([s.lat, s.lon], { | |
| radius: 3, color: '#666', fillColor: '#fff', fillOpacity: 1, | |
| weight: 1.5, | |
| }).addTo(map).bindTooltip(s.name); | |
| } | |
| // Fit to network bounds | |
| const lats = geo.stations.map(s => s.lat).filter(v => v != null); | |
| const lons = geo.stations.map(s => s.lon).filter(v => v != null); | |
| if (lats.length && lons.length) { | |
| map.fitBounds([[Math.min(...lats), Math.min(...lons)], | |
| [Math.max(...lats), Math.max(...lons)]], | |
| {padding: [16, 16]}); | |
| } | |
| window._metroMap = map; | |
| window._routeLayer = null; | |
| window._endpointLayers = []; | |
| }; | |
| init(); | |
| } | |
| """ | |
| DRAW_ROUTE_JS = """ | |
| (route) => { | |
| const map = window._metroMap; | |
| if (!map || !Array.isArray(route)) return; | |
| // Drain prior route layer (everything goes into one LayerGroup so we | |
| // can wipe in a single removeLayer call). | |
| if (window._routeLayer) { map.removeLayer(window._routeLayer); window._routeLayer = null; } | |
| const stops = route.filter(s => s && s.lat != null && s.lon != null); | |
| if (stops.length < 2) return; | |
| // Per-system line colours + names from METRO_GEO so each segment of | |
| // the trip is drawn in its actual line colour. Mirrors the | |
| // /simulator dashboard's drawRoute(). | |
| const geo = window.METRO_GEO || { lines: [] }; | |
| const lineColor = {}; | |
| const lineName = {}; | |
| for (const l of (geo.lines || [])) { | |
| const k = (l.id || (l.name || '').toLowerCase()); | |
| lineColor[k] = l.color || '#888'; | |
| lineName[k] = l.name || k; | |
| } | |
| const colorFor = (line) => lineColor[(line || '').toLowerCase()] || '#888'; | |
| const nameFor = (line) => lineName[(line || '').toLowerCase()] || (line || ''); | |
| // Group stops into per-line segments. The transfer station gets | |
| // copied into BOTH adjacent segments so the polyline bends visibly | |
| // through the transfer point. | |
| const segs = []; | |
| let cur = null; | |
| for (const s of stops) { | |
| if (!cur || s.line !== cur.line) { | |
| const transfer = cur ? cur.stops[cur.stops.length - 1] : null; | |
| cur = { line: s.line, stops: [] }; | |
| segs.push(cur); | |
| if (transfer && transfer.is_transfer) cur.stops.push(transfer); | |
| } | |
| cur.stops.push(s); | |
| } | |
| const layer = L.layerGroup().addTo(map); | |
| const allPts = []; | |
| for (const seg of segs) { | |
| const pts = seg.stops.map(s => [s.lat, s.lon]); | |
| if (pts.length < 2) { allPts.push(...pts); continue; } | |
| const c = colorFor(seg.line); | |
| // White halo behind the coloured line for legibility against | |
| // dense network polylines. | |
| L.polyline(pts, { color: '#fff', weight: 9, opacity: 0.9, interactive: false }).addTo(layer); | |
| L.polyline(pts, { color: c, weight: 5, opacity: 0.95, interactive: false }).addTo(layer); | |
| allPts.push(...pts); | |
| } | |
| const first = stops[0], last = stops[stops.length - 1]; | |
| // Origin (green) + Destination (red) endpoint markers. | |
| L.circleMarker([first.lat, first.lon], { | |
| radius: 8, color: '#198038', fillColor: '#42BE65', fillOpacity: 1, weight: 3, | |
| interactive: true | |
| }).addTo(layer).bindTooltip( | |
| 'Start: ' + (first.name || first.station_id || ''), | |
| { direction: 'auto', offset: [10, 0] } | |
| ); | |
| L.circleMarker([last.lat, last.lon], { | |
| radius: 8, color: '#DA1E28', fillColor: '#FA4D56', fillOpacity: 1, weight: 3, | |
| interactive: true | |
| }).addTo(layer).bindTooltip( | |
| 'End: ' + (last.name || last.station_id || ''), | |
| { direction: 'auto', offset: [10, 0] } | |
| ); | |
| // Transfer markers (orange) — one per unique transfer station, with | |
| // a tooltip explaining the line change. | |
| const seen = new Set(); | |
| let prevLine = first.line; | |
| for (let i = 1; i < stops.length; i++) { | |
| const s = stops[i]; | |
| if (!s.is_transfer) { prevLine = s.line; continue; } | |
| if (s.station_id === first.station_id || s.station_id === last.station_id) continue; | |
| if (seen.has(s.station_id)) { prevLine = s.line; continue; } | |
| seen.add(s.station_id); | |
| const fromN = nameFor(prevLine), toN = nameFor(s.line); | |
| const tip = (fromN && toN && fromN !== toN) | |
| ? 'Transfer: ' + (s.name || s.station_id) + ' (' + fromN + ' → ' + toN + ')' | |
| : 'Transfer: ' + (s.name || s.station_id); | |
| L.circleMarker([s.lat, s.lon], { | |
| radius: 6, color: '#F1C21B', fillColor: '#FFD23F', fillOpacity: 1, weight: 2, | |
| interactive: true | |
| }).addTo(layer).bindTooltip(tip, { direction: 'auto', offset: [10, 0] }); | |
| prevLine = s.line; | |
| } | |
| window._routeLayer = layer; | |
| if (allPts.length >= 2) { | |
| map.fitBounds(L.latLngBounds(allPts).pad(0.15)); | |
| } | |
| // Mobile: when a route is freshly drawn, scroll the map into view so | |
| // users see the result instead of staying parked at the chat. Brief | |
| // delay so the fitBounds animation can settle. Skipped on desktop | |
| // where the map is always visible side-by-side with the chat. | |
| if (window.innerWidth <= 800) { | |
| setTimeout(() => { | |
| const wrap = document.getElementById('metro-map-wrap'); | |
| if (wrap) wrap.scrollIntoView({behavior: 'smooth', block: 'start'}); | |
| }, 250); | |
| } | |
| } | |
| """ | |
| # kiosk_state.change handler — renders fare + advisory + action banners under | |
| # the map. Bound JS instead of Python so we don't pay a server round-trip | |
| # just to ship HTML. | |
| RENDER_BANNERS_JS = """ | |
| (payload) => { | |
| const el = document.getElementById('kiosk-display'); | |
| if (!el) return; | |
| // Clear when payload is null (start of run, system switch). | |
| if (!payload) { | |
| el.innerHTML = ''; | |
| el.classList.remove('has-banners'); | |
| return; | |
| } | |
| const esc = (s) => String(s == null ? '' : s) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| const parts = []; | |
| // Kiosk message — passenger-facing prose, rendered first as the | |
| // headline. Different typography from the structured cards below | |
| // (Plex Sans, larger, comfortable line-height). | |
| const msg = (payload.message || '').trim(); | |
| if (msg) { | |
| parts.push( | |
| '<div class="kiosk-card kiosk-message">' + | |
| '<div class="kiosk-message-body">' + esc(msg) + '</div>' + | |
| '</div>' | |
| ); | |
| } | |
| // Fare card | |
| const fare = payload.fare; | |
| if (fare && fare.total != null) { | |
| const total = (typeof fare.total === 'number') ? fare.total.toFixed(2) : esc(String(fare.total)); | |
| const currency = esc(fare.currency || ''); | |
| const psum = fare.passenger_summary || {}; | |
| const paxParts = []; | |
| for (const k in psum) { | |
| const v = psum[k]; | |
| if (v && v > 0) paxParts.push(v + '× ' + k); | |
| } | |
| const pax = paxParts.join(', '); | |
| parts.push( | |
| '<div class="kiosk-card kiosk-fare">' + | |
| '<div class="kiosk-card-key">FARE</div>' + | |
| '<div class="kiosk-fare-total">' + currency + ' ' + total + '</div>' + | |
| (pax ? '<div class="kiosk-fare-pax">' + esc(pax) + '</div>' : '') + | |
| '</div>' | |
| ); | |
| } | |
| // Advisory cards (severity drives the left rule colour) | |
| const advisories = payload.advisories || []; | |
| for (const a of advisories) { | |
| const sev = String(a.severity || 'info').toLowerCase(); | |
| parts.push( | |
| '<div class="kiosk-card kiosk-advisory kiosk-sev-' + esc(sev) + '">' + | |
| '<div class="kiosk-card-key">' + esc(sev.toUpperCase()) + '</div>' + | |
| '<div class="kiosk-advisory-title">' + esc(a.title || '') + '</div>' + | |
| (a.body ? '<div class="kiosk-advisory-body">' + esc(a.body) + '</div>' : '') + | |
| '</div>' | |
| ); | |
| } | |
| // Action card (only emit for the actions that imply a passenger gesture; | |
| // display_info is purely informational and doesn't need its own card). | |
| const action = payload.action; | |
| const actionMap = { | |
| 'prompt_purchase': {label: 'Tap to purchase', cls: 'kiosk-action-purchase'}, | |
| 'block_purchase': {label: 'Cannot purchase', cls: 'kiosk-action-block'}, | |
| 'refer_to_staff': {label: 'Please refer to staff', cls: 'kiosk-action-staff'}, | |
| }; | |
| if (action && action.action && actionMap[action.action]) { | |
| const a = actionMap[action.action]; | |
| parts.push( | |
| '<div class="kiosk-card kiosk-action ' + a.cls + '">' + | |
| '<div class="kiosk-action-label">' + esc(a.label) + '</div>' + | |
| '</div>' | |
| ); | |
| } | |
| el.innerHTML = parts.join(''); | |
| if (parts.length) { | |
| el.classList.add('has-banners'); | |
| // Mobile: scroll the kiosk display into view so the user sees the | |
| // resolution without hunting below the fold (chat is above the map | |
| // in single-column layout, and the map alone is already 240px tall). | |
| if (window.matchMedia('(max-width: 800px)').matches) { | |
| setTimeout(() => el.scrollIntoView({behavior: 'smooth', block: 'start'}), 100); | |
| } | |
| } else { | |
| el.classList.remove('has-banners'); | |
| } | |
| } | |
| """ | |
| # Lock the rollout-triggering buttons (scenario cards, plan-trip, | |
| # system selectors) for the duration of a rollout. CSS for body.kiosk-running | |
| # turns them non-interactive; this just toggles the class. | |
| # 5-minute safety net auto-unlock guards against the .then(unlock) step | |
| # never firing (e.g. if _run_agent raises mid-stream). | |
| LOCK_RUNNING_JS = """() => { | |
| document.body.classList.add('kiosk-running'); | |
| if (window._kioskUnlockTimer) clearTimeout(window._kioskUnlockTimer); | |
| window._kioskUnlockTimer = setTimeout(() => { | |
| document.body.classList.remove('kiosk-running'); | |
| }, 300000); | |
| }""" | |
| UNLOCK_RUNNING_JS = """() => { | |
| document.body.classList.remove('kiosk-running'); | |
| if (window._kioskUnlockTimer) { | |
| clearTimeout(window._kioskUnlockTimer); | |
| window._kioskUnlockTimer = null; | |
| } | |
| }""" | |
| # Mobile: when the Output tab becomes active (via Plan click → tab-flip | |
| # chain, or a manual tap), scroll the chat into view. On desktop the | |
| # layout is two columns side by side so the chat is always visible — | |
| # scrolling there would be jarring. Reads the live aria-selected DOM | |
| # state after a short delay so the tab swap has settled. | |
| SCROLL_TO_OUTPUT_JS = """() => { | |
| if (window.innerWidth > 800) return; | |
| setTimeout(() => { | |
| const tabs = document.querySelectorAll('[role="tab"]'); | |
| const sel = Array.from(tabs).find(t => t.getAttribute('aria-selected') === 'true'); | |
| if (!sel || (sel.textContent || '').trim() !== 'Output') return; | |
| // Scroll the whole controls column into view so the tabs strip | |
| // (showing which tab is active) is visible above the chat. | |
| const target = document.getElementById('kiosk-controls') | |
| || document.getElementById('chat-thread'); | |
| if (target) target.scrollIntoView({behavior: 'smooth', block: 'start'}); | |
| }, 150); | |
| }""" | |
| # --- CSS ------------------------------------------------------------------- | |
| # Flat color blocks, IBM Plex Sans, no rounding/shadows, no borders. We use | |
| # elem_id/elem_classes everywhere so the rules are stable across Gradio | |
| # versions. | |
| CUSTOM_CSS = """ | |
| /* ---------- Tokens ---------- | |
| Most colors come from KIOSK_THEME (Gradio auto-swaps _dark variants). | |
| Only multi-level surfaces and brand tokens that Gradio doesn't expose | |
| need a manual .dark override. */ | |
| .gradio-container { | |
| --surface: var(--body-background-fill); | |
| --ink: var(--body-text-color); | |
| --ink-soft: var(--body-text-color-subdued); | |
| --rule: var(--border-color-primary); | |
| --surface-2: #ECEEF1; | |
| --surface-3: #DEE2E6; | |
| --ink-faint: #6A737D; | |
| --code-bg: #F2F4F8; | |
| --accent: #C21924; | |
| --warning: #F1C21B; | |
| --positive: #198038; | |
| } | |
| .dark .gradio-container, | |
| .gradio-container.dark, | |
| body.dark .gradio-container { | |
| --surface-2: #1E2227; | |
| --surface-3: #2A3038; | |
| --ink-faint: #767D87; | |
| --code-bg: #1B1F24; | |
| --accent: #FA4D56; | |
| } | |
| /* ---------- Container layout ---------- | |
| Gradio's own .svelte-* class sets a fixed width on .gradio-container that | |
| beats our max-width. Force width: 100% so it expands to the viewport and | |
| max-width can actually cap it. */ | |
| .gradio-container { | |
| width: 100% !important; | |
| max-width: 1440px !important; | |
| margin: 0 auto !important; | |
| padding: 24px 32px !important; | |
| box-sizing: border-box !important; | |
| } | |
| #kiosk-app { max-width: 1440px; margin: 0 auto; } | |
| /* Flatten Gradio's per-block chrome inside the kiosk app — we draw our own | |
| borders only where wanted (chat, map, scenario cards). */ | |
| #kiosk-app .block, | |
| #kiosk-app .form, | |
| #kiosk-app .panel, | |
| #kiosk-app .wrap, | |
| #kiosk-app .secondary-wrap, | |
| #kiosk-app .container, | |
| #kiosk-app .html-container, | |
| #kiosk-app .prose.gradio-style { | |
| background: transparent !important; | |
| border: 0 !important; | |
| box-shadow: none !important; | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| gap: 0 !important; | |
| } | |
| /* gr.HTML inserts <a> styling via .prose; reset for our meta links */ | |
| #site-header .site-meta a, | |
| #site-footer .footer-link { | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| } | |
| /* Children of the kiosk-app column / row should stretch to the full width | |
| so dropdowns and chat align flush with the title/rules above them. */ | |
| #kiosk-app > .block, | |
| #kiosk-app > .form, | |
| #kiosk-header > .block, | |
| #kiosk-header > .form { | |
| width: 100% !important; | |
| flex: 1 1 100% !important; | |
| min-width: 0 !important; | |
| } | |
| #kiosk-app .gradio-container .form { width: 100% !important; } | |
| /* ---------- Site header (title + tagline + meta + specs) ---------- */ | |
| #site-header { | |
| padding: 12px 0 18px 0; | |
| margin-bottom: 4px; | |
| border-bottom: 4px double var(--rule); | |
| } | |
| .site-title { | |
| font-family: 'Tektur', 'IBM Plex Sans', system-ui, sans-serif; | |
| font-size: 2.1rem; | |
| font-weight: 600; | |
| /* Tektur is geometric/squarish — neutral tracking reads cleaner than | |
| the negative letter-spacing IBM Plex wanted. */ | |
| letter-spacing: 0.005em; | |
| line-height: 1.05; | |
| color: var(--ink); | |
| margin: 0 0 6px 0; | |
| /* Wider glyphs than Plex; clamp prevents overflow on very narrow | |
| viewports without needing a separate media-query branch. */ | |
| max-width: 100%; | |
| overflow-wrap: break-word; | |
| } | |
| .site-subtitle { | |
| font-size: 0.95rem; | |
| line-height: 1.45; | |
| color: var(--ink-soft); | |
| margin: 0 0 14px 0; | |
| max-width: 720px; | |
| } | |
| .site-meta { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px 18px; | |
| margin: 0 0 12px 0; | |
| } | |
| .meta-link { | |
| color: var(--ink) !important; | |
| text-decoration: underline; | |
| text-decoration-thickness: 1px; | |
| text-underline-offset: 3px; | |
| font-size: 0.78rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| font-weight: 600; | |
| } | |
| .meta-link:hover { color: var(--accent) !important; } | |
| .meta-link[data-placeholder="true"]::after { | |
| content: " (soon)"; | |
| color: var(--ink-faint); | |
| font-size: 0.68rem; | |
| letter-spacing: 1px; | |
| } | |
| .meta-link[data-placeholder="true"] { | |
| color: var(--ink-faint) !important; | |
| cursor: default; | |
| text-decoration-style: dashed; | |
| } | |
| .site-specs { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px 18px; | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace !important; | |
| font-size: 0.75rem; | |
| color: var(--ink-soft); | |
| } | |
| .site-specs .spec-key { | |
| color: var(--ink-faint); | |
| text-transform: uppercase; | |
| letter-spacing: 1.2px; | |
| margin-right: 6px; | |
| } | |
| /* ---------- System selector row ---------- */ | |
| #kiosk-header { | |
| padding: 14px 0 18px 0 !important; | |
| margin: 0 0 14px 0 !important; | |
| align-items: stretch !important; | |
| border-bottom: 1px solid var(--rule); | |
| display: flex !important; | |
| flex-direction: row !important; | |
| flex-wrap: wrap !important; | |
| gap: 8px !important; | |
| } | |
| #kiosk-header > .block, | |
| #kiosk-header > .form { flex: 1 1 auto !important; } | |
| /* System cards — one per metro, two-line label. | |
| Line 1 (system name) is bigger via ::first-line; line 2 (city, country) | |
| inherits the smaller mono. Active card flips to inverse fill. */ | |
| .system-card { | |
| background: var(--surface-2) !important; | |
| color: var(--ink) !important; | |
| border: 1px solid var(--rule) !important; | |
| border-radius: 0 !important; | |
| padding: 8px 12px !important; | |
| margin: 0 !important; | |
| cursor: pointer !important; | |
| display: flex !important; | |
| flex-direction: column !important; | |
| align-items: flex-start !important; | |
| justify-content: center !important; | |
| text-align: left !important; | |
| white-space: pre-line !important; | |
| line-height: 1.45 !important; | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace !important; | |
| font-size: 0.7rem !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 1.2px !important; | |
| font-weight: 500 !important; | |
| min-height: unset !important; | |
| box-shadow: none !important; | |
| transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease !important; | |
| } | |
| .system-card::first-line { | |
| font-size: 0.88rem; | |
| font-weight: 600; | |
| letter-spacing: 1.6px; | |
| } | |
| .system-card:hover { | |
| background: var(--surface-3) !important; | |
| } | |
| .system-card.system-card-active { | |
| background: var(--ink) !important; | |
| color: var(--surface) !important; | |
| border-color: var(--ink) !important; | |
| } | |
| .system-card.system-card-active:hover { | |
| background: var(--ink) !important; | |
| } | |
| /* ---------- Tabs ---------- */ | |
| .gradio-container .tab-nav { | |
| border-bottom: 1px solid var(--rule) !important; | |
| background: transparent !important; | |
| } | |
| .gradio-container .tab-nav button { | |
| background: transparent !important; | |
| color: var(--ink-soft) !important; | |
| border-radius: 0 !important; | |
| border: 0 !important; | |
| border-bottom: 3px solid transparent !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 1.5px !important; | |
| font-size: 0.78rem !important; | |
| font-weight: 600 !important; | |
| padding: 10px 14px !important; | |
| } | |
| .gradio-container .tab-nav button.selected, | |
| .gradio-container .tab-nav button[aria-selected="true"] { | |
| color: var(--ink) !important; | |
| border-bottom-color: var(--accent) !important; | |
| } | |
| /* ---------- Scenario cards (gr.Button with .scenario-card) ---------- */ | |
| .scenario-card { | |
| background: var(--surface-2) !important; | |
| color: var(--ink) !important; | |
| border: 1px solid var(--rule) !important; | |
| border-radius: 0 !important; | |
| padding: 12px 14px 30px 14px !important; | |
| margin: 0 0 8px 0 !important; | |
| cursor: pointer !important; | |
| /* Gradio button defaults to flex-center; force left alignment so the | |
| title and one-liner share a flush-left edge. */ | |
| display: flex !important; | |
| flex-direction: column !important; | |
| align-items: flex-start !important; | |
| justify-content: flex-start !important; | |
| text-align: left !important; | |
| box-shadow: 3px 3px 0 var(--rule) !important; | |
| transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.12s ease !important; | |
| white-space: pre-line !important; | |
| line-height: 1.35 !important; | |
| font-weight: 500 !important; | |
| min-height: unset !important; | |
| position: relative !important; | |
| } | |
| .scenario-card:hover { | |
| background: var(--surface-3) !important; | |
| } | |
| .scenario-card:active { | |
| transform: translate(2px, 2px) !important; | |
| box-shadow: 1px 1px 0 var(--rule) !important; | |
| } | |
| /* "Play now" affordance in the bottom-right corner of each scenario card. | |
| Subtle by default, brightens on hover — telegraphs that the card is | |
| actionable without dominating the title + oneliner above. The card's | |
| padding-bottom is bumped to 30px to leave room. */ | |
| .scenario-card::after { | |
| content: "▶ Play now"; | |
| position: absolute; | |
| bottom: 7px; | |
| right: 10px; | |
| font-size: 10.5px; | |
| font-weight: 700; | |
| letter-spacing: 0.6px; | |
| text-transform: uppercase; | |
| color: var(--accent); | |
| background: var(--surface); | |
| border: 1px solid var(--rule); | |
| padding: 2px 7px; | |
| box-shadow: 1.5px 1.5px 0 var(--rule); | |
| opacity: 0.75; | |
| transition: opacity 0.15s ease, transform 0.06s ease, box-shadow 0.06s ease; | |
| } | |
| .scenario-card:hover::after { | |
| opacity: 1; | |
| } | |
| .scenario-card:active::after { | |
| transform: translate(1px, 1px); | |
| box-shadow: 0.5px 0.5px 0 var(--rule); | |
| } | |
| /* "Click here" cue on the first scenario card. One-shot pulse animation | |
| that runs 5 cycles (~7.5s total) on landing so a new visitor sees a | |
| clear visual hint without needing any auto-play replay machinery. | |
| The .has-clicked class on body (set by the click delegate further down) | |
| kills the animation the moment the user interacts. */ | |
| @keyframes scenario-hint-pulse { | |
| 0%, 100% { | |
| box-shadow: 1px 1px 0 var(--rule), 0 0 0 0 var(--accent-soft, rgba(80, 120, 255, 0.45)); | |
| } | |
| 50% { | |
| box-shadow: 1px 1px 0 var(--rule), 0 0 0 8px transparent; | |
| } | |
| } | |
| .scenario-card:first-of-type { | |
| animation: scenario-hint-pulse 1.5s ease-in-out 5; | |
| } | |
| body.has-clicked .scenario-card:first-of-type { | |
| animation: none; | |
| } | |
| /* ---------- Primary button (Plan trip) — geometry only; theme.set() owns | |
| colors, hover, border. We just stamp the shadow + uppercase styling. */ | |
| .gradio-container button.primary, | |
| .gradio-container button[variant="primary"] { | |
| box-shadow: 3px 3px 0 var(--accent) !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 1.5px !important; | |
| font-weight: 600 !important; | |
| padding: 10px 18px !important; | |
| transition: transform 0.06s ease, box-shadow 0.06s ease !important; | |
| } | |
| .gradio-container button.primary:hover, | |
| .gradio-container button[variant="primary"]:hover { | |
| transform: translate(1px, 1px) !important; | |
| box-shadow: 2px 2px 0 var(--accent) !important; | |
| } | |
| .gradio-container button.primary:active, | |
| .gradio-container button[variant="primary"]:active { | |
| transform: translate(3px, 3px) !important; | |
| box-shadow: 0 0 0 var(--accent) !important; | |
| } | |
| /* ---------- Form controls — sizing + restore border (the flatten rule | |
| above zeroed every .block/.wrap border). theme.set() picks the colour | |
| via --input-border-color but our flatten kills the actual border-width. | |
| Re-add it here only for wraps that hold an input or textarea. */ | |
| .gradio-container select, | |
| .gradio-container input[type="text"], | |
| .gradio-container textarea, | |
| .gradio-container .wrap input { | |
| padding: 10px 14px !important; | |
| font-size: 0.95rem !important; | |
| line-height: 1.4 !important; | |
| } | |
| #kiosk-app .wrap:has(input[type="text"]), | |
| #kiosk-app .wrap:has(input[role="combobox"]), | |
| #kiosk-app .wrap:has(textarea), | |
| #kiosk-app .secondary-wrap:has(input) { | |
| border: 1px solid var(--rule) !important; | |
| } | |
| /* Dropdown options popup keeps visible padding even after we flatten .block. */ | |
| .gradio-container [data-testid="dropdown"] ul li { | |
| padding: 8px 14px !important; | |
| font-size: 0.95rem !important; | |
| } | |
| .gradio-container label, | |
| .gradio-container .label-wrap span, | |
| .gradio-container span[data-testid="block-label"] { | |
| font-weight: 600 !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 1.2px !important; | |
| font-size: 0.72rem !important; | |
| } | |
| /* ---------- Optional events checkbox group ---------- | |
| Plain vertical list: one row per option, native/Gradio-styled checkbox | |
| glyph + label text. No per-chip background or border on the label | |
| wrapper. The cascade fix forces text-transform/letter-spacing back to | |
| normal on the label (and descendants — Gradio wraps text in a span | |
| that doesn't otherwise pick up the override). Each label is forced | |
| to width:100% so even if Gradio's wrapper is flex-row, the labels | |
| take a full line each (vertical stack). */ | |
| .gradio-container [data-testid="checkbox-group"] .wrap, | |
| .gradio-container [data-testid="checkboxgroup"] .wrap, | |
| .gradio-container .checkbox-group, | |
| .gradio-container .gradio-checkboxgroup .wrap, | |
| .gradio-container .gradio-checkboxgroup { | |
| display: flex !important; | |
| flex-direction: column !important; | |
| align-items: stretch !important; | |
| gap: 2px !important; | |
| padding: 4px 0 !important; | |
| width: 100% !important; | |
| } | |
| .gradio-container [data-testid="checkbox-group"] label, | |
| .gradio-container [data-testid="checkboxgroup"] label, | |
| .gradio-container .checkbox-group label, | |
| .gradio-container .gradio-checkboxgroup label, | |
| .gradio-container label:has(input[type="checkbox"]) { | |
| display: flex !important; | |
| width: 100% !important; | |
| flex: 0 0 auto !important; | |
| align-items: flex-start !important; | |
| gap: 8px !important; | |
| background: transparent !important; | |
| border: 0 !important; | |
| padding: 4px 0 !important; | |
| margin: 0 !important; | |
| font-family: inherit !important; | |
| font-size: 0.88rem !important; | |
| font-weight: 500 !important; | |
| text-transform: none !important; | |
| letter-spacing: 0 !important; | |
| color: var(--ink) !important; | |
| cursor: pointer !important; | |
| box-shadow: none !important; | |
| line-height: 1.4 !important; | |
| white-space: normal !important; | |
| overflow-wrap: anywhere !important; | |
| } | |
| /* Force the inner text span to drop the inherited uppercase + tracking. */ | |
| .gradio-container [data-testid="checkbox-group"] label *, | |
| .gradio-container [data-testid="checkboxgroup"] label *, | |
| .gradio-container .checkbox-group label *, | |
| .gradio-container .gradio-checkboxgroup label * { | |
| text-transform: none !important; | |
| letter-spacing: 0 !important; | |
| font-size: inherit !important; | |
| font-weight: inherit !important; | |
| color: inherit !important; | |
| } | |
| /* Checked state — emphasize via weight + accent colour, no box fill on the row. */ | |
| .gradio-container [data-testid="checkbox-group"] label:has(input:checked), | |
| .gradio-container [data-testid="checkboxgroup"] label:has(input:checked), | |
| .gradio-container .checkbox-group label:has(input:checked), | |
| .gradio-container .gradio-checkboxgroup label:has(input:checked), | |
| .gradio-container label:has(input[type="checkbox"]:checked) { | |
| color: var(--accent) !important; | |
| font-weight: 700 !important; | |
| } | |
| /* ---------- Chat thread — flat stream layout ---------- | |
| No outer border, no per-bubble shadow. User vs bot distinction by fill | |
| only. <details> blocks (reasoning, tool calls) are flat — open/close | |
| state alone communicates structure. Code blocks keep a subtle fill so | |
| JSON stands out from prose. */ | |
| #chat-thread { | |
| border: 0 !important; | |
| box-shadow: none !important; | |
| padding: 0 !important; | |
| } | |
| /* Empty-state hint inside Gradio's chatbot placeholder (shown until the | |
| first message arrives — Gradio replaces .placeholder-content with the | |
| messages list once any content is present, so this disappears | |
| automatically on rollout start). */ | |
| #chat-thread .placeholder-content { | |
| min-height: 200px; | |
| display: flex !important; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #chat-thread .placeholder-content::before { | |
| content: "Pick a scenario from the Scenarios tab — or build one in the Custom tab. The model's reasoning, tool calls, and final state will stream here."; | |
| display: block; | |
| padding: 24px; | |
| color: var(--ink-faint); | |
| font-size: 0.92rem; | |
| font-style: italic; | |
| text-align: center; | |
| line-height: 1.55; | |
| max-width: 480px; | |
| } | |
| /* Hide chatbot's clear-chat trashcan (Gradio chrome we don't need). */ | |
| #chat-thread button[aria-label*="lear" i], | |
| #chat-thread button[title*="lear" i], | |
| #chat-thread .clear-button, | |
| #chat-thread [class*="clear-btn"] { | |
| display: none !important; | |
| } | |
| /* User bubble — fill only. Tight outer padding, then clear all inner | |
| wrappers (Gradio nests message-row > message > bubble). */ | |
| #chat-thread [data-testid="user"], | |
| #chat-thread .message.user { | |
| background: var(--surface-2) !important; | |
| border: 0 !important; | |
| box-shadow: none !important; | |
| padding: 8px 14px !important; | |
| margin: 0 0 14px 0 !important; | |
| line-height: 1.55 !important; | |
| } | |
| /* Bot turn — transparent. */ | |
| #chat-thread [data-testid="bot"], | |
| #chat-thread .message.bot { | |
| background: transparent !important; | |
| border: 0 !important; | |
| box-shadow: none !important; | |
| padding: 4px 14px !important; | |
| margin: 0 0 8px 0 !important; | |
| line-height: 1.55 !important; | |
| } | |
| /* Strip every inner wrapper inside a message — bubbles, metadata-blocks, | |
| message-content shells. Outer padding alone defines breathing room. */ | |
| #chat-thread [data-testid="user"] *, | |
| #chat-thread [data-testid="bot"] *, | |
| #chat-thread .message *, | |
| #chat-thread [class*="bubble"], | |
| #chat-thread [class*="message-bubble"], | |
| #chat-thread [class*="message-content"] { | |
| border: 0 !important; | |
| box-shadow: none !important; | |
| } | |
| #chat-thread [class*="bubble"], | |
| #chat-thread [class*="message-bubble"], | |
| #chat-thread [class*="message-content"], | |
| /* Gradio nests .message.panel-full-width as an ANCESTOR of [data-testid], | |
| not a descendant — that's where the "weird 6px" was coming from. Plus | |
| .message-wrap adds 16px between turns. Zero both. */ | |
| #chat-thread .message.panel-full-width, | |
| #chat-thread .panel-full-width, | |
| #chat-thread .message-wrap { | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| background: transparent !important; | |
| } | |
| #chat-thread .avatar-container, | |
| #chat-thread [class*="avatar"] { | |
| display: none !important; | |
| } | |
| /* Animated "thinking" placeholder. Pulses opacity so the user sees the | |
| model is working rather than wondering if the page hung. */ | |
| #chat-thread .loading-dots { | |
| display: inline-block; | |
| color: var(--ink-faint); | |
| animation: kiosk-loading-pulse 1.2s ease-in-out infinite; | |
| } | |
| /* Per-round stats line appended after each generation round — | |
| elapsed / tokens / tok/s. Small, mono, dim. Lets us eyeball decode | |
| throughput across deploys (e.g. before vs after flash-attn) without | |
| any extra UI surface. */ | |
| #chat-thread .round-stats { | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; | |
| font-size: 0.7rem; | |
| color: var(--ink-faint); | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| margin-top: 6px; | |
| padding-top: 4px; | |
| border-top: 1px dashed var(--rule); | |
| } | |
| @keyframes kiosk-loading-pulse { | |
| 0%, 100% { opacity: 0.3; } | |
| 50% { opacity: 1; } | |
| } | |
| /* Reasoning + tool metadata <details> — flat, no borders. */ | |
| #chat-thread details { | |
| border: 0 !important; | |
| background: transparent !important; | |
| margin: 0 0 4px 0 !important; | |
| } | |
| #chat-thread details summary { | |
| font-family: 'IBM Plex Mono', ui-monospace, monospace !important; | |
| font-size: 0.78rem !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 1px !important; | |
| padding: 6px 0 !important; | |
| color: var(--ink-soft) !important; | |
| border: 0 !important; | |
| } | |
| #chat-thread details[open] { | |
| background: transparent !important; | |
| } | |
| /* Code blocks inside reasoning — keep a subtle fill so JSON tool args | |
| stand out from prose. No border. */ | |
| #chat-thread pre, | |
| #chat-thread code { | |
| background: var(--code-bg) !important; | |
| border: 0 !important; | |
| font-size: 0.78rem !important; | |
| padding: 10px 12px !important; | |
| } | |
| /* ---------- Tool call card ---------- | |
| Inlined as raw HTML in the assistant bubble (chatbot has sanitize_html | |
| disabled). Status drives the left rule colour. Power users can crack | |
| open the <details> for the raw JSON. */ | |
| #chat-thread .metro-tool-card { | |
| background: var(--surface-2) !important; | |
| border: 1px solid var(--rule) !important; | |
| border-left: 3px solid var(--positive) !important; | |
| padding: 10px 12px !important; | |
| margin: 8px 0 !important; | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace !important; | |
| font-size: 0.82rem !important; | |
| line-height: 1.5 !important; | |
| } | |
| #chat-thread .metro-tool-rejected { border-left-color: var(--warning) !important; } | |
| #chat-thread .metro-tool-hallucinated { border-left-color: var(--accent) !important; } | |
| #chat-thread .metro-tool-head { | |
| display: flex !important; | |
| gap: 8px !important; | |
| align-items: baseline !important; | |
| margin-bottom: 6px !important; | |
| } | |
| #chat-thread .metro-tool-glyph { | |
| color: var(--positive) !important; | |
| font-weight: 600 !important; | |
| } | |
| #chat-thread .metro-tool-rejected .metro-tool-glyph { color: var(--warning) !important; } | |
| #chat-thread .metro-tool-hallucinated .metro-tool-glyph { color: var(--accent) !important; } | |
| #chat-thread .metro-tool-name { | |
| font-weight: 600 !important; | |
| color: var(--ink) !important; | |
| } | |
| #chat-thread .metro-tool-time { | |
| margin-left: auto !important; | |
| color: var(--ink-faint) !important; | |
| font-size: 0.72rem !important; | |
| letter-spacing: 0.5px !important; | |
| } | |
| #chat-thread .metro-tool-row { | |
| display: grid !important; | |
| grid-template-columns: 36px 1fr !important; | |
| gap: 8px !important; | |
| align-items: baseline !important; | |
| } | |
| #chat-thread .metro-tool-key { | |
| color: var(--ink-faint) !important; | |
| text-transform: uppercase !important; | |
| font-size: 0.7rem !important; | |
| letter-spacing: 1.2px !important; | |
| } | |
| #chat-thread .metro-tool-val { | |
| color: var(--ink) !important; | |
| word-break: break-word !important; | |
| } | |
| #chat-thread .metro-tool-raw { | |
| margin-top: 8px !important; | |
| border-top: 1px solid var(--rule) !important; | |
| padding-top: 4px !important; | |
| } | |
| #chat-thread .metro-tool-raw summary { | |
| color: var(--ink-faint) !important; | |
| font-size: 0.68rem !important; | |
| text-transform: uppercase !important; | |
| letter-spacing: 1.2px !important; | |
| cursor: pointer !important; | |
| padding: 4px 0 !important; | |
| list-style: none !important; | |
| } | |
| #chat-thread .metro-tool-raw summary::-webkit-details-marker { display: none !important; } | |
| #chat-thread .metro-tool-raw summary::before { | |
| content: "▸ "; | |
| display: inline-block; | |
| transition: transform 0.1s ease; | |
| } | |
| #chat-thread .metro-tool-raw[open] summary::before { content: "▾ "; } | |
| #chat-thread .metro-tool-raw-label { | |
| color: var(--ink-faint) !important; | |
| text-transform: uppercase !important; | |
| font-size: 0.65rem !important; | |
| letter-spacing: 1.2px !important; | |
| margin: 6px 0 2px 0 !important; | |
| } | |
| #chat-thread .metro-tool-raw pre { | |
| margin: 0 0 4px 0 !important; | |
| font-size: 0.72rem !important; | |
| line-height: 1.4 !important; | |
| white-space: pre-wrap !important; | |
| word-break: break-word !important; | |
| } | |
| /* ---------- Kiosk display (under the map) ---------- | |
| Fare card + advisory cards + action card. Populated by RENDER_BANNERS_JS | |
| off kiosk_state. Hidden until populated. */ | |
| #kiosk-display-wrap { width: 100%; } | |
| #kiosk-display { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .kiosk-disclaimer { | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; | |
| font-size: 0.68rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1.2px; | |
| color: var(--ink-faint); | |
| text-align: center; | |
| margin-top: 8px; | |
| padding: 4px 8px; | |
| } | |
| #kiosk-display:not(.has-banners) { display: none; } | |
| #kiosk-display.has-banners { | |
| margin-top: 0; | |
| border-top: 1px solid var(--rule); | |
| padding-top: 12px; | |
| } | |
| .kiosk-card { | |
| background: var(--surface-2); | |
| border: 1px solid var(--rule); | |
| border-left: 3px solid var(--rule); | |
| padding: 10px 12px; | |
| font-size: 0.85rem; | |
| line-height: 1.45; | |
| } | |
| .kiosk-card-key { | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; | |
| font-size: 0.65rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1.4px; | |
| color: var(--ink-faint); | |
| margin-bottom: 4px; | |
| } | |
| /* Kiosk message — passenger-facing prose, larger sans-serif, no | |
| uppercase key. Sits at the top of the kiosk-display block. */ | |
| .kiosk-message { | |
| border-left-color: var(--ink-soft) !important; | |
| padding: 14px 16px !important; | |
| } | |
| .kiosk-message-body { | |
| font-family: 'IBM Plex Sans', system-ui, sans-serif !important; | |
| font-size: 1rem; | |
| font-weight: 400; | |
| line-height: 1.55; | |
| color: var(--ink); | |
| white-space: pre-wrap; | |
| } | |
| /* Fare */ | |
| .kiosk-fare { border-left-color: var(--ink); } | |
| .kiosk-fare-total { | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; | |
| font-size: 1.15rem; | |
| font-weight: 600; | |
| color: var(--ink); | |
| letter-spacing: -0.01em; | |
| } | |
| .kiosk-fare-pax { | |
| font-size: 0.78rem; | |
| color: var(--ink-soft); | |
| margin-top: 2px; | |
| } | |
| /* Advisories — severity-coded left rule */ | |
| .kiosk-sev-info { border-left-color: var(--ink-faint); } | |
| .kiosk-sev-warning { border-left-color: var(--warning); } | |
| .kiosk-sev-critical { border-left-color: var(--accent); } | |
| .kiosk-advisory-title { | |
| font-weight: 600; | |
| color: var(--ink); | |
| } | |
| .kiosk-advisory-body { | |
| color: var(--ink-soft); | |
| margin-top: 2px; | |
| font-size: 0.82rem; | |
| } | |
| /* Action — full-width pill, status-coded fill */ | |
| .kiosk-action { | |
| border-left: 1px solid var(--rule); | |
| padding: 12px 14px; | |
| text-align: center; | |
| } | |
| .kiosk-action-label { | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; | |
| font-size: 0.85rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| } | |
| .kiosk-action-purchase { | |
| background: var(--positive); | |
| border-color: var(--positive); | |
| } | |
| .kiosk-action-purchase .kiosk-action-label { color: #fff; } | |
| .kiosk-action-block { | |
| background: var(--accent); | |
| border-color: var(--accent); | |
| } | |
| .kiosk-action-block .kiosk-action-label { color: #fff; } | |
| .kiosk-action-staff { | |
| background: var(--warning); | |
| border-color: var(--warning); | |
| } | |
| .kiosk-action-staff .kiosk-action-label { color: var(--ink); } | |
| /* Hidden state textboxes used as JS↔Python transport for map clicks. */ | |
| .map-state-hidden { display: none !important; } | |
| #map-origin-id, #map-dest-id { display: none !important; } | |
| /* ---------- Leaflet controls — dark-mode override ---------- | |
| Leaflet ships its own CSS that hardcodes white backgrounds and dark text. | |
| In dark mode the +/- zoom buttons render white-on-white. Override them. */ | |
| body.dark .leaflet-bar a, | |
| body.dark .leaflet-control-zoom a { | |
| background: var(--surface-2) !important; | |
| color: var(--ink) !important; | |
| border-color: var(--rule) !important; | |
| } | |
| body.dark .leaflet-bar a:hover, | |
| body.dark .leaflet-control-zoom a:hover { | |
| background: var(--surface-3) !important; | |
| } | |
| body.dark .leaflet-control-attribution { | |
| background: rgba(20, 23, 26, 0.85) !important; | |
| color: var(--ink-soft) !important; | |
| } | |
| body.dark .leaflet-control-attribution a { | |
| color: var(--ink) !important; | |
| } | |
| /* Tooltip bubbles on station markers (hover names + origin/dest pick labels). */ | |
| body.dark .leaflet-tooltip { | |
| background: var(--surface-2) !important; | |
| color: var(--ink) !important; | |
| border: 1px solid var(--rule) !important; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.6) !important; | |
| } | |
| body.dark .leaflet-tooltip-right::before { | |
| border-right-color: var(--surface-2) !important; | |
| } | |
| body.dark .leaflet-tooltip-left::before { | |
| border-left-color: var(--surface-2) !important; | |
| } | |
| body.dark .leaflet-tooltip-top::before { | |
| border-top-color: var(--surface-2) !important; | |
| } | |
| body.dark .leaflet-tooltip-bottom::before { | |
| border-bottom-color: var(--surface-2) !important; | |
| } | |
| /* ---------- Map ---------- */ | |
| /* CTA row above the map: hint text expands, Plan-trip button stays compact | |
| on the right. The button is disabled (dimmed, pointer-events: none) until | |
| JS adds .map-plan-ready when both map-pick textboxes are populated. */ | |
| #map-cta-row { | |
| display: flex !important; | |
| flex-direction: row !important; | |
| flex-wrap: nowrap !important; | |
| align-items: stretch !important; | |
| gap: 0 !important; | |
| margin: 0 !important; | |
| } | |
| #map-cta-row > #map-status-wrap { flex: 1 1 auto !important; min-width: 0 !important; } | |
| #map-cta-row > #map-plan-btn, | |
| #map-cta-row > .block:has(#map-plan-btn) { flex: 0 0 auto !important; } | |
| #map-status { | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace !important; | |
| font-size: 0.78rem !important; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: var(--ink-soft); | |
| padding: 8px 12px; | |
| background: var(--surface-2); | |
| border: 1px solid var(--rule); | |
| border-right: 0; | |
| border-bottom: 0; | |
| margin: 0 !important; | |
| height: 100%; | |
| display: flex; | |
| align-items: center; | |
| box-sizing: border-box; | |
| /* Reserve enough vertical space for the longest expected wrap (3 lines | |
| on ≤360px) so the status bar height stays constant across state | |
| changes — no map jitter when origin/dest text shortens or grows. */ | |
| min-height: 2.8em; | |
| } | |
| /* Plan-trip button styled as a flush extension of the status bar */ | |
| #map-plan-btn { | |
| border-radius: 0 !important; | |
| box-shadow: none !important; | |
| border: 1px solid var(--rule) !important; | |
| border-bottom: 0 !important; | |
| padding: 8px 16px !important; | |
| font-size: 0.78rem !important; | |
| height: auto !important; | |
| min-width: 0 !important; | |
| transition: opacity 0.2s ease !important; | |
| } | |
| .map-plan-disabled #map-plan-btn, | |
| #map-plan-btn.map-plan-disabled, | |
| .map-plan-disabled.map-plan-disabled { | |
| opacity: 0.35 !important; | |
| pointer-events: none !important; | |
| cursor: not-allowed !important; | |
| filter: grayscale(0.5); | |
| } | |
| #metro-map-wrap { | |
| width: 100%; | |
| border: 1px solid var(--rule); | |
| border-top: 4px double var(--rule); | |
| } | |
| #metro-map { | |
| width: 100%; | |
| height: 600px; | |
| background: var(--surface-2); | |
| cursor: pointer; | |
| } | |
| /* ---------- Two-column body (≥641px) ---------- | |
| Left column: controls (tabs). Right column: map + chat. | |
| Gradio wraps each gr.Column in a .block we've already flattened, so the | |
| flex math operates on the columns directly. */ | |
| #kiosk-body { | |
| display: flex !important; | |
| flex-direction: row !important; | |
| gap: 36px !important; | |
| align-items: flex-start !important; | |
| flex-wrap: nowrap !important; | |
| margin-top: 8px !important; | |
| } | |
| #kiosk-controls { | |
| flex: 0 0 calc(45% - 18px) !important; | |
| min-width: 0 !important; | |
| } | |
| #kiosk-output { | |
| flex: 1 1 calc(55% - 18px) !important; | |
| min-width: 0 !important; | |
| display: flex !important; | |
| flex-direction: column !important; | |
| gap: 18px !important; | |
| } | |
| /* Default Gradio footer hide (we draw our own #site-footer) */ | |
| .gradio-container footer { display: none !important; } | |
| /* ---------- Site footer (closure for the page) ---------- */ | |
| #site-footer { | |
| padding: 22px 0 10px 0; | |
| margin-top: 26px; | |
| border-top: 1px solid var(--rule); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .footer-meta { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px 18px; | |
| align-items: baseline; | |
| } | |
| .footer-link { | |
| color: var(--ink-soft) !important; | |
| text-decoration: underline; | |
| text-decoration-thickness: 1px; | |
| text-underline-offset: 3px; | |
| font-size: 0.72rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1.4px; | |
| font-weight: 500; | |
| } | |
| .footer-link:hover { color: var(--accent) !important; } | |
| .footer-link[data-placeholder="true"]::after { | |
| content: " (soon)"; | |
| color: var(--ink-faint); | |
| font-size: 0.65rem; | |
| letter-spacing: 1px; | |
| } | |
| .footer-link[data-placeholder="true"] { | |
| color: var(--ink-faint) !important; | |
| cursor: default; | |
| text-decoration-style: dashed; | |
| } | |
| .footer-attribution { | |
| color: var(--ink-soft); | |
| font-size: 0.72rem; | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; | |
| } | |
| .footer-attribution-link { | |
| text-transform: none !important; | |
| letter-spacing: 0 !important; | |
| font-size: 0.72rem !important; | |
| } | |
| .footer-credit { | |
| color: var(--ink-faint); | |
| font-size: 0.7rem; | |
| font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; | |
| letter-spacing: 0.6px; | |
| } | |
| /* ---------- Rollout lock: while body.kiosk-running, no rollout-triggering | |
| button can be clicked. The streaming chat already serves as the visible | |
| "running" indicator; this just prevents a second click from queueing a | |
| parallel rollout (would clobber chat / kiosk_state). ---------- */ | |
| body.kiosk-running .scenario-card, | |
| body.kiosk-running .system-card, | |
| body.kiosk-running #kiosk-controls button[variant="primary"], | |
| body.kiosk-running #kiosk-controls .primary { | |
| pointer-events: none !important; | |
| opacity: 0.55 !important; | |
| cursor: wait !important; | |
| filter: grayscale(0.4); | |
| transition: opacity 0.2s ease; | |
| } | |
| /* ---------- Mobile / narrow tablet (≤800px) — collapse to single column ---------- */ | |
| @media (max-width: 800px) { | |
| .gradio-container { padding: 10px 8px !important; } | |
| /* Gradio's inner .main.fillable also has 32px horizontal padding by | |
| default — combined with the container padding above we'd lose | |
| ~80-128px on a 375px screen. Force both flush on mobile. */ | |
| .gradio-container .main.fillable { | |
| padding: 0 !important; | |
| } | |
| /* 4:5 portrait map on mobile — at 360px content width that's ~450px | |
| tall, enough to fit a metro network legibly without panning. */ | |
| #metro-map { | |
| height: auto; | |
| aspect-ratio: 4 / 5; | |
| } | |
| /* Tighter chat messages on mobile. Bot keeps a small gap; user | |
| collapses flush so the fill-coloured user bubble sits tight | |
| against the bot reply. Smaller font + tighter line-height than | |
| desktop — the column is narrow and the inherited 1.55 line-height | |
| is way too much air. */ | |
| #chat-thread [data-testid="bot"], | |
| #chat-thread .message.bot { | |
| padding: 4px 6px !important; | |
| margin: 0 0 6px 0 !important; | |
| font-size: 0.78rem !important; | |
| line-height: 1.35 !important; | |
| } | |
| #chat-thread [data-testid="user"], | |
| #chat-thread .message.user { | |
| padding: 4px 6px !important; | |
| margin: 0 !important; | |
| font-size: 0.78rem !important; | |
| line-height: 1.35 !important; | |
| } | |
| /* Compact scenario cards on mobile: tighter padding, smaller gap, | |
| smaller oneliner. Keep the 3×3 box-shadow — it gives the cards | |
| their tactile "play" and visually rhymes with the system selector | |
| above. ::first-line styles the title slightly larger than the body | |
| so the oneliner shrinks without dragging the title with it. The | |
| 28px bottom padding reserves room for the ▶ Play now pill so it | |
| never overlaps the oneliner text on narrow viewports. */ | |
| .scenario-card { | |
| padding: 7px 10px 28px 10px !important; | |
| margin: 0 0 4px 0 !important; | |
| font-size: 0.78rem !important; | |
| line-height: 1.3 !important; | |
| } | |
| .scenario-card::first-line { | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| } | |
| .scenario-card::after { | |
| bottom: 6px; | |
| right: 8px; | |
| } | |
| #kiosk-body { | |
| flex-direction: column !important; | |
| gap: 14px !important; | |
| } | |
| #kiosk-controls, | |
| #kiosk-output { | |
| flex: 1 1 100% !important; | |
| width: 100% !important; | |
| } | |
| /* Tektur is wider than IBM Plex — drop the title size so | |
| "MetroLLM-Bench" doesn't bump the right edge on a 360px screen. */ | |
| .site-title { font-size: 1.5rem; } | |
| .site-subtitle { font-size: 0.88rem; } | |
| /* Empty-state hint inside #kiosk-display before any rollout. The | |
| global rule above hides #kiosk-display when .has-banners is | |
| absent (so it doesn't take vertical space on desktop). On | |
| mobile we want the opposite: a visible placeholder so the | |
| space between map and footer isn't void. RENDER_BANNERS_JS | |
| adds .has-banners after submit_assistant_state, which makes | |
| the :not(.has-banners) selector stop matching and the hint | |
| gives way to the real cards. */ | |
| #kiosk-display:not(.has-banners) { | |
| display: block !important; | |
| } | |
| #kiosk-display:not(.has-banners)::before { | |
| content: 'Fare, advisory and action will appear here once the model submits.'; | |
| display: block; | |
| margin-top: 10px; | |
| padding: 14px 14px; | |
| text-align: center; | |
| color: var(--ink-faint); | |
| font-size: 0.82rem; | |
| font-style: italic; | |
| background: var(--surface-2); | |
| border: 1px dashed var(--rule); | |
| border-radius: 4px; | |
| } | |
| } | |
| @media (max-width: 420px) { | |
| /* Very narrow phones — give the title another notch of headroom. */ | |
| .site-title { font-size: 1.3rem; } | |
| } | |
| """ | |
| # Per-system colours injected at the end so they win over the generic | |
| # .system-card-active rule. Each card lights up in its own line colour | |
| # when active. | |
| CUSTOM_CSS += "\n".join( | |
| f"#system-card-{slug}.system-card-active {{ " | |
| f"background: {info['bg']} !important; " | |
| f"border-color: {info['bg']} !important; " | |
| f"color: {info['fg']} !important; }}" | |
| for slug, info in SYSTEM_INFO.items() | |
| ) | |
| # --- UI builders ----------------------------------------------------------- | |
| def _scenario_button_label(sc: dict) -> str: | |
| """Build the multiline label that becomes the button text.""" | |
| return f"{sc['title']}\n{sc.get('oneliner', '')}" | |
| def _line_choices(bundle: dict) -> list[tuple[str, str]]: | |
| """[(line_name, line_id), ...] for the line picker.""" | |
| return [(l["name"], l.get("id") or l["name"].lower()) for l in bundle["lines"]] | |
| def _stations_on_line(bundle: dict, line_id: str) -> list[tuple[str, str]]: | |
| """[(station_name, station_id), ...] alphabetically, restricted to the | |
| given line. Returns [] if line_id is None/unknown.""" | |
| if not line_id: | |
| return [] | |
| return sorted( | |
| [ | |
| (s["name"], s["id"]) | |
| for s in bundle["stations"] | |
| if line_id in (s.get("lines") or []) | |
| ], | |
| key=lambda x: x[0], | |
| ) | |
| def _event_chips(bundle: dict) -> list[tuple[str, str]]: | |
| """[(human label, event_id), ...] for the optional-events checkbox group. | |
| Labels are unique per event (no synthetic collisions like two distinct | |
| 'Red planned maintenance' events sharing a label) and not truncated | |
| mid-word — CSS handles any wrapping. Distinguishers come from the data: | |
| segment endpoints (resolved to names), schedule, or hurricane phase.""" | |
| sb = bundle["station_by_id"] | |
| chips: list[tuple[str, str]] = [] | |
| for ev in bundle["events"]: | |
| d = ev.get("disruption") or {} | |
| ev_type_raw = d.get("type", "") or "" | |
| sn = ev.get("station_name") | |
| if sn: | |
| label = f"{sn} closed" if ev_type_raw == "station_closure" else sn | |
| elif ev_type_raw == "hurricane_warning": | |
| # Hurricane events vary by phase (advisory / landfall_warning / | |
| # reduced_service / full_suspension) — that's the unique key. | |
| phase = (ev.get("phase") or "").replace("_", " ") | |
| cat = ev.get("category") or "" | |
| if phase: | |
| label = f"Hurricane: {phase}" | |
| elif cat: | |
| label = f"Hurricane ({cat})" | |
| else: | |
| label = "Hurricane warning" | |
| else: | |
| line = ev.get("line") or d.get("line") or "" | |
| ev_type = ev_type_raw.replace("_", " ") | |
| head = f"{line.title()} {ev_type}".strip() if line else ev_type | |
| extras = [] | |
| seg = ev.get("segment") or d.get("segment") or {} | |
| if isinstance(seg, dict): | |
| f_name = (sb.get(seg.get("from_station")) or {}).get("name") | |
| t_name = (sb.get(seg.get("to_station")) or {}).get("name") | |
| if f_name and t_name: | |
| extras.append(f"{f_name}↔{t_name}") | |
| sched = ev.get("schedule") or d.get("schedule") | |
| if sched: | |
| extras.append(sched.replace("_", " ")) | |
| if extras: | |
| label = f"{head}: {', '.join(extras)}" | |
| else: | |
| label = head or (d.get("message") or "") | |
| chips.append((label or ev.get("id", "?"), ev.get("id", ""))) | |
| return chips | |
| # _strip_think_block, _tool_status, _summarise_result, _route_with_coords | |
| # extracted to render.py (imported above). | |
| # _summarise_args, _format_tool_card extracted to render.py (imported above). | |
| def _run_agent(system: str, origin_id: str, dest_id: str, event_ids: list[str]): | |
| """Multi-round agent loop. Generator: yields | |
| (history, route_for_map, kiosk_payload) after each round. | |
| - history drives the chatbot (Output tab on left) | |
| - route_for_map drives the polyline redraw via DRAW_ROUTE_JS | |
| - kiosk_payload drives the fare/advisory/action banners under the map | |
| via RENDER_BANNERS_JS. Stays None until submit_assistant_state accepts | |
| so banners only appear after the run resolves.""" | |
| history: list = [] | |
| current_route: list | None = None | |
| current_kiosk: dict | None = None | |
| bundle = SYSTEMS.get(system) or SYSTEMS[DEFAULT_SYSTEM] | |
| system = bundle["slug"] | |
| if not origin_id or not dest_id: | |
| history.append({"role": "assistant", "content": "Pick an origin and a destination first."}) | |
| yield history, current_route, current_kiosk | |
| return | |
| origin = prompts.validate_station(system, origin_id) | |
| dest = prompts.validate_station(system, dest_id) | |
| events = [prompts.validate_event(system, e) for e in event_ids if e] | |
| session_id = f"sess-{uuid.uuid4().hex[:12]}" | |
| tools.set_disruptions( | |
| session_id=session_id, | |
| system=system, | |
| disruptions=prompts.event_for_disruption_payload(events), | |
| ) | |
| system_context = prompts.build_system_context(events) | |
| system_prompt = mdl.build_system_prompt(system, system_context) | |
| user_prompt = prompts.build_user_prompt(origin["name"], dest["name"], events) | |
| display_prompt = prompts.build_user_prompt_display(origin["name"], dest["name"], events) | |
| history.append({"role": "user", "content": display_prompt}) | |
| yield history, current_route, current_kiosk | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| for round_n in range(MAX_ROUNDS): | |
| t0 = time.time() | |
| # Live reasoning placeholder — content fills in as tokens arrive. | |
| # No metadata wrapper: reasoning renders as plain assistant prose so | |
| # it stays expanded (the user reads it as the model thinks). | |
| reasoning_idx = len(history) | |
| history.append({"role": "assistant", "content": _LOADING_DOTS}) | |
| yield history, current_route, current_kiosk | |
| raw_output = "" | |
| # Throttle UI yields so we don't flush per single-token chunk on | |
| # streamers that emit at >50/s — yield at most every 0.15s. | |
| last_yield = time.time() | |
| for _chunk, accum in _stream_one_round(messages): | |
| raw_output = accum | |
| narrative = re.sub(r"<tool_call>.*?</tool_call>\s*", "", | |
| raw_output, flags=re.DOTALL) | |
| narrative = _strip_think_block(narrative) | |
| history[reasoning_idx]["content"] = narrative or _LOADING_DOTS | |
| now = time.time() | |
| if now - last_yield >= 0.15: | |
| yield history, current_route, current_kiosk | |
| last_yield = now | |
| elapsed = time.time() - t0 | |
| # Per-round timing/throughput line — appended to the chat so we can | |
| # eyeball decode performance (e.g. before/after flash-attn) without | |
| # any extra UI. Uses the model's own tokenizer for accuracy. | |
| try: | |
| tok_count = len(mdl.tokenizer.encode(raw_output, add_special_tokens=False)) | |
| except Exception: | |
| tok_count = max(1, len(raw_output) // 4) | |
| tok_rate = tok_count / elapsed if elapsed > 0 else 0.0 | |
| stats_line = ( | |
| f'<div class="round-stats">⏱ round {round_n + 1}: ' | |
| f'{elapsed:.1f}s · {tok_count} tok · {tok_rate:.1f} tok/s</div>' | |
| ) | |
| # Finalize the reasoning block — content becomes the final narrative. | |
| narrative = re.sub(r"<tool_call>.*?</tool_call>\s*", "", | |
| raw_output, flags=re.DOTALL) | |
| narrative = _strip_think_block(narrative) | |
| if narrative: | |
| history[reasoning_idx] = { | |
| "role": "assistant", | |
| "content": narrative + "\n\n" + stats_line, | |
| } | |
| else: | |
| # Model went straight to a tool call with no prose — keep the | |
| # stats line in place of the placeholder so the timing is still | |
| # visible for that round. | |
| history[reasoning_idx] = {"role": "assistant", "content": stats_line} | |
| yield history, current_route, current_kiosk | |
| calls = mdl.parse_tool_calls(raw_output) | |
| # No tool calls → model emitted plain text. Surface and finish. | |
| if not calls: | |
| cleaned = _strip_think_block(raw_output) | |
| history.append({"role": "assistant", "content": cleaned or "(no response)"}) | |
| yield history, current_route, current_kiosk | |
| return | |
| # Append the assistant turn (with the raw tool_call XML) so the next | |
| # round's chat template sees what the model just wrote. | |
| messages.append({"role": "assistant", "content": raw_output}) | |
| for call in calls: | |
| t_tool = time.time() | |
| result = tools.dispatch( | |
| call["name"], call["arguments"], session_id, system_context | |
| ) | |
| tool_elapsed = time.time() - t_tool | |
| messages.append({"role": "tool", "content": json.dumps(result)}) | |
| status, glyph = _tool_status(call["name"], result) | |
| card_html = _format_tool_card( | |
| name=call["name"], | |
| args=call["arguments"], | |
| result=result, | |
| elapsed_ms=tool_elapsed * 1000, | |
| status=status, | |
| glyph=glyph, | |
| bundle=bundle, | |
| ) | |
| history.append({"role": "assistant", "content": card_html}) | |
| yield history, current_route, current_kiosk | |
| if call["name"] == "submit_assistant_state": | |
| accepted = isinstance(result, dict) and result.get("accepted") is True | |
| if not accepted: | |
| continue | |
| args = call["arguments"] | |
| kiosk_msg = ( | |
| args.get("assistant_message") | |
| or mdl.extract_assistant_message(raw_output) | |
| or "Done." | |
| ) | |
| # Build the lat/lon-annotated route for the map redraw. | |
| route_obj = args.get("route") or {} | |
| stops = route_obj.get("stops") if isinstance(route_obj, dict) else [] | |
| current_route = _route_with_coords(stops or [], bundle["station_by_id"]) | |
| # Build the kiosk-display payload (prose message + fare + | |
| # advisories + action). RENDER_BANNERS_JS reads this off | |
| # kiosk_state and writes the banners into #kiosk-display | |
| # under the map. The message card lands first as the | |
| # passenger-facing prose headline; the structured cards | |
| # follow as supporting detail. | |
| current_kiosk = { | |
| "outcome": args.get("outcome"), | |
| "message": kiosk_msg, | |
| "fare": args.get("fare_quote"), | |
| "advisories": args.get("advisory_banners") or [], | |
| "action": args.get("kiosk_action"), | |
| } | |
| history.append({"role": "assistant", "content": kiosk_msg}) | |
| yield history, current_route, current_kiosk | |
| return | |
| history.append({ | |
| "role": "assistant", | |
| "content": "Turn budget exhausted — please refer to staff.", | |
| }) | |
| yield history, current_route, current_kiosk | |
| # --- Build Blocks ---------------------------------------------------------- | |
| def _switch_system_js(slug: str) -> str: | |
| """Build the click-side JS for a specific system-card button. Slug is | |
| baked into the function body — using `inputs=gr.State(slug)` was passing | |
| None to the JS hook for reasons I haven't fully traced; closure-binding | |
| via a Python f-string is the workaround. The returned function does | |
| side effects only (no return value) so the Python `fn` is what populates | |
| the outputs.""" | |
| return f""" | |
| () => {{ | |
| var slug = {json.dumps(slug)}; | |
| if (!window.METRO_GEOS || !window.METRO_GEOS[slug]) return; | |
| // Toggle active highlight on the system-card row. | |
| document.querySelectorAll('.system-card').forEach(b => b.classList.remove('system-card-active')); | |
| var activeBtn = document.getElementById('system-card-' + slug); | |
| if (activeBtn) activeBtn.classList.add('system-card-active'); | |
| window._activeSystem = slug; | |
| window.METRO_GEO = window.METRO_GEOS[slug]; | |
| var el = document.getElementById('metro-map'); | |
| if (el && window._metroMap) {{ | |
| window._metroMap.remove(); | |
| delete window._metroMap; | |
| el._leafletReady = false; | |
| el.innerHTML = ''; | |
| }} | |
| // Reset map-pick state so the new system starts clean. | |
| window._originPickMarker = null; | |
| window._destPickMarker = null; | |
| window._mapClickMode = 'origin'; | |
| var st = document.getElementById('map-status'); | |
| if (st) st.textContent = 'Pick two stations on the map — or use Custom tab.'; | |
| if (typeof window._initMetroMap === 'function') window._initMetroMap(); | |
| }} | |
| """ | |
| with gr.Blocks(title="MetroLLM-Bench Demo") as demo: | |
| with gr.Column(elem_id="kiosk-app"): | |
| gr.HTML(SITE_HEADER_HTML, elem_id="site-header-wrap") | |
| # System selector: one card-button per system, two-line label | |
| # (system name + city/country). Active highlight is toggled by | |
| # SWITCH_SYSTEM_JS via the elem_id `system-card-{slug}`. | |
| with gr.Row(elem_id="kiosk-header"): | |
| system_btns: dict[str, gr.Button] = {} | |
| for _slug in KNOWN_SYSTEMS: | |
| system_btns[_slug] = gr.Button( | |
| _system_card_label(_slug), | |
| elem_id=f"system-card-{_slug}", | |
| elem_classes=["system-card"], | |
| scale=1, | |
| ) | |
| current_system = gr.State(DEFAULT_SYSTEM) | |
| # Two-column body. CSS @media collapses to single column under 640px. | |
| with gr.Row(elem_id="kiosk-body"): | |
| # Left: tabs (Scenarios | Custom | Output). Tabs container is stable | |
| # so chat survives system switches; only the Scenarios and Custom | |
| # contents re-render via @gr.render when current_system changes. | |
| with gr.Column(elem_id="kiosk-controls"): | |
| with gr.Tabs(selected="scenarios") as tabs_left: | |
| with gr.Tab("Scenarios", id="scenarios"): | |
| def _render_scenarios(system): | |
| bundle = SYSTEMS.get(system) or SYSTEMS[DEFAULT_SYSTEM] | |
| for sc in bundle["scenarios"]: | |
| btn = gr.Button( | |
| _scenario_button_label(sc), | |
| elem_classes=["scenario-card"], | |
| ) | |
| # Lock UI → switch to Output tab → run agent → | |
| # Collapse LOCK + tab-flip into one click step | |
| # (Gradio doesn't reliably chain .then() after | |
| # a JS-only fn=None step — the backend never | |
| # acks, so the chain stalls). UNLOCK as the | |
| # last .then() with fn=None is fine — it's | |
| # terminal, doesn't need to chain further. | |
| btn.click( | |
| fn=lambda: gr.update(selected="output"), | |
| outputs=tabs_left, | |
| ).then( | |
| fn=_run_agent, | |
| inputs=[ | |
| gr.State(bundle["slug"]), | |
| gr.State(sc["origin_id"]), | |
| gr.State(sc["dest_id"]), | |
| gr.State(sc.get("event_ids") or []), | |
| ], | |
| outputs=[chat, route_state, kiosk_state], | |
| ) | |
| with gr.Tab("Custom", id="custom"): | |
| def _render_custom(system): | |
| bundle = SYSTEMS.get(system) or SYSTEMS[DEFAULT_SYSTEM] | |
| line_opts = _line_choices(bundle) | |
| initial_line = line_opts[0][1] if line_opts else None | |
| initial_stations = _stations_on_line(bundle, initial_line) | |
| with gr.Row(): | |
| origin_line_dd = gr.Dropdown( | |
| choices=line_opts, value=initial_line, | |
| label="Departure line", interactive=True, scale=1, | |
| ) | |
| origin_dd = gr.Dropdown( | |
| choices=initial_stations, value=None, | |
| label="Departure station", interactive=True, scale=2, | |
| ) | |
| with gr.Row(): | |
| dest_line_dd = gr.Dropdown( | |
| choices=line_opts, value=initial_line, | |
| label="Arrival line", interactive=True, scale=1, | |
| ) | |
| dest_dd = gr.Dropdown( | |
| choices=initial_stations, value=None, | |
| label="Arrival station", interactive=True, scale=2, | |
| ) | |
| # Cascade: changing a line refreshes its station choices. | |
| # The 3-phase race-free pattern: each .then() is a separate | |
| # round-trip, and each atomic update is value-only OR | |
| # choices-only — never both — so the Dropdown validator | |
| # never sees a (value, choices) mismatch. | |
| # 1. clear value (value=None, no validation) | |
| # 2. set choices (choices=new, value still None, no | |
| # validation) | |
| # 3. set value (target ∈ new choices, validates) | |
| def _refresh_clear(): | |
| return gr.update(value=None) | |
| def _refresh_set_choices(line_id, _bundle=bundle): | |
| return gr.update(choices=_stations_on_line(_bundle, line_id)) | |
| def _refresh_set_value(line_id, current_station, map_state, _bundle=bundle): | |
| valid = {s["id"] for s in _bundle["stations"] if line_id in (s.get("lines") or [])} | |
| target = next( | |
| (c for c in (map_state, current_station) if c and c in valid), | |
| None, | |
| ) | |
| return gr.update(value=target) | |
| ( | |
| origin_line_dd.change(fn=_refresh_clear, inputs=None, outputs=origin_dd) | |
| .then(fn=_refresh_set_choices, inputs=origin_line_dd, outputs=origin_dd) | |
| .then( | |
| fn=_refresh_set_value, | |
| inputs=[origin_line_dd, origin_dd, map_origin_id], | |
| outputs=origin_dd, | |
| ) | |
| ) | |
| ( | |
| dest_line_dd.change(fn=_refresh_clear, inputs=None, outputs=dest_dd) | |
| .then(fn=_refresh_set_choices, inputs=dest_line_dd, outputs=dest_dd) | |
| .then( | |
| fn=_refresh_set_value, | |
| inputs=[dest_line_dd, dest_dd, map_dest_id], | |
| outputs=dest_dd, | |
| ) | |
| ) | |
| # Map click → fills the cascading line+station dropdowns | |
| # in two phases via .then() so the station-dropdown's | |
| # choices and value never race. Phase 1 sets the line | |
| # only; phase 2 (_refresh) sets the station atomically | |
| # with the right choices for that line. | |
| def _resolve_line(station_id, _bundle=bundle): | |
| if not station_id: | |
| return gr.update() | |
| station = next( | |
| (s for s in _bundle["stations"] if s["id"] == station_id), | |
| None, | |
| ) | |
| if not station: | |
| return gr.update() | |
| lines = station.get("lines") or [] | |
| line_id = lines[0] if lines else None | |
| if line_id is None: | |
| return gr.update() | |
| return gr.update(value=line_id) | |
| ( | |
| map_origin_id.change(fn=_resolve_line, inputs=map_origin_id, outputs=origin_line_dd) | |
| .then(fn=_refresh_clear, inputs=None, outputs=origin_dd) | |
| .then(fn=_refresh_set_choices, inputs=origin_line_dd, outputs=origin_dd) | |
| .then( | |
| fn=_refresh_set_value, | |
| inputs=[origin_line_dd, origin_dd, map_origin_id], | |
| outputs=origin_dd, | |
| ) | |
| ) | |
| ( | |
| map_dest_id.change(fn=_resolve_line, inputs=map_dest_id, outputs=dest_line_dd) | |
| .then(fn=_refresh_clear, inputs=None, outputs=dest_dd) | |
| .then(fn=_refresh_set_choices, inputs=dest_line_dd, outputs=dest_dd) | |
| .then( | |
| fn=_refresh_set_value, | |
| inputs=[dest_line_dd, dest_dd, map_dest_id], | |
| outputs=dest_dd, | |
| ) | |
| ) | |
| event_box = gr.CheckboxGroup( | |
| choices=_event_chips(bundle), | |
| label="Optional events", | |
| ) | |
| plan_btn = gr.Button( | |
| "Plan trip", variant="primary", | |
| elem_id="custom-plan-btn", | |
| ) | |
| plan_btn.click( | |
| fn=lambda: gr.update(selected="output"), | |
| outputs=tabs_left, | |
| ).then( | |
| fn=_run_agent, | |
| inputs=[gr.State(bundle["slug"]), origin_dd, dest_dd, event_box], | |
| outputs=[chat, route_state, kiosk_state], | |
| ) | |
| with gr.Tab("Output", id="output"): | |
| chat = gr.Chatbot( | |
| label="Trip plan", | |
| show_label=False, | |
| elem_id="chat-thread", | |
| height=600, | |
| buttons=[], | |
| layout="panel", | |
| reasoning_tags=[("<think>", "</think>")], | |
| # Tool-call cards are inlined as <div>/<details> | |
| # HTML; let them through. All user-derived strings | |
| # are html.escape'd in _format_tool_card. | |
| sanitize_html=False, | |
| ) | |
| # Right: map only. | |
| with gr.Column(elem_id="kiosk-output"): | |
| # CTA row: hint text on the left, Plan-trip button on the right. | |
| # Button is rendered always but starts in `.map-plan-disabled` | |
| # state via JS until both map_origin_id and map_dest_id are | |
| # populated. Gives users a path to start a rollout without | |
| # ever opening the Custom tab. | |
| with gr.Row(elem_id="map-cta-row"): | |
| gr.HTML( | |
| '<div id="map-status">Pick two stations on the map — or use Custom tab.</div>', | |
| elem_id="map-status-wrap", | |
| ) | |
| map_plan_btn = gr.Button( | |
| "Plan trip", variant="primary", | |
| elem_id="map-plan-btn", | |
| elem_classes=["map-plan-disabled"], | |
| ) | |
| gr.HTML('<div id="metro-map-wrap"><div id="metro-map"></div></div>') | |
| # Kiosk-display banners (fare + advisories + action) — populated | |
| # via RENDER_BANNERS_JS off kiosk_state. Empty until | |
| # submit_assistant_state accepts; on mobile (≤800px) the JS | |
| # also scrolls this into view so the banners aren't below | |
| # the fold. | |
| gr.HTML('<div id="kiosk-display"></div>', elem_id="kiosk-display-wrap") | |
| # Two-part caveat: numbers are simulated (not live transit | |
| # data) AND the model can hallucinate. Standard "AI can | |
| # make mistakes" wording mirrors the convention visitors | |
| # know from ChatGPT/Copilot/etc. | |
| gr.HTML( | |
| '<div class="kiosk-disclaimer">' | |
| 'Simulated transit data · AI can make mistakes' | |
| '</div>', | |
| elem_id="kiosk-disclaimer-wrap", | |
| ) | |
| # Hidden transport: JS map clicks dispatch input events here, | |
| # then a Python handler resolves the station ID into the | |
| # cascading dropdowns. CSS-hidden (not visible=False) so the | |
| # underlying <input> stays in the DOM and Gradio honours JS | |
| # input events on it. | |
| map_origin_id = gr.Textbox( | |
| value="", elem_id="map-origin-id", | |
| elem_classes=["map-state-hidden"], interactive=True, | |
| show_label=False, | |
| ) | |
| map_dest_id = gr.Textbox( | |
| value="", elem_id="map-dest-id", | |
| elem_classes=["map-state-hidden"], interactive=True, | |
| show_label=False, | |
| ) | |
| route_state = gr.JSON(value=None, visible=False) | |
| kiosk_state = gr.JSON(value=None, visible=False) | |
| # Footer — paper / repo / model card / attribution. Inside | |
| # #kiosk-app so the existing .html-container padding reset | |
| # reaches its Gradio wrapper. | |
| gr.HTML(SITE_FOOTER_HTML, elem_id="site-footer-wrap") | |
| # System change: each card-button triggers the same chain — Python fn | |
| # returns the new state tuple (current_system, cleared chat/route/kiosk, | |
| # cleared map-pick textboxes); JS does the side effects (highlight swap, | |
| # Leaflet remount). Slug is closure-captured in both the Python fn (via | |
| # default arg) and the JS (via _switch_system_js factory). | |
| for _slug, _btn in system_btns.items(): | |
| _btn.click( | |
| fn=(lambda s: (lambda: (s, [], None, None, "", "")))(_slug), | |
| outputs=[current_system, chat, route_state, kiosk_state, map_origin_id, map_dest_id], | |
| js=_switch_system_js(_slug), | |
| ).then( | |
| fn=lambda: gr.update(selected="scenarios"), | |
| outputs=tabs_left, | |
| ) | |
| # Route polyline redraw on route_state change. | |
| route_state.change(fn=None, inputs=route_state, outputs=None, js=DRAW_ROUTE_JS) | |
| # Kiosk-display banners redraw on kiosk_state change. | |
| kiosk_state.change(fn=None, inputs=kiosk_state, outputs=None, js=RENDER_BANNERS_JS) | |
| # Mobile scroll-on-Plan is handled in the head HTML via a DOM event | |
| # delegate on .scenario-card / #map-plan-btn / #custom-plan-btn — | |
| # tabs_left.select does NOT fire when the click chain swaps tabs | |
| # programmatically via gr.update(selected="output"). | |
| # Map Plan-trip CTA: same chain shape as the scenario / Custom-tab plan | |
| # buttons (lock UI → flip to Output → run agent → unlock). Inputs come | |
| # from the map-pick textboxes and the always-on current_system state; | |
| # events default to empty (the map-only path doesn't surface optional | |
| # disruptions — that's the Custom tab's richer job). | |
| map_plan_btn.click( | |
| fn=lambda: gr.update(selected="output"), | |
| outputs=tabs_left, | |
| ).then( | |
| fn=_run_agent, | |
| inputs=[current_system, map_origin_id, map_dest_id, gr.State([])], | |
| outputs=[chat, route_state, kiosk_state], | |
| ) | |
| _ALL_GEOS_JSON = json.dumps({s: SYSTEMS[s]["geo"] for s in SYSTEMS}) | |
| _HEAD_HTML = ( | |
| '<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Tektur:wght@500;700;800&display=swap" rel="stylesheet">' | |
| '<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>' | |
| # Raw <style> bypasses Gradio's CSS scoping | |
| # (which would prepend `.gradio-container.gradio-container-6-13-0 .contain ` | |
| # to every selector and break our outer-container override). The mobile | |
| # padding rules MUST hit `.gradio-container` itself — that element lives | |
| # OUTSIDE `.contain`, so they only work when injected raw via `head=`. | |
| '<style>' | |
| '@media (max-width: 800px) {' | |
| ' .gradio-container,' | |
| ' .gradio-container.gradio-container-6-13-0 {' | |
| ' padding: 10px 8px !important;' | |
| ' }' | |
| ' .gradio-container .main.fillable {' | |
| ' padding: 0 !important;' | |
| ' }' | |
| '}' | |
| '</style>' | |
| f'<script>' | |
| f'window.METRO_GEOS = {_ALL_GEOS_JSON};' | |
| f'window.METRO_GEO = window.METRO_GEOS[{json.dumps(DEFAULT_SYSTEM)}];' | |
| f'window._activeSystem = {json.dumps(DEFAULT_SYSTEM)};' | |
| f'window.METRO_DEFAULT_SYSTEM = {json.dumps(DEFAULT_SYSTEM)};' | |
| # Timezone-based default picker. On first page load, infer the user's | |
| # nearest system from Intl.DateTimeFormat().resolvedOptions().timeZone | |
| # and click that system card if it differs from the Python default. | |
| # Falls through silently when no rule matches (default stays). | |
| # `_pickDefaultSystem(tz)` accepts an override for console testing. | |
| 'window._tzToSystem = function(tz) {' | |
| ' if (!tz) return null;' | |
| ' if (tz === "Asia/Taipei" || tz === "Asia/Hong_Kong" || tz === "Asia/Macau") return "taipei";' | |
| ' if (tz === "Asia/Shanghai" || tz === "Asia/Beijing" || tz === "Asia/Harbin" || tz === "Asia/Urumqi" || tz === "Asia/Chongqing" || tz === "Asia/Kashgar") return "beijing";' | |
| ' if (tz === "Asia/Qatar" || tz === "Asia/Bahrain" || tz === "Asia/Kuwait" || tz === "Asia/Riyadh" || tz === "Asia/Dubai" || tz === "Asia/Muscat") return "doha";' | |
| ' if (tz === "America/Chicago") return "cta";' | |
| ' if (tz === "America/Los_Angeles" || tz === "America/Vancouver" || tz === "America/Tijuana" || tz === "America/Anchorage") return "bart";' | |
| ' return null;' | |
| '};' | |
| 'window._pickDefaultSystem = function(forceTz) {' | |
| ' var tz = forceTz || Intl.DateTimeFormat().resolvedOptions().timeZone;' | |
| ' var slug = window._tzToSystem(tz);' | |
| ' if (!slug) return {tz: tz, slug: null, action: "no-match"};' | |
| ' if (slug === window.METRO_DEFAULT_SYSTEM) return {tz: tz, slug: slug, action: "already-default"};' | |
| ' var card = document.getElementById("system-card-" + slug);' | |
| ' if (!card) return {tz: tz, slug: slug, action: "card-not-mounted-yet"};' | |
| ' card.click();' | |
| ' return {tz: tz, slug: slug, action: "clicked"};' | |
| '};' | |
| 'window._runDefaultPick = function() {' | |
| ' if (window._defaultPicked) return;' | |
| ' var r = window._pickDefaultSystem();' | |
| ' if (r.action === "card-not-mounted-yet") { setTimeout(window._runDefaultPick, 200); return; }' | |
| ' window._defaultPicked = true;' | |
| '};' | |
| 'if (document.readyState === "loading") {' | |
| ' document.addEventListener("DOMContentLoaded", function() { setTimeout(window._runDefaultPick, 400); });' | |
| '} else {' | |
| ' setTimeout(window._runDefaultPick, 400);' | |
| '}' | |
| # Mobile-only scroll-on-Plan: a global capture-phase click delegate | |
| # detects a Plan-triggering button (scenario card, map plan, Custom | |
| # plan) and scrolls #kiosk-controls into view after the tab swap | |
| # has had a chance to settle. Bypasses Gradio's tabs_left.select | |
| # (which doesn't fire on programmatic tab changes). | |
| 'document.addEventListener("click", function(e) {' | |
| ' if (window.innerWidth > 800) return;' | |
| ' var t = e.target.closest(".scenario-card, #map-plan-btn, #custom-plan-btn");' | |
| ' if (!t) return;' | |
| ' if (t.classList && t.classList.contains("map-plan-disabled")) return;' | |
| ' setTimeout(function() {' | |
| ' var k = document.getElementById("kiosk-controls");' | |
| ' if (k) k.scrollIntoView({behavior: "smooth", block: "start"});' | |
| ' }, 250);' | |
| '}, true);' | |
| # Kill the first-scenario-card pulse animation as soon as the visitor | |
| # interacts with any actionable kiosk control (scenarios row, map plan, | |
| # Custom plan, or one of the system cards along the top). Capture-phase | |
| # so we beat any handler that might stop propagation. | |
| 'document.addEventListener("click", function(e) {' | |
| ' var t = e.target.closest(".scenario-card, .system-card, #map-plan-btn, #custom-plan-btn");' | |
| ' if (!t) return;' | |
| ' document.body.classList.add("has-clicked");' | |
| '}, true);' | |
| # Highlight the default system card once Gradio's Svelte runtime mounts | |
| # the buttons. Polls until the element exists then bails — no refresh | |
| # spam after the first hit. | |
| f'(function _setInitialActiveCard() {{' | |
| f' var btn = document.getElementById("system-card-" + window._activeSystem);' | |
| f' if (btn) {{ btn.classList.add("system-card-active"); return; }}' | |
| f' setTimeout(_setInitialActiveCard, 150);' | |
| f'}})();' | |
| f'</script>' | |
| '<script>' | |
| 'window._initMetroMap = function() {' | |
| ' var el = document.getElementById("metro-map");' | |
| ' if (!el) { setTimeout(window._initMetroMap, 200); return; }' | |
| ' if (el._leafletReady) return;' | |
| ' if (typeof L === "undefined") { setTimeout(window._initMetroMap, 100); return; }' | |
| ' el._leafletReady = true;' | |
| ' var map = L.map("metro-map", { zoomControl: true, scrollWheelZoom: false });' | |
| ' var dark = document.body.classList.contains("dark");' | |
| # Tile-style options for eyeballing. Override defaults via ?tile=KEY in URL. | |
| ' var STYLES = {' | |
| ' stadia_smooth_dark: {url:"https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png", attr:"© Stadia Maps © OpenMapTiles © OSM"},' | |
| ' carto_dark: {url:"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", attr:"© OSM © CARTO"},' | |
| ' carto_dark_nolabels:{url:"https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png", attr:"© OSM © CARTO"},' | |
| ' stadia_outdoors: {url:"https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.png", attr:"© Stadia Maps © OpenMapTiles © OSM"},' | |
| ' stadia_toner: {url:"https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png", attr:"© Stamen Design © Stadia Maps © OSM"},' | |
| ' stadia_toner_lite: {url:"https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png", attr:"© Stamen Design © Stadia Maps © OSM"},' | |
| ' stadia_smooth: {url:"https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png", attr:"© Stadia Maps © OpenMapTiles © OSM"},' | |
| ' carto_positron: {url:"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", attr:"© OSM © CARTO"},' | |
| ' carto_voyager: {url:"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",attr:"© OSM © CARTO"}' | |
| ' };' | |
| ' var qs = new URLSearchParams(window.location.search);' | |
| ' var override = qs.get("tile");' | |
| ' var key = override || (dark ? "stadia_smooth_dark" : "stadia_smooth");' | |
| ' var style = STYLES[key] || STYLES[dark ? "stadia_smooth_dark" : "stadia_smooth"];' | |
| ' L.tileLayer(style.url, { attribution: style.attr, maxZoom: 20 }).addTo(map);' | |
| ' var geo = window.METRO_GEO || {stations: [], edges: [], lines: []};' | |
| ' var sById = {};' | |
| ' for (var i = 0; i < geo.stations.length; i++) sById[geo.stations[i].id] = geo.stations[i];' | |
| ' var lineColor = {};' | |
| ' for (var j = 0; j < geo.lines.length; j++) {' | |
| ' var l = geo.lines[j];' | |
| ' lineColor[(l.id || (l.name||"").toLowerCase())] = l.color || "#999";' | |
| ' }' | |
| ' for (var k = 0; k < geo.edges.length; k++) {' | |
| ' var e = geo.edges[k];' | |
| ' var a = sById[e.from], b = sById[e.to];' | |
| ' if (!a || !b || a.lat == null || b.lat == null) continue;' | |
| ' var c = lineColor[(e.line || "").toLowerCase()] || "#999";' | |
| ' L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {color: c, weight: 3, opacity: 0.45}).addTo(map);' | |
| ' }' | |
| # Two markers per station: | |
| # 1. A small visible dot (radius 3, non-interactive — purely visual). | |
| # 2. A larger invisible "hit" circle (radius 12, interactive) that owns | |
| # the click handler + hover tooltip. The hit-area gives mobile fingers | |
| # a generous tap target and lets Leaflet do its native double-tap- | |
| # to-zoom on empty map space (we no longer wire map.on("click")). | |
| # _stationMarkers tracks the HIT marker (where the tooltip lives) so the | |
| # suppress/restore helpers in the pick handler keep working. | |
| ' window._stationMarkers = {};' | |
| ' window._stationNames = {};' | |
| ' for (var n = 0; n < geo.stations.length; n++) {' | |
| ' var s = geo.stations[n];' | |
| ' if (s.lat == null) continue;' | |
| ' L.circleMarker([s.lat, s.lon], {' | |
| ' radius: 3, color: "#666", fillColor: "#fff", fillOpacity: 1, weight: 1.5,' | |
| ' interactive: false' | |
| ' }).addTo(map);' | |
| ' var hit = L.circleMarker([s.lat, s.lon], {' | |
| ' radius: 12, fillOpacity: 0, opacity: 0, weight: 0' | |
| ' }).addTo(map).bindTooltip(s.name);' | |
| ' hit.station = s;' | |
| ' hit.on("click", function(ev) {' | |
| ' L.DomEvent.stopPropagation(ev);' | |
| ' window._pickStation(this.station);' | |
| ' });' | |
| ' window._stationMarkers[s.id] = hit;' | |
| ' window._stationNames[s.id] = s.name;' | |
| ' }' | |
| ' var lats = [], lons = [];' | |
| ' for (var p = 0; p < geo.stations.length; p++) {' | |
| ' if (geo.stations[p].lat != null) { lats.push(geo.stations[p].lat); lons.push(geo.stations[p].lon); }' | |
| ' }' | |
| ' if (lats.length) {' | |
| ' map.fitBounds([[Math.min.apply(null, lats), Math.min.apply(null, lons)], ' | |
| ' [Math.max.apply(null, lats), Math.max.apply(null, lons)]], {padding: [16, 16]});' | |
| ' }' | |
| ' window._metroMap = map;' | |
| ' window._routeLayer = null;' | |
| ' window._endpointLayers = [];' | |
| ' window._mapClickMode = window._mapClickMode || "origin";' | |
| ' window._originPickMarker = null;' | |
| ' window._destPickMarker = null;' | |
| # No map.on("click", ...) — picking is now driven by per-station hit | |
| # markers. Empty-area taps no-op so Leaflet's built-in doubleClickZoom | |
| # works correctly on touch. | |
| '};' | |
| # Helper: write a value into a hidden gr.Textbox so Gradio picks it up | |
| # on the next interaction. We use the native value setter so Svelte | |
| # state stays in sync. | |
| 'window._setHiddenGrInput = function(wrapId, value) {' | |
| ' var wrap = document.getElementById(wrapId);' | |
| ' if (!wrap) return;' | |
| ' var input = wrap.querySelector("input, textarea");' | |
| ' if (!input) return;' | |
| ' var proto = Object.getPrototypeOf(input);' | |
| ' var desc = Object.getOwnPropertyDescriptor(proto, "value");' | |
| ' if (desc && desc.set) desc.set.call(input, value);' | |
| ' else input.value = value;' | |
| ' input.dispatchEvent(new Event("input", {bubbles: true}));' | |
| '};' | |
| # Pick a specific station (called from per-station hit-marker click). | |
| # Toggles origin/destination state; the rest is the same as before. | |
| 'window._pickStation = function(best) {' | |
| ' if (!best) return;' | |
| ' var coord = [best.lat, best.lon];' | |
| ' var map = window._metroMap;' | |
| ' var st = document.getElementById("map-status");' | |
| # Pick markers carry a permanent tooltip ("Origin: X" / "Destination: X"). | |
| # To avoid stacking popovers with the underlying station's hover tooltip, | |
| # we unbind the station's tooltip while it's the picked one and rebind | |
| # when the pick is cleared. Helpers _suppressStationTip / _restoreStationTip | |
| # do the bookkeeping. | |
| ' var _suppress = function(id) {' | |
| ' var sm = window._stationMarkers && window._stationMarkers[id];' | |
| ' if (sm && sm.getTooltip()) sm.unbindTooltip();' | |
| ' };' | |
| ' var _restore = function(id) {' | |
| ' var sm = window._stationMarkers && window._stationMarkers[id];' | |
| ' var nm = window._stationNames && window._stationNames[id];' | |
| ' if (sm && nm && !sm.getTooltip()) sm.bindTooltip(nm);' | |
| ' };' | |
| ' if (window._mapClickMode === "origin") {' | |
| # Restore tooltips on whatever was picked before; this run is a fresh trip. | |
| ' if (window._originPickId) _restore(window._originPickId);' | |
| ' if (window._destPickId) _restore(window._destPickId);' | |
| ' if (window._originPickMarker) map.removeLayer(window._originPickMarker);' | |
| ' if (window._destPickMarker) { map.removeLayer(window._destPickMarker); window._destPickMarker = null; window._destPickId = null; window._destPickName = null; }' | |
| ' window._setHiddenGrInput("map-dest-id", "");' | |
| ' window._originPickMarker = L.circleMarker(coord, {' | |
| ' radius: 9, color: "#198038", fillColor: "#42BE65", fillOpacity: 1, weight: 3,' | |
| ' interactive: false' | |
| ' }).addTo(map).bindTooltip("Origin: " + best.name, {permanent: true, direction: "auto", offset: [10, 0], interactive: false});' | |
| ' window._originPickId = best.id;' | |
| ' window._originPickName = best.name;' | |
| ' _suppress(best.id);' | |
| ' window._setHiddenGrInput("map-origin-id", best.id);' | |
| ' window._mapClickMode = "dest";' | |
| ' if (st) st.textContent = "Origin: " + best.name + " · pick destination";' | |
| ' } else {' | |
| # Replacing dest: restore the previously-picked dest station's tooltip. | |
| ' if (window._destPickId) _restore(window._destPickId);' | |
| ' if (window._destPickMarker) map.removeLayer(window._destPickMarker);' | |
| ' window._destPickMarker = L.circleMarker(coord, {' | |
| ' radius: 9, color: "#DA1E28", fillColor: "#FA4D56", fillOpacity: 1, weight: 3,' | |
| ' interactive: false' | |
| ' }).addTo(map).bindTooltip("Destination: " + best.name, {permanent: true, direction: "auto", offset: [10, 0], interactive: false});' | |
| ' window._destPickId = best.id;' | |
| ' window._destPickName = best.name;' | |
| ' _suppress(best.id);' | |
| ' window._setHiddenGrInput("map-dest-id", best.id);' | |
| ' var oName = window._originPickName || "(origin)";' | |
| ' window._mapClickMode = "origin";' | |
| ' if (st) st.textContent = oName + " → " + best.name;' | |
| ' }' | |
| ' if (window._refreshMapPlanBtn) window._refreshMapPlanBtn();' | |
| '};' | |
| # Toggle the Plan-trip button between disabled (dim, no clicks) and | |
| # ready (full opacity, clickable). The button is active iff both | |
| # hidden textboxes have non-empty values. | |
| 'window._refreshMapPlanBtn = function() {' | |
| ' var btn = document.getElementById("map-plan-btn");' | |
| ' var oWrap = document.getElementById("map-origin-id");' | |
| ' var dWrap = document.getElementById("map-dest-id");' | |
| ' if (!btn || !oWrap || !dWrap) return;' | |
| ' var o = (oWrap.querySelector("input, textarea") || {}).value || "";' | |
| ' var d = (dWrap.querySelector("input, textarea") || {}).value || "";' | |
| ' var ready = o.length > 0 && d.length > 0;' | |
| ' btn.classList.toggle("map-plan-disabled", !ready);' | |
| '};' | |
| # Wire input listeners on the two hidden textboxes so the button also | |
| # tracks Custom-tab dropdown changes (which write back to the same | |
| # textboxes via the cascade) — not just direct map clicks. | |
| 'window._wireMapPlanBtn = function() {' | |
| ' if (window._mapPlanWired) return;' | |
| ' var oWrap = document.getElementById("map-origin-id");' | |
| ' var dWrap = document.getElementById("map-dest-id");' | |
| ' if (!oWrap || !dWrap) { setTimeout(window._wireMapPlanBtn, 200); return; }' | |
| ' var oI = oWrap.querySelector("input, textarea");' | |
| ' var dI = dWrap.querySelector("input, textarea");' | |
| ' if (!oI || !dI) { setTimeout(window._wireMapPlanBtn, 200); return; }' | |
| ' oI.addEventListener("input", window._refreshMapPlanBtn);' | |
| ' dI.addEventListener("input", window._refreshMapPlanBtn);' | |
| ' window._mapPlanWired = true;' | |
| ' window._refreshMapPlanBtn();' | |
| '};' | |
| 'if (document.readyState === "loading") {' | |
| ' document.addEventListener("DOMContentLoaded", function() {' | |
| ' window._initMetroMap();' | |
| ' window._wireMapPlanBtn();' | |
| ' });' | |
| '} else {' | |
| ' window._initMetroMap();' | |
| ' window._wireMapPlanBtn();' | |
| '}' | |
| '</script>' | |
| ) | |
| # Gradio theme: color tokens live here (auto-swap light/dark), so the CSS | |
| # below only does layout-specific work (header, scenario cards, hard shadows). | |
| KIOSK_THEME = gr.themes.Default( | |
| font=[gr.themes.GoogleFont("IBM Plex Sans"), "system-ui", "sans-serif"], | |
| font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"], | |
| radius_size="none", | |
| ).set( | |
| # body / surfaces | |
| body_background_fill="#F6F7F9", | |
| body_background_fill_dark="#14171A", | |
| body_text_color="#2C3338", | |
| body_text_color_dark="#E8ECEF", | |
| body_text_color_subdued="#3A4654", | |
| body_text_color_subdued_dark="#B0B6BD", | |
| # blocks | |
| block_background_fill="#F6F7F9", | |
| block_background_fill_dark="#14171A", | |
| block_label_background_fill="transparent", | |
| block_label_background_fill_dark="transparent", | |
| block_label_text_color="#2C3338", | |
| block_label_text_color_dark="#E8ECEF", | |
| block_border_color="transparent", | |
| block_border_color_dark="transparent", | |
| block_title_text_color="#2C3338", | |
| block_title_text_color_dark="#E8ECEF", | |
| # inputs (dropdown, textbox, etc.) | |
| input_background_fill="#F6F7F9", | |
| input_background_fill_dark="#14171A", | |
| input_background_fill_focus="#ECEEF1", | |
| input_background_fill_focus_dark="#1E2227", | |
| input_border_color="#6A737D", | |
| input_border_color_dark="#3A424B", | |
| input_border_color_focus="#C21924", | |
| input_border_color_focus_dark="#FA4D56", | |
| # primary button | |
| button_primary_background_fill="#0F1419", | |
| button_primary_background_fill_dark="#E8ECEF", | |
| button_primary_background_fill_hover="#000000", | |
| button_primary_background_fill_hover_dark="#FFFFFF", | |
| button_primary_text_color="#F6F7F9", | |
| button_primary_text_color_dark="#14171A", | |
| button_primary_border_color="#0F1419", | |
| button_primary_border_color_dark="#E8ECEF", | |
| # secondary button | |
| button_secondary_background_fill="#ECEEF1", | |
| button_secondary_background_fill_dark="#1E2227", | |
| button_secondary_background_fill_hover="#DEE2E6", | |
| button_secondary_background_fill_hover_dark="#2A3038", | |
| button_secondary_text_color="#2C3338", | |
| button_secondary_text_color_dark="#E8ECEF", | |
| button_secondary_border_color="#6A737D", | |
| button_secondary_border_color_dark="#3A424B", | |
| # global border | |
| border_color_primary="#6A737D", | |
| border_color_primary_dark="#3A424B", | |
| border_color_accent="#C21924", | |
| border_color_accent_dark="#FA4D56", | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=int(os.environ.get("PORT", "7860")), | |
| ssr_mode=False, | |
| css=CUSTOM_CSS, | |
| head=_HEAD_HTML, | |
| theme=KIOSK_THEME, | |
| ) | |