From 4efb8bf36a21d87f5d1797d5bd101e4a6dc3913f Mon Sep 17 00:00:00 2001 From: OchnikBartek Date: Mon, 1 Jun 2026 15:44:00 +0200 Subject: [PATCH 1/5] feat: add AntV diagrams + Leaflet maps --- fastapi_gen/cli.py | 9 ++ fastapi_gen/config.py | 2 + fastapi_gen/prompts.py | 38 ++++++ template/VARIABLES.md | 1 + template/cookiecutter.json | 1 + template/hooks/post_gen_project.py | 9 ++ .../{{cookiecutter.project_slug}}/Makefile | 16 ++- .../backend/.env.example | 14 ++ .../backend/app/agents/assistant.py | 43 ++++++ .../backend/app/agents/crewai_assistant.py | 47 ++++++- .../app/agents/deepagents_assistant.py | 41 +++++- .../backend/app/agents/langchain_assistant.py | 39 ++++++ .../backend/app/agents/langgraph_assistant.py | 39 ++++++ .../backend/app/agents/prompts.py | 51 ++++++- .../app/agents/pydantic_deep_assistant.py | 56 +++++++- .../backend/app/agents/tools/__init__.py | 6 + .../backend/app/agents/tools/antv_chart.py | 128 ++++++++++++++++++ .../backend/app/agents/tools/map_tool.py | 102 ++++++++++++++ .../backend/app/core/config.py | 14 ++ .../backend/pyproject.toml | 13 ++ .../docker-compose.dev.yml | 38 ++++++ .../docker-compose.yml | 38 ++++++ .../frontend/package.json | 7 + .../src/components/chat/map-leaflet.tsx | 72 ++++++++++ .../src/components/chat/map-message.tsx | 39 ++++++ .../src/components/chat/tool-call-card.tsx | 124 ++++++++++++++++- .../frontend/src/types/chat.ts | 20 +++ tests/test_template_integration.py | 113 ++++++++++++++++ 28 files changed, 1109 insertions(+), 11 deletions(-) create mode 100644 template/{{cookiecutter.project_slug}}/backend/app/agents/tools/antv_chart.py create mode 100644 template/{{cookiecutter.project_slug}}/backend/app/agents/tools/map_tool.py create mode 100644 template/{{cookiecutter.project_slug}}/frontend/src/components/chat/map-leaflet.tsx create mode 100644 template/{{cookiecutter.project_slug}}/frontend/src/components/chat/map-message.tsx diff --git a/fastapi_gen/cli.py b/fastapi_gen/cli.py index 5de44f98..ca3cabbc 100644 --- a/fastapi_gen/cli.py +++ b/fastapi_gen/cli.py @@ -471,6 +471,13 @@ def new(output: Path | None, no_input: bool, name: str | None, minimal: bool) -> default=False, help="Enable the chart-generation tool for AI agents (line/bar/pie/area/scatter)", ) +@click.option( + "--antv-charts", + is_flag=True, + default=False, + help="Enable AntV advanced-diagram tools (flowchart, mind-map, sankey, ...) via an " + "mcp-server-chart sidecar, plus an interactive Leaflet/OpenStreetMap map tool", +) @click.option("--session-management", is_flag=True, help="Enable session management") @click.option( "--reverse-proxy", @@ -746,6 +753,7 @@ def create( web_search: bool, web_fetch: bool, charts: bool, + antv_charts: bool, session_management: bool, reverse_proxy: str, kubernetes: bool, @@ -1174,6 +1182,7 @@ def create( enable_web_search=web_search, enable_web_fetch=web_fetch, enable_charts=charts, + enable_antv_charts=antv_charts, enable_session_management=session_management, reverse_proxy=_rp_map[reverse_proxy], enable_kubernetes=kubernetes, diff --git a/fastapi_gen/config.py b/fastapi_gen/config.py index a8b3fafe..aa79d044 100644 --- a/fastapi_gen/config.py +++ b/fastapi_gen/config.py @@ -325,6 +325,7 @@ class ProjectConfig(BaseModel): enable_web_search: bool = False enable_web_fetch: bool = False enable_charts: bool = False + enable_antv_charts: bool = False use_telegram: bool = False use_slack: bool = False enable_cors: bool = True @@ -811,6 +812,7 @@ def to_cookiecutter_context(self) -> dict[str, Any]: ), "enable_charts": self.enable_charts, "charts_channel_png": self.enable_charts and (self.use_slack or self.use_telegram), + "enable_antv_charts": self.enable_antv_charts, "enable_webhooks": self.enable_webhooks, # Legacy fixed values (WebSocket always uses JWT) "websocket_auth": "jwt", diff --git a/fastapi_gen/prompts.py b/fastapi_gen/prompts.py index 604a1e52..138959d9 100644 --- a/fastapi_gen/prompts.py +++ b/fastapi_gen/prompts.py @@ -896,6 +896,34 @@ def prompt_charts() -> bool: ) +def prompt_antv_charts() -> bool: + """Prompt for the AntV advanced-diagram tools + interactive map tool. + + Returns: + Whether the AntV / map tools are enabled. + """ + console.print() + console.print("[bold cyan]AntV Diagrams & Maps[/]") + console.print( + "Adds an interactive map tool (Leaflet/OpenStreetMap, works out of the " + "box) plus AntV advanced-diagram tools (flowchart, mind-map, org-chart, " + "sankey, ...) served by an mcp-server-chart Docker sidecar. The diagrams " + "need the sidecar running (docker compose --profile antv up -d); maps work " + "without it." + ) + console.print() + + return cast( + bool, + _check_cancelled( + questionary.confirm( + "Enable AntV diagram + map tools for the agent?", + default=False, + ).ask() + ), + ) + + def prompt_langsmith() -> bool: """Prompt for LangSmith observability.""" return cast( @@ -1405,6 +1433,7 @@ def run_interactive_prompts() -> ProjectConfig: "enable_web_search": False, "enable_web_fetch": False, "enable_charts": False, + "enable_antv_charts": False, "rag_features": RAGFeatures(), "orm_type": OrmType.SQLALCHEMY, "sandbox_backend": "state", @@ -1563,6 +1592,12 @@ def step_charts() -> None: else: state["enable_charts"] = prompt_charts() + def step_antv_charts() -> None: + if state["ai_framework"] == AIFrameworkType.NONE: + state["enable_antv_charts"] = False + else: + state["enable_antv_charts"] = prompt_antv_charts() + def step_langsmith() -> None: if state["ai_framework"] in ( AIFrameworkType.LANGCHAIN, @@ -1633,6 +1668,7 @@ def step_marketing() -> None: ("Web Search & Fetch", step_web_capabilities), ("RAG", step_rag_config), ("Chart Tool", step_charts), + ("AntV Diagrams & Maps", step_antv_charts), ("LangSmith", step_langsmith), ("Messaging Channels", step_channels), ("Teams & Billing", step_teams_billing), @@ -1679,6 +1715,7 @@ def step_marketing() -> None: enable_web_search = state["enable_web_search"] enable_web_fetch = state["enable_web_fetch"] enable_charts = state["enable_charts"] + enable_antv_charts = state["enable_antv_charts"] rag_features = state["rag_features"] enable_langsmith = state["enable_langsmith"] use_telegram = state["use_telegram"] @@ -1721,6 +1758,7 @@ def step_marketing() -> None: enable_web_search=enable_web_search, enable_web_fetch=enable_web_fetch, enable_charts=enable_charts, + enable_antv_charts=enable_antv_charts, use_telegram=use_telegram, use_slack=use_slack, rate_limit_requests=rate_limit_requests, diff --git a/template/VARIABLES.md b/template/VARIABLES.md index 9704e751..03009819 100644 --- a/template/VARIABLES.md +++ b/template/VARIABLES.md @@ -286,6 +286,7 @@ These variables are set automatically by the generator. | `web_fetch_tool` | bool | `false` | Computed: portable `fetch_url` tool is generated | `enable_web_fetch` and framework is LangChain/LangGraph/CrewAI/DeepAgents | | `enable_charts` | bool | `false` | Enable the chart-generation tool (line/bar/pie/area/scatter); interactive in web chat, PNG on Slack/Telegram | Requires an AI framework | | `charts_channel_png` | bool | `false` | Computed: render charts to PNG for messaging channels | `enable_charts` and (`use_slack` or `use_telegram`) | +| `enable_antv_charts` | bool | `false` | Adds the interactive `create_map` tool (Leaflet/OpenStreetMap, works out of the box) plus AntV advanced-diagram tools (flowchart, mind-map, org-chart, sankey, ...) via an `mcp-server-chart` Docker sidecar. Wired into all 5 frameworks. The AntV diagrams need the sidecar running (`docker compose --profile antv up -d`) and `ENABLE_ANTV_CHARTS=true` at runtime; maps work without it. Not wired into the production compose (internal network blocks AntV's render backend — self-host GPT-Vis-SSR for prod) | Requires an AI framework | **Notes:** diff --git a/template/cookiecutter.json b/template/cookiecutter.json index 3483fa75..c9edd29d 100644 --- a/template/cookiecutter.json +++ b/template/cookiecutter.json @@ -91,6 +91,7 @@ "web_fetch_tool": false, "enable_charts": false, "charts_channel_png": false, + "enable_antv_charts": false, "enable_webhooks": false, "websocket_auth": "jwt", "websocket_auth_jwt": true, diff --git a/template/hooks/post_gen_project.py b/template/hooks/post_gen_project.py index 7e805996..5eb47003 100644 --- a/template/hooks/post_gen_project.py +++ b/template/hooks/post_gen_project.py @@ -48,6 +48,7 @@ web_fetch_tool = "{{ cookiecutter.web_fetch_tool }}" == "True" enable_charts = "{{ cookiecutter.enable_charts }}" == "True" charts_channel_png = "{{ cookiecutter.charts_channel_png }}" == "True" +enable_antv_charts = "{{ cookiecutter.enable_antv_charts }}" == "True" use_pydantic_deep = "{{ cookiecutter.use_pydantic_deep }}" == "True" use_telegram = "{{ cookiecutter.use_telegram }}" == "True" use_slack = "{{ cookiecutter.use_slack }}" == "True" @@ -143,6 +144,14 @@ def remove_dir(path: str) -> None: elif not charts_channel_png: # Chart tool enabled but no Slack/Telegram — PNG renderer not needed. remove_file(os.path.join(backend_app, "agents", "tools", "chart_render.py")) +if not enable_antv_charts: + # AntV diagram MCP client + the create_map tool + its Leaflet renderer. + remove_file(os.path.join(backend_app, "agents", "tools", "antv_chart.py")) + remove_file(os.path.join(backend_app, "agents", "tools", "map_tool.py")) + if use_frontend: + frontend_src = os.path.join(os.getcwd(), "frontend", "src") + remove_file(os.path.join(frontend_src, "components", "chat", "map-leaflet.tsx")) + remove_file(os.path.join(frontend_src, "components", "chat", "map-message.tsx")) # --- No-AI mode: remove all AI/chat/conversation files --- if not use_ai: diff --git a/template/{{cookiecutter.project_slug}}/Makefile b/template/{{cookiecutter.project_slug}}/Makefile index 4db77ec2..1f52c186 100644 --- a/template/{{cookiecutter.project_slug}}/Makefile +++ b/template/{{cookiecutter.project_slug}}/Makefile @@ -23,6 +23,12 @@ endef {%- endif %} {%- if cookiecutter.enable_docker %} +{%- if cookiecutter.enable_antv_charts %} + +# Fold the opt-in "antv" profile into `make dev` so the diagram sidecar starts +# automatically alongside db/app. The agent connects only when ENABLE_ANTV_CHARTS=true. +COMPOSE_DEV_PROFILES := --profile antv +{%- endif %} # === Local dev: build → up → migrate === # Idempotent — re-run anytime. Migrations are no-ops when already at head; @@ -32,12 +38,12 @@ dev: @echo "▶ Building backend image…" docker compose -f docker-compose.dev.yml build app @echo "▶ Starting services…" - @if ! docker compose -f docker-compose.dev.yml up -d; then \ + @if ! docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) up -d; then \ echo ""; \ echo "⚠ First start failed. Tearing down stale containers and retrying once…"; \ echo " (volumes preserved — DB data is safe; use 'make clean' for a full wipe)"; \ docker compose -f docker-compose.dev.yml down --remove-orphans; \ - docker compose -f docker-compose.dev.yml up -d; \ + docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) up -d; \ fi {%- if cookiecutter.use_postgresql or cookiecutter.use_sqlite %} $(call _wait_for_db,docker-compose.dev.yml) @@ -85,18 +91,18 @@ bootstrap: dev {%- endif %} dev-down: - docker compose -f docker-compose.dev.yml down + docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) down # Full wipe — containers, networks, AND volumes. Use after a corrupted state # (e.g. detached networks, port conflicts that left orphans). DESTROYS DB data. docker-clean: @echo "▶ Removing containers, networks, AND volumes for the dev stack…" @echo " ⚠️ This deletes all local DB data and uploaded files." - docker compose -f docker-compose.dev.yml down -v --remove-orphans + docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) down -v --remove-orphans @echo "✅ Cleaned. Run 'make dev' to start fresh." dev-logs: - docker compose -f docker-compose.dev.yml logs -f + docker compose -f docker-compose.dev.yml $(COMPOSE_DEV_PROFILES) logs -f dev-rebuild: docker compose -f docker-compose.dev.yml build --no-cache app diff --git a/template/{{cookiecutter.project_slug}}/backend/.env.example b/template/{{cookiecutter.project_slug}}/backend/.env.example index 77bcd221..0fc8c674 100644 --- a/template/{{cookiecutter.project_slug}}/backend/.env.example +++ b/template/{{cookiecutter.project_slug}}/backend/.env.example @@ -238,6 +238,20 @@ AI_THINKING_EFFORT=medium # low, medium, high # Get your API key at https://tavily.com TAVILY_API_KEY= {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + +# === AntV charts (advanced diagrams via mcp-server-chart sidecar) === +# Opt-in. The create_map tool works without this; only the AntV diagram tools +# need the sidecar, which `make dev` starts automatically. +ENABLE_ANTV_CHARTS=false +# MCP endpoint of the antvis-chart sidecar (default matches docker-compose). +ANTV_MCP_URL=http://antvis-chart:1122/mcp +# Optional self-hosted GPT-Vis render backend (empty = AntV's public service). +ANTV_VIS_REQUEST_SERVER= +# Comma-separated AntV tools to disable. Leave empty to use the sidecar's +# default (drops basic charts that overlap create_chart, and China-only maps). +ANTV_DISABLED_TOOLS= +{%- endif %} {%- if cookiecutter.enable_langsmith %} # === LangSmith Observability === diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py index a48f043c..118887df 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py @@ -54,6 +54,10 @@ {%- if cookiecutter.enable_charts %} from app.agents.tools.chart_tool import create_chart {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +from app.agents.tools.antv_chart import get_antv_toolset +from app.agents.tools.map_tool import MapMarker, create_map +{%- endif %} from app.core.config import settings logger = logging.getLogger(__name__) @@ -203,11 +207,21 @@ def _create_agent(self) -> Agent[Deps, str]: if self.thinking_effort: model_settings["openai_reasoning_summary"] = "auto" # type: ignore[typeddict-unknown-key] # ty: ignore[invalid-key] +{%- if cookiecutter.enable_antv_charts %} + + # None when AntV is disabled or the sidecar is unavailable. + antv_toolset = get_antv_toolset() + toolsets = [antv_toolset] if antv_toolset is not None else [] +{%- endif %} + agent = Agent[Deps, str]( model=model, model_settings=model_settings, system_prompt=self.system_prompt, capabilities=capabilities, +{%- if cookiecutter.enable_antv_charts %} + toolsets=toolsets, +{%- endif %} ) self._register_tools(agent) @@ -293,6 +307,35 @@ def create_chart_tool( style=style, ) {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + @agent.tool_plain + def create_map_tool( + title: str, + markers: list[MapMarker], + center: list[float] | None = None, + zoom: int | None = None, + ) -> str: + """Create an interactive map to show places geographically for the user. + + Use whenever the user asks to show, map, or locate places. Provide + latitude/longitude for each marker from your own knowledge (e.g. + Warsaw ≈ 52.23, 21.01). Do not repeat the returned JSON — just briefly + describe the map you created. + + Args: + title: Short map title. + markers: One entry per place, each with lat, lng and a short label + (plus optional description and color). Must not be empty. + center: Optional [lat, lng] center (auto-fit to markers if omitted). + zoom: Optional zoom level 1-18 (mainly useful for a single marker). + """ + return create_map( + title=title, + markers=[m.model_dump() for m in markers], + center=center, + zoom=zoom, + ) +{%- endif %} @staticmethod diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/crewai_assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/crewai_assistant.py index 3d96d30d..4790805f 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/crewai_assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/crewai_assistant.py @@ -300,6 +300,9 @@ def _default_config(self) -> CrewConfig: writer_tools = ["create_chart"] {%- else %} writer_tools = [] +{%- endif %} +{%- if cookiecutter.enable_antv_charts %} + writer_tools.append("create_map") {%- endif %} return CrewConfig( name="assistant_crew", @@ -445,7 +448,49 @@ def create_chart_tool( tool_map["create_chart"] = create_chart_tool {%- endif %} - return [tool_map[name] for name in agent_tools if name in tool_map] +{%- if cookiecutter.enable_antv_charts %} + from app.agents.tools.antv_chart import get_antv_crewai_tools + from app.agents.tools.map_tool import MapMarker, create_map + from langchain_core.tools import tool as lc_tool_map + + @lc_tool_map + def create_map_tool( + title: str, + markers: list[MapMarker], + center: list[float] | None = None, + zoom: int | None = None, + ) -> str: + """Create an interactive map to show places geographically. + + Use whenever the user asks to show, map, or locate places. + Provide lat/lng for each marker from your own knowledge (e.g. + Warsaw ≈ 52.23, 21.01). Do not repeat the returned JSON — just + describe the map. + + Args: + title: Short map title. + markers: One entry per place, each with lat, lng and a short + label (plus optional description and color). Non-empty. + center: Optional [lat, lng] center (auto-fit if omitted). + zoom: Optional zoom level 1-18 (useful for a single marker). + """ + return create_map( + title=title, + markers=[m.model_dump() for m in markers], + center=center, + zoom=zoom, + ) + + tool_map["create_map"] = create_map_tool +{%- endif %} + resolved = [tool_map[name] for name in agent_tools if name in tool_map] +{%- if cookiecutter.enable_antv_charts %} + # AntV diagram tools from the MCP sidecar go to the visual agent + # (the writer, which also carries create_map/create_chart). + if "create_map" in agent_tools or "create_chart" in agent_tools: + resolved.extend(get_antv_crewai_tools()) +{%- endif %} + return resolved for agent_config in self.config.agents: agent = Agent( diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py index 4e4f5706..1d3add77 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py @@ -32,7 +32,7 @@ from deepagents.backends import StateBackend from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage, HumanMessage, SystemMessage -{%- if cookiecutter.enable_rag or cookiecutter.enable_web_search or cookiecutter.web_fetch_tool %} +{%- if cookiecutter.enable_rag or cookiecutter.enable_web_search or cookiecutter.web_fetch_tool or cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} from langchain_core.tools import tool {%- endif %} from langgraph.checkpoint.memory import MemorySaver @@ -69,6 +69,10 @@ {%- if cookiecutter.enable_charts %} from app.agents.tools.chart_tool import create_chart {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +from app.agents.tools.antv_chart import get_antv_langchain_tools +from app.agents.tools.map_tool import MapMarker, create_map +{%- endif %} from app.core.config import settings logger = logging.getLogger(__name__) @@ -196,6 +200,41 @@ def create_chart_tool( DEEPAGENTS_CUSTOM_TOOLS.append(create_chart_tool) {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + + +@tool +def create_map_tool( + title: str, + markers: list[MapMarker], + center: list[float] | None = None, + zoom: int | None = None, +) -> str: + """Create an interactive map to show places geographically for the user. + + Use whenever the user asks to show, map, or locate places. Provide + latitude/longitude for each marker from your own knowledge (e.g. Warsaw ≈ + 52.23, 21.01). Do not repeat the returned JSON — just briefly describe the + map you created. + + Args: + title: Short map title. + markers: One entry per place, each with lat, lng and a short label + (plus optional description and color). Must not be empty. + center: Optional [lat, lng] center (auto-fit to markers if omitted). + zoom: Optional zoom level 1-18 (mainly useful for a single marker). + """ + return create_map( + title=title, + markers=[m.model_dump() for m in markers], + center=center, + zoom=zoom, + ) + + +DEEPAGENTS_CUSTOM_TOOLS.append(create_map_tool) +DEEPAGENTS_CUSTOM_TOOLS.extend(get_antv_langchain_tools()) +{%- endif %} class AgentContext(TypedDict, total=False): diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/langchain_assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/langchain_assistant.py index 649a2a0e..d066d47c 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/langchain_assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/langchain_assistant.py @@ -43,6 +43,10 @@ {%- if cookiecutter.enable_charts %} from app.agents.tools.chart_tool import create_chart {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +from app.agents.tools.antv_chart import get_antv_langchain_tools +from app.agents.tools.map_tool import MapMarker, create_map +{%- endif %} from app.core.config import settings logger = logging.getLogger(__name__) @@ -181,6 +185,37 @@ def create_chart_tool( style=style, ) {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + + +@tool +def create_map_tool( + title: str, + markers: list[MapMarker], + center: list[float] | None = None, + zoom: int | None = None, +) -> str: + """Create an interactive map to show places geographically for the user. + + Use whenever the user asks to show, map, or locate places. Provide + latitude/longitude for each marker from your own knowledge (e.g. Warsaw ≈ + 52.23, 21.01). Do not repeat the returned JSON — just briefly describe the + map you created. + + Args: + title: Short map title. + markers: One entry per place, each with lat, lng and a short label + (plus optional description and color). Must not be empty. + center: Optional [lat, lng] center (auto-fit to markers if omitted). + zoom: Optional zoom level 1-18 (mainly useful for a single marker). + """ + return create_map( + title=title, + markers=[m.model_dump() for m in markers], + center=center, + zoom=zoom, + ) +{%- endif %} class LangChainAssistant: @@ -225,6 +260,10 @@ def __init__( {%- if cookiecutter.enable_charts %} self._tools.append(create_chart_tool) {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + self._tools.append(create_map_tool) + self._tools.extend(get_antv_langchain_tools()) +{%- endif %} def _create_agent(self) -> CompiledStateGraph: """Create and configure the LangChain agent.""" diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/langgraph_assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/langgraph_assistant.py index 3273dea1..c2770c59 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/langgraph_assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/langgraph_assistant.py @@ -48,6 +48,10 @@ {%- if cookiecutter.enable_charts %} from app.agents.tools.chart_tool import create_chart {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +from app.agents.tools.antv_chart import get_antv_langchain_tools +from app.agents.tools.map_tool import MapMarker, create_map +{%- endif %} from app.core.config import settings logger = logging.getLogger(__name__) @@ -197,6 +201,37 @@ def create_chart_tool( style=style, ) {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + + +@tool +def create_map_tool( + title: str, + markers: list[MapMarker], + center: list[float] | None = None, + zoom: int | None = None, +) -> str: + """Create an interactive map to show places geographically for the user. + + Use whenever the user asks to show, map, or locate places. Provide + latitude/longitude for each marker from your own knowledge (e.g. Warsaw ≈ + 52.23, 21.01). Do not repeat the returned JSON — just briefly describe the + map you created. + + Args: + title: Short map title. + markers: One entry per place, each with lat, lng and a short label + (plus optional description and color). Must not be empty. + center: Optional [lat, lng] center (auto-fit to markers if omitted). + zoom: Optional zoom level 1-18 (mainly useful for a single marker). + """ + return create_map( + title=title, + markers=[m.model_dump() for m in markers], + center=center, + zoom=zoom, + ) +{%- endif %} # List of all available tools @@ -213,6 +248,10 @@ def create_chart_tool( {%- if cookiecutter.enable_charts %} ALL_TOOLS.append(create_chart_tool) {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +ALL_TOOLS.append(create_map_tool) +ALL_TOOLS.extend(get_antv_langchain_tools()) +{%- endif %} class LangGraphAssistant: diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py index 6928c1bc..9baa95d4 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py @@ -38,7 +38,56 @@ - You may override styling via `style` (palette, grid, legend, axis labels, stacked) when the user requests a specific look. - After the tool returns, do not repeat the JSON. Briefly describe the chart - and its key takeaway in plain language.""" + and its key takeaway in plain language. +- Each chart is rendered to the user the moment you call the tool. A chart from + an earlier turn is already on screen — never re-create it. Only call + `create_chart` for what the user is asking for right now.""" +{%- endif %} +{%- if cookiecutter.enable_antv_charts %} + +DEFAULT_SYSTEM_PROMPT += """ + +# Maps +You can render an interactive map with the `create_map` tool. Use it whenever the +user wants to see places located geographically (cities, offices, routes, points +of interest). Supply latitude/longitude for each marker from your own knowledge +(e.g. Warsaw ≈ 52.23, 21.01; New York ≈ 40.71, -74.01). Give each marker a short +label, and an optional description. Don't repeat the JSON — briefly describe the +map you created. Each map is rendered to the user the moment you call +`create_map`; a map from an earlier turn is already on screen, so never re-create +it — only call `create_map` for the user's current request.""" + + +# AntV diagram tools attach only at runtime (when ENABLE_ANTV_CHARTS is set), so +# their guidance is gated the same way. `create_map` above is always available. +ANTV_CHART_GUIDANCE = """ + +# Advanced diagrams +Beyond `create_chart`, you have AntV `generate_*` tools for diagram types the +basic chart tool can't express — flowcharts, mind maps, org charts, sankey, +fishbone, network/graph, treemap, word clouds, radar, funnel, histogram, and +more. Use them when the user asks for that specific diagram, or when the +relationship is structural (process, hierarchy, flow) rather than a plain +numeric series. Prefer `create_chart` for ordinary line/bar/pie/area/scatter. + +Keep every node, label, and description short — a few words at most. Many of +these diagrams render nodes in a fixed-width box and truncate longer text with +an ellipsis ("…"), so write "Verify email", not "Send the verification email and +wait for confirmation". Put any detail in your reply, not in the node. + +After the tool returns an image, briefly describe it — don't paste the URL. The +image is shown to the user immediately; a diagram from an earlier turn is already +on screen, so never regenerate it — only call these tools for the current request.""" + + +def _antv_guidance() -> str: + """AntV diagram guidance — included only when the MCP sidecar is enabled.""" + from app.core.config import settings + + return ANTV_CHART_GUIDANCE if settings.ENABLE_ANTV_CHARTS else "" + + +DEFAULT_SYSTEM_PROMPT += _antv_guidance() {%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/pydantic_deep_assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/pydantic_deep_assistant.py index 811fde25..1450d388 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/pydantic_deep_assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/pydantic_deep_assistant.py @@ -24,6 +24,9 @@ PYDANTIC_DEEP_WEB_SEARCH : enable built-in web search (default: True) """ +{%- if cookiecutter.enable_antv_charts %} +import inspect +{%- endif %} import logging from typing import Any, TypedDict @@ -43,6 +46,10 @@ {%- if cookiecutter.enable_charts %} from app.agents.tools.chart_tool import create_chart {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +from app.agents.tools.antv_chart import get_antv_toolset +from app.agents.tools.map_tool import MapMarker, create_map +{%- endif %} from app.core.config import settings logger = logging.getLogger(__name__) @@ -125,6 +132,38 @@ def create_chart_tool( ) +{%- endif %} +{%- if cookiecutter.enable_antv_charts %} + + +def create_map_tool( + title: str, + markers: list[MapMarker], + center: list[float] | None = None, + zoom: int | None = None, +) -> str: + """Create an interactive map to show places geographically for the user. + + Use whenever the user asks to show, map, or locate places. Provide + latitude/longitude for each marker from your own knowledge (e.g. Warsaw ≈ + 52.23, 21.01). Do not repeat the returned JSON — just briefly describe the + map you created. + + Args: + title: Short map title. + markers: One entry per place, each with lat, lng and a short label + (plus optional description and color). Must not be empty. + center: Optional [lat, lng] center (auto-fit to markers if omitted). + zoom: Optional zoom level 1-18 (mainly useful for a single marker). + """ + return create_map( + title=title, + markers=[m.model_dump() for m in markers], + center=center, + zoom=zoom, + ) + + {%- endif %} @@ -222,7 +261,7 @@ def _build_agent_and_deps(self) -> tuple[Agent[DeepAgentDeps, str], DeepAgentDep self.conversation_id, ) -{%- if cookiecutter.enable_rag or cookiecutter.enable_charts %} +{%- if cookiecutter.enable_rag or cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} # Extra tools exposed as standalone pydantic-ai Tools. # pydantic-deep merges them into the agent automatically. from pydantic_ai import Tool as PAITool @@ -234,9 +273,21 @@ def _build_agent_and_deps(self) -> tuple[Agent[DeepAgentDeps, str], DeepAgentDep {%- if cookiecutter.enable_charts %} extra_tools.append(PAITool(create_chart_tool)) {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + extra_tools.append(PAITool(create_map_tool)) +{%- endif %} {%- else %} extra_tools: list[Any] = [] {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + + # Attach the AntV toolset only if this create_deep_agent build accepts a + # `toolsets` kwarg — guards against versions that don't expose it. + antv_toolset = get_antv_toolset() + antv_kwargs: dict[str, Any] = {} + if antv_toolset is not None and "toolsets" in inspect.signature(create_deep_agent).parameters: + antv_kwargs["toolsets"] = [antv_toolset] +{%- endif %} agent = create_deep_agent( model=model_str, @@ -268,6 +319,9 @@ def _build_agent_and_deps(self) -> tuple[Agent[DeepAgentDeps, str], DeepAgentDep thinking=self.thinking_effort or "auto", # Extra tools (e.g. RAG search) **({"tools": extra_tools} if extra_tools else {}), +{%- if cookiecutter.enable_antv_charts %} + **antv_kwargs, +{%- endif %} ) deps = DeepAgentDeps(backend=backend) diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/__init__.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/__init__.py index c772d027..648e4296 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/__init__.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/__init__.py @@ -14,6 +14,9 @@ {%- if cookiecutter.enable_charts %} from app.agents.tools.chart_tool import create_chart, parse_chart_spec {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +from app.agents.tools.map_tool import create_map +{%- endif %} {%- if cookiecutter.web_fetch_tool %} from app.agents.tools.fetch_url import fetch_url, fetch_url_sync {%- endif %} @@ -28,6 +31,9 @@ {%- if cookiecutter.enable_charts %} __all__ += ["create_chart", "parse_chart_spec"] {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +__all__ += ["create_map"] +{%- endif %} {%- if cookiecutter.web_fetch_tool %} __all__ += ["fetch_url", "fetch_url_sync"] {%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/antv_chart.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/antv_chart.py new file mode 100644 index 00000000..243572a8 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/antv_chart.py @@ -0,0 +1,128 @@ +{%- if cookiecutter.enable_antv_charts %} +"""AntV chart MCP server integration. + +The AntV ``mcp-server-chart`` sidecar exposes advanced diagram tools (flowchart, +mind-map, org-chart, sankey, fishbone, network, treemap, radar, funnel, ...) +over MCP (streamable HTTP). Each AI framework attaches MCP differently, so this +module exposes one loader per framework family. + +All loaders no-op (return ``None`` / ``[]``) when ``ENABLE_ANTV_CHARTS`` is +false, and degrade gracefully (log a warning + return empty) if the sidecar or +adapter is unavailable — so the agent always starts, with or without AntV. +""" + +import logging +from typing import Any + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +{%- if cookiecutter.use_langchain or cookiecutter.use_langgraph or cookiecutter.use_deepagents %} + + +def _run_sync(coro: Any) -> Any: + """Run an async coroutine from sync code, even inside a running event loop. + + MCP tool discovery is async, but agent construction in the LangChain-family + frameworks is sync. When no loop is running we use ``asyncio.run``; otherwise + we run the coroutine in a dedicated event loop on a worker thread so we never + touch a loop that is already running. + """ + import asyncio + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(lambda: asyncio.run(coro)).result() +{%- endif %} +{%- if cookiecutter.use_pydantic_ai or cookiecutter.use_pydantic_deep %} + + +def get_antv_toolset() -> Any | None: + """Return a PydanticAI MCP toolset for the AntV server, or None if disabled. + + PydanticAI connects lazily when the agent runs, so nothing async happens + here — the toolset is simply handed to ``Agent(toolsets=[...])``. + """ + if not settings.ENABLE_ANTV_CHARTS: + return None + try: + from pydantic_ai.mcp import MCPServerStreamableHTTP + + return MCPServerStreamableHTTP(settings.ANTV_MCP_URL) + except Exception as exc: + logger.warning("AntV MCP toolset unavailable, continuing without it: %s", exc) + return None +{%- endif %} +{%- if cookiecutter.use_langchain or cookiecutter.use_langgraph or cookiecutter.use_deepagents %} + + +_antv_langchain_tools: list[Any] | None = None + + +def get_antv_langchain_tools() -> list[Any]: + """Return AntV MCP tools as LangChain tools, or [] if disabled/unavailable. + + Discovery is a blocking MCP round-trip, and the assistant is rebuilt on every + request, so the result is memoized for the process lifetime — we discover once + (the sidecar is long-lived), not on every agent construction. + """ + global _antv_langchain_tools + if not settings.ENABLE_ANTV_CHARTS: + return [] + if _antv_langchain_tools is not None: + return _antv_langchain_tools + try: + from langchain_mcp_adapters.client import MultiServerMCPClient + + async def _load() -> list[Any]: + client = MultiServerMCPClient( + {"antv": {"url": settings.ANTV_MCP_URL, "transport": "streamable_http"}} + ) + return await client.get_tools() + + _antv_langchain_tools = _run_sync(_load()) + except Exception as exc: + logger.warning("AntV MCP tools unavailable, continuing without them: %s", exc) + _antv_langchain_tools = [] + return _antv_langchain_tools +{%- endif %} +{%- if cookiecutter.use_crewai %} + + +_antv_crewai_tools: list[Any] | None = None + + +def get_antv_crewai_tools() -> list[Any]: + """Return AntV MCP tools for CrewAI, or [] if disabled/unavailable. + + ``MCPServerAdapter`` is normally used as a context manager; for a long-lived + crew we start it once and keep the tools for the life of the process (the + sidecar is always up). The assistant is rebuilt on every request, so the + started adapter and its tools are memoized here — without this we would leak + a new adapter connection per request. Failures degrade to an empty list. + """ + global _antv_crewai_tools + if not settings.ENABLE_ANTV_CHARTS: + return [] + if _antv_crewai_tools is not None: + return _antv_crewai_tools + try: + from crewai_tools import MCPServerAdapter + + server_params = {"url": settings.ANTV_MCP_URL, "transport": "streamable-http"} + adapter = MCPServerAdapter(server_params) + adapter.start() + _antv_crewai_tools = list(adapter.tools) + except Exception as exc: + logger.warning("AntV MCP tools unavailable, continuing without them: %s", exc) + _antv_crewai_tools = [] + return _antv_crewai_tools +{%- endif %} +{%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/map_tool.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/map_tool.py new file mode 100644 index 00000000..5972c0d8 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/map_tool.py @@ -0,0 +1,102 @@ +{%- if cookiecutter.enable_antv_charts %} +"""Map-generation tool for agents. + +Mirrors ``chart_tool``: the agent calls ``create_map`` with marker coordinates +and the tool returns a structured ``MapSpec`` as a JSON string. The web frontend +parses it and renders an interactive map with Leaflet + OpenStreetMap tiles +(global coverage, no API key). The model supplies latitude/longitude for known +places directly from its own knowledge. +""" + +from typing import Any + +from pydantic import BaseModel, Field, ValidationError, field_validator + +MAX_MARKERS = 100 + + +class MapMarker(BaseModel): + """A single point on the map.""" + + lat: float = Field(ge=-90, le=90, description="Latitude in decimal degrees.") + lng: float = Field(ge=-180, le=180, description="Longitude in decimal degrees.") + label: str = Field(max_length=120, description="Short marker label.") + description: str | None = Field( + default=None, max_length=300, description="Optional detail shown on click." + ) + color: str | None = Field(default=None, description="Hex color override, e.g. '#ef4444'.") + + +class MapSpec(BaseModel): + """Canonical map payload produced by the tool and rendered by the frontend.""" + + kind: str = Field(default="map") + title: str = Field(max_length=200) + markers: list[MapMarker] + center: list[float] | None = Field( + default=None, description="Optional [lat, lng] center; auto-fit to markers if omitted." + ) + zoom: int | None = Field(default=None, ge=1, le=18, description="Optional zoom (1-18).") + + @field_validator("markers") + @classmethod + def _validate_markers(cls, v: list[MapMarker]) -> list[MapMarker]: + """Require at least one marker and cap the total.""" + if not v: + raise ValueError("markers must contain at least one point") + if len(v) > MAX_MARKERS: + raise ValueError(f"too many markers (max {MAX_MARKERS})") + return v + + @field_validator("center") + @classmethod + def _validate_center(cls, v: list[float] | None) -> list[float] | None: + """Require center, when given, to be an in-range [lat, lng] pair.""" + if v is None: + return v + if len(v) != 2: + raise ValueError("center must be [lat, lng]") + lat, lng = v + if not (-90 <= lat <= 90) or not (-180 <= lng <= 180): + raise ValueError("center out of range (lat must be -90..90, lng -180..180)") + return v + + +def create_map( + title: str, + markers: list[dict[str, Any]], + center: list[float] | None = None, + zoom: int | None = None, +) -> str: + """Create an interactive map for the user. + + Use this whenever the user asks to show, map, or locate places + geographically. Provide latitude/longitude for each marker from your own + knowledge (e.g. Warsaw ≈ 52.23, 21.01). The map renders interactively in the + web chat with OpenStreetMap tiles. + + Args: + title: Short title shown above the map. + markers: List of {"lat", "lng", "label", "description"?, "color"?}. + center: Optional [lat, lng] center. If omitted, the map auto-fits to the + markers. + zoom: Optional zoom level (1-18). Mainly useful with a single marker. + + Returns: + A JSON string with the map specification. Do not repeat this JSON back to + the user — just briefly describe the map you created. + """ + try: + spec = MapSpec( + title=title, + markers=[MapMarker(**m) for m in markers], + center=center, + zoom=zoom, + ) + except ValidationError as e: + return f"Could not build map — invalid arguments: {e.errors()}" + except (TypeError, ValueError) as e: + return f"Could not build map: {e}" + + return spec.model_dump_json() +{%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/backend/app/core/config.py b/template/{{cookiecutter.project_slug}}/backend/app/core/config.py index 677d6d7d..5227905f 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/core/config.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/core/config.py @@ -422,6 +422,20 @@ def REDIS_URL(self) -> str: # === Web Search (Tavily) === TAVILY_API_KEY: str = "" {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + + # === AntV charts (advanced diagrams via mcp-server-chart sidecar) === + # Opt-in at runtime: the code ships, but stays off until you flip this on + # and start the antvis-chart sidecar (docker compose --profile antv). + ENABLE_ANTV_CHARTS: bool = False + # MCP endpoint of the antvis-chart sidecar (streamable HTTP) + ANTV_MCP_URL: str = "http://antvis-chart:1122/mcp" + # Optional self-hosted GPT-Vis render backend (empty = AntV public service) + ANTV_VIS_REQUEST_SERVER: str = "" + # Comma-separated AntV tools to disable — defaults (set on the sidecar) drop + # the basic charts that overlap create_chart and the China-only maps. + ANTV_DISABLED_TOOLS: str = "" +{%- endif %} {%- if cookiecutter.use_deepagents %} # === DeepAgents Configuration === diff --git a/template/{{cookiecutter.project_slug}}/backend/pyproject.toml b/template/{{cookiecutter.project_slug}}/backend/pyproject.toml index cc81fb63..2d204f0a 100644 --- a/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +++ b/template/{{cookiecutter.project_slug}}/backend/pyproject.toml @@ -275,6 +275,19 @@ dependencies = [ {%- if cookiecutter.charts_channel_png %} "matplotlib>=3.9.0", {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + # AntV chart MCP server client (advanced diagrams via mcp-server-chart sidecar). + # Each framework attaches MCP differently, so the client dep is gated per framework. +{%- if cookiecutter.use_pydantic_ai or cookiecutter.use_pydantic_deep %} + "pydantic-ai-slim[mcp]>=1.80.0", +{%- endif %} +{%- if cookiecutter.use_langchain or cookiecutter.use_langgraph or cookiecutter.use_deepagents %} + "langchain-mcp-adapters>=0.1.0", +{%- endif %} +{%- if cookiecutter.use_crewai %} + "crewai-tools[mcp]>=1.0.0", +{%- endif %} +{%- endif %} {%- if cookiecutter.use_telegram or cookiecutter.use_slack %} "cryptography>=44.0.0", {%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml b/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml index b55e07b9..3cf21375 100644 --- a/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml +++ b/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml @@ -398,6 +398,44 @@ services: condition: service_healthy restart: unless-stopped {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + + # AntV mcp-server-chart sidecar: advanced diagram tools over MCP. Opt-in — + # starts only with the "antv" profile; the agent connects when ENABLE_ANTV_CHARTS=true. + antvis-chart: + image: node:20-alpine + container_name: {{ cookiecutter.project_slug }}_antvis_chart + profiles: ["antv"] + command: > + npx -y @antv/mcp-server-chart + --transport streamable --host 0.0.0.0 --port 1122 --endpoint /mcp + environment: + # Empty = AntV's public rendering backend. Point at a self-hosted + # GPT-Vis-SSR for private deployments (data never leaves your infra). + - VIS_REQUEST_SERVER=${ANTV_VIS_REQUEST_SERVER:-} + # Default drops basic charts (covered by create_chart/Recharts) and the + # China-only map tools (covered by create_map/Leaflet). Override via ANTV_DISABLED_TOOLS. + - DISABLED_TOOLS=${ANTV_DISABLED_TOOLS:-generate_area_chart,generate_bar_chart,generate_column_chart,generate_line_chart,generate_pie_chart,generate_scatter_chart,generate_district_map,generate_pin_map,generate_path_map} + ports: + - "1122:1122" + networks: + - backend + healthcheck: + # The MCP streamable-HTTP endpoint rejects a plain GET, so a bare `wget` + # would report the container unhealthy even while it serves fine. Use node + # (always present in this image) and treat ANY HTTP response — including a + # 4xx — as "process is up"; only a connection error counts as a failure. + test: + - "CMD" + - "node" + - "-e" + - "require('http').get('http://localhost:1122/mcp',r=>process.exit(0)).on('error',()=>process.exit(1))" + interval: 30s + timeout: 10s + retries: 5 + start_period: 20s + restart: unless-stopped +{%- endif %} networks: backend: diff --git a/template/{{cookiecutter.project_slug}}/docker-compose.yml b/template/{{cookiecutter.project_slug}}/docker-compose.yml index a0aa0a34..3ad381ab 100644 --- a/template/{{cookiecutter.project_slug}}/docker-compose.yml +++ b/template/{{cookiecutter.project_slug}}/docker-compose.yml @@ -447,6 +447,44 @@ services: {%- endif %} restart: unless-stopped {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + + # AntV mcp-server-chart sidecar: advanced diagram tools over MCP. Opt-in — + # starts only with the "antv" profile; the agent connects when ENABLE_ANTV_CHARTS=true. + antvis-chart: + image: node:20-alpine + container_name: {{ cookiecutter.project_slug }}_antvis_chart + profiles: ["antv"] + command: > + npx -y @antv/mcp-server-chart + --transport streamable --host 0.0.0.0 --port 1122 --endpoint /mcp + environment: + # Empty = AntV's public rendering backend. Point at a self-hosted + # GPT-Vis-SSR for private deployments (data never leaves your infra). + - VIS_REQUEST_SERVER=${ANTV_VIS_REQUEST_SERVER:-} + # Default drops basic charts (covered by create_chart/Recharts) and the + # China-only map tools (covered by create_map/Leaflet). Override via ANTV_DISABLED_TOOLS. + - DISABLED_TOOLS=${ANTV_DISABLED_TOOLS:-generate_area_chart,generate_bar_chart,generate_column_chart,generate_line_chart,generate_pie_chart,generate_scatter_chart,generate_district_map,generate_pin_map,generate_path_map} + ports: + - "1122:1122" + networks: + - backend + healthcheck: + # The MCP streamable-HTTP endpoint rejects a plain GET, so a bare `wget` + # would report the container unhealthy even while it serves fine. Use node + # (always present in this image) and treat ANY HTTP response — including a + # 4xx — as "process is up"; only a connection error counts as a failure. + test: + - "CMD" + - "node" + - "-e" + - "require('http').get('http://localhost:1122/mcp',r=>process.exit(0)).on('error',()=>process.exit(1))" + interval: 30s + timeout: 10s + retries: 5 + start_period: 20s + restart: unless-stopped +{%- endif %} networks: backend: diff --git a/template/{{cookiecutter.project_slug}}/frontend/package.json b/template/{{cookiecutter.project_slug}}/frontend/package.json index 3dacc815..92b73236 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/package.json +++ b/template/{{cookiecutter.project_slug}}/frontend/package.json @@ -69,6 +69,10 @@ "@types/mdx": "^2.0.13", "gray-matter": "^4.0.3", "next-mdx-remote": "^6.0.0" +{%- endif %} +{%- if cookiecutter.enable_antv_charts %}, + "leaflet": "^1.9.4", + "react-leaflet": "^5.0.0" {%- endif %} }, "devDependencies": { @@ -96,6 +100,9 @@ "jsdom": "^25.0.1", "@vitest/coverage-v8": "^2.1.8", "@vitest/ui": "^2.1.8" +{%- if cookiecutter.enable_antv_charts %}, + "@types/leaflet": "^1.9.21" +{%- endif %} }, "lint-staged": { "*.{js,ts,jsx,tsx,mdx}": [ diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/map-leaflet.tsx b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/map-leaflet.tsx new file mode 100644 index 00000000..7ebbde8c --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/map-leaflet.tsx @@ -0,0 +1,72 @@ +{% raw %}"use client"; + +import "leaflet/dist/leaflet.css"; + +import type { LatLngBoundsExpression, LatLngExpression } from "leaflet"; +import { CircleMarker, MapContainer, Popup, TileLayer } from "react-leaflet"; + +import type { MapSpec } from "@/types"; + +const DEFAULT_COLOR = "#6366f1"; + +// Default export so it can be loaded via next/dynamic with ssr:false — Leaflet +// touches `window` at import time and must never run during SSR. +export default function MapLeaflet({ spec }: { spec: MapSpec }) { + const markers = spec.markers; + const hasCenter = Array.isArray(spec.center) && spec.center.length === 2; + + let center: LatLngExpression | undefined; + let zoom: number | undefined; + let bounds: LatLngBoundsExpression | undefined; + + if (hasCenter) { + center = spec.center as [number, number]; + zoom = spec.zoom ?? 10; + } else if (markers.length === 1) { + const m = markers[0]!; + center = [m.lat, m.lng]; + zoom = spec.zoom ?? 12; + } else { + const lats = markers.map((m) => m.lat); + const lngs = markers.map((m) => m.lng); + bounds = [ + [Math.min(...lats), Math.min(...lngs)], + [Math.max(...lats), Math.max(...lngs)], + ]; + } + + return ( + + + {markers.map((m, i) => ( + + + {m.label} + {m.description ?
{m.description}
: null} +
+
+ ))} +
+ ); +} +{% endraw %} \ No newline at end of file diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/map-message.tsx b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/map-message.tsx new file mode 100644 index 00000000..91ab8d18 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/map-message.tsx @@ -0,0 +1,39 @@ +{% raw %}"use client"; + +import dynamic from "next/dynamic"; + +import type { MapSpec } from "@/types"; + +// Leaflet is browser-only — load the renderer with ssr:false so it never +// executes during server rendering. +const MapLeaflet = dynamic(() => import("./map-leaflet"), { + ssr: false, + loading: () =>
, +}); + +/** Parse a `create_map` tool result into a MapSpec, or null if it isn't one. */ +export function parseMapResult(result: unknown): MapSpec | null { + let payload: unknown = result; + if (typeof result === "string") { + try { + payload = JSON.parse(result); + } catch { + return null; + } + } + if (payload && typeof payload === "object" && (payload as { kind?: unknown }).kind === "map") { + return payload as MapSpec; + } + return null; +} + +/** Render a titled card containing the interactive Leaflet map. */ +export function MapMessage({ spec }: { spec: MapSpec }) { + return ( +
+

{spec.title}

+ +
+ ); +} +{% endraw %} \ No newline at end of file diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx index 3759df30..c494d1bd 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx @@ -2,6 +2,8 @@ {%- if cookiecutter.enable_charts %} import { useEffect, useMemo, useState, type MouseEvent } from "react"; +{%- elif cookiecutter.enable_antv_charts %} +import { useEffect, useState, type MouseEvent } from "react"; {%- else %} import { useState, type MouseEvent } from "react"; {%- endif %} @@ -18,15 +20,21 @@ import { ChevronDown, ChevronUp, Code2, -{%- if cookiecutter.enable_charts %} +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} BarChart3, {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + MapPin, +{%- endif %} } from "lucide-react"; import { cn } from "@/lib/utils"; import { CopyButton } from "./copy-button"; {%- if cookiecutter.enable_charts %} import { ChartMessage, parseChartResult } from "./chart-message"; {%- endif %} +{%- if cookiecutter.enable_antv_charts %} +import { MapMessage, parseMapResult } from "./map-message"; +{%- endif %} interface ToolCallCardProps { toolCall: ToolCall; @@ -478,6 +486,62 @@ function GenericToolResult({ ); } +{%- if cookiecutter.enable_antv_charts %} +/** Extract a chart image URL from an AntV `generate_*` tool result. + * AntV mcp-server-chart tools render server-side and return the image URL — + * usually a bare URL string, sometimes wrapped in JSON ({url|resultObj|...}). + * Returns null when no http(s) URL is present (caller falls back to raw text). */ +function parseAntvImageUrl(result: string): string | null { + const trimmed = result.trim(); + if (/^https?:\/\/\S+$/.test(trimmed)) return trimmed; + try { + const p = JSON.parse(trimmed); + const candidate = + typeof p === "string" ? p : (p?.url ?? p?.resultObj ?? p?.image ?? p?.content); + if (typeof candidate === "string" && /^https?:\/\//.test(candidate.trim())) { + return candidate.trim(); + } + } catch { + /* not JSON — fall through to regex */ + } + const match = trimmed.match(/https?:\/\/[^\s")']+/); + return match ? match[0] : null; +} + +/** "generate_mind_map" -> "Mind Map", "generate_line_chart" -> "Line Chart". */ +function antvToolLabel(name: string): string { + return name + .replace(/^generate_/, "") + .split("_") + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +/** Render a server-rendered AntV diagram image with an "open full size" link. */ +function AntvChartImage({ url, title }: { url: string; title: string }) { + return ( +
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + {title} + +
+ + + Open full size + +
+
+ ); +} +{%- endif %} + // --- Main component --- export function ToolCallCard({ toolCall }: ToolCallCardProps) { @@ -485,10 +549,23 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { // formatted view for args + raw output (the button). Charts are the // exception: they're only useful when visible, so expand them by default. const [expanded, setExpanded] = useState( -{%- if cookiecutter.enable_charts %} +{%- if cookiecutter.enable_charts and cookiecutter.enable_antv_charts %} + toolCall.status === "completed" && + ((toolCall.name === "create_chart_tool" && parseChartResult(toolCall.result) !== null) || + (toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || + (toolCall.name.startsWith("generate_") && + typeof toolCall.result === "string" && + parseAntvImageUrl(toolCall.result) !== null)), +{%- elif cookiecutter.enable_charts %} toolCall.name === "create_chart_tool" && toolCall.status === "completed" && parseChartResult(toolCall.result) !== null, +{%- elif cookiecutter.enable_antv_charts %} + toolCall.status === "completed" && + ((toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || + (toolCall.name.startsWith("generate_") && + typeof toolCall.result === "string" && + parseAntvImageUrl(toolCall.result) !== null)), {%- else %} false, {%- endif %} @@ -547,6 +624,27 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { if (isChart) setExpanded(true); }, [isChart]); {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + const mapSpec = + toolCall.name === "create_map_tool" && toolCall.status === "completed" + ? parseMapResult(toolCall.result) + : null; + const isMap = mapSpec !== null; + // AntV mcp-server-chart tools (generate_line_chart, generate_mind_map, ...) + // return a server-rendered image URL. + const antvImageUrl = + toolCall.name.startsWith("generate_") && + toolCall.status === "completed" && + typeof toolCall.result === "string" + ? parseAntvImageUrl(toolCall.result) + : null; + const isAntvChart = antvImageUrl !== null; + // A map/image that finishes after this card mounted (live streaming) won't + // have triggered the initial-state default — expand it on transition. + useEffect(() => { + if (isMap || isAntvChart) setExpanded(true); + }, [isMap, isAntvChart]); +{%- endif %} const hasSpecialRenderer = isDateTime || @@ -554,6 +652,10 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { isWebSearch {%- if cookiecutter.enable_charts %} || isChart +{%- endif %} +{%- if cookiecutter.enable_antv_charts %} + || isMap + || isAntvChart {%- endif %} ; @@ -566,6 +668,12 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { {%- if cookiecutter.enable_charts %} : isChart ? "Chart" +{%- endif %} +{%- if cookiecutter.enable_antv_charts %} + : isMap + ? "Map" + : isAntvChart + ? antvToolLabel(toolCall.name) {%- endif %} : toolCall.name; @@ -578,6 +686,12 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { {%- if cookiecutter.enable_charts %} : isChart ? BarChart3 +{%- endif %} +{%- if cookiecutter.enable_antv_charts %} + : isMap + ? MapPin + : isAntvChart + ? BarChart3 {%- endif %} : Wrench; @@ -658,6 +772,12 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { {%- if cookiecutter.enable_charts %} ) : toolCall.status === "completed" && isChart && chartSpec ? ( +{%- endif %} +{%- if cookiecutter.enable_antv_charts %} + ) : toolCall.status === "completed" && isMap && mapSpec ? ( + + ) : toolCall.status === "completed" && isAntvChart && antvImageUrl ? ( + {%- endif %} ) : ( diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts b/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts index e2393c2e..3a953f97 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts +++ b/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts @@ -100,6 +100,26 @@ export interface ChartSpec { style: ChartStyle; } {%- endif %} +{%- if cookiecutter.enable_antv_charts %} + +/** A single point on a `create_map` map. */ +export interface MapMarker { + lat: number; + lng: number; + label: string; + description?: string | null; + color?: string | null; +} + +/** Structured map payload produced by the agent's `create_map` tool. */ +export interface MapSpec { + kind: "map"; + title: string; + markers: MapMarker[]; + center?: [number, number] | null; + zoom?: number | null; +} +{%- endif %} // WebSocket event types from backend export type WSEventType = diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index c2ada719..00e08310 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -275,6 +275,36 @@ def test_full_project_valid_python_syntax(self, generated_project_full: Path) -> use_telegram=True, use_slack=True, ), + # --- Charts / AntV maps ----------------------------------------------- + # Three separate entries because each AI framework generates a different + "pydantic_ai_antv_charts": dict( + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.PYDANTIC_AI, + enable_logfire=False, + enable_antv_charts=True, + background_tasks=BackgroundTaskType.NONE, + ), + "langchain_antv_charts": dict( + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.LANGCHAIN, + enable_logfire=False, + enable_antv_charts=True, + background_tasks=BackgroundTaskType.NONE, + ), + "crewai_antv_charts": dict( + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.CREWAI, + enable_logfire=False, + enable_antv_charts=True, + background_tasks=BackgroundTaskType.NONE, + ), + "pydantic_ai_charts": dict( + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.PYDANTIC_AI, + enable_logfire=False, + enable_charts=True, + background_tasks=BackgroundTaskType.NONE, + ), "rag_pgvector": dict( database=DatabaseType.POSTGRESQL, background_tasks=BackgroundTaskType.NONE, @@ -343,3 +373,86 @@ def test_passes_ty(self, matrix_project: Path, request: pytest.FixtureRequest) - cwd=backend_path, ) assert result.returncode == 0, f"ty failed:\n{result.stdout}\n{result.stderr}" + + +# --------------------------------------------------------------------------- +# AntV charts / Leaflet maps — generated-content checks +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def generated_project_antv(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Generate a pydantic_ai + sqlite + antv_charts project for content checks.""" + config = ProjectConfig( + project_name="test_antv", + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.PYDANTIC_AI, + enable_logfire=False, + enable_antv_charts=True, + background_tasks=BackgroundTaskType.NONE, + ) + return generate_project(config, tmp_path_factory.mktemp("antv")) + + +@pytest.fixture(scope="module") +def generated_project_antv_langchain(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Generate a langchain + sqlite + antv_charts project for cache-code checks.""" + config = ProjectConfig( + project_name="test_antv_lc", + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.LANGCHAIN, + enable_logfire=False, + enable_antv_charts=True, + background_tasks=BackgroundTaskType.NONE, + ) + return generate_project(config, tmp_path_factory.mktemp("antv_lc")) + + +class TestGeneratedTemplateAntvCharts: + """Verify that the AntV-charts / Leaflet-map feature renders correctly. + + These tests check specific implementation details that are easy to break + via a bad Jinja merge: the MCP cache variables, the typed-marker signature, + coordinate bounds validation, and the absence of removed dead code. + """ + + @pytest.mark.slow + def test_map_tool_has_typed_marker_import(self, generated_project_antv: Path) -> None: + """MapMarker must be defined in map_tool.py (typed schema for schema fidelity).""" + map_tool = generated_project_antv / "backend" / "app" / "agents" / "tools" / "map_tool.py" + assert map_tool.exists() + content = map_tool.read_text() + assert "class MapMarker" in content + + @pytest.mark.slow + def test_map_tool_has_center_bounds_check(self, generated_project_antv: Path) -> None: + """_validate_center must range-check coordinates, not just length.""" + content = ( + generated_project_antv / "backend" / "app" / "agents" / "tools" / "map_tool.py" + ).read_text() + assert "-90 <= lat <= 90" in content + assert "-180 <= lng <= 180" in content + + @pytest.mark.slow + def test_map_tool_no_parse_map_spec(self, generated_project_antv: Path) -> None: + """parse_map_spec was removed (dead export); must not appear in generated code.""" + tools_dir = generated_project_antv / "backend" / "app" / "agents" / "tools" + for f in tools_dir.rglob("*.py"): + assert "parse_map_spec" not in f.read_text(), f"{f.name} still references parse_map_spec" + + @pytest.mark.slow + def test_assistant_uses_typed_markers(self, generated_project_antv: Path) -> None: + """The pydantic_ai assistant must use list[MapMarker] in its create_map wrapper.""" + assistant = generated_project_antv / "backend" / "app" / "agents" / "assistant.py" + content = assistant.read_text() + assert "list[MapMarker]" in content + + @pytest.mark.slow + def test_antv_chart_langchain_has_module_cache(self, generated_project_antv_langchain: Path) -> None: + """get_antv_langchain_tools must use a module-level cache to avoid per-request MCP work.""" + antv = ( + generated_project_antv_langchain / "backend" / "app" / "agents" / "tools" / "antv_chart.py" + ) + content = antv.read_text() + assert "_antv_langchain_tools" in content + assert "if _antv_langchain_tools is not None" in content From bf18dc0ce0f0cb3c26f5fd4693b0d85dc7bb4ea2 Mon Sep 17 00:00:00 2001 From: OchnikBartek Date: Mon, 8 Jun 2026 13:58:09 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix(antv):=20address=20PR=20#83=20review=20?= =?UTF-8?q?=E2=80=94=20compose=20port,=20docs,=20memoization=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - antv_chart.py: guard tool discovery with a module-level lock (CrewAI + LangChain loaders) so requests racing in the FastAPI threadpool can't each start an adapter; memoize only on success so a transient sidecar failure (e.g. still warming up) is retried instead of caching [] and pinning AntV off until the process restarts. - docker-compose.yml (prod): drop the host port mapping for the antvis-chart sidecar — the backend reaches it over the internal network; publishing 1122 exposed an unauthenticated MCP/render endpoint. - docker-compose.dev.yml: keep the host port, documented as dev-only. - VARIABLES.md: correct the claim that the sidecar isn't in the prod compose — it is, just profile-gated and off by default. Co-Authored-By: Claude Opus 4.7 --- template/VARIABLES.md | 2 +- .../backend/app/agents/tools/antv_chart.py | 76 ++++++++++++------- .../docker-compose.dev.yml | 3 + .../docker-compose.yml | 2 - 4 files changed, 51 insertions(+), 32 deletions(-) diff --git a/template/VARIABLES.md b/template/VARIABLES.md index 03009819..bbd1b6df 100644 --- a/template/VARIABLES.md +++ b/template/VARIABLES.md @@ -286,7 +286,7 @@ These variables are set automatically by the generator. | `web_fetch_tool` | bool | `false` | Computed: portable `fetch_url` tool is generated | `enable_web_fetch` and framework is LangChain/LangGraph/CrewAI/DeepAgents | | `enable_charts` | bool | `false` | Enable the chart-generation tool (line/bar/pie/area/scatter); interactive in web chat, PNG on Slack/Telegram | Requires an AI framework | | `charts_channel_png` | bool | `false` | Computed: render charts to PNG for messaging channels | `enable_charts` and (`use_slack` or `use_telegram`) | -| `enable_antv_charts` | bool | `false` | Adds the interactive `create_map` tool (Leaflet/OpenStreetMap, works out of the box) plus AntV advanced-diagram tools (flowchart, mind-map, org-chart, sankey, ...) via an `mcp-server-chart` Docker sidecar. Wired into all 5 frameworks. The AntV diagrams need the sidecar running (`docker compose --profile antv up -d`) and `ENABLE_ANTV_CHARTS=true` at runtime; maps work without it. Not wired into the production compose (internal network blocks AntV's render backend — self-host GPT-Vis-SSR for prod) | Requires an AI framework | +| `enable_antv_charts` | bool | `false` | Adds the interactive `create_map` tool (Leaflet/OpenStreetMap, works out of the box) plus AntV advanced-diagram tools (flowchart, mind-map, org-chart, sankey, ...) via an `mcp-server-chart` Docker sidecar. Wired into all 5 frameworks. The AntV diagrams need the sidecar running (`docker compose --profile antv up -d`) and `ENABLE_ANTV_CHARTS=true` at runtime; maps work without it. Present in the production compose but profile-gated — off by default and publishes no host port, so it's opt-in per environment; for prod, self-host GPT-Vis-SSR via `ANTV_VIS_REQUEST_SERVER` rather than AntV's public render backend | Requires an AI framework | **Notes:** diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/antv_chart.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/antv_chart.py index 243572a8..b77a8726 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/antv_chart.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/antv_chart.py @@ -12,6 +12,9 @@ """ import logging +{%- if cookiecutter.use_langchain or cookiecutter.use_langgraph or cookiecutter.use_deepagents or cookiecutter.use_crewai %} +import threading +{%- endif %} from typing import Any from app.core.config import settings @@ -64,39 +67,48 @@ def get_antv_toolset() -> Any | None: _antv_langchain_tools: list[Any] | None = None +_antv_langchain_lock = threading.Lock() def get_antv_langchain_tools() -> list[Any]: """Return AntV MCP tools as LangChain tools, or [] if disabled/unavailable. - Discovery is a blocking MCP round-trip, and the assistant is rebuilt on every - request, so the result is memoized for the process lifetime — we discover once - (the sidecar is long-lived), not on every agent construction. + Discovery is a blocking MCP round-trip and the assistant is rebuilt on every + request, so a successful result is memoized for the process lifetime — we + discover once (the sidecar is long-lived), not on every agent construction. + A module-level lock makes the check-and-load atomic across the FastAPI + threadpool, and we cache only on success so a transient failure (e.g. the + sidecar still warming up) is retried on the next request instead of pinning + AntV off until the process restarts. """ global _antv_langchain_tools if not settings.ENABLE_ANTV_CHARTS: return [] if _antv_langchain_tools is not None: return _antv_langchain_tools - try: - from langchain_mcp_adapters.client import MultiServerMCPClient - - async def _load() -> list[Any]: - client = MultiServerMCPClient( - {"antv": {"url": settings.ANTV_MCP_URL, "transport": "streamable_http"}} - ) - return await client.get_tools() - - _antv_langchain_tools = _run_sync(_load()) - except Exception as exc: - logger.warning("AntV MCP tools unavailable, continuing without them: %s", exc) - _antv_langchain_tools = [] - return _antv_langchain_tools + with _antv_langchain_lock: + if _antv_langchain_tools is not None: + return _antv_langchain_tools + try: + from langchain_mcp_adapters.client import MultiServerMCPClient + + async def _load() -> list[Any]: + client = MultiServerMCPClient( + {"antv": {"url": settings.ANTV_MCP_URL, "transport": "streamable_http"}} + ) + return await client.get_tools() + + _antv_langchain_tools = _run_sync(_load()) + return _antv_langchain_tools + except Exception as exc: + logger.warning("AntV MCP tools unavailable, continuing without them: %s", exc) + return [] {%- endif %} {%- if cookiecutter.use_crewai %} _antv_crewai_tools: list[Any] | None = None +_antv_crewai_lock = threading.Lock() def get_antv_crewai_tools() -> list[Any]: @@ -106,23 +118,29 @@ def get_antv_crewai_tools() -> list[Any]: crew we start it once and keep the tools for the life of the process (the sidecar is always up). The assistant is rebuilt on every request, so the started adapter and its tools are memoized here — without this we would leak - a new adapter connection per request. Failures degrade to an empty list. + a new adapter connection per request. A module-level lock makes the + check-and-start atomic across the FastAPI threadpool (so two racing requests + can't each start an adapter), and we cache only on success so a transient + failure is retried — and never leaves a half-started adapter cached. """ global _antv_crewai_tools if not settings.ENABLE_ANTV_CHARTS: return [] if _antv_crewai_tools is not None: return _antv_crewai_tools - try: - from crewai_tools import MCPServerAdapter - - server_params = {"url": settings.ANTV_MCP_URL, "transport": "streamable-http"} - adapter = MCPServerAdapter(server_params) - adapter.start() - _antv_crewai_tools = list(adapter.tools) - except Exception as exc: - logger.warning("AntV MCP tools unavailable, continuing without them: %s", exc) - _antv_crewai_tools = [] - return _antv_crewai_tools + with _antv_crewai_lock: + if _antv_crewai_tools is not None: + return _antv_crewai_tools + try: + from crewai_tools import MCPServerAdapter + + server_params = {"url": settings.ANTV_MCP_URL, "transport": "streamable-http"} + adapter = MCPServerAdapter(server_params) + adapter.start() + _antv_crewai_tools = list(adapter.tools) + return _antv_crewai_tools + except Exception as exc: + logger.warning("AntV MCP tools unavailable, continuing without them: %s", exc) + return [] {%- endif %} {%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml b/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml index 3cf21375..efa4fd82 100644 --- a/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml +++ b/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml @@ -416,6 +416,9 @@ services: # Default drops basic charts (covered by create_chart/Recharts) and the # China-only map tools (covered by create_map/Leaflet). Override via ANTV_DISABLED_TOOLS. - DISABLED_TOOLS=${ANTV_DISABLED_TOOLS:-generate_area_chart,generate_bar_chart,generate_column_chart,generate_line_chart,generate_pie_chart,generate_scatter_chart,generate_district_map,generate_pin_map,generate_path_map} + # Dev only: publish the MCP/render port to the host for debugging. The + # backend reaches the sidecar over the internal network, so the prod + # compose omits this (avoids exposing an unauthenticated endpoint). ports: - "1122:1122" networks: diff --git a/template/{{cookiecutter.project_slug}}/docker-compose.yml b/template/{{cookiecutter.project_slug}}/docker-compose.yml index 3ad381ab..497e95c7 100644 --- a/template/{{cookiecutter.project_slug}}/docker-compose.yml +++ b/template/{{cookiecutter.project_slug}}/docker-compose.yml @@ -465,8 +465,6 @@ services: # Default drops basic charts (covered by create_chart/Recharts) and the # China-only map tools (covered by create_map/Leaflet). Override via ANTV_DISABLED_TOOLS. - DISABLED_TOOLS=${ANTV_DISABLED_TOOLS:-generate_area_chart,generate_bar_chart,generate_column_chart,generate_line_chart,generate_pie_chart,generate_scatter_chart,generate_district_map,generate_pin_map,generate_path_map} - ports: - - "1122:1122" networks: - backend healthcheck: From e63cac5388560f2f7a6195178253b4da1abb750a Mon Sep 17 00:00:00 2001 From: OchnikBartek Date: Tue, 9 Jun 2026 10:59:25 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(deps):=20bump=20aiohttp=203.13.5?= =?UTF-8?q?=E2=86=923.14.1=20and=20pyjwt=202.12.1=E2=86=922.13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aiohttp 3.13.5 has CVE-2026-34993 and CVE-2026-47265 (fixed in 3.14.0). pyjwt 2.12.1 has PYSEC-2026-175/177/178/179 (fixed in 2.13.0). Both are transitive deps pulled in via the project venv; bumping them unblocks the Security Scan CI job and re-enables all template test jobs that were skipping because they need: [security]. Co-Authored-By: Claude Sonnet 4.6 --- uv.lock | 198 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 107 insertions(+), 91 deletions(-) diff --git a/uv.lock b/uv.lock index c6c6a5bd..b5e81aaf 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -22,95 +22,111 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] @@ -1628,11 +1644,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [[package]] From 11ca01f045e967779a20074a435f56f98615473c Mon Sep 17 00:00:00 2001 From: OchnikBartek Date: Tue, 9 Jun 2026 15:32:31 +0200 Subject: [PATCH 4/5] feat: add ask_user (Q&A) tool + Monty run_python code execution --- fastapi_gen/cli.py | 8 + fastapi_gen/config.py | 2 + fastapi_gen/prompts.py | 34 ++ template/VARIABLES.md | 1 + template/cookiecutter.json | 1 + template/hooks/post_gen_project.py | 4 + .../backend/.env.example | 9 +- .../backend/app/agents/assistant.py | 91 +++++- .../backend/app/agents/prompts.py | 55 ++++ .../backend/app/agents/tools/ask_user_tool.py | 39 +++ .../app/agents/tools/code_execution.py | 294 ++++++++++++++++++ .../backend/app/core/config.py | 6 + .../backend/app/services/agent_session.py | 83 ++++- .../backend/pyproject.toml | 3 + .../src/components/chat/chat-container.tsx | 28 +- .../src/components/chat/tool-call-card.tsx | 104 +++++-- .../frontend/src/components/ui/index.ts | 2 + .../src/components/ui/question-prompt.tsx | 257 +++++++++++++++ .../frontend/src/hooks/use-chat.ts | 28 ++ .../frontend/src/types/chat.ts | 20 ++ tests/test_template_integration.py | 9 + 21 files changed, 1054 insertions(+), 24 deletions(-) create mode 100644 template/{{cookiecutter.project_slug}}/backend/app/agents/tools/ask_user_tool.py create mode 100644 template/{{cookiecutter.project_slug}}/backend/app/agents/tools/code_execution.py create mode 100644 template/{{cookiecutter.project_slug}}/frontend/src/components/ui/question-prompt.tsx diff --git a/fastapi_gen/cli.py b/fastapi_gen/cli.py index ca3cabbc..b5dfb52e 100644 --- a/fastapi_gen/cli.py +++ b/fastapi_gen/cli.py @@ -478,6 +478,12 @@ def new(output: Path | None, no_input: bool, name: str | None, minimal: bool) -> help="Enable AntV advanced-diagram tools (flowchart, mind-map, sankey, ...) via an " "mcp-server-chart sidecar, plus an interactive Leaflet/OpenStreetMap map tool", ) +@click.option( + "--code-execution", + is_flag=True, + default=False, + help="Enable the run_python code-execution tool backed by the Monty sandbox (PydanticAI only)", +) @click.option("--session-management", is_flag=True, help="Enable session management") @click.option( "--reverse-proxy", @@ -754,6 +760,7 @@ def create( web_fetch: bool, charts: bool, antv_charts: bool, + code_execution: bool, session_management: bool, reverse_proxy: str, kubernetes: bool, @@ -1183,6 +1190,7 @@ def create( enable_web_fetch=web_fetch, enable_charts=charts, enable_antv_charts=antv_charts, + enable_code_execution=code_execution, enable_session_management=session_management, reverse_proxy=_rp_map[reverse_proxy], enable_kubernetes=kubernetes, diff --git a/fastapi_gen/config.py b/fastapi_gen/config.py index aa79d044..bf1c9e79 100644 --- a/fastapi_gen/config.py +++ b/fastapi_gen/config.py @@ -326,6 +326,7 @@ class ProjectConfig(BaseModel): enable_web_fetch: bool = False enable_charts: bool = False enable_antv_charts: bool = False + enable_code_execution: bool = False use_telegram: bool = False use_slack: bool = False enable_cors: bool = True @@ -813,6 +814,7 @@ def to_cookiecutter_context(self) -> dict[str, Any]: "enable_charts": self.enable_charts, "charts_channel_png": self.enable_charts and (self.use_slack or self.use_telegram), "enable_antv_charts": self.enable_antv_charts, + "enable_code_execution": self.enable_code_execution, "enable_webhooks": self.enable_webhooks, # Legacy fixed values (WebSocket always uses JWT) "websocket_auth": "jwt", diff --git a/fastapi_gen/prompts.py b/fastapi_gen/prompts.py index 138959d9..c919df1a 100644 --- a/fastapi_gen/prompts.py +++ b/fastapi_gen/prompts.py @@ -924,6 +924,30 @@ def prompt_antv_charts() -> bool: ) +def prompt_code_execution() -> bool: + """Prompt for the Monty-backed run_python code-execution tool (PydanticAI only).""" + console.print() + console.print("[bold cyan]Code Execution (Monty sandbox)[/]") + console.print( + "Adds a `run_python` tool that lets the agent execute Python in a sandboxed " + "interpreter (pydantic-monty). The model can compute projections, run " + "aggregations, and call create_chart/create_map directly from inside the " + "code. Restricted stdlib: math, asyncio, json, datetime, re — no numpy/pandas. " + "PydanticAI only. Activate at runtime with ENABLE_CODE_EXECUTION=true." + ) + console.print() + + return cast( + bool, + _check_cancelled( + questionary.confirm( + "Enable the run_python code-execution tool (PydanticAI only)?", + default=False, + ).ask() + ), + ) + + def prompt_langsmith() -> bool: """Prompt for LangSmith observability.""" return cast( @@ -1434,6 +1458,7 @@ def run_interactive_prompts() -> ProjectConfig: "enable_web_fetch": False, "enable_charts": False, "enable_antv_charts": False, + "enable_code_execution": False, "rag_features": RAGFeatures(), "orm_type": OrmType.SQLALCHEMY, "sandbox_backend": "state", @@ -1598,6 +1623,12 @@ def step_antv_charts() -> None: else: state["enable_antv_charts"] = prompt_antv_charts() + def step_code_execution() -> None: + if state["ai_framework"] == AIFrameworkType.PYDANTIC_AI: + state["enable_code_execution"] = prompt_code_execution() + else: + state["enable_code_execution"] = False + def step_langsmith() -> None: if state["ai_framework"] in ( AIFrameworkType.LANGCHAIN, @@ -1669,6 +1700,7 @@ def step_marketing() -> None: ("RAG", step_rag_config), ("Chart Tool", step_charts), ("AntV Diagrams & Maps", step_antv_charts), + ("Code Execution", step_code_execution), ("LangSmith", step_langsmith), ("Messaging Channels", step_channels), ("Teams & Billing", step_teams_billing), @@ -1716,6 +1748,7 @@ def step_marketing() -> None: enable_web_fetch = state["enable_web_fetch"] enable_charts = state["enable_charts"] enable_antv_charts = state["enable_antv_charts"] + enable_code_execution = state["enable_code_execution"] rag_features = state["rag_features"] enable_langsmith = state["enable_langsmith"] use_telegram = state["use_telegram"] @@ -1759,6 +1792,7 @@ def step_marketing() -> None: enable_web_fetch=enable_web_fetch, enable_charts=enable_charts, enable_antv_charts=enable_antv_charts, + enable_code_execution=enable_code_execution, use_telegram=use_telegram, use_slack=use_slack, rate_limit_requests=rate_limit_requests, diff --git a/template/VARIABLES.md b/template/VARIABLES.md index bbd1b6df..df9cad31 100644 --- a/template/VARIABLES.md +++ b/template/VARIABLES.md @@ -287,6 +287,7 @@ These variables are set automatically by the generator. | `enable_charts` | bool | `false` | Enable the chart-generation tool (line/bar/pie/area/scatter); interactive in web chat, PNG on Slack/Telegram | Requires an AI framework | | `charts_channel_png` | bool | `false` | Computed: render charts to PNG for messaging channels | `enable_charts` and (`use_slack` or `use_telegram`) | | `enable_antv_charts` | bool | `false` | Adds the interactive `create_map` tool (Leaflet/OpenStreetMap, works out of the box) plus AntV advanced-diagram tools (flowchart, mind-map, org-chart, sankey, ...) via an `mcp-server-chart` Docker sidecar. Wired into all 5 frameworks. The AntV diagrams need the sidecar running (`docker compose --profile antv up -d`) and `ENABLE_ANTV_CHARTS=true` at runtime; maps work without it. Present in the production compose but profile-gated — off by default and publishes no host port, so it's opt-in per environment; for prod, self-host GPT-Vis-SSR via `ANTV_VIS_REQUEST_SERVER` rather than AntV's public render backend | Requires an AI framework | +| `enable_code_execution` | bool | `false` | Adds a `run_python` tool backed by the Monty sandboxed Python interpreter (`pydantic-monty`). The model can compute projections, run aggregations, and call `create_chart`/`create_map` from inside the sandbox — charts render as interactive Recharts cards immediately. Sandbox allows `math`, `asyncio`, `json`, `datetime`, `re`; no `statistics`, `random`, `itertools`, numpy/pandas. Activated at runtime with `ENABLE_CODE_EXECUTION=true`. **PydanticAI only.** Temporary shim — swap to the official `CodeExecutionToolset` when it ships in pydantic-ai | Requires `use_pydantic_ai` | **Notes:** diff --git a/template/cookiecutter.json b/template/cookiecutter.json index c9edd29d..600afa4d 100644 --- a/template/cookiecutter.json +++ b/template/cookiecutter.json @@ -92,6 +92,7 @@ "enable_charts": false, "charts_channel_png": false, "enable_antv_charts": false, + "enable_code_execution": false, "enable_webhooks": false, "websocket_auth": "jwt", "websocket_auth_jwt": true, diff --git a/template/hooks/post_gen_project.py b/template/hooks/post_gen_project.py index 5eb47003..7e820bf7 100644 --- a/template/hooks/post_gen_project.py +++ b/template/hooks/post_gen_project.py @@ -49,6 +49,7 @@ enable_charts = "{{ cookiecutter.enable_charts }}" == "True" charts_channel_png = "{{ cookiecutter.charts_channel_png }}" == "True" enable_antv_charts = "{{ cookiecutter.enable_antv_charts }}" == "True" +enable_code_execution = "{{ cookiecutter.enable_code_execution }}" == "True" use_pydantic_deep = "{{ cookiecutter.use_pydantic_deep }}" == "True" use_telegram = "{{ cookiecutter.use_telegram }}" == "True" use_slack = "{{ cookiecutter.use_slack }}" == "True" @@ -118,6 +119,7 @@ def remove_dir(path: str) -> None: # --- AI Agent files (remove unused framework-specific files) --- if not use_pydantic_ai: remove_file(os.path.join(backend_app, "agents", "assistant.py")) + remove_file(os.path.join(backend_app, "agents", "tools", "ask_user_tool.py")) if not use_langchain: remove_file(os.path.join(backend_app, "agents", "langchain_assistant.py")) if not use_langgraph: @@ -152,6 +154,8 @@ def remove_dir(path: str) -> None: frontend_src = os.path.join(os.getcwd(), "frontend", "src") remove_file(os.path.join(frontend_src, "components", "chat", "map-leaflet.tsx")) remove_file(os.path.join(frontend_src, "components", "chat", "map-message.tsx")) +if not enable_code_execution: + remove_file(os.path.join(backend_app, "agents", "tools", "code_execution.py")) # --- No-AI mode: remove all AI/chat/conversation files --- if not use_ai: diff --git a/template/{{cookiecutter.project_slug}}/backend/.env.example b/template/{{cookiecutter.project_slug}}/backend/.env.example index 0fc8c674..9d354384 100644 --- a/template/{{cookiecutter.project_slug}}/backend/.env.example +++ b/template/{{cookiecutter.project_slug}}/backend/.env.example @@ -243,7 +243,7 @@ TAVILY_API_KEY= # === AntV charts (advanced diagrams via mcp-server-chart sidecar) === # Opt-in. The create_map tool works without this; only the AntV diagram tools # need the sidecar, which `make dev` starts automatically. -ENABLE_ANTV_CHARTS=false +ENABLE_ANTV_CHARTS=true # MCP endpoint of the antvis-chart sidecar (default matches docker-compose). ANTV_MCP_URL=http://antvis-chart:1122/mcp # Optional self-hosted GPT-Vis render backend (empty = AntV's public service). @@ -252,6 +252,13 @@ ANTV_VIS_REQUEST_SERVER= # default (drops basic charts that overlap create_chart, and China-only maps). ANTV_DISABLED_TOOLS= {%- endif %} +{%- if cookiecutter.enable_code_execution %} + +# === Code execution (Monty sandbox) === +ENABLE_CODE_EXECUTION=true +CODE_EXECUTION_TIMEOUT_SECS=10 +CODE_EXECUTION_MAX_ALLOCATIONS=50000000 +{%- endif %} {%- if cookiecutter.enable_langsmith %} # === LangSmith Observability === diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py index 118887df..4d43c4c4 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py @@ -5,10 +5,11 @@ """ import logging +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import Any -from pydantic_ai import Agent, ModelRetry{%- if cookiecutter.enable_rag %}, RunContext{%- endif %} +from pydantic_ai import Agent{%- if cookiecutter.enable_rag %}, ModelRetry{%- endif %}, RunContext from pydantic_ai.capabilities import ( ReinjectSystemPrompt, Thinking, @@ -48,6 +49,7 @@ from app.agents.prompts import get_system_prompt_with_rag {%- endif %} from app.agents.tools import get_current_datetime +from app.agents.tools.ask_user_tool import MAX_QUESTIONS, QuestionItem, format_answers {%- if cookiecutter.enable_rag %} from app.agents.tools.rag_tool import search_knowledge_base {%- endif %} @@ -58,6 +60,10 @@ from app.agents.tools.antv_chart import get_antv_toolset from app.agents.tools.map_tool import MapMarker, create_map {%- endif %} +{%- if cookiecutter.enable_code_execution %} +from app.agents.tools.code_execution import EmitToolEvent +from app.agents.tools.code_execution import run_python as run_python_code +{%- endif %} from app.core.config import settings logger = logging.getLogger(__name__) @@ -135,6 +141,9 @@ def _build_model(model_name: str): {%- endif %} +AskUserCallback = Callable[[list[dict[str, Any]]], Awaitable[list[dict[str, Any]]]] + + @dataclass class Deps: """Dependencies for the assistant agent. @@ -149,6 +158,10 @@ class Deps: kb_collection_names: list[str] = field(default_factory=list) {%- endif %} metadata: dict[str, Any] = field(default_factory=dict) + ask_user: AskUserCallback | None = None +{%- if cookiecutter.enable_code_execution %} + emit_tool_event: EmitToolEvent | None = None +{%- endif %} class AssistantAgent: @@ -337,6 +350,82 @@ def create_map_tool( ) {%- endif %} + @agent.tool + async def ask_user(ctx: RunContext[Deps], questions: list[QuestionItem]) -> str: + """Ask the user one or more questions and wait for their answers. + + Use this when a decision or missing detail would materially change what + you do next and you can't reasonably assume it. You may pass several + questions at once — the user answers them one after another and you get + all the answers back together (good for an intake/setup flow). You can + also call this again later to follow up on what they said. Prefer + answering directly when the request is already clear. + + Args: + questions: The questions to ask. Each has the question text, optional + suggested `options`, and `allow_custom` (whether a free-form + answer is allowed, default True). + + Returns: + The user's answers as a Q/A transcript, with skipped questions marked. + """ + if ctx.deps.ask_user is None: + return ( + "User interaction is unavailable here; proceed with a reasonable " + "assumption and state it briefly." + ) + if not questions: + return "No questions were provided." + payload = [q.model_dump() for q in questions[:MAX_QUESTIONS]] + answers = await ctx.deps.ask_user(payload) + return format_answers(payload, answers) + +{%- if cookiecutter.enable_code_execution %} + + if settings.ENABLE_CODE_EXECUTION: + + @agent.tool + async def run_python(ctx: RunContext[Deps], code: str) -> str: + """Run Python in a sandbox to compute and to build visualizations. + + Use this for multi-step number-crunching (projections, aggregations, + simulations) and whenever you want to produce several charts at once. +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} + Inside the code you can call: + - ``create_chart(chart_type, title, data, series=None, x_key="x", style=None)`` +{%- if cookiecutter.enable_antv_charts %} + - ``create_map(title, markers, center=None, zoom=None)`` +{%- endif %} + - ``current_datetime()`` + ``create_chart``{%- if cookiecutter.enable_antv_charts %}/``create_map``{%- endif %} are async — call them with ``await``, + and run several in parallel with ``await asyncio.gather(...)``. Each one + renders to the user immediately as an interactive chart/map. +{%- else %} + Inside the code you can call ``current_datetime()``. +{%- endif %} + SANDBOX LIMITATIONS — violating these causes "Execution failed" errors: + - NO comma thousands separator in f-strings: ``{x:,}`` or ``{x:,.2f}`` + CRASHES. Use ``f"${int(x)}"`` or ``f"{x:.2f}"`` instead. + - NO ``statistics``, ``random``, ``itertools``, ``collections``, + numpy, pandas — compute stats manually with loops/math. + - NO file I/O, network calls, or OS access. + - NO ``import`` of any module not in: math, asyncio, json, datetime, re. + - Walrus operator ``:=`` is unsupported. + - f-string expressions must be simple: no ``!r``, no ``=`` suffix + (``{x=}`` debug format crashes). Use ``print(f"x = {x}")``. + + Print intermediate values you want to keep; don't paste the returned + chart JSON back to the user. + + Args: + code: The Python source to execute. + + Returns: + The captured stdout plus the final expression value, or an error + message you can read and fix. + """ + return await run_python_code(code, emit=ctx.deps.emit_tool_event) +{%- endif %} @staticmethod def _build_model_history( diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py index 9baa95d4..59605698 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py @@ -23,6 +23,22 @@ # Output Let formatting serve comprehension. Default to clear plain paragraphs for explanations and discussion. Reach for headers, bullets, or numbered lists only when they genuinely make the answer easier to scan — steps, comparisons, or rankings — or when the user asks for them. Honor explicit formatting and length preferences from the user. Lead with the conclusion, then the supporting detail, then any caveats.""" +{%- if cookiecutter.use_pydantic_ai %} + +DEFAULT_SYSTEM_PROMPT += """ + +# Asking the user +You have an `ask_user` tool that puts questions to the user and waits for their +answers before you continue. Reach for it only when a decision or missing detail +would genuinely change what you do next and you can't reasonably assume it — not +for things you can decide yourself. The tool takes a list of questions: pass +several at once when you need to gather a few things up front (an intake/setup +flow), and the user will answer them one after another. You can also call it +again later to follow up on what they said. Give each question a few short +`options` when there are natural choices, and leave `allow_custom` on so the user +can answer in their own words. If the user skips, proceed with a sensible default +and say briefly what you assumed.""" +{%- endif %} {%- if cookiecutter.enable_charts %} DEFAULT_SYSTEM_PROMPT += """ @@ -89,6 +105,45 @@ def _antv_guidance() -> str: DEFAULT_SYSTEM_PROMPT += _antv_guidance() {%- endif %} +{%- if cookiecutter.enable_code_execution %} + + +CODE_EXECUTION_GUIDANCE = """ + +# Running code +You have a `run_python` tool that executes Python in a sandbox. Reach for it when +a task needs real computation — projections, aggregations, simulations, parsing a +table the user pasted — or when you want to produce several charts at once. +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} + +Inside the code you can call `create_chart(...)`{%- if cookiecutter.enable_antv_charts %}, `create_map(...)`{%- endif %} and +`current_datetime()` directly. `create_chart`{%- if cookiecutter.enable_antv_charts %} and `create_map`{%- endif %} are async: call +them with `await`, and fire several in parallel with +`await asyncio.gather(create_chart(...), create_chart(...), ...)`. Each call +renders to the user immediately as an interactive chart/map, just like calling the +tool yourself — so don't separately call `create_chart` for the same data after +the code runs, and don't paste the returned JSON back to the user. +{%- endif %} + +The sandbox is a restricted Python subset: `math`, `asyncio`, `json`, `datetime` +and `re` import fine, but many modules (`statistics`, `random`, `itertools`, +`collections`, `functools`, numpy/pandas) are NOT available — compute means, +sums, and groupings yourself with plain loops and comprehensions. There is no +file, network, or OS access. The f-string `,` thousands separator isn't +supported (write `f"{x:.2f}"`, not `f"{x:,.2f}"`). `print(...)` the intermediate +numbers you want to reason about afterwards. Keep each block focused, then +briefly explain the results and charts in plain language.""" + + +def _code_execution_guidance() -> str: + """run_python guidance — included only when code execution is enabled.""" + from app.core.config import settings + + return CODE_EXECUTION_GUIDANCE if settings.ENABLE_CODE_EXECUTION else "" + + +DEFAULT_SYSTEM_PROMPT += _code_execution_guidance() +{%- endif %} def get_system_prompt_with_rag() -> str: diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/ask_user_tool.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/ask_user_tool.py new file mode 100644 index 00000000..b76751b2 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/ask_user_tool.py @@ -0,0 +1,39 @@ +"""Ask-the-user tool helpers. + +The ``ask_user`` tool lets the agent pause a run and put one or more questions to +the user, then resume with their answers. The actual pause/resume lives in the +WebSocket session (it owns the socket); this module just defines the question +schema and formats the collected answers into a result for the model. +""" + +from typing import Any + +from pydantic import BaseModel, Field + +MAX_QUESTIONS = 10 + + +class QuestionItem(BaseModel): + """One question to put to the user.""" + + question: str = Field(description="The question text.") + options: list[str] = Field( + default_factory=list, + description="Optional suggested answers, shown as numbered choices.", + ) + allow_custom: bool = Field( + default=True, + description="Whether the user may type a free-form answer instead of picking an option.", + ) + + +def format_answers(questions: list[dict[str, Any]], answers: list[dict[str, Any]]) -> str: + """Render the collected answers as a readable Q/A transcript for the model.""" + lines: list[str] = [] + for i, q in enumerate(questions): + a = answers[i] if i < len(answers) else {} + if not isinstance(a, dict): + a = {} + ans = "(skipped)" if a.get("skipped") else str(a.get("answer", "")).strip() or "(no answer)" + lines.append(f"Q: {q.get('question', '')}\nA: {ans}") + return "\n\n".join(lines) diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/code_execution.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/code_execution.py new file mode 100644 index 00000000..8b36cd81 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/code_execution.py @@ -0,0 +1,294 @@ +{%- if cookiecutter.enable_code_execution %} +"""Code-execution tool backed by the Monty sandbox. + +Runs model-written Python inside the Monty sandboxed interpreter, exposing a +curated set of our own tools as plain in-sandbox functions. In one tool turn +the model can compute, loop, transform data, and call those tools — including +several ``create_chart`` calls in a single ``asyncio.gather(...)`` block. + +Visualizations produced from *inside* the executed code are surfaced to the +live session through an ``emit_tool_event`` callback, so each one renders as +an interactive chart/map card (and is persisted) exactly like a direct tool +call — rather than being buried inside the ``run_python`` result. + +This is a thin local stand-in for PydanticAI's forthcoming +``CodeExecutionToolset``; swap to the official class once it ships. +""" + +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from pydantic_monty import CollectString, Monty, MontyError, ResourceLimits + +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} +from app.agents.tools.chart_tool import create_chart +{%- endif %} +from app.agents.tools.datetime_tool import get_current_datetime +{%- if cookiecutter.enable_antv_charts %} +from app.agents.tools.map_tool import create_map +{%- endif %} +from app.core.config import settings + +logger = logging.getLogger(__name__) + +EmitToolEvent = Callable[[str, dict[str, Any], str], Awaitable[None]] + +MAX_OUTPUT_CHARS = 8000 +{%- if cookiecutter.enable_antv_charts %} + + +async def _call_antv(tool_name: str, args: dict[str, Any]) -> str: + """Call an AntV ``mcp-server-chart`` tool over MCP and return the image URL. + + The sidecar renders server-side and returns a bare URL string. We open a + short-lived streamable-HTTP session per call — fine for the handful of + charts a single run produces. + """ + if not settings.ENABLE_ANTV_CHARTS: + return ( + "AntV charts are disabled (ENABLE_ANTV_CHARTS=false). " + "Skip the generate_* chart and rely on create_chart instead." + ) + try: + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + async with ( + streamablehttp_client(settings.ANTV_MCP_URL) as (read, write, _), + ClientSession(read, write) as session, + ): + await session.initialize() + result = await session.call_tool(tool_name, args) + if result.isError: + detail = result.content[0].text if result.content else "unknown error" # type: ignore[union-attr] + return f"Chart generation failed ({tool_name}): {detail}" + for item in result.content: + text = getattr(item, "text", None) + if text: + return text.strip() + return f"Chart generation returned no image ({tool_name})." + except Exception as exc: # noqa: BLE001 — surface a readable message to the model + logger.warning("AntV call %s failed: %s", tool_name, exc) + return f"Chart generation failed ({tool_name}): {exc}" +{%- endif %} + + +def _build_external_functions(emit: EmitToolEvent | None) -> dict[str, Callable[..., Any]]: + """Build the callables exposed to sandboxed code, wired to emit live events.""" + + functions: dict[str, Callable[..., Any]] = {} + +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} + + async def _create_chart( + chart_type: str, + title: str, + data: list[dict[str, Any]], + series: list[dict[str, Any]] | None = None, + x_key: str = "x", + style: dict[str, Any] | None = None, + ) -> str: + spec = create_chart( + chart_type=chart_type, # type: ignore[arg-type] + title=title, + data=data, + series=series, + x_key=x_key, + style=style, + ) + if emit is not None: + await emit( + "create_chart_tool", + { + "chart_type": chart_type, + "title": title, + "data": data, + "series": series, + "x_key": x_key, + "style": style, + }, + spec, + ) + return spec + + functions["create_chart"] = _create_chart +{%- endif %} + +{%- if cookiecutter.enable_antv_charts %} + + async def _create_map( + title: str, + markers: list[dict[str, Any]], + center: list[float] | None = None, + zoom: int | None = None, + ) -> str: + spec = create_map(title=title, markers=markers, center=center, zoom=zoom) + if emit is not None: + await emit( + "create_map_tool", + {"title": title, "markers": markers, "center": center, "zoom": zoom}, + spec, + ) + return spec + + functions["create_map"] = _create_map +{%- endif %} + + def _current_datetime() -> dict[str, str]: + return get_current_datetime() + + functions["current_datetime"] = _current_datetime + +{%- if cookiecutter.enable_antv_charts %} + + async def _emit_antv(tool_name: str, args: dict[str, Any]) -> str: + """Run an AntV chart tool and stream it to the live session like a real + tool call, so it renders as a chart card and gets persisted.""" + # Drop None values so the sidecar applies its own defaults. + clean = {k: v for k, v in args.items() if v is not None and v != ""} + url = await _call_antv(tool_name, clean) + if emit is not None and url.startswith("http"): + await emit(tool_name, clean, url) + return url + + async def _gen_waterfall( + title: str, + data: list[dict[str, Any]], + axisXTitle: str = "", + axisYTitle: str = "", + ) -> str: + return await _emit_antv( + "generate_waterfall_chart", + {"title": title, "data": data, "axisXTitle": axisXTitle, "axisYTitle": axisYTitle}, + ) + + async def _gen_sankey( + title: str, + data: list[dict[str, Any]], + nodeAlign: str = "center", + ) -> str: + return await _emit_antv( + "generate_sankey_chart", + {"title": title, "data": data, "nodeAlign": nodeAlign}, + ) + + async def _gen_funnel(title: str, data: list[dict[str, Any]]) -> str: + return await _emit_antv("generate_funnel_chart", {"title": title, "data": data}) + + async def _gen_treemap(title: str, data: list[dict[str, Any]]) -> str: + return await _emit_antv("generate_treemap_chart", {"title": title, "data": data}) + + async def _gen_radar(title: str, data: list[dict[str, Any]]) -> str: + return await _emit_antv("generate_radar_chart", {"title": title, "data": data}) + + async def _gen_histogram( + title: str, + data: list[Any], + binNumber: int | None = None, + axisXTitle: str = "", + axisYTitle: str = "", + ) -> str: + return await _emit_antv( + "generate_histogram_chart", + { + "title": title, + "data": data, + "binNumber": binNumber, + "axisXTitle": axisXTitle, + "axisYTitle": axisYTitle, + }, + ) + + async def _gen_boxplot( + title: str, + data: list[dict[str, Any]], + axisXTitle: str = "", + axisYTitle: str = "", + ) -> str: + return await _emit_antv( + "generate_boxplot_chart", + {"title": title, "data": data, "axisXTitle": axisXTitle, "axisYTitle": axisYTitle}, + ) + + async def _gen_dual_axes( + title: str, + categories: list[Any], + series: list[dict[str, Any]], + axisXTitle: str = "", + ) -> str: + return await _emit_antv( + "generate_dual_axes_chart", + {"title": title, "categories": categories, "series": series, "axisXTitle": axisXTitle}, + ) + + functions["generate_waterfall_chart"] = _gen_waterfall + functions["generate_sankey_chart"] = _gen_sankey + functions["generate_funnel_chart"] = _gen_funnel + functions["generate_treemap_chart"] = _gen_treemap + functions["generate_radar_chart"] = _gen_radar + functions["generate_histogram_chart"] = _gen_histogram + functions["generate_boxplot_chart"] = _gen_boxplot + functions["generate_dual_axes_chart"] = _gen_dual_axes +{%- endif %} + + return functions + + +def _clip(text: str) -> str: + """Cap text handed back to the model so a huge result/error can't blow up the turn.""" + if len(text) > MAX_OUTPUT_CHARS: + return text[:MAX_OUTPUT_CHARS] + "\n…(output truncated)" + return text + + +def _format_result(stdout: str, output: Any) -> str: + """Combine captured stdout and the final expression value for the model.""" + parts: list[str] = [] + if stdout.strip(): + parts.append(f"stdout:\n{stdout.rstrip()}") + if output is not None: + try: + rendered = json.dumps(output, default=str) + except (TypeError, ValueError): + rendered = str(output) + parts.append(f"result: {rendered}") + text = "\n\n".join(parts) if parts else "(code ran successfully with no output)" + return _clip(text) + + +async def run_python(code: str, *, emit: EmitToolEvent | None = None) -> str: + """Execute model-written Python in the Monty sandbox and return its output. + + Args: + code: The Python source to run. A restricted stdlib subset (``math``, + ``asyncio``, ``json``, ``datetime``, ``re``) works, but modules like + ``statistics``/``random``/``itertools`` are unavailable. + emit: Optional callback used to stream visualizations created inside the + code to the live session. + + Returns: + The captured stdout plus the value of the final expression, or an error + message the model can read and recover from. + """ + limits: ResourceLimits = { + "max_duration_secs": settings.CODE_EXECUTION_TIMEOUT_SECS, + "max_allocations": settings.CODE_EXECUTION_MAX_ALLOCATIONS, + } + collector = CollectString() + try: + monty = await Monty.acreate(code) + output = await monty.run_async( + external_functions=_build_external_functions(emit), + print_callback=collector, + limits=limits, + ) + except MontyError as e: + return _clip(f"Execution failed: {e}") + except Exception as e: + logger.exception("run_python execution failed") + return _clip(f"Execution failed: {e}") + + return _format_result(collector.output, output) +{%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/backend/app/core/config.py b/template/{{cookiecutter.project_slug}}/backend/app/core/config.py index 5227905f..5cadcc45 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/core/config.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/core/config.py @@ -436,6 +436,12 @@ def REDIS_URL(self) -> str: # the basic charts that overlap create_chart and the China-only maps. ANTV_DISABLED_TOOLS: str = "" {%- endif %} +{%- if cookiecutter.enable_code_execution %} + + ENABLE_CODE_EXECUTION: bool = False + CODE_EXECUTION_TIMEOUT_SECS: float = 10.0 + CODE_EXECUTION_MAX_ALLOCATIONS: int = 50_000_000 +{%- endif %} {%- if cookiecutter.use_deepagents %} # === DeepAgents Configuration === diff --git a/template/{{cookiecutter.project_slug}}/backend/app/services/agent_session.py b/template/{{cookiecutter.project_slug}}/backend/app/services/agent_session.py index fad88e91..121dc9d1 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/services/agent_session.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/services/agent_session.py @@ -10,8 +10,14 @@ ``AgentSession.process_message``. """ +{%- if cookiecutter.enable_code_execution %} +import asyncio +{%- endif %} import logging from typing import Any +{%- if cookiecutter.enable_code_execution %} +from uuid import uuid4 +{%- endif %} from fastapi import WebSocket, WebSocketDisconnect from pydantic_ai import ( @@ -70,12 +76,21 @@ def __init__( {%- endif %} self.conversation_history: list[dict[str, str]] = [] self.deps = Deps() + self.deps.ask_user = self._ask_user +{%- if cookiecutter.enable_code_execution %} + self.deps.emit_tool_event = self._emit_tool_event + self._current_tool_calls: list[dict[str, Any]] | None = None + self._emit_lock = asyncio.Lock() +{%- endif %} {%- if cookiecutter.use_database %} self.current_conversation_id: str | None = None {%- endif %} async def process_message(self, data: dict[str, Any]) -> None: """Process one user turn: persist input, run the agent, stream events, persist output.""" + if data.get("type") == "ask_user_response": + return + user_message = data.get("message", "") file_ids = data.get("file_ids", []) @@ -140,10 +155,21 @@ async def process_message(self, data: dict[str, Any]) -> None: {%- endif %} collected_tool_calls: list[dict[str, Any]] = [] +{%- if cookiecutter.enable_code_execution %} + self._current_tool_calls = collected_tool_calls + try: + async with assistant.agent.iter( + user_input, deps=self.deps, message_history=model_history + ) as agent_run: + await self._stream_agent_run(agent_run, user_message, collected_tool_calls) + finally: + self._current_tool_calls = None +{%- else %} async with assistant.agent.iter( user_input, deps=self.deps, message_history=model_history ) as agent_run: await self._stream_agent_run(agent_run, user_message, collected_tool_calls) +{%- endif %} # Update in-memory history only after a complete agent run if agent_run.result is not None: @@ -196,6 +222,59 @@ async def process_message(self, data: dict[str, Any]) -> None: logger.exception(f"Error processing agent request: {e}") await send_event(self.websocket, "error", {"message": str(e)}) + async def _ask_user(self, questions: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Pause the run: ask the client questions and block until they answer. + + Emits an ``ask_user`` event with the whole batch, then reads frames off + this socket until an ``ask_user_response`` arrives. This is safe even + though the route also reads from the socket: while a tool runs, the agent + run (and therefore the route's receive loop) is suspended awaiting us, so + there is exactly one active reader. The client returns a list of answers + parallel to the questions ({answer, skipped}). + """ + await send_event(self.websocket, "ask_user", {"questions": questions}) + while True: + data = await self.websocket.receive_json() + if data.get("type") == "ask_user_response": + answers = data.get("answers") + return answers if isinstance(answers, list) else [] + # Ignore unrelated frames while questions are pending (UI is modal). + +{%- if cookiecutter.enable_code_execution %} + + async def _emit_tool_event( + self, tool_name: str, args: dict[str, Any], result: str + ) -> None: + """Surface a tool call made from *inside* the run_python sandbox. + + Emits the same ``tool_call`` + ``tool_result`` event pair the streaming + loop sends for normal tool calls, so an in-code ``create_chart`` renders + as an interactive card, and records it in the in-flight turn's tool-call + list so it is persisted with the message. + """ + tool_call_id = f"code-{uuid4().hex}" + async with self._emit_lock: + if self._current_tool_calls is not None: + self._current_tool_calls.append( + { + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "args": args, + "result": result, + } + ) + await send_event( + self.websocket, + "tool_call", + {"tool_call_id": tool_call_id, "tool_name": tool_name, "args": args}, + ) + await send_event( + self.websocket, + "tool_result", + {"tool_call_id": tool_call_id, "content": result}, + ) +{%- endif %} + {%- if cookiecutter.enable_billing and cookiecutter.enable_teams and cookiecutter.enable_credits_system and (cookiecutter.use_postgresql or cookiecutter.use_sqlite) %} async def _record_usage( self, @@ -397,7 +476,7 @@ async def _stream_tool_events( tc = { "tool_call_id": tool_event.part.tool_call_id, "tool_name": tool_event.part.tool_name, - "args": tool_event.part.args, + "args": tool_event.part.args_as_dict(raise_if_invalid=False), } collected_tool_calls.append(tc) pending[tool_event.part.tool_call_id] = tc @@ -2465,7 +2544,7 @@ async def _stream_tool_events( tc = { "tool_call_id": tool_event.part.tool_call_id, "tool_name": tool_event.part.tool_name, - "args": tool_event.part.args, + "args": tool_event.part.args_as_dict(raise_if_invalid=False), } collected_tool_calls.append(tc) pending[tool_event.part.tool_call_id] = tc diff --git a/template/{{cookiecutter.project_slug}}/backend/pyproject.toml b/template/{{cookiecutter.project_slug}}/backend/pyproject.toml index 2d204f0a..8b1a2163 100644 --- a/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +++ b/template/{{cookiecutter.project_slug}}/backend/pyproject.toml @@ -288,6 +288,9 @@ dependencies = [ "crewai-tools[mcp]>=1.0.0", {%- endif %} {%- endif %} +{%- if cookiecutter.enable_code_execution %} + "pydantic-monty>=0.0.18", +{%- endif %} {%- if cookiecutter.use_telegram or cookiecutter.use_slack %} "cryptography>=44.0.0", {%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx index 7580a711..de8986e0 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx @@ -9,7 +9,8 @@ import { FilePreviewPanel } from "./file-preview-panel"; import { MessageList } from "./message-list"; import { PendingMessages } from "./pending-messages"; import { ToolApprovalDialog } from "./tool-approval-dialog"; -import type { PendingApproval, Decision } from "@/types"; +import { QuestionPrompt } from "@/components/ui"; +import type { PendingApproval, AskUserQuestion, AskUserAnswer, Decision } from "@/types"; import { useConversationStore, useChatStore } from "@/stores"; import { useConversations } from "@/hooks"; {%- if cookiecutter.use_auth %} @@ -50,6 +51,8 @@ function AuthenticatedChatContainer() { setThinkingEffort, pendingApproval, sendResumeDecisions, + pendingQuestions, + sendAskUserResponses, } = useChat({ conversationId: currentConversationId, onConversationCreated: handleConversationCreated, @@ -238,6 +241,8 @@ function AuthenticatedChatContainer() { scrollContainerRef={scrollContainerRef} pendingApproval={pendingApproval} onResumeDecisions={sendResumeDecisions} + pendingQuestions={pendingQuestions} + onAnswerQuestions={sendAskUserResponses} /> ); } @@ -265,6 +270,8 @@ interface ChatUIProps { scrollContainerRef: React.RefObject; pendingApproval?: PendingApproval | null; onResumeDecisions?: (decisions: Decision[]) => void; + pendingQuestions?: AskUserQuestion[] | null; + onAnswerQuestions?: (answers: AskUserAnswer[]) => void; } function ChatUI({ @@ -285,6 +292,8 @@ function ChatUI({ scrollContainerRef, pendingApproval, onResumeDecisions, + pendingQuestions, + onAnswerQuestions, }: ChatUIProps) { return (
@@ -317,6 +326,17 @@ function ChatUI({
)} + {/* ask_user: interactive question card while the run is paused */} + {pendingQuestions && pendingQuestions.length > 0 && onAnswerQuestions && ( +
+ +
+ )} +
{queuedMessages && queuedMessages.length > 0 && onCancelQueued && ( @@ -325,7 +345,11 @@ function ChatUI({
w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } +{%- endif %} +{%- if cookiecutter.enable_antv_charts %} /** Render a server-rendered AntV diagram image with an "open full size" link. */ function AntvChartImage({ url, title }: { url: string; title: string }) { return ( @@ -542,6 +545,60 @@ function AntvChartImage({ url, title }: { url: string; title: string }) { } {%- endif %} +/** Pull the question texts out of an `ask_user` tool's args (object or + * JSON-string). Handles the `questions` list. Returns [] when none found. */ +function extractQuestions(args: unknown): string[] { + let obj: unknown = args; + if (typeof args === "string") { + try { + obj = JSON.parse(args); + } catch { + return []; + } + } + if (obj && typeof obj === "object" && Array.isArray((obj as { questions?: unknown }).questions)) { + return (obj as { questions: Array<{ question?: unknown }> }).questions.map((q) => + String(q?.question ?? ""), + ); + } + return []; +} + +/** Transcript view of an `ask_user` turn. Once answered, the result is already a + * "Q: …/A: …" transcript, so render it as-is; while waiting, list the + * questions that were asked. */ +function AskUserResult({ args, resultText }: { args: unknown; resultText: string }) { + if (resultText) { + return ( +

+ {resultText} +

+ ); + } + const questions = extractQuestions(args); + return ( +
+
+

+ {questions.length > 1 ? "Questions" : "Question"} +

+ {questions.length > 0 ? ( +
    + {questions.map((q, i) => ( +
  • {q}
  • + ))} +
+ ) : ( +

Waiting for the user…

+ )} +
+ {questions.length > 0 && ( +

Waiting for the user…

+ )} +
+ ); +} + // --- Main component --- export function ToolCallCard({ toolCall }: ToolCallCardProps) { @@ -549,25 +606,26 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { // formatted view for args + raw output (the button). Charts are the // exception: they're only useful when visible, so expand them by default. const [expanded, setExpanded] = useState( + toolCall.name === "ask_user" || {%- if cookiecutter.enable_charts and cookiecutter.enable_antv_charts %} - toolCall.status === "completed" && - ((toolCall.name === "create_chart_tool" && parseChartResult(toolCall.result) !== null) || - (toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || - (toolCall.name.startsWith("generate_") && - typeof toolCall.result === "string" && - parseAntvImageUrl(toolCall.result) !== null)), + (toolCall.status === "completed" && + ((toolCall.name === "create_chart_tool" && parseChartResult(toolCall.result) !== null) || + (toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || + (toolCall.name.startsWith("generate_") && + typeof toolCall.result === "string" && + parseAntvImageUrl(toolCall.result) !== null))), {%- elif cookiecutter.enable_charts %} - toolCall.name === "create_chart_tool" && - toolCall.status === "completed" && - parseChartResult(toolCall.result) !== null, + (toolCall.name === "create_chart_tool" && + toolCall.status === "completed" && + parseChartResult(toolCall.result) !== null), {%- elif cookiecutter.enable_antv_charts %} - toolCall.status === "completed" && - ((toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || - (toolCall.name.startsWith("generate_") && - typeof toolCall.result === "string" && - parseAntvImageUrl(toolCall.result) !== null)), + (toolCall.status === "completed" && + ((toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || + (toolCall.name.startsWith("generate_") && + typeof toolCall.result === "string" && + parseAntvImageUrl(toolCall.result) !== null))), {%- else %} - false, + false, {%- endif %} ); const [showRaw, setShowRaw] = useState(false); @@ -603,6 +661,7 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { ? parseWebSearch(toolCall.result) : null; const isWebSearch = webResults !== null; + const isAskUser = toolCall.name === "ask_user"; {%- if cookiecutter.enable_charts %} // Memoize the parsed chart spec — `parseChartResult` does `JSON.parse` for // string results, returning a NEW object each call. Without this memo, every @@ -649,7 +708,8 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { const hasSpecialRenderer = isDateTime || isRAGSearch || - isWebSearch + isWebSearch || + isAskUser {%- if cookiecutter.enable_charts %} || isChart {%- endif %} @@ -675,7 +735,11 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { : isAntvChart ? antvToolLabel(toolCall.name) {%- endif %} - : toolCall.name; + : isAskUser + ? "Question" + : toolCall.name === "run_python" + ? "Run Python" + : toolCall.name; const ToolIcon = isDateTime ? Clock @@ -693,7 +757,9 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { : isAntvChart ? BarChart3 {%- endif %} - : Wrench; + : isAskUser + ? MessageCircleQuestion + : Wrench; const toggleExpanded = () => { setExpanded((prev) => { @@ -779,6 +845,8 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { ) : toolCall.status === "completed" && isAntvChart && antvImageUrl ? ( {%- endif %} + ) : isAskUser ? ( + ) : ( )} diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts b/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts index 09c9a3e4..64167143 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts @@ -75,3 +75,5 @@ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ export { Checkbox } from "./checkbox"; export { RadioGroup, RadioGroupItem } from "./radio-group"; export { Spinner } from "./spinner"; +export { QuestionPrompt } from "./question-prompt"; +export type { QuestionPromptProps } from "./question-prompt"; diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/question-prompt.tsx b/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/question-prompt.tsx new file mode 100644 index 00000000..67c20d00 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/question-prompt.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { CornerDownLeft, Pencil, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "./button"; + +export interface QuestionPromptItem { + question: string; + options?: string[]; + /** Allow a free-form answer via the "Something else" field (default true). */ + allowCustom?: boolean; +} + +export interface QuestionPromptAnswer { + answer: string; + skipped: boolean; +} + +export interface QuestionPromptProps { + /** Questions to ask, in order. The card steps through them one at a time. */ + questions: QuestionPromptItem[]; + /** Disable all controls (e.g. while the socket is offline). */ + disabled?: boolean; + /** Called once every question has been answered or skipped. */ + onComplete: (answers: QuestionPromptAnswer[]) => void; +} + +/** + * Reusable question card. Steps through `questions` one at a time, collecting an + * answer (a chosen option, free text, or a skip) for each, then returns them all + * via `onComplete`. Keyboard: digit keys pick an option, ↑/↓ move focus, Enter + * selects the focused option (or submits the custom answer). + */ +export function QuestionPrompt({ questions, disabled = false, onComplete }: QuestionPromptProps) { + const [step, setStep] = useState(0); + const answersRef = useRef([]); + + if (questions.length === 0) return null; + const total = questions.length; + const current = questions[step]!; + + const commit = (a: QuestionPromptAnswer) => { + const next = [...answersRef.current, a]; + answersRef.current = next; + if (next.length >= total) onComplete(next); + else setStep((s) => s + 1); + }; + + const dismiss = () => { + const remaining = questions + .slice(answersRef.current.length) + .map(() => ({ answer: "", skipped: true })); + onComplete([...answersRef.current, ...remaining]); + }; + + return ( +
+
+ {total > 1 ? ( + + Question {step + 1} of {total} + + ) : ( + + )} + +
+ + = total} + disabled={disabled} + onAnswer={(text) => commit({ answer: text, skipped: false })} + onSkip={() => commit({ answer: "", skipped: true })} + /> +
+ ); +} + +interface SingleQuestionProps { + question: string; + options: string[]; + allowCustom: boolean; + isLast: boolean; + disabled: boolean; + onAnswer: (answer: string) => void; + onSkip: () => void; +} + +function SingleQuestion({ + question, + options, + allowCustom, + isLast, + disabled, + onAnswer, + onSkip, +}: SingleQuestionProps) { + const hasOptions = options.length > 0; + const [focusIdx, setFocusIdx] = useState(0); + // Open the free-form field straight away when there are no options to pick. + const [customOpen, setCustomOpen] = useState(allowCustom && !hasOptions); + const [customText, setCustomText] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (customOpen) inputRef.current?.focus(); + else containerRef.current?.focus(); + }, [customOpen]); + + const submitCustom = () => { + const text = customText.trim(); + if (text) onAnswer(text); + }; + + const onListKeyDown = (e: KeyboardEvent) => { + if (disabled || customOpen || !hasOptions) return; + if (/^[1-9]$/.test(e.key)) { + const idx = Number(e.key) - 1; + if (idx < options.length) { + e.preventDefault(); + onAnswer(options[idx]!); + } + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setFocusIdx((i) => Math.min(i + 1, options.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + onAnswer(options[focusIdx]!); + } + }; + + return ( +
+

{question}

+ + {hasOptions && ( +
    + {options.map((option, i) => { + const focused = i === focusIdx && !customOpen; + return ( +
  • + +
  • + ); + })} +
+ )} + + {/* Custom answer / skip row */} +
+ {customOpen ? ( +
+ + setCustomText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitCustom(); + } + }} + className="text-foreground placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none" + /> + +
+ ) : ( +
+ {allowCustom ? ( + + ) : ( + + )} + +
+ )} +
+
+ ); +} diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts b/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts index 5cdde9de..002a6049 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts +++ b/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts @@ -9,6 +9,8 @@ import { useChatStore, useAuthStore, useKBSelectionStore } from "@/stores"; import { useChatStore, useAuthStore } from "@/stores"; {%- endif %} import type { + AskUserAnswer, + AskUserQuestion, ChatMessageFile, Decision, PendingApproval, @@ -69,6 +71,7 @@ export function useChat(options: UseChatOptions = {}) { const thinkingEffortRef = useRef<"low" | "medium" | "high" | null>(null); // Human-in-the-Loop: pending tool approval state const [pendingApproval, setPendingApproval] = useState(null); + const [pendingQuestions, setPendingQuestions] = useState(null); const handleWebSocketMessage = useCallback( (event: MessageEvent) => { @@ -414,6 +417,20 @@ export function useChat(options: UseChatOptions = {}) { break; } + case "ask_user": { + const { questions } = wsEvent.data as { + questions: { question: string; options: string[]; allow_custom: boolean }[]; + }; + setPendingQuestions( + (questions ?? []).map((q) => ({ + question: q.question, + options: q.options ?? [], + allowCustom: q.allow_custom, + })), + ); + break; + } + case "complete": { setIsProcessing(false); // Clear currentMessageId after complete (message_saved should have handled ID mapping) @@ -560,6 +577,15 @@ export function useChat(options: UseChatOptions = {}) { [updateMessage, sendMessage], ); + const sendAskUserResponses = useCallback( + (answers: AskUserAnswer[]) => { + if (!isConnected) return; + setPendingQuestions(null); + sendMessage({ type: "ask_user_response", answers }); + }, + [isConnected, sendMessage], + ); + // Drain message queue when processing finishes AND we're back online. // Re-runs on either flip so a reconnect after offline → drains; a busy turn // ending → drains the next one. @@ -598,5 +624,7 @@ export function useChat(options: UseChatOptions = {}) { // Human-in-the-Loop support pendingApproval, sendResumeDecisions, + pendingQuestions, + sendAskUserResponses, }; } diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts b/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts index 3a953f97..9d4834a8 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts +++ b/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts @@ -142,6 +142,7 @@ export type WSEventType = | "message_saved" // DeepAgents Human-in-the-Loop event | "tool_approval_required" + | "ask_user" // CrewAI-specific events | "crew_start" | "crew_started" @@ -236,3 +237,22 @@ export interface ToolApprovalRequiredEvent { review_configs: ReviewConfig[]; }; } + +export interface AskUserQuestion { + question: string; + options: string[]; + /** Whether the user may type a free-form answer instead of picking an option. */ + allowCustom: boolean; +} + +export interface AskUserAnswer { + answer: string; + skipped: boolean; +} + +export interface AskUserEvent { + type: "ask_user"; + data: { + questions: { question: string; options: string[]; allow_custom: boolean }[]; + }; +} diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 00e08310..3157bc2b 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -305,6 +305,15 @@ def test_full_project_valid_python_syntax(self, generated_project_full: Path) -> enable_charts=True, background_tasks=BackgroundTaskType.NONE, ), + "pydantic_ai_code_execution": dict( + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.PYDANTIC_AI, + enable_logfire=False, + enable_code_execution=True, + enable_charts=True, + enable_antv_charts=True, + background_tasks=BackgroundTaskType.NONE, + ), "rag_pgvector": dict( database=DatabaseType.POSTGRESQL, background_tasks=BackgroundTaskType.NONE, From 85debd8c17bffd6053392dd04e374e6856968573 Mon Sep 17 00:00:00 2001 From: OchnikBartek Date: Tue, 9 Jun 2026 16:29:12 +0200 Subject: [PATCH 5/5] add SkillsToolset support (load SKILL.md skills) --- fastapi_gen/cli.py | 8 +++ fastapi_gen/config.py | 2 + fastapi_gen/prompts.py | 32 ++++++++++ template/VARIABLES.md | 1 + template/cookiecutter.json | 1 + .../backend/app/agents/assistant.py | 17 ++++- .../backend/pyproject.toml | 3 + .../src/components/chat/tool-call-card.tsx | 63 +++++++++++++++++++ tests/test_message_ratings.py | 20 +++--- tests/test_template_integration.py | 24 +++++-- 10 files changed, 154 insertions(+), 17 deletions(-) diff --git a/fastapi_gen/cli.py b/fastapi_gen/cli.py index b5dfb52e..940c02d1 100644 --- a/fastapi_gen/cli.py +++ b/fastapi_gen/cli.py @@ -484,6 +484,12 @@ def new(output: Path | None, no_input: bool, name: str | None, minimal: bool) -> default=False, help="Enable the run_python code-execution tool backed by the Monty sandbox (PydanticAI only)", ) +@click.option( + "--skills", + is_flag=True, + default=False, + help="Enable the skills system (SkillsToolset loads SKILL.md files from backend/skills/, PydanticAI only)", +) @click.option("--session-management", is_flag=True, help="Enable session management") @click.option( "--reverse-proxy", @@ -761,6 +767,7 @@ def create( charts: bool, antv_charts: bool, code_execution: bool, + skills: bool, session_management: bool, reverse_proxy: str, kubernetes: bool, @@ -1191,6 +1198,7 @@ def create( enable_charts=charts, enable_antv_charts=antv_charts, enable_code_execution=code_execution, + enable_skills=skills, enable_session_management=session_management, reverse_proxy=_rp_map[reverse_proxy], enable_kubernetes=kubernetes, diff --git a/fastapi_gen/config.py b/fastapi_gen/config.py index bf1c9e79..73268186 100644 --- a/fastapi_gen/config.py +++ b/fastapi_gen/config.py @@ -327,6 +327,7 @@ class ProjectConfig(BaseModel): enable_charts: bool = False enable_antv_charts: bool = False enable_code_execution: bool = False + enable_skills: bool = False use_telegram: bool = False use_slack: bool = False enable_cors: bool = True @@ -815,6 +816,7 @@ def to_cookiecutter_context(self) -> dict[str, Any]: "charts_channel_png": self.enable_charts and (self.use_slack or self.use_telegram), "enable_antv_charts": self.enable_antv_charts, "enable_code_execution": self.enable_code_execution, + "enable_skills": self.enable_skills, "enable_webhooks": self.enable_webhooks, # Legacy fixed values (WebSocket always uses JWT) "websocket_auth": "jwt", diff --git a/fastapi_gen/prompts.py b/fastapi_gen/prompts.py index c919df1a..2afbb958 100644 --- a/fastapi_gen/prompts.py +++ b/fastapi_gen/prompts.py @@ -948,6 +948,28 @@ def prompt_code_execution() -> bool: ) +def prompt_skills() -> bool: + """Prompt for the skills system (SkillsToolset, PydanticAI only).""" + console.print() + console.print("[bold cyan]Skills System (pydantic-ai-skills)[/]") + console.print( + "Adds a SkillsToolset that loads SKILL.md files from `backend/skills/` as " + "agent tools. Drop in your own skills; pair with code execution (Monty " + "sandbox) for skills that compute. PydanticAI only." + ) + console.print() + + return cast( + bool, + _check_cancelled( + questionary.confirm( + "Enable the skills system (PydanticAI only)?", + default=False, + ).ask() + ), + ) + + def prompt_langsmith() -> bool: """Prompt for LangSmith observability.""" return cast( @@ -1459,6 +1481,7 @@ def run_interactive_prompts() -> ProjectConfig: "enable_charts": False, "enable_antv_charts": False, "enable_code_execution": False, + "enable_skills": False, "rag_features": RAGFeatures(), "orm_type": OrmType.SQLALCHEMY, "sandbox_backend": "state", @@ -1629,6 +1652,12 @@ def step_code_execution() -> None: else: state["enable_code_execution"] = False + def step_skills() -> None: + if state["ai_framework"] == AIFrameworkType.PYDANTIC_AI: + state["enable_skills"] = prompt_skills() + else: + state["enable_skills"] = False + def step_langsmith() -> None: if state["ai_framework"] in ( AIFrameworkType.LANGCHAIN, @@ -1701,6 +1730,7 @@ def step_marketing() -> None: ("Chart Tool", step_charts), ("AntV Diagrams & Maps", step_antv_charts), ("Code Execution", step_code_execution), + ("Skills System", step_skills), ("LangSmith", step_langsmith), ("Messaging Channels", step_channels), ("Teams & Billing", step_teams_billing), @@ -1749,6 +1779,7 @@ def step_marketing() -> None: enable_charts = state["enable_charts"] enable_antv_charts = state["enable_antv_charts"] enable_code_execution = state["enable_code_execution"] + enable_skills = state["enable_skills"] rag_features = state["rag_features"] enable_langsmith = state["enable_langsmith"] use_telegram = state["use_telegram"] @@ -1793,6 +1824,7 @@ def step_marketing() -> None: enable_charts=enable_charts, enable_antv_charts=enable_antv_charts, enable_code_execution=enable_code_execution, + enable_skills=enable_skills, use_telegram=use_telegram, use_slack=use_slack, rate_limit_requests=rate_limit_requests, diff --git a/template/VARIABLES.md b/template/VARIABLES.md index df9cad31..85908a10 100644 --- a/template/VARIABLES.md +++ b/template/VARIABLES.md @@ -288,6 +288,7 @@ These variables are set automatically by the generator. | `charts_channel_png` | bool | `false` | Computed: render charts to PNG for messaging channels | `enable_charts` and (`use_slack` or `use_telegram`) | | `enable_antv_charts` | bool | `false` | Adds the interactive `create_map` tool (Leaflet/OpenStreetMap, works out of the box) plus AntV advanced-diagram tools (flowchart, mind-map, org-chart, sankey, ...) via an `mcp-server-chart` Docker sidecar. Wired into all 5 frameworks. The AntV diagrams need the sidecar running (`docker compose --profile antv up -d`) and `ENABLE_ANTV_CHARTS=true` at runtime; maps work without it. Present in the production compose but profile-gated — off by default and publishes no host port, so it's opt-in per environment; for prod, self-host GPT-Vis-SSR via `ANTV_VIS_REQUEST_SERVER` rather than AntV's public render backend | Requires an AI framework | | `enable_code_execution` | bool | `false` | Adds a `run_python` tool backed by the Monty sandboxed Python interpreter (`pydantic-monty`). The model can compute projections, run aggregations, and call `create_chart`/`create_map` from inside the sandbox — charts render as interactive Recharts cards immediately. Sandbox allows `math`, `asyncio`, `json`, `datetime`, `re`; no `statistics`, `random`, `itertools`, numpy/pandas. Activated at runtime with `ENABLE_CODE_EXECUTION=true`. **PydanticAI only.** Temporary shim — swap to the official `CodeExecutionToolset` when it ships in pydantic-ai | Requires `use_pydantic_ai` | +| `enable_skills` | bool | `false` | Adds a `pydantic-ai-skills` `SkillsToolset` that loads `SKILL.md` files from `backend/skills/` as agent tools (the model picks a skill, then follows its instructions). Ships the loader only — drop your own skills into `backend/skills/`; the toolset no-ops when the directory is empty. Pairs well with `enable_code_execution` for skills that compute. **PydanticAI only.** | Requires `use_pydantic_ai` | **Notes:** diff --git a/template/cookiecutter.json b/template/cookiecutter.json index 600afa4d..c09fee88 100644 --- a/template/cookiecutter.json +++ b/template/cookiecutter.json @@ -93,6 +93,7 @@ "charts_channel_png": false, "enable_antv_charts": false, "enable_code_execution": false, + "enable_skills": false, "enable_webhooks": false, "websocket_auth": "jwt", "websocket_auth_jwt": true, diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py index 4d43c4c4..b09de32c 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py @@ -64,6 +64,11 @@ from app.agents.tools.code_execution import EmitToolEvent from app.agents.tools.code_execution import run_python as run_python_code {%- endif %} +{%- if cookiecutter.enable_skills %} +from pathlib import Path + +from pydantic_ai_skills import SkillsToolset +{%- endif %} from app.core.config import settings logger = logging.getLogger(__name__) @@ -225,6 +230,16 @@ def _create_agent(self) -> Agent[Deps, str]: # None when AntV is disabled or the sidecar is unavailable. antv_toolset = get_antv_toolset() toolsets = [antv_toolset] if antv_toolset is not None else [] +{%- else %} +{%- if cookiecutter.enable_skills %} + toolsets: list = [] +{%- endif %} +{%- endif %} +{%- if cookiecutter.enable_skills %} + + skills_dir = Path(__file__).parent.parent.parent / "skills" + if skills_dir.exists(): + toolsets.append(SkillsToolset(directories=[str(skills_dir)])) {%- endif %} agent = Agent[Deps, str]( @@ -232,7 +247,7 @@ def _create_agent(self) -> Agent[Deps, str]: model_settings=model_settings, system_prompt=self.system_prompt, capabilities=capabilities, -{%- if cookiecutter.enable_antv_charts %} +{%- if cookiecutter.enable_antv_charts or cookiecutter.enable_skills %} toolsets=toolsets, {%- endif %} ) diff --git a/template/{{cookiecutter.project_slug}}/backend/pyproject.toml b/template/{{cookiecutter.project_slug}}/backend/pyproject.toml index 8b1a2163..d02b6e8b 100644 --- a/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +++ b/template/{{cookiecutter.project_slug}}/backend/pyproject.toml @@ -291,6 +291,9 @@ dependencies = [ {%- if cookiecutter.enable_code_execution %} "pydantic-monty>=0.0.18", {%- endif %} +{%- if cookiecutter.enable_skills %} + "pydantic-ai-skills>=0.11.0", +{%- endif %} {%- if cookiecutter.use_telegram or cookiecutter.use_slack %} "cryptography>=44.0.0", {%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx index 68fee586..9caab753 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx @@ -520,6 +520,42 @@ function antvToolLabel(name: string): string { } {%- endif %} +{%- if cookiecutter.enable_skills %} +/** "market_data" -> "Market Data", "fire" -> "Fire". */ +function formatSkillName(name: string): string { + return name + .split("_") + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +/** Extract the description text from a `load_skill` XML result. + * The library returns . */ +function parseLoadSkillResult(result: string): { description: string } | null { + const m = result.match(/([\s\S]*?)<\/description>/); + if (!m) return null; + return { description: m[1].trim() }; +} + +/** Clean card for a loaded skill — just the description, no raw XML. */ +function LoadSkillResult({ resultText, status }: { resultText: string; status: string }) { + if (!resultText || status !== "completed") { + return ( +

+ {status === "error" ? "Failed to load skill." : "Loading…"} +

+ ); + } + const parsed = parseLoadSkillResult(resultText); + if (!parsed) return null; + + return ( +

{parsed.description}

+ ); +} +{%- endif %} + {%- if cookiecutter.enable_antv_charts %} /** Render a server-rendered AntV diagram image with an "open full size" link. */ function AntvChartImage({ url, title }: { url: string; title: string }) { @@ -662,6 +698,15 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { : null; const isWebSearch = webResults !== null; const isAskUser = toolCall.name === "ask_user"; +{%- if cookiecutter.enable_skills %} + // pydantic-ai-skills tool names + const isLoadSkill = toolCall.name === "load_skill"; + const isListSkills = toolCall.name === "list_skills"; + const loadedSkillName = + isLoadSkill && typeof toolCall.args?.skill_name === "string" + ? toolCall.args.skill_name + : null; +{%- endif %} {%- if cookiecutter.enable_charts %} // Memoize the parsed chart spec — `parseChartResult` does `JSON.parse` for // string results, returning a NEW object each call. Without this memo, every @@ -737,9 +782,21 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { {%- endif %} : isAskUser ? "Question" +{%- if cookiecutter.enable_skills %} + : isLoadSkill + ? loadedSkillName + ? formatSkillName(loadedSkillName) + : "Load Skill" + : isListSkills + ? "Available Skills" + : toolCall.name === "run_python" + ? "Run Python" + : toolCall.name; +{%- else %} : toolCall.name === "run_python" ? "Run Python" : toolCall.name; +{%- endif %} const ToolIcon = isDateTime ? Clock @@ -847,7 +904,13 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { {%- endif %} ) : isAskUser ? ( +{%- if cookiecutter.enable_skills %} + ) : isLoadSkill ? ( + + ) : isListSkills ? null : ( +{%- else %} ) : ( +{%- endif %} )} diff --git a/tests/test_message_ratings.py b/tests/test_message_ratings.py index f6b251ed..b0d0deb4 100644 --- a/tests/test_message_ratings.py +++ b/tests/test_message_ratings.py @@ -315,10 +315,10 @@ def test_rating_model_passes_ruff(self, project_with_ratings: Path) -> None: / "message_rating.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(model_path)], + ["uvx", "ruff", "check", str(model_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for message_rating.py:\n{result.stdout}" @@ -329,10 +329,10 @@ def test_rating_repository_passes_ruff(self, project_with_ratings: Path) -> None project_with_ratings / "backend" / "app" / "repositories" / "message_rating.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(repo_path)], + ["uvx", "ruff", "check", str(repo_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for rating repository:\n{result.stdout}" @@ -343,10 +343,10 @@ def test_rating_service_passes_ruff(self, project_with_ratings: Path) -> None: project_with_ratings / "backend" / "app" / "services" / "message_rating.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(service_path)], + ["uvx", "ruff", "check", str(service_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for rating service:\n{result.stdout}" @@ -357,10 +357,10 @@ def test_rating_schemas_pass_ruff(self, project_with_ratings: Path) -> None: project_with_ratings / "backend" / "app" / "schemas" / "message_rating.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(schema_path)], + ["uvx", "ruff", "check", str(schema_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for rating schemas:\n{result.stdout}" @@ -377,10 +377,10 @@ def test_admin_ratings_route_passes_ruff(self, project_with_ratings: Path) -> No / "admin_ratings.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(route_path)], + ["uvx", "ruff", "check", str(route_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for admin_ratings route:\n{result.stdout}" diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 3157bc2b..7fce07d8 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -90,10 +90,10 @@ def test_minimal_project_passes_ruff(self, generated_project_minimal: Path) -> N """Test minimal project passes ruff check.""" backend_path = generated_project_minimal / "backend" result = subprocess.run( - ["uv", "run", "ruff", "check", str(backend_path)], + ["uvx", "ruff", "check", str(backend_path)], capture_output=True, text=True, - cwd=generated_project_minimal, + cwd=backend_path, ) assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}" @@ -102,10 +102,10 @@ def test_full_project_passes_ruff(self, generated_project_full: Path) -> None: """Test full project passes ruff check.""" backend_path = generated_project_full / "backend" result = subprocess.run( - ["uv", "run", "ruff", "check", str(backend_path)], + ["uvx", "ruff", "check", str(backend_path)], capture_output=True, text=True, - cwd=generated_project_full, + cwd=backend_path, ) assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}" @@ -314,6 +314,18 @@ def test_full_project_valid_python_syntax(self, generated_project_full: Path) -> enable_antv_charts=True, background_tasks=BackgroundTaskType.NONE, ), + # SkillsToolset wiring: enable_skills attaches the toolset (no bundled + # skills here, so it no-ops at runtime) — generated together with code + # execution to confirm the two coexist and lint/type-check cleanly. + "pydantic_ai_skills": dict( + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.PYDANTIC_AI, + enable_logfire=False, + enable_skills=True, + enable_code_execution=True, + enable_charts=True, + background_tasks=BackgroundTaskType.NONE, + ), "rag_pgvector": dict( database=DatabaseType.POSTGRESQL, background_tasks=BackgroundTaskType.NONE, @@ -359,10 +371,10 @@ class TestGeneratedTemplateMatrix: def test_passes_ruff(self, matrix_project: Path) -> None: backend_path = matrix_project / "backend" result = subprocess.run( - ["uv", "run", "ruff", "check", str(backend_path)], + ["uvx", "ruff", "check", str(backend_path)], capture_output=True, text=True, - cwd=matrix_project, + cwd=backend_path, ) assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}"