Spaces:
Running
Running
| from __future__ import annotations | |
| import html | |
| import os | |
| from dataclasses import dataclass | |
| from typing import Any | |
| import gradio as gr | |
| from agent import ( | |
| DraftRecommendationRequest, | |
| DraftTeam, | |
| recommend_draft_action, | |
| warm_static_dota_data, | |
| ) | |
| from mcp import initialize_opendota_mcp_server | |
| class DraftStep: | |
| number: int | |
| phase: str | |
| action: str | |
| slot: int | |
| team: str | |
| DRAFT_SEQUENCE = [ | |
| DraftStep(1, "Phase 1", "Ban", 1, "A"), | |
| DraftStep(2, "Phase 1", "Ban", 2, "B"), | |
| DraftStep(3, "Phase 1", "Ban", 3, "A"), | |
| DraftStep(4, "Phase 1", "Ban", 4, "B"), | |
| DraftStep(5, "Phase 1", "Pick", 1, "A"), | |
| DraftStep(6, "Phase 1", "Pick", 2, "B"), | |
| DraftStep(7, "Phase 1", "Pick", 3, "B"), | |
| DraftStep(8, "Phase 1", "Pick", 4, "A"), | |
| DraftStep(9, "Phase 2", "Ban", 5, "A"), | |
| DraftStep(10, "Phase 2", "Ban", 6, "B"), | |
| DraftStep(11, "Phase 2", "Ban", 7, "A"), | |
| DraftStep(12, "Phase 2", "Ban", 8, "B"), | |
| DraftStep(13, "Phase 2", "Pick", 5, "A"), | |
| DraftStep(14, "Phase 2", "Pick", 6, "B"), | |
| DraftStep(15, "Phase 2", "Pick", 7, "B"), | |
| DraftStep(16, "Phase 2", "Pick", 8, "A"), | |
| DraftStep(17, "Phase 3", "Ban", 9, "A"), | |
| DraftStep(18, "Phase 3", "Ban", 10, "B"), | |
| DraftStep(19, "Phase 3", "Pick", 9, "A"), | |
| DraftStep(20, "Phase 3", "Pick", 10, "B"), | |
| ] | |
| HERO_SUGGESTIONS = [ | |
| "Abaddon", | |
| "Alchemist", | |
| "Ancient Apparition", | |
| "Anti-Mage", | |
| "Arc Warden", | |
| "Axe", | |
| "Bane", | |
| "Batrider", | |
| "Beastmaster", | |
| "Bloodseeker", | |
| "Bounty Hunter", | |
| "Brewmaster", | |
| "Bristleback", | |
| "Broodmother", | |
| "Centaur Warrunner", | |
| "Chaos Knight", | |
| "Chen", | |
| "Clinkz", | |
| "Clockwerk", | |
| "Crystal Maiden", | |
| "Dark Seer", | |
| "Dark Willow", | |
| "Dawnbreaker", | |
| "Dazzle", | |
| "Death Prophet", | |
| "Disruptor", | |
| "Doom", | |
| "Dragon Knight", | |
| "Drow Ranger", | |
| "Earth Spirit", | |
| "Earthshaker", | |
| "Elder Titan", | |
| "Ember Spirit", | |
| "Enchantress", | |
| "Enigma", | |
| "Faceless Void", | |
| "Grimstroke", | |
| "Gyrocopter", | |
| "Hoodwink", | |
| "Huskar", | |
| "Invoker", | |
| "Io", | |
| "Jakiro", | |
| "Juggernaut", | |
| "Keeper of the Light", | |
| "Kez", | |
| "Kunkka", | |
| "Largo", | |
| "Legion Commander", | |
| "Leshrac", | |
| "Lich", | |
| "Lifestealer", | |
| "Lina", | |
| "Lion", | |
| "Lone Druid", | |
| "Luna", | |
| "Lycan", | |
| "Magnus", | |
| "Marci", | |
| "Mars", | |
| "Medusa", | |
| "Meepo", | |
| "Mirana", | |
| "Monkey King", | |
| "Muerta", | |
| "Naga Siren", | |
| "Nature's Prophet", | |
| "Necrophos", | |
| "Night Stalker", | |
| "Nyx Assassin", | |
| "Ogre Magi", | |
| "Omniknight", | |
| "Oracle", | |
| "Outworld Destroyer", | |
| "Pangolier", | |
| "Phantom Assassin", | |
| "Phantom Lancer", | |
| "Phoenix", | |
| "Primal Beast", | |
| "Puck", | |
| "Pudge", | |
| "Pugna", | |
| "Queen of Pain", | |
| "Razor", | |
| "Riki", | |
| "Ringmaster", | |
| "Rubick", | |
| "Sand King", | |
| "Shadow Demon", | |
| "Shadow Fiend", | |
| "Shadow Shaman", | |
| "Silencer", | |
| "Skywrath Mage", | |
| "Slardar", | |
| "Slark", | |
| "Snapfire", | |
| "Sniper", | |
| "Spectre", | |
| "Spirit Breaker", | |
| "Storm Spirit", | |
| "Sven", | |
| "Techies", | |
| "Templar Assassin", | |
| "Terrorblade", | |
| "Tidehunter", | |
| "Timbersaw", | |
| "Tinker", | |
| "Tiny", | |
| "Treant Protector", | |
| "Troll Warlord", | |
| "Tusk", | |
| "Underlord", | |
| "Undying", | |
| "Ursa", | |
| "Vengeful Spirit", | |
| "Venomancer", | |
| "Viper", | |
| "Visage", | |
| "Void Spirit", | |
| "Warlock", | |
| "Weaver", | |
| "Windranger", | |
| "Winter Wyvern", | |
| "Witch Doctor", | |
| "Wraith King", | |
| "Zeus", | |
| ] | |
| def new_state() -> dict[str, Any]: | |
| opendota_api_key = os.getenv("OPENDOTA_API_KEY", "") | |
| return { | |
| "api_token": opendota_api_key, | |
| "api_token_configured": bool(opendota_api_key), | |
| "initialized": False, | |
| "team_names": {"A": "Team A", "B": "Team B"}, | |
| "user_team": "A", | |
| "opponent_team": "B", | |
| "sides": {"A": "Radiant", "B": "Dire"}, | |
| "athlete": "", | |
| "player_ids": {"A": [], "B": []}, | |
| "history": [], | |
| "insights": [], | |
| } | |
| def preserve_token(source: dict[str, Any] | None, target: dict[str, Any]) -> dict[str, Any]: | |
| if source: | |
| target["api_token"] = source.get("api_token", "") or os.getenv("OPENDOTA_API_KEY", "") | |
| target["api_token_configured"] = bool(target["api_token"]) | |
| return target | |
| def team_label(state: dict[str, Any], team_key: str) -> str: | |
| marker = "You" if team_key == state["user_team"] else "Opponent" | |
| return f'{state["team_names"][team_key]} ({marker}, {state["sides"][team_key]})' | |
| def current_step(state: dict[str, Any]) -> DraftStep | None: | |
| index = len(state["history"]) | |
| if index >= len(DRAFT_SEQUENCE): | |
| return None | |
| return DRAFT_SEQUENCE[index] | |
| def build_state( | |
| user_team_name: str, | |
| opponent_team_name: str, | |
| user_side: str, | |
| first_drafter: str, | |
| opponent_athlete: str, | |
| user_player_ids: str, | |
| opponent_player_ids: str, | |
| existing_state: dict[str, Any] | None = None, | |
| ) -> dict[str, Any]: | |
| user_key = "A" if first_drafter == "You draft first" else "B" | |
| opponent_key = "B" if user_key == "A" else "A" | |
| opponent_side = "Dire" if user_side == "Radiant" else "Radiant" | |
| names = { | |
| user_key: user_team_name.strip() or "Your Team", | |
| opponent_key: opponent_team_name.strip() or "Opponent", | |
| } | |
| sides = {user_key: user_side, opponent_key: opponent_side} | |
| state = preserve_token(existing_state, new_state()) | |
| state.update( | |
| { | |
| "initialized": True, | |
| "team_names": names, | |
| "user_team": user_key, | |
| "opponent_team": opponent_key, | |
| "sides": sides, | |
| "athlete": opponent_athlete.strip(), | |
| "player_ids": { | |
| user_key: parse_player_ids(user_player_ids), | |
| opponent_key: parse_player_ids(opponent_player_ids), | |
| }, | |
| } | |
| ) | |
| return state | |
| def parse_player_ids(raw_ids: str) -> list[str]: | |
| return [ | |
| item.strip() | |
| for item in raw_ids.replace("\n", ",").split(",") | |
| if item.strip() | |
| ] | |
| def build_timeline(state: dict[str, Any]) -> list[list[str]]: | |
| rows = [] | |
| for index, step in enumerate(DRAFT_SEQUENCE): | |
| entry = state["history"][index] if index < len(state["history"]) else None | |
| status = "Done" if entry else "Current" if index == len(state["history"]) else "Pending" | |
| hero = entry["hero"] if entry else "" | |
| rows.append( | |
| [ | |
| str(step.number), | |
| step.phase, | |
| step.action, | |
| team_label(state, step.team), | |
| hero, | |
| status, | |
| ] | |
| ) | |
| return rows | |
| def _render_slots(heroes: list[str]) -> str: | |
| cells = [] | |
| for index in range(5): | |
| if index < len(heroes): | |
| cells.append(f'<span class="filled">{html.escape(heroes[index])}</span>') | |
| else: | |
| cells.append('<span class="empty"></span>') | |
| return "".join(cells) | |
| def build_board(state: dict[str, Any]) -> str: | |
| sections = [] | |
| for key in ["A", "B"]: | |
| picks = [ | |
| item["hero"] | |
| for item in state["history"] | |
| if item["team"] == key and item["action"] == "Pick" | |
| ] | |
| bans = [ | |
| item["hero"] | |
| for item in state["history"] | |
| if item["team"] == key and item["action"] == "Ban" | |
| ] | |
| owner = "you" if key == state.get("user_team") else "opponent" | |
| sections.append( | |
| f""" | |
| <section class="draft-panel {owner}"> | |
| <h3>{html.escape(team_label(state, key))}</h3> | |
| <div class="slot-label">Picks</div> | |
| <div class="slot-grid picks">{_render_slots(picks)}</div> | |
| <div class="slot-label">Bans</div> | |
| <div class="slot-grid bans">{_render_slots(bans)}</div> | |
| </section> | |
| """ | |
| ) | |
| return f'<div class="draft-board">{"".join(sections)}</div>' | |
| def next_prompt(state: dict[str, Any]) -> str: | |
| if not state["initialized"]: | |
| return "Initialize the draft to begin." | |
| step = current_step(state) | |
| if step is None: | |
| return "Draft complete. Review the final picks, bans, and backend insight log." | |
| return ( | |
| f"Step {step.number}/20 - {step.phase}: {step.action} {step.slot} for " | |
| f"{team_label(state, step.team)}" | |
| ) | |
| def heroes_by_team_and_action( | |
| state: dict[str, Any], team_key: str, action: str | |
| ) -> list[str]: | |
| return [ | |
| item["hero"] | |
| for item in state["history"] | |
| if item["team"] == team_key and item["action"] == action | |
| ] | |
| def team_draft_state(state: dict[str, Any], team_key: str) -> dict[str, Any]: | |
| return { | |
| "team": state["team_names"][team_key], | |
| "side": state["sides"][team_key], | |
| "picks": heroes_by_team_and_action(state, team_key, "Pick"), | |
| "bans": heroes_by_team_and_action(state, team_key, "Ban"), | |
| "player_ids": state.get("player_ids", {}).get(team_key, []), | |
| } | |
| def make_backend_payload(state: dict[str, Any]) -> dict[str, Any]: | |
| user_key = state["user_team"] | |
| opponent_key = state["opponent_team"] | |
| return { | |
| "event": "draft_state_update", | |
| "draft_position": len(state["history"]), | |
| "complete": current_step(state) is None, | |
| "you": team_draft_state(state, user_key), | |
| "opponent": { | |
| **team_draft_state(state, opponent_key), | |
| "athlete": state["athlete"] or "unknown", | |
| }, | |
| "opendota_api_token_configured": bool(state.get("api_token_configured")), | |
| } | |
| def sync_backend_payload(state: dict[str, Any]) -> dict[str, Any]: | |
| state["last_payload"] = make_backend_payload(state) if state.get("initialized") else {} | |
| return state | |
| def insight_marker(position: int) -> str: | |
| return f"<!-- draft-position:{position} -->" | |
| def mark_insight(position: int, text: str) -> str: | |
| return f"{insight_marker(position)}\n{text}" | |
| def remove_insights_for_position(state: dict[str, Any], position: int) -> None: | |
| marker = insight_marker(position) | |
| insights = state.get("insights", []) | |
| marked = [insight for insight in insights if insight.startswith(marker)] | |
| if marked: | |
| state["insights"] = [ | |
| insight for insight in insights if not insight.startswith(marker) | |
| ] | |
| return | |
| remove_count = 1 | |
| if recommendation_request_from_state(state) is not None: | |
| remove_count += 1 | |
| del insights[:remove_count] | |
| def hero_key(hero: str) -> str: | |
| return "".join(char for char in hero.lower() if char.isalnum()) | |
| def hero_is_unavailable(state: dict[str, Any], hero: str) -> bool: | |
| normalized = hero_key(hero) | |
| return any( | |
| hero_key(item.get("hero", "")) == normalized | |
| for item in state.get("history", []) | |
| ) | |
| def placeholder_insight(payload: dict[str, Any]) -> str: | |
| opponent = payload["opponent"] | |
| athlete = opponent["athlete"] | |
| athlete_text = ( | |
| f" for athlete `{athlete}`" | |
| if athlete and athlete != "unknown" | |
| else "" | |
| ) | |
| return ( | |
| f"Queued backend lookup for the current draft state{athlete_text}.\n\n" | |
| "Backend draft context:\n" | |
| f"- Your picks: {', '.join(payload['you']['picks']) or 'none'}.\n" | |
| f"- Your bans: {', '.join(payload['you']['bans']) or 'none'}.\n" | |
| f"- Opponent picks: {', '.join(opponent['picks']) or 'none'}.\n" | |
| f"- Opponent bans: {', '.join(opponent['bans']) or 'none'}.\n\n" | |
| "Temporary UI guidance: treat this panel as the actionable recommendation surface " | |
| "that will be replaced by generated insights." | |
| ) | |
| def recommendation_request_from_state(state: dict[str, Any]) -> DraftRecommendationRequest | None: | |
| step = current_step(state) | |
| if not step or step.team != state["user_team"]: | |
| return None | |
| user_key = state["user_team"] | |
| opponent_key = state["opponent_team"] | |
| unavailable_heroes = [ | |
| item["hero"] | |
| for item in state["history"] | |
| if item.get("hero") | |
| ] | |
| return DraftRecommendationRequest( | |
| action=step.action, # type: ignore[arg-type] | |
| step_number=step.number, | |
| phase=step.phase, | |
| slot=step.slot, | |
| you=DraftTeam( | |
| name=state["team_names"][user_key], | |
| side=state["sides"][user_key], | |
| picks=heroes_by_team_and_action(state, user_key, "Pick"), | |
| bans=heroes_by_team_and_action(state, user_key, "Ban"), | |
| player_ids=state.get("player_ids", {}).get(user_key, []), | |
| ), | |
| opponent=DraftTeam( | |
| name=state["team_names"][opponent_key], | |
| side=state["sides"][opponent_key], | |
| picks=heroes_by_team_and_action(state, opponent_key, "Pick"), | |
| bans=heroes_by_team_and_action(state, opponent_key, "Ban"), | |
| player_ids=state.get("player_ids", {}).get(opponent_key, []), | |
| ), | |
| unavailable_heroes=unavailable_heroes, | |
| opendota_api_key=state.get("api_token", ""), | |
| ) | |
| async def add_recommendation_if_user_turn(state: dict[str, Any]) -> dict[str, Any]: | |
| request = recommendation_request_from_state(state) | |
| if not request: | |
| return state | |
| recommendation = await recommend_draft_action(request) | |
| state["insights"].insert(0, mark_insight(len(state["history"]), recommendation)) | |
| return state | |
| def render_outputs(state: dict[str, Any], selected_hero: str | None = ""): | |
| prompt = next_prompt(state) | |
| board = build_board(state) | |
| timeline = build_timeline(state) if state["initialized"] else [] | |
| insight = "\n\n---\n\n".join(state["insights"]) or ( | |
| "Backend updates will appear here as soon as you submit a pick or ban." | |
| ) | |
| payload = state.get("last_payload", {}) | |
| submit_enabled = bool(state["initialized"] and current_step(state)) | |
| return ( | |
| state, | |
| prompt, | |
| gr.update() if selected_hero is None else selected_hero, | |
| board, | |
| timeline, | |
| insight, | |
| payload, | |
| gr.update(interactive=submit_enabled), | |
| gr.update(interactive=bool(state["history"])), | |
| ) | |
| THINKING_INSIGHT = "⏳ _Analyzing the current draft and generating a recommendation…_" | |
| async def stream_with_recommendation( | |
| state: dict[str, Any], | |
| selected_hero: str = "", | |
| extra: tuple[Any, ...] = (), | |
| ): | |
| """Render the draft surface immediately, then stream in the recommendation. | |
| The agent call (OpenAI + OpenDota MCP) can take several seconds, so we yield | |
| the updated board/timeline first and re-yield once the recommendation lands | |
| instead of blocking the UI on the whole agent loop. | |
| """ | |
| pending = recommendation_request_from_state(state) is not None | |
| snapshot = list(render_outputs(state, selected_hero)) | |
| if pending: | |
| existing = state["insights"] | |
| snapshot[5] = ( | |
| f"{THINKING_INSIGHT}\n\n---\n\n" + "\n\n---\n\n".join(existing) | |
| if existing | |
| else THINKING_INSIGHT | |
| ) | |
| yield (*snapshot, *extra) | |
| if pending: | |
| await add_recommendation_if_user_turn(state) | |
| yield (*render_outputs(state, None), *extra) | |
| async def start_draft( | |
| api_token: str, | |
| user_team_name: str, | |
| opponent_team_name: str, | |
| user_side: str, | |
| first_drafter: str, | |
| opponent_athlete: str, | |
| user_player_ids: str, | |
| opponent_player_ids: str, | |
| existing_state: dict[str, Any], | |
| ): | |
| seeded = dict(existing_state) if existing_state else new_state() | |
| clean_token = (api_token or "").strip() or os.getenv("OPENDOTA_API_KEY", "") | |
| seeded["api_token"] = clean_token | |
| seeded["api_token_configured"] = bool(clean_token) | |
| state = build_state( | |
| user_team_name, | |
| opponent_team_name, | |
| user_side, | |
| first_drafter, | |
| opponent_athlete, | |
| user_player_ids, | |
| opponent_player_ids, | |
| seeded, | |
| ) | |
| sync_backend_payload(state) | |
| async for chunk in stream_with_recommendation( | |
| state, | |
| extra=(gr.update(visible=False), gr.update(visible=True)), | |
| ): | |
| yield chunk | |
| async def submit_hero(hero: str, state: dict[str, Any]): | |
| if not state or not state.get("initialized"): | |
| state = preserve_token(state, new_state()) | |
| yield render_outputs(state, hero.strip()) | |
| return | |
| step = current_step(state) | |
| clean_hero = hero.strip() | |
| if step is None or not clean_hero: | |
| yield render_outputs(state, clean_hero) | |
| return | |
| if hero_is_unavailable(state, clean_hero): | |
| yield render_outputs(state, clean_hero) | |
| return | |
| entry = { | |
| "step": step.number, | |
| "phase": step.phase, | |
| "action": step.action, | |
| "slot": str(step.slot), | |
| "team": step.team, | |
| "hero": clean_hero, | |
| } | |
| state["history"].append(entry) | |
| sync_backend_payload(state) | |
| state["insights"].insert( | |
| 0, | |
| mark_insight(len(state["history"]), placeholder_insight(state["last_payload"])), | |
| ) | |
| async for chunk in stream_with_recommendation(state): | |
| yield chunk | |
| async def undo_last(state: dict[str, Any]): | |
| if not state: | |
| state = new_state() | |
| if not state.get("history"): | |
| yield render_outputs(state) | |
| return | |
| undone_position = len(state["history"]) | |
| remove_insights_for_position(state, undone_position) | |
| state["history"].pop() | |
| sync_backend_payload(state) | |
| async for chunk in stream_with_recommendation(state): | |
| yield chunk | |
| async def reset_draft(state: dict[str, Any]): | |
| if not state or not state.get("initialized"): | |
| state = preserve_token(state, new_state()) | |
| sync_backend_payload(state) | |
| yield render_outputs(state) | |
| return | |
| state["history"] = [] | |
| state["insights"] = [] | |
| sync_backend_payload(state) | |
| async for chunk in stream_with_recommendation(state): | |
| yield chunk | |
| CSS = """ | |
| .gradio-container { | |
| max-width: 1240px !important; | |
| } | |
| /* Page header */ | |
| .app-header h1 { | |
| font-size: 28px; | |
| font-weight: 700; | |
| line-height: 1.2; | |
| margin: 0 0 4px; | |
| color: var(--body-text-color); | |
| } | |
| .app-header p { | |
| margin: 0; | |
| font-size: 15px; | |
| color: var(--body-text-color-subdued); | |
| } | |
| /* Setup page: centered, narrow */ | |
| .setup-page { | |
| max-width: 820px; | |
| margin: 24px auto; | |
| } | |
| /* Section subtitle */ | |
| .section-title h3 { | |
| margin: 0 0 2px; | |
| font-size: 16px; | |
| font-weight: 600; | |
| color: var(--body-text-color); | |
| } | |
| /* Current-step banner */ | |
| .action-banner { | |
| border: 1px solid var(--border-color-primary); | |
| border-left: 4px solid var(--primary-500); | |
| border-radius: var(--radius-lg); | |
| background: var(--background-fill-secondary); | |
| padding: 14px 18px !important; | |
| } | |
| .action-banner p { | |
| margin: 0; | |
| font-size: 16px; | |
| font-weight: 600; | |
| color: var(--body-text-color); | |
| } | |
| /* Draft board */ | |
| .draft-board { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 16px; | |
| } | |
| .draft-panel { | |
| border: 1px solid var(--border-color-primary); | |
| border-radius: var(--radius-lg); | |
| padding: 16px; | |
| background: var(--background-fill-secondary); | |
| } | |
| .draft-panel.you { | |
| border-top: 3px solid var(--primary-500); | |
| } | |
| .draft-panel.opponent { | |
| border-top: 3px solid var(--neutral-400); | |
| } | |
| .draft-panel h3 { | |
| font-size: 15px; | |
| font-weight: 700; | |
| margin: 0 0 8px; | |
| color: var(--body-text-color); | |
| } | |
| .slot-label { | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 0.05em; | |
| text-transform: uppercase; | |
| color: var(--body-text-color-subdued); | |
| margin: 12px 0 6px; | |
| } | |
| .slot-grid { | |
| display: grid; | |
| grid-template-columns: repeat(5, minmax(0, 1fr)); | |
| gap: 6px; | |
| } | |
| .slot-grid span { | |
| min-height: 44px; | |
| border-radius: var(--radius-sm); | |
| padding: 6px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| text-align: center; | |
| font-size: 12px; | |
| line-height: 1.2; | |
| overflow-wrap: anywhere; | |
| color: var(--body-text-color); | |
| background: var(--background-fill-primary); | |
| border: 1px dashed var(--border-color-primary); | |
| } | |
| .slot-grid span.filled { | |
| border-style: solid; | |
| font-weight: 600; | |
| } | |
| .slot-grid.picks span.filled { | |
| background: rgba(34, 197, 94, 0.16); | |
| border-color: rgba(34, 197, 94, 0.55); | |
| } | |
| .slot-grid.bans span.filled { | |
| background: rgba(239, 68, 68, 0.14); | |
| border-color: rgba(239, 68, 68, 0.5); | |
| } | |
| @media (max-width: 760px) { | |
| .draft-board { | |
| grid-template-columns: 1fr; | |
| } | |
| .slot-grid { | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| } | |
| } | |
| """ | |
| THEME = gr.themes.Soft( | |
| primary_hue="red", | |
| neutral_hue="slate", | |
| font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"], | |
| ) | |
| warm_static_dota_data(HERO_SUGGESTIONS) | |
| initialize_opendota_mcp_server() | |
| with gr.Blocks(title="Dota 2 Draft Assistant") as demo: | |
| state = gr.State(new_state()) | |
| with gr.Column(elem_classes=["setup-page"]) as setup_page: | |
| gr.HTML( | |
| """ | |
| <div class="app-header"> | |
| <h1>⚔️ Dota 2 Draft Assistant</h1> | |
| <p>Set up your Captains Mode draft once, then get live pick & ban recommendations.</p> | |
| </div> | |
| """ | |
| ) | |
| with gr.Group(): | |
| gr.Markdown("### Teams & draft order", elem_classes=["section-title"]) | |
| with gr.Row(): | |
| user_team_name = gr.Textbox( | |
| label="Your team", | |
| value="Your Team", | |
| max_lines=1, | |
| ) | |
| opponent_team_name = gr.Textbox( | |
| label="Opponent team", | |
| value="Opponent", | |
| max_lines=1, | |
| ) | |
| with gr.Row(): | |
| user_side = gr.Radio( | |
| ["Radiant", "Dire"], | |
| value="Radiant", | |
| label="Your side", | |
| ) | |
| first_drafter = gr.Radio( | |
| ["You draft first", "Opponent drafts first"], | |
| value="You draft first", | |
| label="Draft order", | |
| ) | |
| opponent_athlete = gr.Textbox( | |
| label="Opponent esports athlete", | |
| placeholder="Optional player handle for later stat lookup", | |
| max_lines=1, | |
| ) | |
| with gr.Row(): | |
| user_player_ids = gr.Textbox( | |
| label="Your Steam player IDs", | |
| placeholder="Optional, comma-separated for future player stat weighting", | |
| max_lines=2, | |
| ) | |
| opponent_player_ids = gr.Textbox( | |
| label="Opponent Steam player IDs", | |
| placeholder="Optional, comma-separated for future player stat weighting", | |
| max_lines=2, | |
| ) | |
| with gr.Group(): | |
| gr.Markdown("### OpenDota API token", elem_classes=["section-title"]) | |
| opendota_token = gr.Textbox( | |
| label="OpenDota API token", | |
| type="password", | |
| max_lines=1, | |
| placeholder="Optional — leave blank to use default limits", | |
| ) | |
| gr.Markdown( | |
| "Detected from the environment — leave blank to use it." | |
| if bool(os.getenv("OPENDOTA_API_KEY", "")) | |
| else "Optional. Adds higher OpenDota rate limits if provided." | |
| ) | |
| start_button = gr.Button("Start draft", variant="primary", size="lg") | |
| with gr.Column(visible=False) as draft_page: | |
| gr.HTML( | |
| """ | |
| <div class="app-header"> | |
| <h1>⚔️ Dota 2 Draft Assistant</h1> | |
| <p>Record each pick and ban — recommendations refresh on your turn.</p> | |
| </div> | |
| """ | |
| ) | |
| current_action = gr.Markdown( | |
| "Initialize the draft to begin.", | |
| elem_classes=["action-banner"], | |
| ) | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=5): | |
| with gr.Group(): | |
| with gr.Row(): | |
| hero_input = gr.Dropdown( | |
| choices=HERO_SUGGESTIONS, | |
| allow_custom_value=True, | |
| filterable=True, | |
| label="Hero", | |
| value="", | |
| scale=3, | |
| ) | |
| submit_button = gr.Button( | |
| "Submit step", | |
| variant="primary", | |
| interactive=False, | |
| scale=1, | |
| ) | |
| with gr.Row(): | |
| undo_button = gr.Button("Undo last step", interactive=False) | |
| reset_button = gr.Button("Reset picks & bans") | |
| board = gr.HTML(build_board(new_state())) | |
| timeline = gr.Dataframe( | |
| headers=["#", "Phase", "Action", "Team", "Hero", "Status"], | |
| datatype=["str", "str", "str", "str", "str", "str"], | |
| label="Draft path", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| with gr.Column(scale=4): | |
| gr.Markdown("### Recommendation", elem_classes=["section-title"]) | |
| insight_box = gr.Markdown( | |
| "Backend updates will appear here as soon as you submit a pick or ban." | |
| ) | |
| with gr.Accordion("Backend payload", open=False): | |
| payload_box = gr.JSON() | |
| outputs = [ | |
| state, | |
| current_action, | |
| hero_input, | |
| board, | |
| timeline, | |
| insight_box, | |
| payload_box, | |
| submit_button, | |
| undo_button, | |
| ] | |
| start_button.click( | |
| start_draft, | |
| inputs=[ | |
| opendota_token, | |
| user_team_name, | |
| opponent_team_name, | |
| user_side, | |
| first_drafter, | |
| opponent_athlete, | |
| user_player_ids, | |
| opponent_player_ids, | |
| state, | |
| ], | |
| outputs=outputs + [setup_page, draft_page], | |
| ) | |
| submit_button.click(submit_hero, inputs=[hero_input, state], outputs=outputs) | |
| undo_button.click(undo_last, inputs=state, outputs=outputs) | |
| reset_button.click(reset_draft, inputs=state, outputs=outputs) | |
| if __name__ == "__main__": | |
| demo.launch(theme=THEME, css=CSS) | |