| """FLIGHTDECK — live flights on a transparent 3D globe, with an LLM flight agent.
|
|
|
| Data: FlightRadar24 API (https://fr24api.flightradar24.com/docs/getting-started)
|
| Globe: Globe.gl / Three.js (3D, transparent, neon glow)
|
| LLM: openbmb/MiniCPM5-1B via transformers (default safetensors model)
|
|
|
| Set FR24_API_TOKEN in your environment (see .env.example), then `python app.py`.
|
| """
|
| from __future__ import annotations
|
|
|
| import datetime as dt
|
| import os
|
|
|
| import gradio as gr
|
|
|
| try:
|
| from dotenv import load_dotenv
|
|
|
| load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
| except Exception:
|
| pass
|
|
|
| import agent
|
| import fr24
|
| import geo
|
| import globe as globe_mod
|
| import liquid
|
| import sidebar as sidebar_mod
|
| import trace_sync
|
| import transit_agent
|
| import transit_map
|
|
|
|
|
|
|
| print(f"[flightdeck] {trace_sync.start()}")
|
|
|
|
|
| REGIONS = {
|
| "Europe": (60.0, 36.0, -11.0, 30.0),
|
| "North America": (50.0, 25.0, -125.0, -67.0),
|
| "USA — East Coast": (45.0, 30.0, -82.0, -68.0),
|
| "UK & Ireland": (59.0, 49.0, -11.0, 2.0),
|
| "Middle East": (33.0, 22.0, 34.0, 60.0),
|
| "East Asia": (46.0, 22.0, 100.0, 146.0),
|
| "Australia": (-10.0, -44.0, 112.0, 154.0),
|
| "World (sampled)": (70.0, -60.0, -170.0, 170.0),
|
| }
|
|
|
| MAX_FLIGHTS = 220
|
|
|
|
|
| def normalize_flight(raw: dict) -> dict | None:
|
| """Map an FR24 record to the fields the rest of the app expects."""
|
| lat = raw.get("lat")
|
| lon = raw.get("lon")
|
| if lat is None or lon is None:
|
| return None
|
|
|
| eta_raw = raw.get("eta")
|
| eta_iso, eta_secs = fr24.parse_eta(eta_raw)
|
|
|
| dest_code = raw.get("dest_icao") or raw.get("dest_iata")
|
| orig_code = raw.get("orig_icao") or raw.get("orig_iata")
|
| dest_ll = fr24.airport_coords(dest_code) if dest_code else None
|
|
|
|
|
|
|
|
|
| if dest_ll:
|
| est_path = (dest_ll[0], dest_ll[1])
|
| dist_km = round(geo.haversine_km(lat, lon, dest_ll[0], dest_ll[1]), 1)
|
| else:
|
| est_path = None
|
| dist_km = None
|
|
|
| return {
|
| "fr24_id": raw.get("fr24_id"),
|
| "callsign": raw.get("callsign") or raw.get("flight"),
|
| "flight": raw.get("flight"),
|
| "lat": float(lat), "lon": float(lon),
|
| "alt": raw.get("alt"),
|
| "gspeed": raw.get("gspeed"),
|
| "track": raw.get("track"),
|
| "orig": orig_code, "dest": dest_code,
|
| "type": raw.get("type"),
|
| "reg": raw.get("reg"),
|
| "eta_iso": eta_iso,
|
| "eta_secs": eta_secs,
|
| "eta_human": fr24.human_duration(eta_secs) if eta_secs is not None else "—",
|
| "est_dist_km": dist_km,
|
| "est_to_dest": est_path is not None,
|
| "est_path": est_path,
|
| }
|
|
|
|
|
| def _focus_point(flights):
|
| if not flights:
|
| return None
|
| avg_lat = sum(f["lat"] for f in flights) / len(flights)
|
| avg_lon = sum(f["lon"] for f in flights) / len(flights)
|
| return {"lat": avg_lat, "lng": avg_lon}
|
|
|
|
|
| def _table_rows(flights):
|
| rows = []
|
| for f in flights:
|
| rows.append([
|
| f.get("callsign") or "—",
|
| f.get("type") or "—",
|
| f"{f.get('orig') or '?'} → {f.get('dest') or '?'}",
|
| f.get("alt") if f.get("alt") is not None else "—",
|
| f.get("gspeed") if f.get("gspeed") is not None else "—",
|
| f.get("eta_human") or "—",
|
| (f"{f.get('est_dist_km')} km →dest" if f.get("est_to_dest") else "—"),
|
| ])
|
| return rows
|
|
|
|
|
|
|
| def scan(region_name, custom_bounds, state):
|
| region = REGIONS.get(region_name)
|
| if custom_bounds and custom_bounds.strip():
|
| try:
|
| n, s, w, e = [float(x) for x in custom_bounds.split(",")]
|
| region = (n, s, w, e)
|
| region_label = f"custom[{custom_bounds.strip()}]"
|
| except Exception:
|
| return (
|
| "<div style='color:#ff5277'>Bounds must be: north,south,west,east</div>",
|
| [], "Bad bounds.", "", state,
|
| )
|
| else:
|
| region_label = region_name
|
|
|
| if region is None:
|
| return ("<div style='color:#ff5277'>Pick a region.</div>", [],
|
| "No region.", "", state)
|
|
|
| n, s, w, e = region
|
| try:
|
| raw = fr24.live_positions(n, s, w, e)
|
| except fr24.FR24Error as ex:
|
| return (f"<div style='color:#ff5277'>FR24: {ex}</div>", [], str(ex), "", state)
|
| except Exception as ex:
|
| return (f"<div style='color:#ff5277'>Error: {ex}</div>", [], str(ex), "", state)
|
|
|
| flights = []
|
| for r in raw[:MAX_FLIGHTS]:
|
| nf = normalize_flight(r)
|
| if nf:
|
| flights.append(nf)
|
|
|
| stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
| html = globe_mod.build_globe_html(flights, stamp=stamp, focus=_focus_point(flights))
|
| rows = _table_rows(flights)
|
| side = sidebar_mod.build_sidebar_html(flights)
|
|
|
| state = {"flights": flights, "region": region_label}
|
| status = f">> {len(flights)} contacts in {region_label} @ {stamp}"
|
| return html, rows, status, side, state
|
|
|
|
|
| import json as _json
|
|
|
|
|
| STANDBY_GLOBE = globe_mod.build_globe_html([], stamp="STANDBY // DRAG TO SPIN")
|
|
|
|
|
| def _read_trace(path):
|
| """Load a saved trace file for the trace-viewer panel."""
|
| try:
|
| with open(path, encoding="utf-8") as fh:
|
| return _json.load(fh)
|
| except Exception as e:
|
| return {"trace_error": str(e), "path": path}
|
|
|
|
|
| def _render(flights, label):
|
| """Build globe + table + sidebar outputs for a set of normalized flights."""
|
| stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
| html = globe_mod.build_globe_html(flights, stamp=stamp, focus=_focus_point(flights))
|
| rows = _table_rows(flights)
|
| side = sidebar_mod.build_sidebar_html(flights)
|
| return html, rows, side, stamp
|
|
|
|
|
| def assistant(query, state):
|
| """Primary entry point: the LLM agent decides + runs the FR24 call."""
|
| if not query or not query.strip():
|
| return (STANDBY_GLOBE, [], "Ask e.g. 'flights from London to Dubai'.",
|
| "<div id='fd-sidebar'></div>", state,
|
| "Type a flight query above.", {})
|
|
|
| result = agent.run(query.strip())
|
| flights = [nf for nf in (normalize_flight(r) for r in result["flights"]) if nf]
|
|
|
| if flights:
|
|
|
| html, rows, side, stamp = _render(flights, query)
|
| else:
|
| html, rows, side = STANDBY_GLOBE, [], "<div id='fd-sidebar'></div>"
|
|
|
| state = {"flights": flights, "region": query.strip()}
|
| trace_file = os.path.basename(result["trace_path"])
|
| status = (f">> agent[{result['mode']}] tools={result['tool_calls'] or '—'} "
|
| f"→ {len(flights)} flights · trace {result['trace_id']}")
|
| answer_md = (
|
| f"{result['answer']}\n\n"
|
| f"<small>`mode={result['mode']}` · `tools={result['tool_calls'] or 'none'}` "
|
| f"· `flights={len(flights)}` · trace saved → "
|
| f"`traces/{trace_file}`</small>")
|
| return html, rows, status, side, state, answer_md, _read_trace(result["trace_path"])
|
|
|
|
|
| TRANSIT_EXAMPLES = [
|
| "fastest way from Berkeley to SFO",
|
| "next train from Embarcadero",
|
| "traffic on the Bay Bridge",
|
| "fastest way from Oakland to San Jose",
|
| ]
|
|
|
| _EMPTY_MAP = ("<div style='height:560px;display:flex;align-items:center;"
|
| "justify-content:center;color:#FF9A1E;text-shadow:0 0 10px #FF7A00;"
|
| "border:1px solid rgba(255,154,30,0.35);border-radius:10px'>"
|
| "◌ AWAITING TRANSIT QUERY ◌</div>")
|
|
|
|
|
| def transit_assistant(query, t_state):
|
| """Bay Area transit agent: picks a 511 tool, runs it, recommends."""
|
| if not query or not query.strip():
|
| return (_EMPTY_MAP, "Ask e.g. 'fastest way from Berkeley to SFO'.",
|
| "Type a Bay Area transit query above.", t_state, {})
|
|
|
| r = transit_agent.run(query.strip())
|
| stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
| map_html = transit_map.build_map_html(r["markers"], stamp) if r["markers"] else _EMPTY_MAP
|
| trace_file = os.path.basename(r["trace_path"])
|
| status = (f">> agent[{r['mode']}] tools={r['tool_calls'] or '—'} · "
|
| f"{len(r['markers'])} pins · trace {r['trace_id']}")
|
| answer_md = (
|
| f"{r['answer']}\n\n"
|
| f"<small>`mode={r['mode']}` · `tools={r['tool_calls'] or 'none'}` · "
|
| f"trace saved → `traces/{trace_file}`</small>")
|
| t_state = {"transit_last": query.strip()}
|
| return map_html, status, answer_md, t_state, _read_trace(r["trace_path"])
|
|
|
|
|
|
|
| NEON_CSS = """
|
| /* Palette is variable-driven so the transit tab can repaint by swapping vars. */
|
| :root, .gradio-container {
|
| --neon: #39FF14; --neon-rgb: 57,255,20;
|
| --neon2: #00E5FF; --neon2-rgb: 0,229,255;
|
| --bg: #02060a; --bg2: #061a12;
|
| --panel-rgb: 2,18,12; --in-bg: #02100a;
|
| --btn-a: #0a3a22; --btn-b: #04150d;
|
| }
|
| /* Transit mode: glow-in-the-dark ORANGE on BLACK — just redefine the vars. */
|
| .gradio-container.transit-mode {
|
| --neon: #FF9A1E; --neon-rgb: 255,154,30;
|
| --neon2: #FFC65C; --neon2-rgb: 255,198,92;
|
| --bg: #000000; --bg2: #1a0c00;
|
| --panel-rgb: 20,10,0; --in-bg: #160a00;
|
| --btn-a: #3a2200; --btn-b: #150b00;
|
| }
|
| .gradio-container, body, .gradio-container * {
|
| font-family: 'Courier New', Consolas, monospace !important;
|
| }
|
| .gradio-container {
|
| background:
|
| radial-gradient(circle at 20% 0%, var(--bg2) 0%, var(--bg) 55%),
|
| repeating-linear-gradient(0deg, rgba(var(--neon-rgb),0.035) 0px,
|
| rgba(var(--neon-rgb),0.035) 1px, transparent 1px, transparent 3px) !important;
|
| color: var(--neon) !important;
|
| }
|
| h1, h2, h3, label, .prose, span, p, li, strong, em {
|
| color: var(--neon) !important;
|
| text-shadow: 0 0 8px rgba(var(--neon-rgb),0.55) !important;
|
| }
|
| #title { font-size: 30px; letter-spacing: 4px; text-align:center;
|
| text-shadow: 0 0 12px var(--neon), 0 0 24px var(--neon) !important; }
|
| #subtitle { text-align:center; color: var(--neon2) !important;
|
| text-shadow: 0 0 8px var(--neon2) !important; letter-spacing:2px; }
|
| .block, .form, .gr-box, .gr-panel, .panel {
|
| background: rgba(var(--panel-rgb),0.66) !important;
|
| border: 1px solid rgba(var(--neon-rgb),0.35) !important;
|
| box-shadow: 0 0 14px rgba(var(--neon-rgb),0.18), inset 0 0 12px rgba(0,0,0,0.6) !important;
|
| border-radius: 10px !important;
|
| }
|
| input, textarea, select, .gr-input {
|
| background: var(--in-bg) !important; color: var(--neon) !important;
|
| border: 1px solid rgba(var(--neon-rgb),0.4) !important;
|
| text-shadow: 0 0 6px rgba(var(--neon-rgb),0.5) !important;
|
| }
|
| button {
|
| background: linear-gradient(180deg, var(--btn-a), var(--btn-b)) !important;
|
| color: var(--neon) !important;
|
| border: 1px solid var(--neon) !important;
|
| text-shadow: 0 0 8px var(--neon) !important;
|
| box-shadow: 0 0 12px rgba(var(--neon-rgb),0.4) !important;
|
| text-transform: uppercase; letter-spacing: 2px;
|
| }
|
| button:hover { box-shadow: 0 0 22px var(--neon), 0 0 8px var(--neon2) !important; }
|
| .gr-dataframe table { color: var(--neon) !important; }
|
| .gr-dataframe th { color: var(--neon2) !important;
|
| text-shadow: 0 0 6px var(--neon2) !important; }
|
| footer { display:none !important; }
|
|
|
| /* ---- sidebar (MOST TRACKED / AIRPORT DISRUPTIONS) ---- */
|
| #fd-sidebar { display:flex; flex-direction:column; gap:10px; }
|
| .fd-panel {
|
| background: rgba(2,16,11,0.78);
|
| border: 1px solid rgba(57,255,20,0.35);
|
| border-radius: 10px; padding: 6px 8px 8px;
|
| box-shadow: 0 0 14px rgba(57,255,20,0.18), inset 0 0 12px rgba(0,0,0,0.6);
|
| }
|
| .fd-panel > summary {
|
| list-style:none; cursor:pointer; user-select:none;
|
| display:flex; align-items:center; gap:8px;
|
| font-size:12px; letter-spacing:2px; color:var(--neon);
|
| text-shadow:0 0 8px var(--neon); padding:4px 2px;
|
| }
|
| .fd-panel > summary::-webkit-details-marker { display:none; }
|
| .fd-chev { margin-left:auto; transition:transform .2s; }
|
| .fd-panel[open] > summary .fd-chev { transform:rotate(180deg); }
|
| .fd-live {
|
| font-size:9px; font-weight:bold; color:#02060a;
|
| background:var(--neon); padding:1px 5px; border-radius:3px;
|
| box-shadow:0 0 8px var(--neon); animation:fd-pulse 1.6s infinite;
|
| }
|
| @keyframes fd-pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
|
| .fd-note { font-size:9px; color:#5fe0a0; opacity:0.7; margin:2px 2px 6px;
|
| letter-spacing:0.5px; text-shadow:none; }
|
| .fd-list { display:flex; flex-direction:column; gap:6px; }
|
| .fd-item {
|
| background: rgba(0,0,0,0.35);
|
| border:1px solid rgba(57,255,20,0.18);
|
| border-left:3px solid var(--neon);
|
| border-radius:6px; padding:6px 8px;
|
| }
|
| .fd-item:hover { border-left-color:var(--neon2);
|
| box-shadow:0 0 10px rgba(0,229,255,0.25); }
|
| .fd-row { display:flex; align-items:center; gap:6px; }
|
| .fd-rank { color:var(--neon2); font-weight:bold; text-shadow:0 0 6px var(--neon2); }
|
| .fd-cs { font-weight:bold; color:var(--neon); text-shadow:0 0 6px var(--neon); }
|
| .fd-badge {
|
| font-size:9px; color:var(--neon2); border:1px solid rgba(0,229,255,0.5);
|
| border-radius:3px; padding:0 4px; text-shadow:0 0 5px var(--neon2);
|
| }
|
| .fd-metric { margin-left:auto; color:#FFD23F; text-shadow:0 0 6px #FFD23F;
|
| font-weight:bold; }
|
| .fd-metric small { font-size:8px; opacity:0.8; }
|
| .fd-route { font-size:11px; color:#9fffd0; opacity:0.85; margin-top:2px;
|
| text-shadow:none; }
|
| .fd-arrow { color:var(--neon2); }
|
| .fd-wx { display:flex; justify-content:space-between; font-size:11px;
|
| color:#9fffd0; margin-top:3px; text-shadow:none; }
|
| .fd-wind { color:var(--neon2); }
|
| .fd-scores { display:flex; justify-content:space-between; font-size:11px;
|
| margin-top:3px; color:var(--neon); }
|
| .fd-dot { font-weight:bold; }
|
| .fd-empty { font-size:11px; color:#5fe0a0; opacity:0.7; padding:6px;
|
| text-align:center; }
|
| .fd-hint { font-size:11px; color:#5fe0a0; opacity:0.8; margin:2px 0 8px;
|
| letter-spacing:0.5px; }
|
| .fd-hint code { color:var(--neon2); }
|
|
|
| /* Transit mode is driven entirely by the variable swap above; just a couple
|
| of orange accents that reference the (now-orange) vars. */
|
| #transit_hint { font-size:11px; color:var(--neon2); opacity:0.9; margin:2px 0 8px; }
|
| #transit_hint code { color:var(--neon); }
|
| """
|
|
|
| theme = gr.themes.Base(
|
| primary_hue=gr.themes.colors.green,
|
| secondary_hue=gr.themes.colors.cyan,
|
| neutral_hue=gr.themes.colors.gray,
|
| ).set(
|
| body_background_fill="#02060a",
|
| body_text_color="#39FF14",
|
| block_background_fill="rgba(2,18,12,0.66)",
|
| )
|
|
|
|
|
| FORCE_DARK_JS = """
|
| function() {
|
| const url = new URL(window.location);
|
| if (url.searchParams.get('__theme') !== 'dark') {
|
| url.searchParams.set('__theme', 'dark');
|
| window.location.replace(url.href);
|
| }
|
| }
|
| """
|
|
|
| EXAMPLE_QUERIES = [
|
| "flights from London to Dubai",
|
| "arrivals into JFK",
|
| "departures from LAX",
|
| "flights from Tokyo to Singapore",
|
| ]
|
|
|
| _JS_FLIGHT = ("() => document.querySelector('.gradio-container')"
|
| ".classList.remove('transit-mode')")
|
| _JS_TRANSIT = ("() => document.querySelector('.gradio-container')"
|
| ".classList.add('transit-mode')")
|
|
|
| with gr.Blocks(title="FLIGHTDECK", theme=theme, css=NEON_CSS, js=FORCE_DARK_JS) as demo:
|
| state = gr.State({})
|
| transit_state = gr.State({})
|
|
|
| with gr.Tabs():
|
|
|
| with gr.Tab("✈ FLIGHTDECK", elem_id="flight_tab") as flight_tab:
|
| gr.HTML("<div id='title'>▚ F L I G H T D E C K ▞</div>"
|
| "<div id='subtitle'>ai flight agent // 3D globe // live FR24 uplink</div>")
|
| with gr.Row():
|
| question = gr.Textbox(
|
| label="ASK THE FLIGHT AGENT", scale=5, autofocus=True,
|
| placeholder="flights from London to Dubai · arrivals into JFK · "
|
| "departures from LAX")
|
| ask_btn = gr.Button("▶ DISPATCH", scale=1, variant="primary")
|
| with gr.Row():
|
| example_btns = [gr.Button(q, size="sm") for q in EXAMPLE_QUERIES]
|
| gr.HTML("<div class='fd-hint'>scope: live flights TO / FROM an airport or "
|
| "on a route only · drag to spin the globe · all traces are saved "
|
| "to a dataset with almost the same name as this Space — look for it "
|
| "in the Hackathon Datasets</div>")
|
| answer = gr.Markdown(f"**UPLINK:** {liquid.status()} — ask a flight "
|
| "question above to dispatch the agent.")
|
| with gr.Row():
|
| with gr.Column(scale=1, min_width=240):
|
| sidebar_out = gr.HTML(
|
| "<div id='fd-sidebar'><div class='fd-empty'>◌ awaiting agent ◌</div></div>")
|
| status = gr.Textbox(label="AGENT STATUS", interactive=False)
|
| with gr.Accordion("⛭ MANUAL OVERRIDE (region scan)", open=False):
|
| region = gr.Dropdown(
|
| choices=list(REGIONS.keys()), value="Europe", label="SECTOR")
|
| custom = gr.Textbox(
|
| label="CUSTOM BOUNDS (north,south,west,east)",
|
| placeholder="e.g. 51.7,51.2,-0.6,0.3")
|
| scan_btn = gr.Button("◈ SCAN AIRSPACE")
|
| with gr.Column(scale=3):
|
| globe_out = gr.HTML(STANDBY_GLOBE)
|
| table = gr.Dataframe(
|
| headers=["CALLSIGN", "TYPE", "ROUTE", "ALT ft", "GS kt", "ETA", "EST. PATH"],
|
| label="CONTACTS", interactive=False, wrap=True)
|
| with gr.Accordion("🧾 AGENT TRACE (reasoning · tool call · API URL)",
|
| open=False):
|
| trace_view = gr.JSON(label="last run — saved to ./traces/")
|
|
|
|
|
| with gr.Tab("🚉 BAY TRANSIT", elem_id="transit_tab") as transit_tab:
|
| gr.HTML("<div id='title'>◢ B A Y L I N E ◣</div>"
|
| "<div id='subtitle'>bay area transit agent // 511 live data // "
|
| "fastest-route reasoning</div>")
|
| with gr.Row():
|
| t_question = gr.Textbox(
|
| label="ASK THE TRANSIT AGENT", scale=5,
|
| placeholder="fastest way from Berkeley to SFO · next train from "
|
| "Embarcadero · traffic on the Bay Bridge")
|
| t_ask = gr.Button("▶ DISPATCH", scale=1, variant="primary")
|
| with gr.Row():
|
| t_example_btns = [gr.Button(q, size="sm") for q in TRANSIT_EXAMPLES]
|
| gr.HTML("<div id='transit_hint'>scope: Bay Area transit & traffic only · "
|
| "powered by <code>511.org</code> live data · the agent reasons "
|
| "the fastest option · all traces are saved to a dataset with almost "
|
| "the same name as this Space — look for it in the Hackathon Datasets</div>")
|
| t_answer = gr.Markdown("**511 UPLINK ready** — ask a Bay Area transit "
|
| "question above.")
|
| t_status = gr.Textbox(label="AGENT STATUS", interactive=False)
|
| t_map = gr.HTML(_EMPTY_MAP)
|
| with gr.Accordion("🧾 AGENT TRACE (reasoning · tool call · API URL)",
|
| open=False):
|
| t_trace_view = gr.JSON(label="last run — saved to ./traces/")
|
|
|
|
|
| flight_tab.select(None, None, None, js=_JS_FLIGHT)
|
| transit_tab.select(None, None, None, js=_JS_TRANSIT)
|
|
|
|
|
| agent_outputs = [globe_out, table, status, sidebar_out, state, answer, trace_view]
|
| ask_btn.click(assistant, [question, state], agent_outputs)
|
| question.submit(assistant, [question, state], agent_outputs)
|
| for btn, q in zip(example_btns, EXAMPLE_QUERIES):
|
| btn.click(lambda q=q: q, None, question).then(
|
| assistant, [question, state], agent_outputs)
|
| scan_btn.click(scan, [region, custom, state],
|
| [globe_out, table, status, sidebar_out, state])
|
|
|
|
|
| transit_outputs = [t_map, t_status, t_answer, transit_state, t_trace_view]
|
| t_ask.click(transit_assistant, [t_question, transit_state], transit_outputs)
|
| t_question.submit(transit_assistant, [t_question, transit_state], transit_outputs)
|
| for btn, q in zip(t_example_btns, TRANSIT_EXAMPLES):
|
| btn.click(lambda q=q: q, None, t_question).then(
|
| transit_assistant, [t_question, transit_state], transit_outputs)
|
|
|
| if __name__ == "__main__":
|
| demo.launch(
|
|
|
|
|
| server_name=os.environ.get("HOST", "0.0.0.0"),
|
| server_port=int(os.environ.get("PORT", "7860")),
|
| )
|
|
|