"""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""" """ 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'' f'Qwen3.5-{size} + LoRA v23' ) else: attribution = f"Qwen3.5-{size} + LoRA v23" return f""" """ 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 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 = '' # 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, ) @GPU(duration=120) def _generate_one_round(messages: list[dict]) -> str: """Blocking single-round generate. Kept as a fallback path.""" return mdl.generate_one_round(messages) @GPU(duration=120) 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, '''); 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( '
' + '
' + esc(msg) + '
' + '
' ); } // 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( '
' + '
FARE
' + '
' + currency + ' ' + total + '
' + (pax ? '
' + esc(pax) + '
' : '') + '
' ); } // 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( '
' + '
' + esc(sev.toUpperCase()) + '
' + '
' + esc(a.title || '') + '
' + (a.body ? '
' + esc(a.body) + '
' : '') + '
' ); } // 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( '
' + '
' + esc(a.label) + '
' + '
' ); } 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 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.
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
— 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
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".*?\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'
⏱ round {round_n + 1}: ' f'{elapsed:.1f}s · {tok_count} tok · {tok_rate:.1f} tok/s
' ) # Finalize the reasoning block — content becomes the final narrative. narrative = re.sub(r".*?\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"): @gr.render(inputs=current_system) 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"): @gr.render(inputs=current_system) 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=[("", "")], # Tool-call cards are inlined as
/
# 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( '
Pick two stations on the map — or use Custom tab.
', 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('
') # 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('
', 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( '
' 'Simulated transit data · AI can make mistakes' '
', 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 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 = ( '' '' '' # Raw ' f'' '' ) # 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, )