chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,45 @@
# ========================================
# API Keys
# ========================================
# Google API Key (for ADK agents: Orchestrator, Budget, Restaurant, Weather)
# Get your key from: https://aistudio.google.com/app/apikey
GOOGLE_API_KEY=your_google_api_key_here
# OpenAI API Key (for LangGraph agents: Itinerary)
# Get your key from: https://platform.openai.com/api-keys
OPENAI_API_KEY=your_openai_api_key_here
# ========================================
# Agent URLs (Frontend → Agents)
# Optional - these are the defaults
# ========================================
# Orchestrator (ADK + AG-UI Protocol)
ORCHESTRATOR_URL=http://localhost:9000
# LangGraph Agents (Python + OpenAI + A2A Protocol)
ITINERARY_AGENT_URL=http://localhost:9001
RESTAURANT_AGENT_URL=http://localhost:9003
# ADK Agents (Python + Gemini + A2A Protocol)
BUDGET_AGENT_URL=http://localhost:9002
WEATHER_AGENT_URL=http://localhost:9005
# ========================================
# Agent Ports (Python Agents)
# Optional - used by Python agents to bind to specific ports
# ========================================
# Orchestrator
ORCHESTRATOR_PORT=9000
# LangGraph Agents
ITINERARY_PORT=9001
RESTAURANT_PORT=9003
# ADK Agents
BUDGET_PORT=9002
WEATHER_PORT=9005
+100
View File
@@ -0,0 +1,100 @@
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
*.pyc
venv/
env/
ENV/
env.bak/
venv.bak/
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
lerna-debug.log*
.next/
out/
.turbo
.vercel
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Testing
coverage/
.coverage
.pytest_cache/
.nyc_output/
htmlcov/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
pnpm-debug.log*
# Temporary files
*.tmp
*.temp
.cache/
.parcel-cache/
# OS
Thumbs.db
Desktop.ini
# ADK specific
.adk/
*.evalset.json.lock
# Build outputs
*.tsbuildinfo
# API keys and secrets (extra safety)
*_secret*
*_key*
*_token*
!.env.example
*.md
!README.md
examples/
docs/
.cursorrules
context/
starter/
+168
View File
@@ -0,0 +1,168 @@
# AG-UI + A2A Multi-Agent Communication Demo
<img width="1600" height="1040" alt="How to Make Agents Talk to Each Other (and Your App) Using A2A + AG-UI (6)" src="https://github.com/user-attachments/assets/1bb698a1-d187-4666-b2c1-b558323de2c4" />
A demonstration of Agent-to-Agent (A2A) communication between different AI agent frameworks using the AG-UI Protocol and A2A Middleware.
## Quick Start
### Prerequisites
- Node.js 18+
- Python 3.10+
- [Google API Key](https://aistudio.google.com/app/apikey)
- [OpenAI API Key](https://platform.openai.com/api-keys)
### Setup
1. Install frontend dependencies:
```bash
npm install
```
2. Install Python dependencies:
```bash
cd agents
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -r requirements.txt
```
3. Configure environment variables:
```bash
cp .env.example .env
# Edit .env and add your GOOGLE_API_KEY and OPENAI_API_KEY
```
4. Start all services:
```bash
npm run dev
```
This starts:
- UI on `http://localhost:3000`
- Orchestrator on `http://localhost:9000`
- Itinerary Agent on `http://localhost:9001`
- Budget Agent on `http://localhost:9002`
- Restaurant Agent on `http://localhost:9003`
- Weather Agent on `http://localhost:9005`
## Usage
Try asking: "Plan a 3-day trip to Tokyo" or "I want to visit New York for 5 days"
The orchestrator will coordinate the agents to:
1. Collect trip requirements (destination, days, people, budget level)
2. Generate an itinerary
3. Provide weather forecast
4. Recommend restaurants for each day
5. Estimate budget and request approval
Agent interactions are visible in the UI with message flow visualization.
## What This Demonstrates
This demo shows how specialized agents built with different frameworks can communicate via the A2A protocol:
### LangGraph Agents (Python + OpenAI)
- **Itinerary Agent** (Port 9001) - Creates day-by-day travel itineraries
- **Restaurant Agent** (Port 9003) - Recommends meal plans
### ADK Agents (Python + Gemini)
- **Budget Agent** (Port 9002) - Estimates travel costs
- **Weather Agent** (Port 9005) - Provides weather forecasts
### Orchestrator
- **Orchestrator Agent** (Port 9000) - Coordinates all agents via A2A middleware
The demo includes multi-framework integration, structured JSON outputs, generative UI components, human-in-the-loop workflows, and real-time message visualization
## Architecture
```
┌──────────────────────────────────────────┐
│ Next.js UI (CopilotKit) │
└────────────┬─────────────────────────────┘
│ AG-UI Protocol
┌────────────┴─────────────────────────────┐
│ A2A Middleware │
│ - Routes messages between agents │
└──────┬───────────────────────────────────┘
│ A2A Protocol
├─────► LangGraph Agents (OpenAI)
│ ├── Itinerary (9001)
│ └── Restaurant (9003)
└─────► ADK Agents (Gemini)
├── Budget (9002)
└── Weather (9005)
┌──────┴──────────┐
│ Orchestrator │
│ (Port 9000) │
└─────────────────┘
```
## Project Structure
```
ag-ui-a2a-demo/
├── app/
│ ├── api/copilotkit/route.ts # A2A middleware setup
│ └── page.tsx # Main UI
├── components/
│ ├── a2a/ # A2A message components
│ ├── travel-chat.tsx # Chat orchestration
│ └── [other UI components]
├── agents/ # Python agents
│ ├── orchestrator.py # Orchestrator (9000)
│ ├── itinerary_agent.py # LangGraph (9001)
│ ├── budget_agent.py # ADK (9002)
│ ├── restaurant_agent.py # LangGraph (9003)
│ └── weather_agent.py # ADK (9005)
└── .env.example
```
## Technologies
- **Frontend**: Next.js, CopilotKit, AG-UI Client, Tailwind CSS
- **Backend**: Google ADK (Gemini), LangGraph (OpenAI), FastAPI
- **Protocols**: A2A (agent-to-agent), AG-UI (agent-UI)
- **Middleware**: @ag-ui/a2a-middleware
## Troubleshooting
**Agents not connecting?**
Verify all services are running by checking `http://localhost:9000-9005`
**Missing API keys?**
Ensure `.env` contains `GOOGLE_API_KEY` and `OPENAI_API_KEY`
**Python issues?**
Activate the virtual environment: `cd agents && source .venv/bin/activate`
## Learn More
- [AG-UI Protocol](https://docs.ag-ui.com)
- [A2A Protocol](https://github.com/agent-matrix/a2a)
- [Google ADK](https://google.github.io/adk-docs/)
- [LangGraph](https://langchain-ai.github.io/langgraph/)
- [CopilotKit](https://docs.copilotkit.ai)
## License
MIT
@@ -0,0 +1,257 @@
"""
Budget Agent (ADK + A2A Protocol)
This agent estimates travel costs and creates budgets using Google ADK.
It exposes an A2A Protocol endpoint so it can be called by the orchestrator.
"""
import uvicorn
import os
import json
from typing import List
from dotenv import load_dotenv
from pydantic import BaseModel, Field
load_dotenv()
# A2A Protocol imports
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentSkill,
)
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.utils import new_agent_text_message
# Google ADK imports
from google.adk.agents.llm_agent import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.artifacts import InMemoryArtifactService
from google.genai import types
class BudgetCategory(BaseModel):
category: str = Field(
description="Budget category name (e.g., Accommodation, Food)"
)
amount: float = Field(description="Amount in USD")
percentage: float = Field(description="Percentage of total budget")
class StructuredBudget(BaseModel):
totalBudget: float = Field(description="Total budget in USD")
currency: str = Field(default="USD", description="Currency code")
breakdown: List[BudgetCategory] = Field(description="Budget breakdown by category")
notes: str = Field(description="Additional notes about the budget estimate")
class BudgetAgent:
def __init__(self):
self._agent = self._build_agent()
self._user_id = "remote_agent"
self._runner = Runner(
app_name=self._agent.name,
agent=self._agent,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
)
def _build_agent(self) -> LlmAgent:
# Use native Gemini model directly
model_name = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
return LlmAgent(
model=model_name,
name="budget_agent",
description="An agent that estimates travel costs and creates detailed budget breakdowns",
instruction="""
You are a travel budget planning agent. Your role is to estimate realistic travel budgets based on user requests.
When you receive a travel request, analyze the destination, duration, and any other details provided.
Then create a detailed budget breakdown in JSON format.
Return ONLY a valid JSON object with this exact structure:
{
"totalBudget": 5000.00,
"currency": "USD",
"breakdown": [
{
"category": "Accommodation",
"amount": 1500.00,
"percentage": 30.0
},
{
"category": "Food & Dining",
"amount": 1000.00,
"percentage": 20.0
},
{
"category": "Transportation",
"amount": 800.00,
"percentage": 16.0
},
{
"category": "Activities & Attractions",
"amount": 1200.00,
"percentage": 24.0
},
{
"category": "Miscellaneous",
"amount": 500.00,
"percentage": 10.0
}
],
"notes": "Budget estimate based on mid-range travel for one person. Prices reflect average costs in the destination."
}
Make realistic estimates based on:
- The destination (cost of living in that location)
- Duration of the trip
- Type of travel (budget, mid-range, luxury if specified)
- Number of people if mentioned
- Any specific activities or requirements mentioned
Return ONLY valid JSON, no markdown code blocks, no other text.
""",
tools=[],
)
async def invoke(self, query: str, session_id: str) -> str:
session = await self._runner.session_service.get_session(
app_name=self._agent.name,
user_id=self._user_id,
session_id=session_id,
)
content = types.Content(role="user", parts=[types.Part.from_text(text=query)])
if session is None:
session = await self._runner.session_service.create_session(
app_name=self._agent.name,
user_id=self._user_id,
state={},
session_id=session_id,
)
response_text = ""
async for event in self._runner.run_async(
user_id=self._user_id, session_id=session.id, new_message=content
):
if event.is_final_response():
if (
event.content
and event.content.parts
and event.content.parts[0].text
):
response_text = "\n".join(
[p.text for p in event.content.parts if p.text]
)
break
content_str = response_text.strip()
if "```json" in content_str:
content_str = content_str.split("```json")[1].split("```")[0].strip()
elif "```" in content_str:
content_str = content_str.split("```")[1].split("```")[0].strip()
try:
structured_data = json.loads(content_str)
validated_budget = StructuredBudget(**structured_data)
final_response = json.dumps(validated_budget.model_dump(), indent=2)
print("✅ Successfully created structured budget")
return final_response
except json.JSONDecodeError as e:
print(f"❌ JSON parsing error: {e}")
print(f"Content: {content_str}")
return json.dumps(
{
"error": "Failed to generate structured budget",
"raw_content": content_str[:200],
}
)
except Exception as e:
print(f"❌ Validation error: {e}")
return json.dumps({"error": f"Validation failed: {str(e)}"})
port = int(os.getenv("BUDGET_PORT", 9002))
skill = AgentSkill(
id="budget_agent",
name="Budget Planning Agent",
description="Estimates travel costs and creates detailed budget breakdowns using ADK",
tags=["travel", "budget", "finance", "adk"],
examples=[
"Estimate the budget for a 3-day trip to Tokyo",
"How much would a week in Paris cost?",
"Create a budget for my New York trip",
],
)
cardUrl = os.getenv("RENDER_EXTERNAL_URL", f"http://localhost:{port}")
public_agent_card = AgentCard(
name="Budget Agent",
description="ADK-powered agent that estimates travel budgets and creates cost breakdowns",
url=cardUrl,
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
skills=[skill],
supportsAuthenticatedExtendedCard=False,
)
class BudgetAgentExecutor(AgentExecutor):
def __init__(self):
self.agent = BudgetAgent()
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
query = context.get_user_input()
session_id = getattr(context, "context_id", "default_session")
final_content = await self.agent.invoke(query, session_id)
await event_queue.enqueue_event(new_agent_text_message(final_content))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise Exception("cancel not supported")
def main():
if not os.getenv("GOOGLE_API_KEY") and not os.getenv("GEMINI_API_KEY"):
print("⚠️ Warning: No API key found!")
print(" Set either GOOGLE_API_KEY or GEMINI_API_KEY environment variable")
print(" Example: export GOOGLE_API_KEY='your-key-here'")
print(" Get a key from: https://aistudio.google.com/app/apikey")
print()
request_handler = DefaultRequestHandler(
agent_executor=BudgetAgentExecutor(),
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
extended_agent_card=public_agent_card,
)
print(f"💰 Starting Budget Agent (ADK + A2A) on http://0.0.0.0:{port}")
print(f" Agent: {public_agent_card.name}")
print(f" Description: {public_agent_card.description}")
uvicorn.run(server.build(), host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
@@ -0,0 +1,247 @@
"""
Itinerary Agent (LangGraph + A2A Protocol)
This agent creates day-by-day travel itineraries using LangGraph.
It exposes an A2A Protocol endpoint so it can be called by the orchestrator.
"""
import uvicorn
import json
import os
from dotenv import load_dotenv
load_dotenv()
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Message
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.utils import new_agent_text_message
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from typing import TypedDict, List, Optional
from pydantic import BaseModel, Field
class TimeSlot(BaseModel):
activities: List[str] = Field(description="List of activities for this time slot")
location: str = Field(description="Main location for these activities")
class Meals(BaseModel):
breakfast: str = Field(description="Breakfast recommendation with place name")
lunch: str = Field(description="Lunch recommendation with place name")
dinner: str = Field(description="Dinner recommendation with place name")
class DayItinerary(BaseModel):
day: int = Field(description="Day number")
title: str = Field(description="Title or theme for this day")
morning: TimeSlot = Field(description="Morning activities")
afternoon: TimeSlot = Field(description="Afternoon activities")
evening: TimeSlot = Field(description="Evening activities")
meals: Meals = Field(description="Meal recommendations for the day")
class StructuredItinerary(BaseModel):
destination: str = Field(description="Travel destination")
days: int = Field(description="Number of days")
itinerary: List[DayItinerary] = Field(description="Day-by-day itinerary")
class ItineraryState(TypedDict):
destination: str
days: int
message: str
itinerary: str
structured_itinerary: Optional[dict]
class ItineraryAgent:
def __init__(self):
self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
self.graph = self._build_graph()
def _build_graph(self):
workflow = StateGraph(ItineraryState)
workflow.add_node("parse_request", self._parse_request)
workflow.add_node("create_itinerary", self._create_itinerary)
workflow.set_entry_point("parse_request")
workflow.add_edge("parse_request", "create_itinerary")
workflow.add_edge("create_itinerary", END)
return workflow.compile()
def _parse_request(self, state: ItineraryState) -> ItineraryState:
message = state["message"]
prompt = f"""
Extract the destination and number of days from this travel request.
Return ONLY a JSON string with 'destination' and 'days' fields.
Request: {message}
Example output: {{"destination": "Tokyo", "days": 3}}
"""
response = self.llm.invoke(prompt)
print(response.content)
try:
parsed = json.loads(response.content)
state["destination"] = parsed.get("destination", "Unknown")
state["days"] = int(parsed.get("days", 3))
except:
state["destination"] = "Unknown"
state["days"] = 3
return state
def _create_itinerary(self, state: ItineraryState) -> ItineraryState:
destination = state["destination"]
days = state["days"]
prompt = f"""
Create a detailed {days}-day travel itinerary for {destination}.
Return ONLY a valid JSON object with this exact structure:
{{
"destination": "{destination}",
"days": {days},
"itinerary": [
{{
"day": 1,
"title": "Day theme/title",
"morning": {{
"activities": ["Activity 1", "Activity 2"],
"location": "Main area/neighborhood"
}},
"afternoon": {{
"activities": ["Activity 1", "Activity 2"],
"location": "Main area/neighborhood"
}},
"evening": {{
"activities": ["Activity 1", "Activity 2"],
"location": "Main area/neighborhood"
}},
"meals": {{
"breakfast": "Restaurant name and dish",
"lunch": "Restaurant name and dish",
"dinner": "Restaurant name and dish"
}}
}}
]
}}
Make it realistic, interesting, and include specific place names.
Return ONLY valid JSON, no markdown, no other text.
"""
response = self.llm.invoke(prompt)
content = response.content.strip()
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
try:
structured_data = json.loads(content)
validated_itinerary = StructuredItinerary(**structured_data)
state["structured_itinerary"] = validated_itinerary.model_dump()
state["itinerary"] = json.dumps(validated_itinerary.model_dump(), indent=2)
print("✅ Successfully created structured itinerary")
except json.JSONDecodeError as e:
print(f"❌ JSON parsing error: {e}")
print(f"Content: {content}")
state["itinerary"] = json.dumps(
{
"error": "Failed to generate structured itinerary",
"raw_content": content[:200],
}
)
state["structured_itinerary"] = None
except Exception as e:
print(f"❌ Validation error: {e}")
state["itinerary"] = json.dumps({"error": f"Validation failed: {str(e)}"})
state["structured_itinerary"] = None
return state
async def invoke(self, message: Message) -> str:
message_text = message.parts[0].root.text
print("Invoking itinerary agent with message: ", message_text)
result = self.graph.invoke(
{"message": message_text, "destination": "", "days": 3, "itinerary": ""}
)
return result["itinerary"]
port = int(os.getenv("ITINERARY_PORT", 9001))
skill = AgentSkill(
id="itinerary_agent",
name="Itinerary Planning Agent",
description="Creates detailed day-by-day travel itineraries using LangGraph",
tags=["travel", "itinerary", "langgraph"],
examples=[
"Create a 3-day itinerary for Tokyo",
"Plan a week-long trip to Paris",
"What should I do in New York for 5 days?",
],
)
cardUrl = os.getenv("RENDER_EXTERNAL_URL", f"http://localhost:{port}")
public_agent_card = AgentCard(
name="Itinerary Agent",
description="LangGraph-powered agent that creates detailed day-by-day travel itineraries in plain text format with activities and meal recommendations.",
url=cardUrl,
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
skills=[skill],
supportsAuthenticatedExtendedCard=False,
)
class ItineraryAgentExecutor(AgentExecutor):
def __init__(self):
self.agent = ItineraryAgent()
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
result = await self.agent.invoke(context.message)
await event_queue.enqueue_event(new_agent_text_message(result))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise Exception("cancel not supported")
def main():
if not os.getenv("OPENAI_API_KEY"):
print("⚠️ Warning: OPENAI_API_KEY environment variable not set!")
print(" Set it with: export OPENAI_API_KEY='your-key-here'")
print()
request_handler = DefaultRequestHandler(
agent_executor=ItineraryAgentExecutor(),
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
extended_agent_card=public_agent_card,
)
print(f"🗺️ Starting Itinerary Agent (LangGraph + A2A) on http://0.0.0.0:{port}")
uvicorn.run(server.build(), host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
@@ -0,0 +1,124 @@
"""
Orchestrator Agent (ADK + AG-UI Protocol)
This agent receives user requests via AG-UI Protocol and delegates tasks
to specialized A2A agents (Itinerary and Budget agents).
The A2A middleware in the frontend will wrap this agent and give it the
send_message_to_a2a_agent tool to communicate with other agents.
"""
from __future__ import annotations
from dotenv import load_dotenv
load_dotenv()
import os
import uvicorn
from fastapi import FastAPI
from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint
from google.adk.agents import LlmAgent
orchestrator_agent = LlmAgent(
name="OrchestratorAgent",
model="gemini-2.5-pro",
instruction="""
You are a travel planning orchestrator agent. Your role is to coordinate specialized agents
to create personalized travel plans.
AVAILABLE SPECIALIZED AGENTS:
1. **Itinerary Agent** (LangGraph) - Creates day-by-day travel itineraries with activities
2. **Restaurant Agent** (LangGraph) - Recommends restaurants for breakfast, lunch, and dinner by day
3. **Weather Agent** (ADK) - Provides weather forecasts and packing advice
4. **Budget Agent** (ADK) - Estimates travel costs and creates budget breakdowns
CRITICAL CONSTRAINTS:
- You MUST call agents ONE AT A TIME, never make multiple tool calls simultaneously
- After making a tool call, WAIT for the result before making another tool call
- Do NOT make parallel/concurrent tool calls - this is not supported
RECOMMENDED WORKFLOW FOR TRAVEL PLANNING:
0. **FIRST STEP - Gather Trip Requirements**:
- Before doing ANYTHING else, call 'gather_trip_requirements' to collect essential trip information
- Try to extract any mentioned details from the user's message (city, days, people, budget level)
- Pass any extracted values as parameters to pre-fill the form:
* city: Extract destination city if mentioned (e.g., "Paris", "Tokyo")
* numberOfDays: Extract if mentioned (e.g., "5 days", "a week")
* numberOfPeople: Extract if mentioned (e.g., "2 people", "family of 4")
* budgetLevel: Extract if mentioned (e.g., "budget", "luxury") -> map to Economy/Comfort/Premium
- Wait for the user to submit the complete requirements
- Use the returned values for all subsequent agent calls
1. **Itinerary Agent** - Create the base itinerary using trip requirements
- Pass: city, numberOfDays from trip requirements
- Wait for structured JSON response with day-by-day activities
- Note: Meals section will be empty initially
2. **Weather Agent** - Get weather forecast
- Pass: city and numberOfDays from trip requirements
- Wait for forecast with daily conditions and packing advice
- This helps inform activity planning
3. **Restaurant Agent** - Get meal recommendations
- Pass: city and numberOfDays from trip requirements
- Request day-by-day meal recommendations (breakfast, lunch, dinner)
- Wait for structured JSON with meals matching the itinerary days
- These will populate the meals section in the itinerary display
4. **Budget Agent** - Create comprehensive cost estimate
- Pass: city, numberOfDays, numberOfPeople, budgetLevel from trip requirements
- Wait for detailed budget breakdown
- This requires user approval via the request_budget_approval tool
IMPORTANT WORKFLOW DETAILS:
- ALWAYS START by calling 'gather_trip_requirements' FIRST before any agent calls
- The Itinerary Agent creates the structure but leaves meals empty
- The Restaurant Agent fills in the meals section with specific recommendations
- The Weather Agent provides context for outdoor activities and what to pack
- The Budget Agent runs last and requires human-in-the-loop approval
TRIP REQUIREMENTS EXTRACTION EXAMPLES:
- "Plan a trip to Paris" -> call gather_trip_requirements with city: "Paris"
- "5 day trip to Tokyo for 2 people" -> city: "Tokyo", numberOfDays: 5, numberOfPeople: 2
- "Budget vacation to Bali" -> city: "Bali", budgetLevel: "Economy"
- "Luxury 3-day getaway for my family of 4" -> numberOfDays: 3, numberOfPeople: 4, budgetLevel: "Premium"
- "Plan a trip to New York" -> city: "New York"
- "I want to visit Rome for a week" -> city: "Rome", numberOfDays: 7
RESPONSE STRATEGY:
- After each agent response, briefly acknowledge what you received
- Build up the travel plan incrementally as you gather information
- At the end, present a complete, well-organized travel plan
- Don't just list agent responses - synthesize them into a cohesive plan
IMPORTANT: Once you have received a response from an agent, do NOT call that same
agent again for the same information. Use the information you already have.
""",
)
# Expose the agent via AG-UI Protocol
adk_orchestrator_agent = ADKAgent(
adk_agent=orchestrator_agent,
app_name="orchestrator_app",
user_id="demo_user",
session_timeout_seconds=3600,
use_in_memory_services=True,
)
app = FastAPI(title="Travel Planning Orchestrator (ADK)")
add_adk_fastapi_endpoint(app, adk_orchestrator_agent, path="/")
if __name__ == "__main__":
if not os.getenv("GOOGLE_API_KEY"):
print("⚠️ Warning: GOOGLE_API_KEY environment variable not set!")
print(" Set it with: export GOOGLE_API_KEY='your-key-here'")
print(" Get a key from: https://aistudio.google.com/app/apikey")
print()
port = int(os.getenv("ORCHESTRATOR_PORT", 9000))
print(f"🚀 Starting Orchestrator Agent (ADK + AG-UI) on http://0.0.0.0:{port}")
uvicorn.run(app, host="0.0.0.0", port=port)
@@ -0,0 +1,64 @@
# ============================================================================
# Python Agent Dependencies
# ============================================================================
# This file contains all packages needed to run the multi-agent demo.
# Install with: pip install -r requirements.txt
# ============================================================================
# AG-UI Protocol Implementation
# ============================================================================
# ag-ui-adk: AG-UI Protocol adapter for Google ADK agents
# Allows ADK agents to communicate with the frontend using AG-UI Protocol
ag-ui-adk>=0.0.1
# ============================================================================
# A2A Protocol Implementation
# ============================================================================
# a2a: A2A Protocol SDK for agent-to-agent communication
# a2a-sdk: Additional utilities including HTTP server support
# These packages allow agents to communicate with each other via A2A Protocol
a2a>=0.1.0
a2a-sdk[http-server]
# ============================================================================
# Google ADK (Agent Development Kit)
# ============================================================================
# google-adk: Google's framework for building AI agents with Gemini models
# Used by: Orchestrator, Budget Agent, Weather Agent, Restaurant Agent
# litellm: Unified interface for multiple LLM providers (used by ADK)
google-adk>=0.1.0
litellm>=1.0.0
# ============================================================================
# LangGraph Framework
# ============================================================================
# langgraph: LangChain's framework for building stateful agent workflows
# langchain: Core LangChain library for LLM applications
# langchain-openai: OpenAI integration for LangChain
# Used by: Itinerary Agent
langgraph>=0.2.0
langchain>=0.3.0
langchain-openai>=0.2.0
# ============================================================================
# Web Server
# ============================================================================
# fastapi: Modern Python web framework for building APIs
# uvicorn: ASGI server for running FastAPI applications
# All agents expose HTTP endpoints using FastAPI
fastapi>=0.115.0
uvicorn>=0.30.0
# ============================================================================
# Utilities
# ============================================================================
# python-dotenv: Loads environment variables from .env file
# Used to configure API keys and agent URLs
python-dotenv>=1.0.0
# ============================================================================
# LLM Providers
# ============================================================================
# openai: OpenAI Python SDK for GPT models
# Used by LangGraph agents (Itinerary Agent)
openai>=1.0.0
@@ -0,0 +1,253 @@
"""
Restaurant Agent (ADK + A2A Protocol)
This agent provides restaurant recommendations based on travel itinerary.
It exposes an A2A Protocol endpoint and can be called by other agents.
Features:
- Can be called by the orchestrator via A2A middleware
- Can be called directly by other A2A agents (peer-to-peer)
- Returns structured JSON with restaurant recommendations
"""
import uvicorn
import os
import json
from typing import List
from dotenv import load_dotenv
from pydantic import BaseModel, Field
load_dotenv()
# A2A Protocol imports
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentSkill,
)
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.utils import new_agent_text_message
# Google ADK imports
from google.adk.agents.llm_agent import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.artifacts import InMemoryArtifactService
from google.genai import types
class DayMeals(BaseModel):
day: int = Field(description="Day number")
breakfast: str = Field(
description="Breakfast recommendation with restaurant name and dish"
)
lunch: str = Field(description="Lunch recommendation with restaurant name and dish")
dinner: str = Field(
description="Dinner recommendation with restaurant name and dish"
)
class StructuredRestaurants(BaseModel):
destination: str = Field(description="Destination city/location")
days: int = Field(description="Number of days")
meals: List[DayMeals] = Field(description="Day-by-day meal recommendations")
class RestaurantAgent:
def __init__(self):
self._agent = self._build_agent()
self._user_id = "remote_agent"
self._runner = Runner(
app_name=self._agent.name,
agent=self._agent,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
)
def _build_agent(self) -> LlmAgent:
model_name = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
return LlmAgent(
model=model_name,
name="restaurant_agent",
description="An agent that provides restaurant and dining recommendations for travelers",
instruction="""
You are a restaurant recommendation agent for travelers. Your role is to provide day-by-day
meal recommendations (breakfast, lunch, dinner) that match the traveler's itinerary.
When you receive a request, analyze:
- The destination city/location
- The number of days for the trip
- Any cuisine preferences or dietary needs mentioned
Return ONLY a valid JSON object with this exact structure:
{
"destination": "City Name",
"days": 3,
"meals": [
{
"day": 1,
"breakfast": "Café Sunrise - French pastries and coffee",
"lunch": "Noodle House - Traditional ramen and gyoza",
"dinner": "Skyline Restaurant - Sushi and city views"
},
{
"day": 2,
"breakfast": "Morning Market - Fresh fruit and local breakfast",
"lunch": "Street Food Alley - Various local vendors",
"dinner": "Family Kitchen - Home-style cooking"
}
]
}
IMPORTANT RULES:
- The number of meal entries in the "meals" array MUST match the "days" field
- Each day should have breakfast, lunch, and dinner recommendations
- Include the restaurant/venue name and a brief description of the food
- Make recommendations specific to the destination's food culture
- Vary the cuisine types and price points across the days
- Consider the local dining schedule and customs
Return ONLY valid JSON, no markdown code blocks, no other text.
""",
tools=[],
)
async def invoke(self, query: str, session_id: str) -> str:
session = await self._runner.session_service.get_session(
app_name=self._agent.name,
user_id=self._user_id,
session_id=session_id,
)
content = types.Content(role="user", parts=[types.Part.from_text(text=query)])
if session is None:
session = await self._runner.session_service.create_session(
app_name=self._agent.name,
user_id=self._user_id,
state={},
session_id=session_id,
)
response_text = ""
async for event in self._runner.run_async(
user_id=self._user_id, session_id=session.id, new_message=content
):
if event.is_final_response():
if (
event.content
and event.content.parts
and event.content.parts[0].text
):
response_text = "\n".join(
[p.text for p in event.content.parts if p.text]
)
break
content_str = response_text.strip()
if "```json" in content_str:
content_str = content_str.split("```json")[1].split("```")[0].strip()
elif "```" in content_str:
content_str = content_str.split("```")[1].split("```")[0].strip()
try:
structured_data = json.loads(content_str)
validated_restaurants = StructuredRestaurants(**structured_data)
final_response = json.dumps(validated_restaurants.model_dump(), indent=2)
print("✅ Successfully created structured restaurant recommendations")
return final_response
except json.JSONDecodeError as e:
print(f"❌ JSON parsing error: {e}")
print(f"Content: {content_str}")
return json.dumps(
{
"error": "Failed to generate structured restaurant recommendations",
"raw_content": content_str[:200],
}
)
except Exception as e:
print(f"❌ Validation error: {e}")
return json.dumps({"error": f"Validation failed: {str(e)}"})
port = int(os.getenv("RESTAURANT_PORT", 9003))
skill = AgentSkill(
id="restaurant_agent",
name="Restaurant Recommendation Agent",
description="Provides restaurant and dining recommendations for travelers using ADK",
tags=["travel", "restaurants", "dining", "food", "adk"],
examples=[
"Recommend restaurants for my trip to Tokyo",
"Where should I eat in Paris?",
"Find good restaurants near my itinerary locations",
],
)
cardUrl = os.getenv("RENDER_EXTERNAL_URL", f"http://localhost:{port}")
public_agent_card = AgentCard(
name="Restaurant Agent",
description="ADK-powered agent that provides personalized restaurant and dining recommendations for travelers",
url=cardUrl,
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
skills=[skill],
supportsAuthenticatedExtendedCard=False,
)
class RestaurantAgentExecutor(AgentExecutor):
def __init__(self):
self.agent = RestaurantAgent()
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
query = context.get_user_input()
session_id = getattr(context, "context_id", "default_session")
final_content = await self.agent.invoke(query, session_id)
await event_queue.enqueue_event(new_agent_text_message(final_content))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise Exception("cancel not supported")
def main():
if not os.getenv("GOOGLE_API_KEY") and not os.getenv("GEMINI_API_KEY"):
print("⚠️ Warning: No API key found!")
print(" Set either GOOGLE_API_KEY or GEMINI_API_KEY environment variable")
print(" Example: export GOOGLE_API_KEY='your-key-here'")
print(" Get a key from: https://aistudio.google.com/app/apikey")
print()
request_handler = DefaultRequestHandler(
agent_executor=RestaurantAgentExecutor(),
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
extended_agent_card=public_agent_card,
)
print(f"🍽️ Starting Restaurant Agent (ADK + A2A) on http://0.0.0.0:{port}")
print(f" Agent: {public_agent_card.name}")
print(f" Description: {public_agent_card.description}")
uvicorn.run(server.build(), host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
@@ -0,0 +1,263 @@
"""
Weather Agent (ADK + A2A Protocol)
This agent provides weather forecasts and travel weather advice.
It exposes an A2A Protocol endpoint and can be called by the orchestrator.
Features:
- Provides weather forecasts for travel destinations
- Returns structured JSON with weather predictions
- Helps travelers plan activities based on weather conditions
"""
import uvicorn
import os
import json
from typing import List
from dotenv import load_dotenv
from pydantic import BaseModel, Field
load_dotenv()
# A2A Protocol imports
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentSkill,
)
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.utils import new_agent_text_message
# Google ADK imports
from google.adk.agents.llm_agent import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.artifacts import InMemoryArtifactService
from google.genai import types
class DailyWeather(BaseModel):
day: int = Field(description="Day number")
date: str = Field(description="Date (e.g., 'Dec 15')")
condition: str = Field(
description="Weather condition (e.g., 'Sunny', 'Rainy', 'Cloudy')"
)
highTemp: int = Field(description="High temperature in Fahrenheit")
lowTemp: int = Field(description="Low temperature in Fahrenheit")
precipitation: int = Field(description="Chance of precipitation as percentage")
humidity: int = Field(description="Humidity percentage")
windSpeed: int = Field(description="Wind speed in mph")
description: str = Field(description="Detailed weather description")
class StructuredWeather(BaseModel):
destination: str = Field(description="Destination city/location")
forecast: List[DailyWeather] = Field(description="Daily weather forecasts")
travelAdvice: str = Field(
description="Weather-based travel advice and what to pack"
)
bestDays: List[int] = Field(
description="Best days for outdoor activities based on weather"
)
class WeatherAgent:
def __init__(self):
self._agent = self._build_agent()
self._user_id = "remote_agent"
self._runner = Runner(
app_name=self._agent.name,
agent=self._agent,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
)
def _build_agent(self) -> LlmAgent:
model_name = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
return LlmAgent(
model=model_name,
name="weather_agent",
description="An agent that provides weather forecasts and travel weather advice",
instruction="""
You are a weather forecast agent for travelers. Your role is to provide realistic weather
predictions and help travelers prepare for weather conditions.
When you receive a request, analyze:
- The destination city/location
- Travel dates or trip duration
- Any specific weather concerns mentioned
Return ONLY a valid JSON object with this exact structure:
{
"destination": "City Name",
"forecast": [
{
"day": 1,
"date": "Dec 15",
"condition": "Sunny",
"highTemp": 75,
"lowTemp": 60,
"precipitation": 10,
"humidity": 45,
"windSpeed": 8,
"description": "Clear skies with pleasant temperatures, perfect for sightseeing"
}
],
"travelAdvice": "Pack light layers, sunscreen, and comfortable walking shoes. Evenings may be cool, so bring a light jacket.",
"bestDays": [1, 3, 5]
}
Provide weather forecasts based on:
- Typical weather patterns for that destination and season
- Realistic temperature ranges
- Appropriate precipitation chances
- Helpful packing advice
- Identification of best days for outdoor activities
Make forecasts realistic for the destination's climate and current season.
Include helpful travel advice based on the weather conditions.
Return ONLY valid JSON, no markdown code blocks, no other text.
""",
tools=[],
)
async def invoke(self, query: str, session_id: str) -> str:
session = await self._runner.session_service.get_session(
app_name=self._agent.name,
user_id=self._user_id,
session_id=session_id,
)
content = types.Content(role="user", parts=[types.Part.from_text(text=query)])
if session is None:
session = await self._runner.session_service.create_session(
app_name=self._agent.name,
user_id=self._user_id,
state={},
session_id=session_id,
)
response_text = ""
async for event in self._runner.run_async(
user_id=self._user_id, session_id=session.id, new_message=content
):
if event.is_final_response():
if (
event.content
and event.content.parts
and event.content.parts[0].text
):
response_text = "\n".join(
[p.text for p in event.content.parts if p.text]
)
break
content_str = response_text.strip()
if "```json" in content_str:
content_str = content_str.split("```json")[1].split("```")[0].strip()
elif "```" in content_str:
content_str = content_str.split("```")[1].split("```")[0].strip()
try:
structured_data = json.loads(content_str)
validated_weather = StructuredWeather(**structured_data)
final_response = json.dumps(validated_weather.model_dump(), indent=2)
print("✅ Successfully created structured weather forecast")
return final_response
except json.JSONDecodeError as e:
print(f"❌ JSON parsing error: {e}")
print(f"Content: {content_str}")
return json.dumps(
{
"error": "Failed to generate structured weather forecast",
"raw_content": content_str[:200],
}
)
except Exception as e:
print(f"❌ Validation error: {e}")
return json.dumps({"error": f"Validation failed: {str(e)}"})
port = int(os.getenv("WEATHER_PORT", 9005))
skill = AgentSkill(
id="weather_agent",
name="Weather Forecast Agent",
description="Provides weather forecasts and travel weather advice using ADK",
tags=["travel", "weather", "forecast", "climate", "adk"],
examples=[
"What will the weather be like in Tokyo next week?",
"Should I pack an umbrella for my Paris trip?",
"Give me the weather forecast for my 5-day New York visit",
],
)
cardUrl = os.getenv("RENDER_EXTERNAL_URL", f"http://localhost:{port}")
public_agent_card = AgentCard(
name="Weather Agent",
description="ADK-powered agent that provides weather forecasts and packing advice for travelers",
url=cardUrl,
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
skills=[skill],
supportsAuthenticatedExtendedCard=False,
)
class WeatherAgentExecutor(AgentExecutor):
def __init__(self):
self.agent = WeatherAgent()
async def execute(
self,
context: RequestContext,
event_queue: EventQueue,
) -> None:
query = context.get_user_input()
session_id = getattr(context, "context_id", "default_session")
final_content = await self.agent.invoke(query, session_id)
await event_queue.enqueue_event(new_agent_text_message(final_content))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise Exception("cancel not supported")
def main():
if not os.getenv("GOOGLE_API_KEY") and not os.getenv("GEMINI_API_KEY"):
print("⚠️ Warning: No API key found!")
print(" Set either GOOGLE_API_KEY or GEMINI_API_KEY environment variable")
print(" Example: export GOOGLE_API_KEY='your-key-here'")
print(" Get a key from: https://aistudio.google.com/app/apikey")
print()
request_handler = DefaultRequestHandler(
agent_executor=WeatherAgentExecutor(),
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
extended_agent_card=public_agent_card,
)
print(f"🌤️ Starting Weather Agent (ADK + A2A) on http://0.0.0.0:{port}")
print(f" Agent: {public_agent_card.name}")
print(f" Description: {public_agent_card.description}")
uvicorn.run(server.build(), host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
@@ -0,0 +1,147 @@
/**
* CopilotKit API Route with A2A Middleware
*
* Sets up the connection between:
* - Frontend (CopilotKit) → A2A Middleware → Orchestrator → A2A Agents
*
* KEY CONCEPTS:
* - AG-UI Protocol: Agent-UI communication (CopilotKit ↔ Orchestrator)
* - A2A Protocol: Agent-to-agent communication (Orchestrator ↔ Specialized Agents)
* - A2A Middleware: Injects send_message_to_a2a_agent tool to bridge AG-UI and A2A
*/
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import { A2AMiddlewareAgent } from "@ag-ui/a2a-middleware";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
// STEP 1: Define A2A agent URLs
const itineraryAgentUrl =
process.env.ITINERARY_AGENT_URL || "http://localhost:9001";
const budgetAgentUrl =
process.env.BUDGET_AGENT_URL || "http://localhost:9002";
const restaurantAgentUrl =
process.env.RESTAURANT_AGENT_URL || "http://localhost:9003";
const weatherAgentUrl =
process.env.WEATHER_AGENT_URL || "http://localhost:9005";
// STEP 2: Define orchestrator URL (speaks AG-UI Protocol)
const orchestratorUrl =
process.env.ORCHESTRATOR_URL || "http://localhost:9000";
// STEP 3: Wrap orchestrator with HttpAgent (AG-UI client)
const orchestrationAgent = new HttpAgent({
url: orchestratorUrl,
});
// STEP 4: Create A2A Middleware Agent
// This bridges AG-UI and A2A protocols by:
// 1. Wrapping the orchestrator
// 2. Registering all A2A agents
// 3. Injecting send_message_to_a2a_agent tool
// 4. Routing messages between orchestrator and A2A agents
const a2aMiddlewareAgent = new A2AMiddlewareAgent({
description:
"Travel planning assistant with 4 specialized agents: Itinerary and Restaurant (LangGraph), Weather and Budget (ADK)",
agentUrls: [
itineraryAgentUrl, // LangGraph + OpenAI
restaurantAgentUrl, // ADK + Gemini
budgetAgentUrl, // ADK + Gemini
weatherAgentUrl, // ADK + Gemini
],
orchestrationAgent,
// Workflow instructions (middleware auto-adds routing info)
instructions: `
You are a travel planning assistant that orchestrates between 4 specialized agents.
AVAILABLE AGENTS:
- Itinerary Agent (LangGraph): Creates day-by-day travel itineraries with activities
- Restaurant Agent (LangGraph): Recommends breakfast, lunch, dinner for each day
- Weather Agent (ADK): Provides weather forecasts and packing advice
- Budget Agent (ADK): Estimates travel costs and creates budget breakdowns
WORKFLOW STRATEGY (SEQUENTIAL - ONE AT A TIME):
0. **FIRST STEP - Gather Trip Requirements**:
- Before doing ANYTHING else, call 'gather_trip_requirements' to collect essential trip information
- Try to extract any mentioned details from the user's message (city, days, people, budget level)
- Pass any extracted values as parameters to pre-fill the form:
* city: Extract destination city if mentioned (e.g., "Paris", "Tokyo")
* numberOfDays: Extract if mentioned (e.g., "5 days", "a week")
* numberOfPeople: Extract if mentioned (e.g., "2 people", "family of 4")
* budgetLevel: Extract if mentioned (e.g., "budget", "luxury") -> map to Economy/Comfort/Premium
- Wait for the user to submit the complete requirements
- Use the returned values for all subsequent agent calls
1. Itinerary Agent - Create the base itinerary using the trip requirements
- Pass: city, numberOfDays from trip requirements
- The itinerary will have empty meals initially
2. Weather Agent - Get forecast to inform planning
- Pass: city and numberOfDays from trip requirements
3. Restaurant Agent - Get day-by-day meal recommendations
- Pass: city and numberOfDays from trip requirements
- The meals will populate the itinerary display
4. Budget Agent - Create cost estimate
- Pass: city, numberOfDays, numberOfPeople, budgetLevel from trip requirements
- This creates an accurate budget based on all the information
5. **IMPORTANT**: Use 'request_budget_approval' tool for budget approval
- Pass the budget JSON data to this tool
- Wait for the user's decision before proceeding
6. Present complete plan to user
CRITICAL RULES:
- **ALWAYS START by calling 'gather_trip_requirements' FIRST before any agent calls**
- Call tools/agents ONE AT A TIME - never make multiple tool calls simultaneously
- After making a tool call, WAIT for the result before making the next call
- Pass information from trip requirements and earlier agents to later agents
- You MUST call 'request_budget_approval' after receiving the budget
- After receiving approval, present a complete summary to the user
TRIP REQUIREMENTS EXTRACTION EXAMPLES:
- "Plan a trip to Paris" -> city: "Paris"
- "5 day trip to Tokyo for 2 people" -> city: "Tokyo", numberOfDays: 5, numberOfPeople: 2
- "Budget vacation to Bali" -> city: "Bali", budgetLevel: "Economy"
- "Luxury 3-day getaway for my family of 4" -> numberOfDays: 3, numberOfPeople: 4, budgetLevel: "Premium"
Human-in-the-Loop (HITL):
- Always gather trip requirements using 'gather_trip_requirements' at the start
- Always request budget approval using 'request_budget_approval' after budget is created
- Wait for user responses before proceeding
Additional Rules:
- Once you have received information from an agent, do not call that agent again
- Each agent returns structured JSON - acknowledge and build on the information
- Always provide a final response that synthesizes ALL gathered information
`,
});
// STEP 5: Create CopilotKit Runtime
const runtime = new CopilotRuntime({
agents: {
a2a_chat: a2aMiddlewareAgent, // Must match frontend: <CopilotKit agent="a2a_chat">
},
});
// STEP 6: Set up Next.js endpoint handler
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
});
return handleRequest(request);
}
@@ -0,0 +1,291 @@
/**
* Global Styles
*
* This file contains Tailwind setup and custom styles
* for the AG-UI + A2A Multi-Agent Demo.
*/
@import "../styles/typography.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme {
/* Base Shadcn Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* CopilotCloud Palette Colors */
--color-palette-grey-0: #ffffff;
--color-palette-grey-25: #fafcfa;
--color-palette-grey-100: #f7f7f9;
--color-palette-grey-200: #f0f0f4;
--color-palette-grey-300: #e9e9ef;
--color-palette-grey-400: #e2e2ea;
--color-palette-grey-500: #dbdbe5;
--color-palette-grey-600: #afafb7;
--color-palette-grey-700: #838389;
--color-palette-grey-800: #575758;
--color-palette-grey-900: #2b2b2b;
--color-palette-grey-1000: #010507;
--color-palette-mint-40030: rgba(133, 224, 206, 0.3);
--color-palette-mint-400: #85e0ce;
--color-palette-mint-800: #1b936f;
--color-palette-lilac-40010: rgba(190, 194, 255, 0.1);
--color-palette-lilac-40020: rgba(190, 194, 255, 0.2);
--color-palette-lilac-40030: rgba(190, 194, 255, 0.3);
--color-palette-lilac-400: #bec2ff;
--color-palette-yellow-40030: rgba(255, 243, 136, 0.3);
--color-palette-yellow-400: #fff388;
--color-palette-orange-40020: rgba(255, 172, 77, 0.2);
--color-palette-orange-400: #ffac4d;
--color-palette-surface-main: #dedee9;
--color-palette-surface-solidEquivalentDefault70: #f8f8fb;
--color-palette-surface-default70: rgba(255, 255, 255, 0.7);
--color-palette-surface-default50: rgba(255, 255, 255, 0.5);
--color-palette-surface-default30: rgba(255, 255, 255, 0.3);
--color-palette-surface-container: #ffffff;
--color-palette-surface-containerHovered: #fafcfa;
--color-palette-surface-containerFocusedPressed: rgba(190, 194, 255, 0.1);
--color-palette-surface-containerActive: #bec2ff1a;
--color-palette-surface-containerActiveHovered: rgba(190, 194, 255, 0.2);
--color-palette-surface-containerActiveFocused: rgba(190, 194, 255, 0.3);
--color-palette-surface-containerMint: #b5e0ce;
--color-palette-surface-containerMint30: rgba(181, 224, 206, 0.3);
--color-palette-surface-containerLilac: #bec2ff;
--color-palette-surface-containerInvert: #010507;
--color-palette-surface-background: #dbdbe5;
--color-palette-surface-progressBarEmpty: #0105071a;
--color-palette-surface-progressBarFull: #189370;
--color-palette-surface-surfaceActionFilledHoveredAndFocused: #2b2b2b;
--color-palette-surface-surfaceActionFilledPressed: #57575b;
--color-palette-surface-containerPressed: #bec2ff4d;
--color-palette-surface-containerEnabledSolidEquivalent: #f8f9ff;
--color-palette-surface-containerPressedHoverSolidEquivalent: #f1f2ff;
--color-palette-surface-containerActivePressedSolidEquivalent: #e5e7fd;
--color-palette-surface-containerHoveredAndFocused: #f0f0f4;
--color-palette-surface-actionGhostHoveredAndFocused: #0105070d;
--color-palette-text-primary: #010507;
--color-palette-text-secondary: #57575b;
--color-palette-text-disabled: #838389;
--color-palette-text-invert: #ffffff;
--color-palette-text-details: #189370;
--color-palette-text-title: #3c464a;
--color-palette-text-progressBar: #525252;
--color-palette-text-link: #0d2e41;
--color-palette-icon-default: #010507;
--color-palette-icon-disabled: #838389;
--color-palette-icon-invert: #ffffff;
--color-palette-border-default: #ffffff;
--color-palette-border-container: #dbdbe5;
--color-palette-border-actionEnabled: #bec2ff;
--color-palette-border-divider: #dbdbe5;
--color-palette-gradient-primary: linear-gradient(
90deg,
#85e0ce 0%,
#fff388 100%
);
/* CopilotCloud Spacing */
--spacing-spacing-1: 4px;
--spacing-spacing-2: 8px;
--spacing-spacing-3: 12px;
--spacing-spacing-4: 16px;
--spacing-spacing-5: 20px;
--spacing-spacing-6: 24px;
--spacing-spacing-7: 28px;
--spacing-spacing-8: 32px;
--spacing-spacing-9: 36px;
--spacing-spacing-10: 40px;
--spacing-spacing-11: 44px;
--spacing-spacing-12: 48px;
--spacing-spacing-13: 52px;
--spacing-spacing-14: 56px;
--spacing-spacing-15: 60px;
--spacing-spacing-16: 64px;
--spacing-spacing-17: 68px;
--spacing-spacing-18: 72px;
/* CopilotCloud Border Radius */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-2xl: 48px;
--radius-3xl: 200px;
/* Font Families */
--font-family-sans: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
--font-family-mono:
"Spline Sans Mono", ui-monospace, SFMono-Regular, monospace;
/* Elevation/Shadows */
--shadow-sm: 0px 1px 3px 0px rgba(1, 5, 7, 0.08);
--shadow-md: 0px 6px 6px -2px rgba(1, 5, 7, 0.08);
--shadow-lg: 0px 16px 24px -8px rgba(1, 5, 7, 0.12);
--shadow-xl: 0px 24px 32px -12px rgba(1, 5, 7, 0.16);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.5rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--card: hsl(222.2 84% 4.9%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%);
--primary: hsl(210 40% 98%);
--primary-foreground: hsl(222.2 47.4% 11.2%);
--secondary: hsl(217.2 32.6% 17.5%);
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(217.2 32.6% 17.5%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 50.6%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 17.5%);
--input: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%);
--sidebar: hsl(222.2 84% 4.9%);
--sidebar-foreground: hsl(210 40% 98%);
--sidebar-primary: hsl(210 40% 98%);
--sidebar-primary-foreground: hsl(222.2 47.4% 11.2%);
--sidebar-accent: hsl(217.2 32.6% 17.5%);
--sidebar-accent-foreground: hsl(210 40% 98%);
--sidebar-border: hsl(217.2 32.6% 17.5%);
--sidebar-ring: hsl(212.7 26.8% 83.9%);
--chart-1: hsl(240 80% 60%);
--chart-2: hsl(160 70% 50%);
--chart-3: hsl(0 80% 60%);
--chart-4: hsl(280 70% 60%);
--chart-5: hsl(30 80% 60%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans;
}
}
/* Tailwind utility extensions */
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
/* Custom Range Slider Styling */
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #ffffff;
border: 3px solid #bec2ff;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #ffffff;
border: 3px solid #bec2ff;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
input[type="range"]::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { Plus_Jakarta_Sans, Spline_Sans_Mono } from "next/font/google";
import "./globals.css";
import "@copilotkit/react-ui/styles.css";
import { ThemeProvider } from "@/components/theme-provider";
const plusJakartaSans = Plus_Jakarta_Sans({
variable: "--font-plus-jakarta-sans",
subsets: ["latin"],
});
const splineSansMono = Spline_Sans_Mono({
variable: "--font-spline-sans-mono",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "AG-UI + A2A Multi-Agent Demo",
description:
"Agent-to-Agent communication demo with ADK and LangGraph using A2A Protocol",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${plusJakartaSans.variable} ${splineSansMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
themes={["light"]}
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import TravelChat from "@/components/travel-chat";
import { ItineraryCard, type ItineraryData } from "@/components/ItineraryCard";
import { BudgetBreakdown, type BudgetData } from "@/components/BudgetBreakdown";
import { WeatherCard, type WeatherData } from "@/components/WeatherCard";
import { type RestaurantData } from "@/components/ItineraryCard";
export default function Home() {
const [itineraryData, setItineraryData] = useState<ItineraryData | null>(
null,
);
const [budgetData, setBudgetData] = useState<BudgetData | null>(null);
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
const [restaurantData, setRestaurantData] = useState<RestaurantData | null>(
null,
);
return (
<div className="relative flex h-screen overflow-hidden bg-[#DEDEE9] p-2">
<div
className="absolute w-[445.84px] h-[445.84px] left-[1040px] top-[11px] rounded-full z-0"
style={{
background: "rgba(255, 172, 77, 0.2)",
filter: "blur(103.196px)",
}}
/>
<div
className="absolute w-[609.35px] h-[609.35px] left-[1338.97px] top-[624.5px] rounded-full z-0"
style={{ background: "#C9C9DA", filter: "blur(103.196px)" }}
/>
<div
className="absolute w-[609.35px] h-[609.35px] left-[670px] top-[-365px] rounded-full z-0"
style={{ background: "#C9C9DA", filter: "blur(103.196px)" }}
/>
<div
className="absolute w-[609.35px] h-[609.35px] left-[507.87px] top-[702.14px] rounded-full z-0"
style={{ background: "#F3F3FC", filter: "blur(103.196px)" }}
/>
<div
className="absolute w-[445.84px] h-[445.84px] left-[127.91px] top-[331px] rounded-full z-0"
style={{
background: "rgba(255, 243, 136, 0.3)",
filter: "blur(103.196px)",
}}
/>
<div
className="absolute w-[445.84px] h-[445.84px] left-[-205px] top-[802.72px] rounded-full z-0"
style={{
background: "rgba(255, 172, 77, 0.2)",
filter: "blur(103.196px)",
}}
/>
<div className="flex flex-1 overflow-hidden z-10 gap-2">
<div className="w-[450px] flex-shrink-0 border-2 border-white bg-white/50 backdrop-blur-md shadow-elevation-lg flex flex-col rounded-lg overflow-hidden">
<div className="p-6 border-b border-[#DBDBE5]">
<h1 className="text-2xl font-semibold text-[#010507] mb-1">
Travel Planning
</h1>
<p className="text-sm text-[#57575B] leading-relaxed">
Multi-Agent A2A Demo:{" "}
<span className="text-[#1B936F] font-semibold">2 LangGraph</span>{" "}
+ <span className="text-[#BEC2FF] font-semibold">2 ADK</span>{" "}
agents
</p>
<p className="text-xs text-[#838389] mt-1">
Orchestrator-mediated A2A Protocol
</p>
</div>
<div className="flex-1 overflow-hidden">
<TravelChat
onItineraryUpdate={setItineraryData}
onBudgetUpdate={setBudgetData}
onWeatherUpdate={setWeatherData}
onRestaurantUpdate={setRestaurantData}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto rounded-lg bg-white/30 backdrop-blur-sm">
<div className="max-w-5xl mx-auto p-8">
<div className="mb-8">
<h2 className="text-3xl font-semibold text-[#010507] mb-2">
Your Travel Plan
</h2>
<p className="text-[#57575B]">
Multi-agent coordination: 2 LangGraph + 2 ADK agents with A2A
Protocol and human-in-the-loop approval
</p>
</div>
{!itineraryData && !budgetData && !weatherData && (
<div className="flex items-center justify-center h-[400px] bg-white/60 backdrop-blur-md rounded-xl border-2 border-dashed border-[#DBDBE5] shadow-elevation-sm">
<div className="text-center">
<div className="text-6xl mb-4"></div>
<h3 className="text-xl font-semibold text-[#010507] mb-2">
Start Planning Your Trip
</h3>
<p className="text-[#57575B] max-w-md">
Ask the assistant to plan a trip. Watch as 4 specialized
agents collaborate through A2A Protocol to create your
personalized plan.
</p>
</div>
</div>
)}
{itineraryData && (
<div className="mb-4">
<ItineraryCard
data={itineraryData}
restaurantData={restaurantData}
/>
</div>
)}
{(weatherData || budgetData) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{weatherData && (
<div>
<WeatherCard data={weatherData} />
</div>
)}
{budgetData && (
<div>
<BudgetBreakdown data={budgetData} />
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,123 @@
/**
* BudgetBreakdown Component
*
* Displays a beautiful budget breakdown with visual bars showing
* the percentage breakdown of travel costs by category.
*/
import React from "react";
// Type definitions matching the backend structure
interface BudgetCategory {
category: string;
amount: number;
percentage: number;
}
export interface BudgetData {
totalBudget: number;
currency: string;
breakdown: BudgetCategory[];
notes: string;
}
interface BudgetBreakdownProps {
data: BudgetData;
}
export const BudgetBreakdown: React.FC<BudgetBreakdownProps> = ({ data }) => {
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: data.currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
// Color mapping for categories using CopilotCloud Palette
const getCategoryColor = (index: number) => {
const colors = [
{ bg: "#BEC2FF", light: "rgba(190, 194, 255, 0.1)", text: "#010507" }, // Lilac
{ bg: "#85E0CE", light: "rgba(133, 224, 206, 0.1)", text: "#010507" }, // Mint
{ bg: "#FFF388", light: "rgba(255, 243, 136, 0.1)", text: "#010507" }, // Yellow
{ bg: "#FFAC4D", light: "rgba(255, 172, 77, 0.1)", text: "#010507" }, // Orange
{ bg: "#C9C9DA", light: "rgba(201, 201, 218, 0.1)", text: "#010507" }, // Grey
{ bg: "#F3F3FC", light: "rgba(243, 243, 252, 0.1)", text: "#010507" }, // Light Purple
];
return colors[index % colors.length];
};
return (
<div className="bg-white/60 backdrop-blur-md rounded-xl p-4 my-3 border-2 border-[#DBDBE5] shadow-elevation-md animate-fade-in-up">
{/* Header */}
<div className="mb-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">💰</span>
<h2 className="text-xl font-semibold text-[#010507]">
Budget Estimate
</h2>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-[#010507]">
{formatCurrency(data.totalBudget)}
</div>
<div className="text-xs text-[#57575B]">{data.currency}</div>
</div>
</div>
{data.notes && (
<p className="text-xs text-[#57575B] bg-[#F7F7F9] rounded p-2 border border-[#DBDBE5]">
{data.notes}
</p>
)}
</div>
{/* Breakdown */}
<div className="space-y-2">
{data.breakdown.map((category, index) => {
const colors = getCategoryColor(index);
return (
<div
key={index}
className="bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-elevation-sm border border-[#E9E9EF]"
>
{/* Category Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: colors.bg }}
></div>
<span className="text-sm font-semibold text-[#010507]">
{category.category}
</span>
</div>
<div className="text-right">
<div className="text-sm font-bold text-[#010507]">
{formatCurrency(category.amount)}
</div>
<div className="text-xs text-[#838389]">
{category.percentage.toFixed(1)}%
</div>
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-[#E9E9EF] rounded-full h-2 overflow-hidden">
<div
className="h-full transition-all duration-1000 ease-out rounded-full"
style={{
width: `${category.percentage}%`,
backgroundColor: colors.bg,
}}
></div>
</div>
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,271 @@
/**
* ItineraryCard Component
*
* Displays a beautiful, structured travel itinerary with day-by-day breakdown.
* Shows activities for morning, afternoon, evening, and meal recommendations.
*/
import React from "react";
// Type definitions matching the backend structure
interface TimeSlot {
activities: string[];
location: string;
}
interface Meals {
breakfast: string;
lunch: string;
dinner: string;
}
interface DayItinerary {
day: number;
title: string;
morning: TimeSlot;
afternoon: TimeSlot;
evening: TimeSlot;
meals: Meals;
}
export interface ItineraryData {
destination: string;
days: number;
itinerary: DayItinerary[];
}
// Restaurant data structure for day-by-day meals
export interface RestaurantData {
destination: string;
days: number;
meals: Array<{
day: number;
breakfast: string;
lunch: string;
dinner: string;
}>;
}
interface ItineraryCardProps {
data: ItineraryData;
restaurantData?: RestaurantData | null; // Optional restaurant data to populate meals
}
export const ItineraryCard: React.FC<ItineraryCardProps> = ({
data,
restaurantData,
}) => {
// Get meals for a specific day from restaurant data
const getMealsForDay = (dayNumber: number): Meals | null => {
if (!restaurantData) return null;
const dayMeals = restaurantData.meals.find((m) => m.day === dayNumber);
if (!dayMeals) return null;
return {
breakfast: dayMeals.breakfast,
lunch: dayMeals.lunch,
dinner: dayMeals.dinner,
};
};
return (
<div className="bg-white/60 backdrop-blur-md rounded-xl p-4 my-3 border-2 border-[#DBDBE5] shadow-elevation-md animate-fade-in-up h-[520px] flex flex-col">
{/* Header */}
<div className="mb-3 flex-shrink-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xl">🗺</span>
<h2 className="text-xl font-semibold text-[#010507]">
{data.destination} Itinerary
</h2>
</div>
<p className="text-[#57575B] text-xs">
{data.days} day{data.days > 1 ? "s" : ""} of adventure
</p>
</div>
{/* Days - Scrollable */}
<div className="space-y-3 overflow-y-auto flex-1">
{data.itinerary.map((day, index) => {
// Try to get restaurant meals for this day
const restaurantMeals = getMealsForDay(day.day);
// Use restaurant meals if available, otherwise use original itinerary meals
const mealsToDisplay = restaurantMeals || day.meals;
return (
<div
key={index}
className="bg-white/80 backdrop-blur-sm rounded-lg p-3 shadow-elevation-sm border border-[#E9E9EF]"
>
{/* Day Header */}
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-[#DBDBE5]">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-[#BEC2FF] text-white font-bold text-sm">
{day.day}
</div>
<h3 className="text-lg font-semibold text-[#010507]">
{day.title}
</h3>
</div>
{/* Time Slots and Meals Side-by-Side */}
<div className="grid grid-cols-1 lg:grid-cols-7 gap-2">
{/* Time Slots - Takes 1 column */}
<div className="lg:col-span-3 space-y-2">
<div className="flex items-center gap-1 mb-1">
<span className="text-sm">📅</span>
<h4 className="text-sm font-semibold text-[#010507]">
Day Itinerary
</h4>
</div>
{/* Morning */}
<TimeSlotSection
icon="🌅"
title="Morning"
location={day.morning.location}
activities={day.morning.activities}
color="orange"
/>
{/* Afternoon */}
<TimeSlotSection
icon="☀️"
title="Afternoon"
location={day.afternoon.location}
activities={day.afternoon.activities}
color="yellow"
/>
{/* Evening */}
<TimeSlotSection
icon="🌆"
title="Evening"
location={day.evening.location}
activities={day.evening.activities}
color="blue"
/>
</div>
{/* Meals - Takes 2 columns */}
<div className="lg:col-span-4 flex flex-col">
<div className="lg:border-l lg:border-[#DBDBE5] lg:pl-2 flex flex-col h-full">
<div className="flex items-center gap-1 mb-1">
<span className="text-sm">🍽</span>
<h4 className="text-sm font-semibold text-[#010507]">
Meals
</h4>
{!restaurantMeals && (
<span className="ml-auto text-[9px] text-[#BEC2FF] font-medium animate-pulse">
Loading...
</span>
)}
</div>
<div className="flex flex-col justify-between flex-1 space-y-1">
{restaurantMeals ? (
<>
<MealItem
icon="🥐"
label="Breakfast"
meal={mealsToDisplay.breakfast}
/>
<MealItem
icon="🍜"
label="Lunch"
meal={mealsToDisplay.lunch}
/>
<MealItem
icon="🍷"
label="Dinner"
meal={mealsToDisplay.dinner}
/>
</>
) : (
// Show placeholder while waiting for restaurant data
<>
<div className="flex-1 flex items-center justify-center bg-[#F7F7F9] rounded p-1">
<span className="text-[10px] text-[#838389]">
Awaiting recommendations...
</span>
</div>
<div className="flex-1 flex items-center justify-center bg-[#F7F7F9] rounded p-1">
<span className="text-[10px] text-[#838389]">
Awaiting recommendations...
</span>
</div>
<div className="flex-1 flex items-center justify-center bg-[#F7F7F9] rounded p-1">
<span className="text-[10px] text-[#838389]">
Awaiting recommendations...
</span>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
// Helper component for time slots
interface TimeSlotSectionProps {
icon: string;
title: string;
location: string;
activities: string[];
color: "orange" | "yellow" | "blue";
}
const TimeSlotSection: React.FC<TimeSlotSectionProps> = ({
icon,
title,
location,
activities,
color,
}) => {
const colorClasses = {
orange: "bg-orange-50 border-orange-200",
yellow: "bg-amber-50 border-amber-200",
blue: "bg-blue-50 border-blue-200",
};
return (
<div className={`rounded-lg p-2 border ${colorClasses[color]}`}>
<div className="flex items-center gap-1 mb-1">
<span className="text-sm">{icon}</span>
<h4 className="text-sm font-semibold text-[#010507]">{title}</h4>
<span className="text-xs text-[#838389]"> {location}</span>
</div>
<ul className="space-y-0.5 ml-5">
{activities.map((activity, idx) => (
<li key={idx} className="text-xs text-[#57575B] flex items-start">
<span className="text-[#838389] mr-1"></span>
<span>{activity}</span>
</li>
))}
</ul>
</div>
);
};
// Helper component for meals
interface MealItemProps {
icon: string;
label: string;
meal: string;
}
const MealItem: React.FC<MealItemProps> = ({ icon, label, meal }) => {
return (
<div className="flex items-start gap-1 p-1 bg-[#F7F7F9] rounded flex-1 border border-[#E9E9EF]">
<span className="text-xs">{icon}</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-[#838389]">{label}</div>
<div className="text-xs text-[#010507] break-words">{meal}</div>
</div>
</div>
);
};
@@ -0,0 +1,179 @@
/**
* WeatherCard Component
*
* Displays weather forecast with daily conditions, temperatures, and travel advice.
* Uses ADK blue styling to match the Weather Agent branding.
*/
import React from "react";
// Type definition matching the backend Weather Agent structure
export interface WeatherData {
destination: string;
forecast: Array<{
day: number;
date: string;
condition: string;
highTemp: number;
lowTemp: number;
precipitation: number;
humidity: number;
windSpeed: number;
description: string;
}>;
travelAdvice: string;
bestDays: number[];
}
interface WeatherCardProps {
data: WeatherData;
}
/**
* Get weather icon based on condition
*/
const getWeatherIcon = (condition: string): string => {
const cond = condition.toLowerCase();
if (cond.includes("sun") || cond.includes("clear")) return "☀️";
if (cond.includes("cloud")) return "☁️";
if (cond.includes("rain")) return "🌧️";
if (cond.includes("storm")) return "⛈️";
if (cond.includes("snow")) return "❄️";
if (cond.includes("fog") || cond.includes("mist")) return "🌫️";
return "🌤️";
};
/**
* Get condition color styling using CopilotCloud Palette
*/
const getConditionStyle = (condition: string) => {
const cond = condition.toLowerCase();
if (cond.includes("sun") || cond.includes("clear"))
return "bg-[#FFF388]/20 border-[#FFF388] text-[#010507]";
if (cond.includes("cloud"))
return "bg-[#C9C9DA]/20 border-[#C9C9DA] text-[#010507]";
if (cond.includes("rain") || cond.includes("storm"))
return "bg-[#BEC2FF]/20 border-[#BEC2FF] text-[#010507]";
return "bg-[#85E0CE]/20 border-[#85E0CE] text-[#010507]";
};
export const WeatherCard: React.FC<WeatherCardProps> = ({ data }) => {
return (
<div className="bg-white/60 backdrop-blur-md rounded-xl p-4 my-3 border-2 border-[#DBDBE5] shadow-elevation-md animate-fade-in-up">
{/* Header */}
<div className="mb-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xl">🌤</span>
<h2 className="text-xl font-semibold text-[#010507]">
{data.destination} Weather
</h2>
</div>
<p className="text-[#57575B] text-xs">
{data.forecast.length}-day forecast
</p>
</div>
{/* Forecast Days */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 mb-3">
{data.forecast.map((day, index) => {
const isBestDay = data.bestDays.includes(day.day);
return (
<div
key={index}
className={`bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-elevation-sm border ${
isBestDay
? "border-[#85E0CE] ring-2 ring-[#85E0CE]/30"
: "border-[#E9E9EF]"
} relative`}
>
{/* Best Day Badge */}
{isBestDay && (
<div className="absolute -top-2 -right-2 bg-[#1B936F] text-white text-[9px] px-1.5 py-0.5 rounded-full font-bold">
BEST
</div>
)}
{/* Day Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-[#BEC2FF] text-white font-bold text-[10px]">
{day.day}
</div>
<span className="text-[10px] font-semibold text-[#57575B]">
{day.date}
</span>
</div>
<span className="text-xl">{getWeatherIcon(day.condition)}</span>
</div>
{/* Condition */}
<div
className={`text-[10px] font-medium px-1.5 py-0.5 rounded mb-1 text-center ${getConditionStyle(
day.condition,
)}`}
>
{day.condition}
</div>
{/* Temperature */}
<div className="flex items-center justify-center gap-2 mb-1">
<span className="text-lg font-bold text-[#010507]">
{day.highTemp}°
</span>
<span className="text-xs text-[#838389]">{day.lowTemp}°</span>
</div>
{/* Weather Stats */}
<div className="space-y-0.5">
<div className="flex items-center justify-between text-[9px] text-[#57575B]">
<span>💧 {day.precipitation}%</span>
<span>💨 {day.windSpeed}mph</span>
</div>
<div className="text-[9px] text-[#57575B] text-center">
Humidity {day.humidity}%
</div>
</div>
{/* Description */}
{day.description && (
<div className="mt-1 pt-1 border-t border-[#E9E9EF]">
<p className="text-[9px] text-[#57575B] line-clamp-2">
{day.description}
</p>
</div>
)}
</div>
);
})}
</div>
{/* Travel Advice */}
{data.travelAdvice && (
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-3 border border-[#DBDBE5] shadow-elevation-sm">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm">💼</span>
<h3 className="text-sm font-semibold text-[#010507]">
Travel Advice
</h3>
</div>
<p className="text-xs text-[#57575B] leading-relaxed">
{data.travelAdvice}
</p>
</div>
)}
{/* Best Days Highlight */}
{data.bestDays && data.bestDays.length > 0 && (
<div className="mt-2 bg-[#85E0CE]/20 border border-[#85E0CE] rounded-lg p-2">
<div className="flex items-center gap-1">
<span className="text-xs"></span>
<span className="text-xs font-semibold text-[#010507]">
Best days for outdoor activities: Day{" "}
{data.bestDays.join(", Day ")}
</span>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,60 @@
/**
* MessageFromA2A Component
*
* Visualizes agent → orchestrator responses as a blue box showing
* sender/receiver badges and confirmation. Actual structured data
* is rendered separately in the main content area.
*/
import React from "react";
import { MessageActionRenderProps } from "../types";
import { getAgentStyle } from "./agent-styles";
export const MessageFromA2A: React.FC<MessageActionRenderProps> = ({
status,
args,
}) => {
switch (status) {
case "complete":
break;
default:
return null;
}
const agentStyle = getAgentStyle(args.agentName);
return (
<div className="my-2">
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 min-w-[200px] flex-shrink-0">
<div className="flex flex-col items-center">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border-2 ${agentStyle.bgColor} ${agentStyle.textColor} ${agentStyle.borderColor} flex items-center gap-1`}
>
<span>{agentStyle.icon}</span>
<span>{args.agentName}</span>
</span>
{agentStyle.framework && (
<span className="text-[9px] text-gray-500 mt-0.5">
{agentStyle.framework}
</span>
)}
</div>
<span className="text-gray-400 text-sm"></span>
<div className="flex flex-col items-center">
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-gray-700 text-white">
Orchestrator
</span>
<span className="text-[9px] text-gray-500 mt-0.5">ADK</span>
</div>
</div>
<span className="text-xs text-gray-600"> Response received</span>
</div>
</div>
</div>
);
};
@@ -0,0 +1,63 @@
/**
* MessageToA2A Component
*
* Visualizes orchestrator → agent communication as a green box showing
* sender/receiver badges and task description.
*/
import React from "react";
import { MessageActionRenderProps } from "../types";
import { getAgentStyle, truncateTask } from "./agent-styles";
export const MessageToA2A: React.FC<MessageActionRenderProps> = ({
status,
args,
}) => {
switch (status) {
case "executing":
case "complete":
break;
default:
return null;
}
const agentStyle = getAgentStyle(args.agentName);
return (
<div className="bg-green-50 border border-green-200 rounded-lg px-4 py-3 my-2 a2a-message-enter">
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-col items-center">
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-gray-700 text-white">
Orchestrator
</span>
<span className="text-[9px] text-gray-500 mt-0.5">ADK</span>
</div>
<span className="text-gray-400 text-sm"></span>
<div className="flex flex-col items-center">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border-2 ${agentStyle.bgColor} ${agentStyle.textColor} ${agentStyle.borderColor} flex items-center gap-1`}
>
<span>{agentStyle.icon}</span>
<span>{args.agentName}</span>
</span>
{agentStyle.framework && (
<span className="text-[9px] text-gray-500 mt-0.5">
{agentStyle.framework}
</span>
)}
</div>
</div>
<span
className="text-gray-700 text-sm flex-1 min-w-0 break-words"
title={args.task}
>
{truncateTask(args.task)}
</span>
</div>
</div>
);
};
@@ -0,0 +1,84 @@
/**
* Agent Styling Utilities
*
* This module provides consistent styling for agent badges across the UI.
* Each agent framework (LangGraph vs ADK) has distinct branding:
* - LangGraph: Green/Emerald colors with 🔗 icon
* - ADK: Blue/Sky colors with ✨ icon
* - Orchestrator: Gray with no specific icon
*/
import { AgentStyle } from "../types";
/**
* Get the styling configuration for an agent based on its name
*
* This function determines the visual branding (colors, icons, framework label)
* for agent badges in the UI. It helps users visually distinguish between:
* - LangGraph agents (Itinerary, Restaurant)
* - ADK agents (Budget, Weather)
* - The Orchestrator
*
* @param agentName - The name of the agent (case-insensitive)
* @returns AgentStyle object with colors, icon, and framework label
*/
export function getAgentStyle(agentName: string): AgentStyle {
// Handle undefined/null agentName gracefully
if (!agentName) {
return {
bgColor: "bg-gray-100",
textColor: "text-gray-700",
borderColor: "border-gray-300",
icon: "🤖",
framework: "",
};
}
const nameLower = agentName.toLowerCase();
// LangGraph agents - Green branding
if (nameLower.includes("itinerary") || nameLower.includes("restaurant")) {
return {
bgColor: "bg-gradient-to-r from-emerald-100 to-green-100",
textColor: "text-emerald-800",
borderColor: "border-emerald-400",
icon: "🔗",
framework: "LangGraph",
};
}
// ADK agents - Blue/Google branding
if (nameLower.includes("budget") || nameLower.includes("weather")) {
return {
bgColor: "bg-gradient-to-r from-blue-100 to-sky-100",
textColor: "text-blue-800",
borderColor: "border-blue-400",
icon: "✨",
framework: "ADK",
};
}
// Default/Unknown agent
return {
bgColor: "bg-gray-100",
textColor: "text-gray-700",
borderColor: "border-gray-300",
icon: "🤖",
framework: "",
};
}
/**
* Truncate long text with ellipsis
*
* Used to keep agent task descriptions readable in the UI
* without taking up too much horizontal space.
*
* @param text - The text to truncate
* @param maxLength - Maximum length before truncation (default: 50)
* @returns Truncated text with "..." if needed
*/
export function truncateTask(text: string, maxLength: number = 50): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
}
@@ -0,0 +1,255 @@
/**
* TripRequirementsForm Component
*
* HITL form that collects trip details (city, days, people, budget level)
* at the start of the workflow. Supports pre-filling from user messages
* and validates input before submission.
*/
import React, { useState, useEffect } from "react";
interface TripRequirementsFormProps {
args: any;
respond: any;
}
export const TripRequirementsForm: React.FC<TripRequirementsFormProps> = ({
args,
respond,
}) => {
let parsedArgs = args;
if (typeof args === "string") {
try {
parsedArgs = JSON.parse(args);
} catch (e) {
parsedArgs = {};
}
}
const [city, setCity] = useState("");
const [numberOfDays, setNumberOfDays] = useState(3);
const [numberOfPeople, setNumberOfPeople] = useState(2);
const [budgetLevel, setBudgetLevel] = useState("Comfort");
const [submitted, setSubmitted] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// Pre-fill form from orchestrator extraction
useEffect(() => {
if (parsedArgs && parsedArgs.city && parsedArgs.city !== city) {
setCity(parsedArgs.city);
}
if (
parsedArgs &&
parsedArgs.numberOfDays &&
parsedArgs.numberOfDays !== numberOfDays
) {
setNumberOfDays(parsedArgs.numberOfDays);
}
if (
parsedArgs &&
parsedArgs.numberOfPeople &&
parsedArgs.numberOfPeople !== numberOfPeople
) {
setNumberOfPeople(parsedArgs.numberOfPeople);
}
if (
parsedArgs &&
parsedArgs.budgetLevel &&
parsedArgs.budgetLevel !== budgetLevel
) {
setBudgetLevel(parsedArgs.budgetLevel);
}
}, [
parsedArgs?.city,
parsedArgs?.numberOfDays,
parsedArgs?.numberOfPeople,
parsedArgs?.budgetLevel,
]);
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!city.trim()) {
newErrors.city = "Please enter a destination city";
}
if (numberOfDays < 1 || numberOfDays > 7) {
newErrors.numberOfDays = "Number of days must be between 1 and 7";
}
if (numberOfPeople < 1 || numberOfPeople > 15) {
newErrors.numberOfPeople = "Number of people must be between 1 and 15";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (!validateForm()) {
return;
}
setSubmitted(true);
respond?.({
city: city.trim(),
numberOfDays,
numberOfPeople,
budgetLevel,
});
};
if (submitted) {
return (
<div className="bg-[#85E0CE]/30 backdrop-blur-md border-2 border-[#85E0CE] rounded-lg p-4 my-3 shadow-elevation-md">
<div className="flex items-center gap-2">
<div className="text-2xl"></div>
<div>
<h3 className="text-base font-semibold text-[#010507]">
Trip Requirements Submitted
</h3>
<p className="text-xs text-[#57575B]">
Planning your {numberOfDays}-day trip to {city} for{" "}
{numberOfPeople} people with {budgetLevel} budget...
</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-[#BEC2FF]/30 backdrop-blur-md border-2 border-[#BEC2FF] rounded-lg p-4 my-3 shadow-elevation-md">
<div className="flex items-center gap-2 mb-4">
<div className="text-2xl"></div>
<div>
<h3 className="text-base font-semibold text-[#010507]">
Trip Planning Details
</h3>
<p className="text-xs text-[#57575B]">
Please provide some information about your trip
</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-[#010507] mb-1.5">
Destination City *
</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="e.g., Paris, Tokyo, New York"
className={`w-full px-3 py-2 text-sm rounded-lg border-2 transition-colors ${
errors.city
? "border-[#FFAC4D] bg-[#FFAC4D]/10"
: "border-[#DBDBE5] bg-white/80 backdrop-blur-sm focus:border-[#BEC2FF] focus:outline-none"
}`}
/>
{errors.city && (
<p className="text-xs text-[#FFAC4D] mt-1">{errors.city}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[#010507] mb-1.5">
Days (1-7) *
</label>
<div className="flex items-center gap-2 bg-white/80 backdrop-blur-sm border-2 border-[#DBDBE5] rounded-lg px-3 py-2.5">
<div className="flex-1 px-1">
<input
type="range"
min="1"
max="7"
value={numberOfDays}
onChange={(e) => setNumberOfDays(parseInt(e.target.value))}
className="w-full h-1.5 bg-[#E9E9EF] rounded-lg appearance-none cursor-pointer"
style={{
WebkitAppearance: "none",
background: `linear-gradient(to right, #BEC2FF 0%, #BEC2FF ${((numberOfDays - 1) / 6) * 100}%, #E9E9EF ${((numberOfDays - 1) / 6) * 100}%, #E9E9EF 100%)`,
}}
/>
</div>
<span className="text-lg font-bold text-[#010507] min-w-[24px] text-center">
{numberOfDays}
</span>
</div>
{errors.numberOfDays && (
<p className="text-xs text-[#FFAC4D] mt-1">
{errors.numberOfDays}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-[#010507] mb-1.5">
People (1-15) *
</label>
<div className="flex items-center gap-2 bg-white/80 backdrop-blur-sm border-2 border-[#DBDBE5] rounded-lg px-3 py-2.5">
<div className="flex-1 px-1">
<input
type="range"
min="1"
max="15"
value={numberOfPeople}
onChange={(e) => setNumberOfPeople(parseInt(e.target.value))}
className="w-full h-1.5 bg-[#E9E9EF] rounded-lg appearance-none cursor-pointer"
style={{
WebkitAppearance: "none",
background: `linear-gradient(to right, #85E0CE 0%, #85E0CE ${((numberOfPeople - 1) / 14) * 100}%, #E9E9EF ${((numberOfPeople - 1) / 14) * 100}%, #E9E9EF 100%)`,
}}
/>
</div>
<span className="text-lg font-bold text-[#010507] min-w-[24px] text-center">
{numberOfPeople}
</span>
</div>
{errors.numberOfPeople && (
<p className="text-xs text-[#FFAC4D] mt-1">
{errors.numberOfPeople}
</p>
)}
</div>
</div>
<div>
<label className="block text-xs font-medium text-[#010507] mb-1.5">
Budget Level *
</label>
<div className="grid grid-cols-3 gap-2">
{["Economy", "Comfort", "Premium"].map((level) => (
<button
key={level}
onClick={() => setBudgetLevel(level)}
className={`py-2 px-3 rounded-lg font-medium text-xs transition-all shadow-elevation-sm ${
budgetLevel === level
? "bg-[#BEC2FF] text-white shadow-elevation-md scale-105"
: "bg-white/80 backdrop-blur-sm text-[#010507] border-2 border-[#DBDBE5] hover:border-[#BEC2FF]"
}`}
>
<div className="text-base mb-0.5">
{level === "Economy" && "💰"}
{level === "Comfort" && "✨"}
{level === "Premium" && "👑"}
</div>
<div>{level}</div>
</button>
))}
</div>
</div>
</div>
<div className="mt-4">
<button
onClick={handleSubmit}
className="w-full bg-[#1B936F] hover:bg-[#189370] text-white font-semibold py-2.5 px-4 text-sm rounded-lg transition-all shadow-elevation-md hover:shadow-elevation-lg"
>
Start Planning My Trip
</button>
</div>
</div>
);
};
@@ -0,0 +1,127 @@
/**
* BudgetApprovalCard Component
*
* HITL component for budget approval. Displays budget breakdown and
* pauses workflow until user approves or rejects.
*/
import React from "react";
import { BudgetData } from "../types";
interface BudgetApprovalCardProps {
budgetData: BudgetData;
isApproved: boolean;
isRejected: boolean;
onApprove: () => void;
onReject: () => void;
}
export const BudgetApprovalCard: React.FC<BudgetApprovalCardProps> = ({
budgetData,
isApproved,
isRejected,
onApprove,
onReject,
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: budgetData.currency || "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
return (
<div className="bg-[#E4D4F4]/30 backdrop-blur-md border-2 border-[#BEC2FF] rounded-lg p-4 my-3 shadow-elevation-md">
<div className="flex items-center gap-2 mb-3">
<div className="text-2xl">💰</div>
<div>
<h3 className="text-base font-semibold text-[#010507]">
Budget Approval Required
</h3>
<p className="text-xs text-[#57575B]">
Please review and approve the estimated budget
</p>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-3 mb-3 border border-[#DBDBE5] shadow-elevation-sm">
<div className="flex items-center justify-between mb-2">
<span className="text-[#57575B] font-medium text-sm">
Total Budget
</span>
<span className="text-2xl font-bold text-[#010507]">
{formatCurrency(budgetData.totalBudget)}
</span>
</div>
<div className="space-y-1.5">
{budgetData.breakdown?.map((category, idx) => (
<div
key={idx}
className="flex items-center justify-between text-xs"
>
<span className="text-[#57575B]">{category.category}</span>
<div className="flex items-center gap-2">
<span className="font-semibold text-[#010507]">
{formatCurrency(category.amount)}
</span>
<span className="text-[#838389]">
({category.percentage.toFixed(0)}%)
</span>
</div>
</div>
))}
</div>
{budgetData.notes && (
<div className="mt-2 pt-2 border-t border-[#E9E9EF]">
<p className="text-xs text-[#57575B]">{budgetData.notes}</p>
</div>
)}
</div>
{isRejected && (
<div className="bg-[#FFAC4D]/20 border border-[#FFAC4D] rounded-lg p-2.5 mb-3">
<div className="flex items-center gap-2 text-[#010507]">
<span className="text-base"></span>
<div>
<p className="font-semibold text-xs">Budget Rejected</p>
<p className="text-xs text-[#57575B]">
The agent has been notified and may revise the budget.
</p>
</div>
</div>
</div>
)}
<div className="flex gap-2">
<button
onClick={onApprove}
disabled={isApproved || isRejected}
className={`flex-1 text-xs font-semibold py-2.5 px-3 rounded-lg transition-all shadow-elevation-sm ${
isApproved
? "bg-[#1B936F] text-white cursor-not-allowed"
: isRejected
? "bg-[#838389] text-white cursor-not-allowed"
: "bg-[#1B936F] hover:bg-[#189370] text-white"
}`}
>
{isApproved ? "✓ Approved" : "Approve Budget"}
</button>
<button
onClick={onReject}
disabled={isApproved || isRejected}
className={`flex-1 text-xs font-semibold py-2.5 px-3 rounded-lg transition-all shadow-elevation-sm ${
isRejected
? "bg-[#FFAC4D] text-white cursor-not-allowed"
: "bg-[#FFAC4D] hover:bg-[#FF9E3D] text-white disabled:opacity-50 disabled:cursor-not-allowed"
}`}
>
{isRejected ? "✗ Rejected" : "Reject"}
</button>
</div>
</div>
);
};
@@ -0,0 +1,79 @@
/**
* Component-specific Styles
*
* This file contains styling overrides and custom animations
* for CopilotKit components and A2A message visualization.
*/
/* ===== CopilotKit Component Overrides ===== */
/* These styles customize the appearance of CopilotKit chat components */
.copilotKitInput {
border-bottom-left-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
border: 1px solid var(--copilot-kit-separator-color) !important;
}
/* Make CopilotKit backgrounds transparent to blend with our gradient */
.copilotKitChat {
background-color: transparent !important;
}
.copilotKitMessages {
background-color: transparent !important;
}
.copilotKitInputContainer {
background-color: transparent !important;
}
.poweredBy {
background-color: transparent !important;
}
/* ===== Animations for Generative UI Components ===== */
/**
* Fade in and slide up animation
* Used by ItineraryCard, BudgetBreakdown, and WeatherCard components
* to create smooth entrance effects when data is received from agents
*/
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Apply fade-in-up animation to generative UI cards */
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out forwards;
}
/* Stagger animations for list items (optional, for future use) */
.animate-stagger-1 {
animation-delay: 0.1s;
}
.animate-stagger-2 {
animation-delay: 0.2s;
}
.animate-stagger-3 {
animation-delay: 0.3s;
}
/**
* A2A message entrance animation
* Used by MessageToA2A and MessageFromA2A components
* to animate A2A protocol communication badges
*/
.a2a-message-enter {
animation: fadeInUp 0.4s ease-out;
}
@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import {
ThemeProvider as NextThemesProvider,
ThemeProviderProps as NextThemeProviderProps,
} from "next-themes";
export function ThemeProvider({ children, ...props }: NextThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -0,0 +1,315 @@
"use client";
/**
* Travel Chat Component
*
* Demonstrates key patterns:
* - A2A Communication: Visualizes message flow between orchestrator and agents
* - HITL: Trip requirements form and budget approval workflows
* - Generative UI: Extracts structured data from agent responses
* - Multi-Agent: Coordinates 4 agents across LangGraph + ADK via A2A Protocol
*/
import React, { useState, useEffect } from "react";
import { CopilotKit, useCopilotChat } from "@copilotkit/react-core";
import { CopilotChat } from "@copilotkit/react-ui";
import { useCopilotAction } from "@copilotkit/react-core";
import "@copilotkit/react-ui/styles.css";
import "./style.css";
import type {
TravelChatProps,
ItineraryData,
BudgetData,
WeatherData,
RestaurantData,
MessageActionRenderProps,
} from "./types";
import { MessageToA2A } from "./a2a/MessageToA2A";
import { MessageFromA2A } from "./a2a/MessageFromA2A";
import { TripRequirementsForm } from "./forms/TripRequirementsForm";
import { BudgetApprovalCard } from "./hitl/BudgetApprovalCard";
import { WeatherCard } from "./WeatherCard";
const ChatInner = ({
onItineraryUpdate,
onBudgetUpdate,
onWeatherUpdate,
onRestaurantUpdate,
}: TravelChatProps) => {
const [approvalStates, setApprovalStates] = useState<
Record<string, { approved: boolean; rejected: boolean }>
>({});
const { visibleMessages } = useCopilotChat();
// Extract structured data from A2A agent responses
useEffect(() => {
const extractDataFromMessages = () => {
for (const message of visibleMessages) {
const msg = message as any;
if (
msg.type === "ResultMessage" &&
msg.actionName === "send_message_to_a2a_agent"
) {
try {
const result = msg.result;
let parsed;
if (typeof result === "string") {
let cleanResult = result;
if (result.startsWith("A2A Agent Response: ")) {
cleanResult = result.substring("A2A Agent Response: ".length);
}
parsed = JSON.parse(cleanResult);
} else if (typeof result === "object" && result !== null) {
parsed = result;
}
if (parsed) {
if (
parsed.destination &&
parsed.itinerary &&
Array.isArray(parsed.itinerary)
) {
onItineraryUpdate?.(parsed as ItineraryData);
} else if (
parsed.totalBudget &&
parsed.breakdown &&
Array.isArray(parsed.breakdown)
) {
const budgetKey = `budget-${parsed.totalBudget}`;
const isApproved = approvalStates[budgetKey]?.approved || false;
if (isApproved) {
onBudgetUpdate?.(parsed as BudgetData);
}
} else if (
parsed.destination &&
parsed.forecast &&
Array.isArray(parsed.forecast)
) {
const weatherDataParsed = parsed as WeatherData;
onWeatherUpdate?.(weatherDataParsed);
} else if (
parsed.destination &&
parsed.meals &&
Array.isArray(parsed.meals)
) {
onRestaurantUpdate?.(parsed as RestaurantData);
}
}
} catch (e) {}
}
}
};
extractDataFromMessages();
}, [
visibleMessages,
approvalStates,
onItineraryUpdate,
onBudgetUpdate,
onWeatherUpdate,
onRestaurantUpdate,
]);
// Register A2A message visualizer (renders green/blue communication boxes)
useCopilotAction({
name: "send_message_to_a2a_agent",
description: "Sends a message to an A2A agent",
available: "frontend",
parameters: [
{
name: "agentName",
type: "string",
description: "The name of the A2A agent to send the message to",
},
{
name: "task",
type: "string",
description: "The message to send to the A2A agent",
},
],
render: (actionRenderProps: MessageActionRenderProps) => {
return (
<>
<MessageToA2A {...actionRenderProps} />
<MessageFromA2A {...actionRenderProps} />
</>
);
},
});
// Register HITL budget approval workflow (pauses agent until user approves/rejects)
useCopilotAction(
{
name: "request_budget_approval",
description: "Request user approval for the travel budget",
parameters: [
{
name: "budgetData",
type: "object",
description: "The budget breakdown data requiring approval",
},
],
renderAndWaitForResponse: ({ args, respond }) => {
if (!args.budgetData || typeof args.budgetData !== "object") {
return (
<div className="text-xs text-gray-500 p-2">
Loading budget data...
</div>
);
}
const budget = args.budgetData as BudgetData;
if (!budget.totalBudget || !budget.breakdown) {
return (
<div className="text-xs text-gray-500 p-2">
Loading budget data...
</div>
);
}
const budgetKey = `budget-${budget.totalBudget}`;
const currentState = approvalStates[budgetKey] || {
approved: false,
rejected: false,
};
const handleApprove = () => {
setApprovalStates((prev) => ({
...prev,
[budgetKey]: { approved: true, rejected: false },
}));
respond?.({ approved: true, message: "Budget approved by user" });
};
const handleReject = () => {
setApprovalStates((prev) => ({
...prev,
[budgetKey]: { approved: false, rejected: true },
}));
respond?.({ approved: false, message: "Budget rejected by user" });
};
return (
<BudgetApprovalCard
budgetData={budget}
isApproved={currentState.approved}
isRejected={currentState.rejected}
onApprove={handleApprove}
onReject={handleReject}
/>
);
},
},
[approvalStates],
);
// Register HITL trip requirements form (collects trip info at start)
useCopilotAction({
name: "gather_trip_requirements",
description:
"Gather trip requirements from the user (city, days, people, budget level)",
parameters: [
{
name: "city",
type: "string",
description:
"The destination city (may be pre-filled from user message)",
required: false,
},
{
name: "numberOfDays",
type: "number",
description: "Number of days for the trip (1-7)",
required: false,
},
{
name: "numberOfPeople",
type: "number",
description: "Number of people in the group (1-15)",
required: false,
},
{
name: "budgetLevel",
type: "string",
description: "Budget level: Economy, Comfort, or Premium",
required: false,
},
],
renderAndWaitForResponse: ({ args, respond }) => {
return <TripRequirementsForm args={args} respond={respond} />;
},
});
// Display WeatherCard inline in chat (also shown in main content area)
useCopilotAction({
name: "display_weather_forecast",
description: "Display weather forecast data as generative UI in the chat",
available: "frontend",
parameters: [
{
name: "weatherData",
type: "object",
description: "Weather forecast data to display",
},
],
render: ({ args }) => {
if (!args.weatherData || typeof args.weatherData !== "object") {
return <></>;
}
const weather = args.weatherData as WeatherData;
if (
!weather.destination ||
!weather.forecast ||
!Array.isArray(weather.forecast)
) {
return <></>;
}
return (
<div className="my-3">
<WeatherCard data={weather} />
</div>
);
},
});
return (
<div className="h-full">
<CopilotChat
className="h-full"
labels={{
initial:
"👋 Hi! I'm your travel planning assistant.\n\nAsk me to plan a trip and I'll coordinate with specialized agents to create your perfect itinerary!",
}}
instructions="You are a helpful travel planning assistant. Help users plan their trips by coordinating with specialized agents."
/>
</div>
);
};
export default function TravelChat({
onItineraryUpdate,
onBudgetUpdate,
onWeatherUpdate,
onRestaurantUpdate,
}: TravelChatProps) {
return (
<CopilotKit
runtimeUrl="/api/copilotkit"
showDevConsole={false}
agent="a2a_chat"
>
<ChatInner
onItineraryUpdate={onItineraryUpdate}
onBudgetUpdate={onBudgetUpdate}
onWeatherUpdate={onWeatherUpdate}
onRestaurantUpdate={onRestaurantUpdate}
/>
</CopilotKit>
);
}
@@ -0,0 +1,211 @@
/**
* Shared Type Definitions
*
* This file contains all TypeScript interfaces and types used across
* the travel planning demo components. Centralizing types makes them
* easier to maintain and reuse.
*/
import { ActionRenderProps } from "@copilotkit/react-core";
// ============================================================================
// A2A Action Types
// ============================================================================
/**
* Type for the send_message_to_a2a_agent action parameters
* Used when the orchestrator sends tasks to A2A agents
*/
export type MessageActionRenderProps = ActionRenderProps<
[
{
readonly name: "agentName";
readonly type: "string";
readonly description: "The name of the A2A agent to send the message to";
},
{
readonly name: "task";
readonly type: "string";
readonly description: "The message to send to the A2A agent";
},
]
>;
/**
* Type for the budget approval action parameters
* Used in Human-in-the-Loop (HITL) budget approval workflow
*/
export type BudgetApprovalActionRenderProps = ActionRenderProps<
[
{
readonly name: "budgetData";
readonly type: "object";
readonly description: "The budget data to approve";
},
]
>;
/**
* Type for trip requirements action parameters
* Used to gather essential trip information at the start
*/
export type TripRequirementsActionRenderProps = ActionRenderProps<
[
{
readonly name: "city";
readonly type: "string";
readonly description: "The destination city (may be pre-filled from user message)";
},
{
readonly name: "numberOfDays";
readonly type: "number";
readonly description: "Number of days for the trip (1-7)";
},
{
readonly name: "numberOfPeople";
readonly type: "number";
readonly description: "Number of people in the group (1-15)";
},
{
readonly name: "budgetLevel";
readonly type: "string";
readonly description: "Budget level: Economy, Comfort, or Premium";
},
]
>;
// ============================================================================
// Agent Data Structures
// ============================================================================
/**
* Time slot structure for activities during a day
* Used in the itinerary to organize morning/afternoon/evening activities
*/
export interface TimeSlot {
activities: string[];
location: string;
}
/**
* Meals structure for a day
* Contains breakfast, lunch, and dinner recommendations
*/
export interface Meals {
breakfast: string;
lunch: string;
dinner: string;
}
/**
* Single day itinerary structure
* Contains all activities and meals for one day of travel
*/
export interface DayItinerary {
day: number;
title: string;
morning: TimeSlot;
afternoon: TimeSlot;
evening: TimeSlot;
meals: Meals;
}
/**
* Complete itinerary data from Itinerary Agent
* Structured JSON output from the LangGraph itinerary agent
*/
export interface ItineraryData {
destination: string;
days: number;
itinerary: DayItinerary[];
}
/**
* Restaurant recommendations data from Restaurant Agent
* Day-by-day meal recommendations that populate the itinerary meals section
*/
export interface RestaurantData {
destination: string;
days: number;
meals: Array<{
day: number;
breakfast: string;
lunch: string;
dinner: string;
}>;
}
/**
* Budget category breakdown
* Individual category with amount and percentage of total
*/
export interface BudgetCategory {
category: string;
amount: number;
percentage: number;
}
/**
* Complete budget data from Budget Agent
* Structured JSON output from the ADK budget agent
*/
export interface BudgetData {
totalBudget: number;
currency: string;
breakdown: BudgetCategory[];
notes: string;
}
/**
* Daily weather forecast
* Contains weather conditions, temperatures, and description
*/
export interface DailyWeather {
day: number;
date: string;
condition: string;
highTemp: number;
lowTemp: number;
precipitation: number;
humidity: number;
windSpeed: number;
description: string;
}
/**
* Complete weather data from Weather Agent
* Structured JSON output from the ADK weather agent
*/
export interface WeatherData {
destination: string;
forecast: DailyWeather[];
travelAdvice: string;
bestDays: number[];
}
// ============================================================================
// Component Props
// ============================================================================
/**
* Props for the main TravelChat component
* Callbacks to update parent component state with agent data
*/
export interface TravelChatProps {
onItineraryUpdate?: (data: ItineraryData | null) => void;
onBudgetUpdate?: (data: BudgetData | null) => void;
onWeatherUpdate?: (data: WeatherData | null) => void;
onRestaurantUpdate?: (data: RestaurantData | null) => void;
}
/**
* Agent styling configuration
* Used to style agent badges with consistent colors and icons
*/
export interface AgentStyle {
bgColor: string;
textColor: string;
borderColor: string;
icon: string;
framework: string;
}
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4239f84835aa69e7292d60ad533c141e866fa49129f307a9902fef67d443b851
size 1200922
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
{
"name": "ag-ui-a2a-demo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently --names \"UI,Orch,Itin,Weather,Rest,Budget\" --prefix-colors \"cyan,gray,green,blue,blue,blue\" \"npm run dev:ui\" \"npm run dev:orchestrator\" \"npm run dev:itinerary\" \"npm run dev:weather\" \"npm run dev:restaurant\" \"npm run dev:budget\"",
"dev:ui": "next dev --turbopack",
"dev:orchestrator": "agents/.venv/bin/python agents/orchestrator.py",
"dev:itinerary": "agents/.venv/bin/python agents/itinerary_agent.py",
"dev:budget": "agents/.venv/bin/python agents/budget_agent.py",
"dev:restaurant": "agents/.venv/bin/python agents/restaurant_agent.py",
"dev:weather": "agents/.venv/bin/python agents/weather_agent.py",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@a2a-js/sdk": "latest",
"@ag-ui/a2a-middleware": "0.0.2",
"@ag-ui/client": "0.0.40",
"@copilotkit/react-core": "latest",
"@copilotkit/react-ui": "latest",
"@copilotkit/runtime": "latest",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dedent": "^1.7.0",
"lucide-react": "^0.477.0",
"next": "15.5.15",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
"overrides": {
"@ag-ui/client": "0.0.40"
}
}
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
@@ -0,0 +1,240 @@
/* CopilotCloud Typography Components */
/* Headings */
.H1-SemiBold {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 56px;
line-height: 64px;
letter-spacing: 0px;
font-weight: 600;
}
.H1-Medium {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 56px;
line-height: 64px;
letter-spacing: 0px;
font-weight: 500;
}
.H2-SemiBold {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 40px;
line-height: 46px;
letter-spacing: 0px;
font-weight: 600;
}
.H2-Medium {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 40px;
line-height: 46px;
letter-spacing: 0px;
font-weight: 500;
}
.H3-SemiBold {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 32px;
line-height: 36px;
letter-spacing: 0px;
font-weight: 600;
}
.H3-Medium {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 32px;
line-height: 36px;
letter-spacing: 0px;
font-weight: 500;
}
.H4-SemiBold {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 24px;
line-height: 28px;
letter-spacing: 0px;
font-weight: 600;
}
.H4-Medium {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 24px;
line-height: 28px;
letter-spacing: 0px;
font-weight: 500;
}
.H5-SemiBold {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 20px;
line-height: 24px;
letter-spacing: 0px;
font-weight: 600;
}
.H5-Medium {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 20px;
line-height: 24px;
letter-spacing: 0px;
font-weight: 500;
}
.H6-SemiBold {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 18px;
line-height: 20px;
letter-spacing: 0px;
font-weight: 600;
}
.H6-Medium {
font-family: "Plus Jakarta Sans", sans-serif;
font-size: 18px;
line-height: 20px;
letter-spacing: 0px;
font-weight: 500;
}
/* Paragraphs */
.paragraphs-Large-Bold {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
font-weight: 700;
}
.paragraphs-Large-SemiBold {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
font-weight: 600;
}
.paragraphs-Large-Medium {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
font-weight: 500;
}
.paragraphs-Large-Regular {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 24px;
letter-spacing: 0px;
font-weight: 400;
}
.paragraphs-Medium-SemiBold {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
font-weight: 600;
}
.paragraphs-Medium-Medium {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
font-weight: 500;
}
.paragraphs-Medium-Regular {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 22px;
letter-spacing: 0px;
font-weight: 400;
}
.paragraphs-Small-SemiBold {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
line-height: 16px;
letter-spacing: 0px;
font-weight: 600;
}
.paragraphs-Small-Medium {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
line-height: 16px;
letter-spacing: 0px;
font-weight: 500;
}
.paragraphs-Small-Regular {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
line-height: 16px;
letter-spacing: 0px;
font-weight: 400;
}
.paragraphs-Small-Regular-Uppercase {
font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
line-height: 16px;
letter-spacing: 0px;
font-weight: 400;
text-transform: uppercase;
}
/* Details */
.details-Medium-Medium {
font-family: "Spline Sans Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 14px;
line-height: 14px;
letter-spacing: 0px;
font-weight: 500;
}
.details-Medium-Medium-Uppercase {
font-family: "Spline Sans Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 14px;
line-height: 14px;
letter-spacing: 0px;
font-weight: 500;
text-transform: uppercase;
}
.details-Small-Medium {
font-family: "Spline Sans Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 12px;
line-height: 12px;
letter-spacing: 0px;
font-weight: 500;
}
.details-Small-Medium-Uppercase {
font-family: "Spline Sans Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 12px;
line-height: 12px;
letter-spacing: 0px;
font-weight: 500;
text-transform: uppercase;
}
.details-ExtraSmall-Medium {
font-family: "Spline Sans Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 10px;
line-height: 10px;
letter-spacing: 0px;
font-weight: 500;
}
.details-ExtraSmall-Medium-Uppercase {
font-family: "Spline Sans Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 10px;
line-height: 10px;
letter-spacing: 0px;
font-weight: 500;
text-transform: uppercase;
}
@@ -0,0 +1,82 @@
import type { Config } from "tailwindcss";
const config = {
darkMode: "class",
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
],
theme: {
extend: {
colors: {
// Shadcn/ui colors
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
fontFamily: {
sans: ["Plus Jakarta Sans", "ui-sans-serif", "system-ui", "sans-serif"],
mono: [
"Spline Sans Mono",
"ui-monospace",
"SFMono-Regular",
"monospace",
],
},
boxShadow: {
"elevation-sm": "var(--shadow-sm)",
"elevation-md": "var(--shadow-md)",
"elevation-lg": "var(--shadow-lg)",
"elevation-xl": "var(--shadow-xl)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config;
export default config;
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,54 @@
# ── Secrets ────────────────────────────────────────────────────────
.env
.env.*
!.env.example
# ── OS / editor ────────────────────────────────────────────────────
.DS_Store
.idea/
.vscode/
*.swp
*~
# ── Node / Next.js ─────────────────────────────────────────────────
node_modules/
.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# Next.js build output + caches
.next/
out/
build/
*.tsbuildinfo
next-env.d.ts
# Test / coverage
coverage/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Vercel
.vercel
# ── Python / uv (agent/) ──────────────────────────────────────────
.venv/
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# ── Misc ───────────────────────────────────────────────────────────
*.log
*.pem
@@ -0,0 +1,138 @@
# A2UI PDF Analyst
Chat with your PDF and watch the agent build the UI for each answer. Powered by **A2UI v0.9 (Agent-to-UI)** — the open protocol that lets an agent describe a surface as structured component operations your frontend renders against its own design system. Same chat input, two rendering strategies, one shared 21-component catalog.
https://github.com/user-attachments/assets/c053d2e8-1d40-43cb-8c5a-8e5c121b851f
**Three routes:**
- **`/fixed`** — hand-authored JSON dashboard. The agent only extracts the data (KPIs, trend, segment splits, table rows) and fills the slots. Predictable layout, brand-locked, single LLM call per turn. Best when the shape of the answer is known up front.
- **`/dynamic`** — no pre-written layout. The agent reads the question, picks components from the catalog, and composes the surface on the fly. A net-income query lands as a single StatCard; a segment breakdown becomes a DonutChart; a research-paper summary composes Overline + Heading + Text + Callout + BulletList. Best when the right answer's _form_ varies with the question.
- **`/catalog`** — every component rendered live, filterable by group (Layout, Content, Data viz, Interactive). Doubles as a sanity check on the renderers and a reference for what the agent is allowed to draw from.
All three routes share the same brand tokens (`src/a2ui/theme.css`), the same React renderers (`src/a2ui/catalog/renderers.tsx`), and the same client-side PDF text extraction pipeline (`src/lib/pdf.ts`). Re-skin one stylesheet, every surface updates.
## Prerequisites
- Node.js 20+ and [pnpm](https://pnpm.io/) (npm works too)
- Python 3.12
- [uv](https://docs.astral.sh/uv/) for the Python agent
- An OpenAI API key
## Run locally
```bash
git clone https://github.com/CopilotKit/CopilotKit.git
cd CopilotKit/examples/showcases/a2ui-pdf-analyst
cp agent/.env.example agent/.env # then put your OPENAI_API_KEY in agent/.env
pnpm install # installs Next.js + runs `uv sync` for the agent
pnpm dev # boots web on :3000, agent on :8123
```
Open <http://localhost:3000>. `npm install && npm run dev` works identically.
## Environment variables
`agent/.env`:
| Variable | Required | Notes |
| ---------------- | -------- | ------------------------------------------------------------------------------------- |
| `OPENAI_API_KEY` | yes | used by the main agent and by the secondary LLMs inside `query_pdf` / `generate_a2ui` |
## Architecture
```
a2ui-pdf-analyst/
├── package.json → Next.js manifest + concurrently runs the agent alongside
├── next.config.ts
├── postcss.config.mjs
├── tsconfig.json
├── public/ → static assets (CopilotKit brand SVGs)
├── src/ → Next.js 16 · React 19 · Tailwind v4
│ ├── app/
│ │ ├── api/copilotkit/ → CopilotKit V2 runtime endpoint (HttpAgent → Python)
│ │ ├── fixed/ → fixed-schema route: pre-authored dashboard
│ │ ├── dynamic/ → dynamic-schema route: agent invents the layout
│ │ ├── catalog/ → live showcase of all 21 components
│ │ ├── globals.css → app-wide tokens, fonts
│ │ ├── layout.tsx → root layout + Providers
│ │ └── page.tsx → overview
│ ├── a2ui/
│ │ ├── catalog/
│ │ │ ├── definitions.ts → Zod prop schemas + agent-facing descriptions
│ │ │ ├── renderers.tsx → React renderers (Recharts charts, tables, cards)
│ │ │ └── index.ts → createCatalog() (definitions + renderers, catalogId)
│ │ ├── theme.css → brand tokens, scoped to .a2ui-surface
│ │ ├── surface-bus.ts → per-agent A2UI op stream the canvas subscribes to
│ │ └── MirrorRenderer.tsx → activity renderer that forwards ops to the canvas
│ ├── components/
│ │ ├── SurfaceCanvas.tsx → mounts A2UIProvider + renders surfaces
│ │ ├── FilteredUserMessage.tsx → strips inlined PDF text from chat
│ │ ├── FilteredAssistantMessage.tsx → suppresses JSON-shaped agent replies
│ │ ├── Split.tsx → VS-Code-style resizable chat/canvas split
│ │ ├── Providers.tsx → <CopilotKit> + activity renderers
│ │ └── Brand.tsx → SiteNav + PageHeader
│ └── lib/pdf.ts → client-side PDF text extraction (pdfjs-dist)
└── agent/ → Python · LangChain · LangGraph · FastAPI · AG-UI
├── main.py → /fixed and /dynamic FastAPI endpoints
├── pyproject.toml
├── uv.lock
└── src/
├── catalog.py → CATALOG_ID + system-prompt fragment listing components
├── fixed_agent.py → render_dashboard backend tool
├── dynamic_agent.py → query_pdf + generate_a2ui tools
├── pdf_tools.py → query_pdf: PDF text → structured JSON answer
├── multimodal_middleware.py → ag-ui-langgraph patch so PDF text survives the trip to OpenAI
└── a2ui/schemas/dashboard.json → the fixed dashboard layout (Stack / Grid / charts / table)
```
## How it works
**PDF attachment** — CopilotKit's multimodal attachment support lets the user attach a PDF directly in the chat input. The frontend extracts the full text client-side via `pdfjs-dist` and inlines it into the user message under a `[Document: <filename>]` header. `multimodal_middleware.py` patches `ag-ui-langgraph` so this text block survives serialization and arrives intact at OpenAI. The agent scans every message in the conversation history for the most recent `[Document: ...]` header — attach once, ask many questions.
**Fixed schema (`/fixed`)**`agent/src/a2ui/schemas/dashboard.json` is a static A2UI component tree the agent never touches. The `render_dashboard` tool takes typed arguments (KPIs, trend, share, rows, scope chips), packages them as A2UI `update_data_model` ops, and the existing tree picks them up via `{path}` bindings. One LLM pass, one tool call, surface streams in.
**Dynamic schema (`/dynamic`)** — five steps per turn:
1. User attaches a PDF and asks a question. Frontend inlines the PDF text into the message.
2. Agent calls `query_pdf` → a sub-LLM reads the document and returns structured JSON: `shape_hint`, `title`, `summary`, `data`.
3. Agent calls `generate_a2ui` (no arguments) → spawns a second sub-LLM bound to a no-op `render_a2ui` shim with `tool_choice` forced to that shim.
4. The second LLM's tool-call arguments (surfaceId, catalogId, components, data) become A2UI `create_surface` + `update_components` + `update_data_model` operations.
5. The JS-side A2UI middleware detects `a2ui_operations` in the tool result and emits the snapshot events the canvas listens for. Surface renders. Agent emits an empty chat message.
## Sample PDFs
These work well for the dynamic-schema demo:
- Apple Q4 FY24 Consolidated Financial Statements ([download](https://www.apple.com/newsroom/pdfs/fy2024-q4/FY24_Q4_Consolidated_Financial_Statements.pdf)) — structured tables, multiple categorical breakdowns
- Tesla Q3 2024 Update ([download](https://www.tesla.com/sites/default/files/downloads/TSLA-Q3-2024-Update.pdf)) — multi-quarter time-series + production / delivery pairs
- Anthropic's _Constitutional AI: Harmlessness from AI Feedback_ ([download](https://arxiv.org/pdf/2212.08073)) — research paper, mostly prose, for text-heavy explainer surfaces
## Prompts to try
On `/dynamic` after attaching a PDF:
| Ask the agent | Expected surface |
| --------------------------------------------------------------------------------------------- | ------------------------------------- |
| `What was net income last quarter?` | one StatCard |
| `Break iPhone vs Mac vs iPad vs Wearables vs Services as a donut.` | DonutChart |
| `Show Q4 net sales by category as horizontal bars.` | HorizontalBarChart |
| `Plot quarterly production against deliveries across the last 5 quarters as a scatter chart.` | ScatterChart |
| `Explain the main idea of this paper in plain English.` | Heading + Text + Callout + BulletList |
| `Show me the revenue trend over the last 6 quarters.` | LineChart |
On `/fixed` after attaching a PDF:
| Ask the agent | What happens |
| ------------------------------------------- | ---------------------------------------------------------------------- |
| `Render the dashboard.` | full dashboard with KPIs, trend chart, share donut, table, scope chips |
| `Switch scope to FY24.` (or click the chip) | re-renders the same dashboard with FY24 data |
## Tech stack
| Layer | Stack |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Frontend | Next.js 16 · React 19 · Tailwind v4 · TypeScript · `@copilotkit/react-core/v2` · `@copilotkit/a2ui-renderer` · `pdfjs-dist` · Recharts |
| Runtime bridge | `@copilotkit/runtime/v2` · `@ag-ui/client` (HttpAgent) |
| Backend | Python 3.12 · FastAPI · `ag-ui-langgraph` · `copilotkit` (Python SDK) · `langchain` agents + LangGraph · `langchain-openai` |
| Model | `gpt-5.5` for both the main agent and the secondary LLMs |
@@ -0,0 +1 @@
OPENAI_API_KEY=sk-your-openai-key
@@ -0,0 +1,93 @@
"""FastAPI server exposing two AG-UI agents:
POST /fixed/ . multi-step launch wizard using fixed A2UI schemas
POST /dynamic/ . dynamic A2UI surfaces generated at runtime
Run with: uvicorn main:app --port 8123 --reload
"""
from __future__ import annotations
import os
import uvicorn
from ag_ui_langgraph import add_langgraph_fastapi_endpoint
from copilotkit import LangGraphAGUIAgent
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
# Patch ag-ui-langgraph's multimodal converter so PDF text shipped via
# CopilotChat attachments survives the trip to OpenAI. Must run BEFORE
# the agent graphs are imported so the agents pick up the patched
# converter at first message conversion.
from src.multimodal_middleware import install as _install_doc_inlining # noqa: E402
_install_doc_inlining()
from src.dynamic_agent import graph as dynamic_graph # noqa: E402
from src.fixed_agent import graph as fixed_graph # noqa: E402
app = FastAPI(title="A2UI Demo Agents")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# LangGraph's default recursion_limit is 25. ag_ui_langgraph builds its own
# RunnableConfig per run, which overrides any `.with_config()` we bind at
# compile time on the graph. Pass recursion_limit here so it's honored.
# 50 is plenty for our two-tool flow; bump higher only if a deeper agent
# loop is genuinely needed.
_AGENT_CONFIG = {"recursion_limit": 50}
add_langgraph_fastapi_endpoint(
app=app,
agent=LangGraphAGUIAgent(
name="fixed_agent",
description="Multi-step launch wizard with fixed A2UI schemas.",
graph=fixed_graph,
config=_AGENT_CONFIG,
),
path="/fixed",
)
add_langgraph_fastapi_endpoint(
app=app,
agent=LangGraphAGUIAgent(
name="dynamic_agent",
description="Dynamic A2UI surfaces generated by a secondary LLM.",
graph=dynamic_graph,
config=_AGENT_CONFIG,
),
path="/dynamic",
)
@app.get("/")
def root():
return {
"ok": True,
"agents": {
"fixed_agent": "/fixed/",
"dynamic_agent": "/dynamic/",
},
}
def main():
uvicorn.run(
"main:app",
host="0.0.0.0",
port=int(os.environ.get("PORT", "8123")),
reload=True,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,15 @@
[project]
name = "agent"
version = "0.1.0"
requires-python = ">=3.12,<3.14"
dependencies = [
"ag-ui-langgraph>=0.0.36",
"copilotkit>=0.1.90",
"fastapi>=0.136.3",
"langchain>=1.0",
"langchain-core>=1.4.0",
"langchain-openai>=1.2.2",
"langgraph>=1.0",
"python-dotenv>=1.2.2",
"uvicorn>=0.48.0",
]
@@ -0,0 +1,163 @@
[
{
"id": "root",
"component": "Stack",
"gap": "lg",
"children": [
"header-card",
"scope-chips",
"kpi-grid",
"charts-grid",
"table-section"
]
},
{
"id": "header-card",
"component": "Card",
"child": "header-stack"
},
{
"id": "header-stack",
"component": "Stack",
"gap": "sm",
"children": ["header-eyebrow", "header-heading", "header-text"]
},
{
"id": "header-eyebrow",
"component": "Overline",
"text": { "path": "/eyebrow" }
},
{
"id": "header-heading",
"component": "Heading",
"level": "1",
"text": { "path": "/title" }
},
{
"id": "header-text",
"component": "Text",
"tone": "muted",
"text": { "path": "/subtitle" }
},
{
"id": "scope-chips",
"component": "ChoiceChips",
"label": "Scope",
"value": { "path": "/scope/selected" },
"options": { "path": "/scope/options" }
},
{
"id": "kpi-grid",
"component": "Grid",
"columns": 4,
"gap": "md",
"children": ["kpi-1", "kpi-2", "kpi-3", "kpi-4"]
},
{
"id": "kpi-1",
"component": "StatCard",
"label": { "path": "/kpis/0/label" },
"value": { "path": "/kpis/0/value" },
"delta": { "path": "/kpis/0/delta" },
"deltaTone": "positive",
"caption": { "path": "/kpis/0/caption" }
},
{
"id": "kpi-2",
"component": "StatCard",
"label": { "path": "/kpis/1/label" },
"value": { "path": "/kpis/1/value" },
"delta": { "path": "/kpis/1/delta" },
"deltaTone": "positive",
"caption": { "path": "/kpis/1/caption" }
},
{
"id": "kpi-3",
"component": "StatCard",
"label": { "path": "/kpis/2/label" },
"value": { "path": "/kpis/2/value" },
"delta": { "path": "/kpis/2/delta" },
"deltaTone": "positive",
"caption": { "path": "/kpis/2/caption" }
},
{
"id": "kpi-4",
"component": "StatCard",
"label": { "path": "/kpis/3/label" },
"value": { "path": "/kpis/3/value" },
"delta": { "path": "/kpis/3/delta" },
"deltaTone": "negative",
"caption": { "path": "/kpis/3/caption" }
},
{
"id": "charts-grid",
"component": "Grid",
"columns": 2,
"gap": "md",
"children": ["trend-section", "share-section"]
},
{
"id": "trend-section",
"component": "Section",
"eyebrow": "TREND",
"title": "Monthly performance",
"child": "trend-card"
},
{
"id": "trend-card",
"component": "Card",
"child": "trend-chart"
},
{
"id": "trend-chart",
"component": "LineChart",
"height": 240,
"data": { "path": "/trend" }
},
{
"id": "share-section",
"component": "Section",
"eyebrow": "SHARE OF TOTAL",
"title": "Breakdown",
"child": "share-card"
},
{
"id": "share-card",
"component": "Card",
"child": "share-chart"
},
{
"id": "share-chart",
"component": "DonutChart",
"height": 240,
"data": { "path": "/share" }
},
{
"id": "table-section",
"component": "Section",
"eyebrow": "DETAIL",
"title": "Top rows",
"child": "table-card"
},
{
"id": "table-card",
"component": "Card",
"child": "table"
},
{
"id": "table",
"component": "DataTable",
"columns": [
{ "key": "name", "label": "Name", "align": "left" },
{ "key": "category", "label": "Category", "align": "left" },
{ "key": "value", "label": "Value", "align": "right" },
{ "key": "delta", "label": "Δ", "align": "right" }
],
"rows": { "path": "/rows" }
}
]
@@ -0,0 +1,75 @@
"""The custom A2UI catalog. Python mirror.
This must stay in sync with web/src/a2ui/catalog/definitions.ts. Only the
catalog ID and the component prop summary live here; the JSON Schema for each
component is owned by the frontend (it's where the renderers are).
The agent reads CATALOG_PROMPT to know which components exist and what they
accept; createSurface uses CATALOG_ID so the runtime resolves to our renderers.
"""
CATALOG_ID = "https://cpk-a2ui.local/catalogs/copilotkit/v1"
CATALOG_PROMPT = """\
## Available A2UI components. CopilotKit custom catalog
You may ONLY use the components listed here. Do not invent new component
types. All `id` values must be unique within the surface; exactly one
component must have `id: "root"`.
### Layout
- **Stack** { children: [ids] | { componentId, path }, gap?: xs|sm|md|lg|xl, align?: start|center|end|stretch }
Vertical layout. The default container for surfaces and sections.
- **Row** { children: [ids], gap?: xs|sm|md|lg, justify?: start|center|end|spaceBetween, align?: start|center|end }
Horizontal layout (wraps). Use for toolbars, metric rows, badge groups.
- **Grid** { children: [ids], columns?: 1-6, gap?: xs|sm|md|lg }
Responsive grid. Use for stat-card rows and chart pairs.
- **Section** { title: string, eyebrow?: string, child: id }
Titled section header + child container.
- **Card** { child: id, tone?: default|lilac|mint|warning }
Bordered, padded surface. Pass a Stack/Row/Grid as child.
- **Divider** { }
1px line.
### Content
- **Heading** { text: string|{path}, level?: "1"|"2"|"3" }
- **Text** { text: string|{path}, tone?: default|muted, size?: sm|md|lg, weight?: regular|medium|semibold }
- **Overline** { text: string|{path} }
Tiny ALL-CAPS mono label above a heading. Also known as "overline" in typography.
- **Badge** { label: string|{path}, tone?: neutral|positive|warning|danger|info }
- **Callout** { body: string|{path}, title?: string|{path}, tone?: info|positive|warning|neutral }
Block-level highlight for a key insight, definition, or warning. Use for "the takeaway" moments inside an explanation surface.
- **BulletList** { items: [string] | {path}, ordered?: bool }
Bulleted or numbered list. Use for short enumerations like "three contributions" or "steps to reproduce".
### Data viz
- **StatCard** { label, value, delta?, deltaTone?: positive|negative|neutral, caption? }
Single big-number metric.
- **BarChart** { data: [{label,value}], height?: 120-480 }
Vertical bars. Use when labels are short (< 7 chars).
- **HorizontalBarChart** { data: [{label,value}], height?: 120-640 }
Bars rendered as rows. Use for ranked lists with long labels (top customers, country names).
- **LineChart** { data: [{label,value}], height?: 120-480 }
Trend where direction is the main signal.
- **DonutChart** { data: [{label,value}], height?: 120-480 }
Share-of-total breakdown (3-6 slices).
- **ScatterChart** { data: [{x:number, y:number, label?}], xLabel?: string, yLabel?: string, height?: 160-560 }
X/Y dots for correlation. Always provide xLabel and yLabel so the user knows what each axis is.
- **DataTable** { columns: [{key,label,align?}], rows: [record by column key] }
### Interactive (use only when the surface needs interactivity)
- **Button** { label, variant?: primary|secondary|ghost, action: { event: { name, context? } } }
- **ChoiceChips** { label, options: [{label,value}], value: {path}, multi?: bool }
### Rules
1. Exactly one component has id="root". Everything else must be reachable from root.
2. Repeating content uses `children: { componentId: "card-id", path: "/items" }`.
Components INSIDE a List template use RELATIVE paths (no leading slash).
3. Chart `data` and DataTable `rows` may be inline arrays or `{ path: "/..." }`.
When you pass `data` as a `{ path }` binding, populate that path via updateDataModel.
4. Inline values are preferred for everything else; do not use `{ path }`
bindings on properties whose schema doesn't accept them.
5. Buttons must include an `action`. Action format:
"action": { "event": { "name": "approve_plan", "context": { ... } } }
"""
@@ -0,0 +1,232 @@
"""Dynamic-schema Q&A agent.
The agent answers any question about the most-recently-uploaded PDF by
inventing the UI for the answer using our custom catalog.
## Why this looks the way it does
The first cut wired things up to use the JS-runtime-injected `render_a2ui`
frontend tool. That works on turn 1 but leaves an orphan
`function_call` in agent state (CopilotKitMiddleware strips frontend
tool calls in `after_model` and restores them in `after_agent`. between
those two phases ToolNode never sees the call, so no `ToolMessage` is
ever produced). The result is turn 2 hitting OpenAI's Responses API
with an unanswered function_call → INCOMPLETE_STREAM / "terminated".
The CopilotKit reference example in
`CopilotKit/examples/integrations/langgraph-python` solves this by
NOT injecting `render_a2ui` as a frontend tool at all. Instead, the
agent has a real Python tool (`generate_a2ui` here) that:
1. Runs server-side as a normal LangChain tool.
2. Spawns a secondary LLM bound to a no-op `render_a2ui` tool to
force structured output.
3. Wraps the LLM's tool_call args into A2UI `create_surface` +
`update_components` + `update_data_model` operations.
4. Returns the rendered ops as a JSON string. a normal tool result.
The JS-side a2ui middleware detects `a2ui_operations` in the
TOOL_CALL_RESULT and emits the ACTIVITY_SNAPSHOT events the canvas
listens for. No frontend tool stripping. No orphan. No turn-2 crash.
`web/src/app/api/copilotkit/route.ts` sets `injectA2UITool: false` to
match.
"""
from __future__ import annotations
import json
from typing import Any
from copilotkit import CopilotKitMiddleware, a2ui
from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from langchain_core.messages import SystemMessage
from langchain_core.tools import tool as lc_tool
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from src.catalog import CATALOG_ID, CATALOG_PROMPT
from src.pdf_tools import query_pdf
# No-op shim. The secondary LLM is forced to call this tool so its output
# is a structured tool_call (surfaceId / catalogId / components / data)
# instead of free-form prose we'd have to JSON-parse and validate.
@lc_tool
def render_a2ui(
surfaceId: str,
catalogId: str,
components: list[dict],
data: dict | None = None,
) -> str:
"""Render a dynamic A2UI v0.9 surface.
Args:
surfaceId: Unique surface identifier (kebab-case).
catalogId: The catalog ID. Use the one provided in context.
components: A2UI v0.9 flat component array; root component MUST
have id="root".
data: Optional initial data model for the surface.
"""
return "rendered"
_RENDER_MODEL = ChatOpenAI(model="gpt-5.5", temperature=0)
@tool()
def generate_a2ui(runtime: ToolRuntime[Any]) -> str:
"""Render the answer to the user's question as an A2UI surface.
Call this AFTER `query_pdf`. It reads the conversation (including the
query_pdf result) and the available A2UI catalog from context, then
designs the surface and returns the operations for the client to
render. You do NOT pass any arguments. It picks up everything from
state.
"""
messages = runtime.state["messages"][:-1]
context_entries = runtime.state.get("copilotkit", {}).get("context", [])
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
# The runtime context only carries the basic catalog. Append our
# custom catalog spec so the secondary LLM picks our components, not
# the generic A2UI primitives.
custom_catalog_note = (
f"\n\n## Use THIS catalog (NOT the basic one above):\n"
f"catalogId: {CATALOG_ID}\n\n"
f"{CATALOG_PROMPT}\n"
)
prompt = (
f"{context_text}\n{custom_catalog_note}\n"
"Design the surface using ONLY components from the catalog above. "
"Inline all data (use plain values, not {{path}} bindings, unless a "
"property explicitly accepts a path). The user's request is in the "
"most recent messages. Honor the words they used (chart type, "
"comparison, etc.)."
)
model_with_tool = _RENDER_MODEL.bind_tools([render_a2ui], tool_choice="render_a2ui")
response = model_with_tool.invoke([SystemMessage(content=prompt), *messages])
if not response.tool_calls:
return json.dumps({"error": "secondary LLM did not call render_a2ui"})
args = response.tool_calls[0]["args"]
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CATALOG_ID)
components = args.get("components", [])
data = args.get("data", {})
ops = [
a2ui.create_surface(surface_id, catalog_id=catalog_id),
a2ui.update_components(surface_id, components),
]
if data:
ops.append(a2ui.update_data_model(surface_id, data))
return a2ui.render(operations=ops)
SYSTEM_PROMPT = f"""\
You answer questions about a user's attached PDF and render the answer
as an A2UI surface using our custom catalog.
## Where the PDF lives
The frontend extracts the PDF text and inlines it into the user's
message under a `[Document: <filename>]` header. The PDF may have been
attached on the CURRENT turn or on ANY EARLIER turn in this conversation.
A user typically attaches a PDF once and then asks several follow-up
questions about it without re-attaching.
## How to find the PDF text
Scan the entire conversation history (every user message, oldest to
newest). Find the MOST RECENT user message that contains a
`[Document: <filename>]` header. That message's body is the active PDF
text and applies to every subsequent follow-up question UNTIL the user
attaches a different PDF.
Only if NO message in the history has ever contained a
`[Document: ...]` header should you ask the user to attach a PDF.
## How a turn MUST go (do not deviate)
1. If NO message in the conversation history has a `[Document: ...]`
header, reply with a single sentence: "Attach a PDF and I'll render
the answer." STOP. Do not call any tool.
2. Otherwise (a PDF is available from this turn or a previous one):
a. ONE call to `query_pdf(pdf_text=<the document text from the most
recent [Document: ...] message>, question=<the user's question on
THIS turn>)`. The tool returns JSON with shape_hint, title,
summary, data. Read it silently. DO NOT type the JSON anywhere.
b. ONE call to `generate_a2ui()`. No arguments.
c. STOP. Do not call any more tools. Do not write any chat content.
Your final assistant message MUST be an empty string. The rendered
surface IS the user-visible answer.
## Absolute hard rules. Breaking ANY of these causes a crash.
- After `generate_a2ui` returns, you are DONE for this turn. Do not call
`query_pdf` again. Do not call `generate_a2ui` again. Do not write
anything except an empty string.
- NEVER include the query_pdf JSON in your reply.
- NEVER include any tool's return value in your reply.
- NEVER quote the PDF text, summarize the document, or echo any part of
`pdf_text` back into the chat.
- The chat reply MUST be either empty ("") or a single very short
sentence (under 10 words). Empty is preferred.
## Layout guidance for generate_a2ui
The secondary LLM sees the same conversation you do. When the user is
specific ("three line charts stacked", "side-by-side cards"), the
secondary LLM will honor it. Defaults per shape_hint:
- `stat` -> Stack(Overline, StatCard)
- `trend` -> Stack(Section -> Card -> LineChart)
- `share` -> Stack(Section -> Card -> DonutChart)
- `table` -> Stack(Section -> Card -> DataTable)
- `text` -> Rich explainer. Compose multiple components, not just one Card:
Stack(
Overline(topic),
Heading(title),
Text(intro paragraph, 2-4 sentences),
Callout(tone=info, title="Key idea", body=core insight),
Section(title="Why it matters", child=Card(Text(...))),
BulletList(items=[3 key points]),
)
Use Callout for the "headline takeaway", BulletList for
enumerations, and Text for paragraphs. Mix with one chart
ONLY if the question genuinely benefits from data viz.
Heuristic for research-paper questions: prefer the rich `text` layout
above. Skip charts unless the user explicitly asked for data viz.
## Restating the loop guard
- Max two tool calls per turn. query_pdf (once) + generate_a2ui (once).
- After generate_a2ui returns, STOP IMMEDIATELY.
- Never describe the surface in prose. The surface IS the answer.
{CATALOG_PROMPT}
"""
def build_dynamic_agent():
return create_agent(
model="openai:gpt-5.5",
tools=[query_pdf, generate_a2ui],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
checkpointer=MemorySaver(),
)
graph = build_dynamic_agent()
@@ -0,0 +1,191 @@
"""Fixed-schema dashboard agent.
The user attaches a PDF in the chat. The deep agent reads the PDF text
(inlined into the user message by InlineDocumentsMiddleware) and calls
`render_dashboard` with the structured data extracted in the same model
pass. The dashboard surface includes an interactive scope-chips strip
that the agent populates from the document. Clicking a chip fires a
user action back to the agent, which re-renders with the new scope.
"""
from __future__ import annotations
from pathlib import Path
from typing import TypedDict
from copilotkit import CopilotKitMiddleware, a2ui
from langchain.agents import create_agent
from langchain.tools import tool
from langgraph.checkpoint.memory import MemorySaver
from src.catalog import CATALOG_ID, CATALOG_PROMPT
SCHEMA_DIR = Path(__file__).parent / "a2ui" / "schemas"
DASHBOARD_SCHEMA = a2ui.load_schema(SCHEMA_DIR / "dashboard.json")
SURFACE = "pdf-dashboard"
class Kpi(TypedDict):
label: str
value: str
delta: str
caption: str
class Point(TypedDict):
label: str
value: float
class Row(TypedDict):
name: str
category: str
value: str
delta: str
class ScopeOption(TypedDict):
label: str
value: str
@tool
def render_dashboard(
eyebrow: str,
title: str,
subtitle: str,
kpis: list[Kpi],
trend: list[Point],
share: list[Point],
rows: list[Row],
scope_options: list[ScopeOption],
scope_selected: str,
) -> str:
"""Render the interactive dashboard for the loaded PDF.
Pass data INLINE. Call ONCE per turn.
Required shapes:
- kpis: EXACTLY 4 cards. Each {label, value, delta, caption}.
STRICT FIELD RULES (very important; the badge breaks if you ignore):
* `value` = the headline number, formatted ("$94,930M", "23.4%",
"1.2M units"). 18 chars typically.
* `delta` = JUST the magnitude of change. Format: "+X%", "-X%",
or "" (empty string when there's no comparison).
MAX 8 chars. NEVER prose. NEVER "vs. last quarter"
or "vs. $89,498M". The arrow and color come from
the renderer.
Examples: "+6.1%", "-3%", "+12%", "+$2.4B", ""
Bad: "↑ vs. $89,498M in Q4 FY23"
"up 6% YoY"
"increased from $89,498M"
* `caption` = the comparison/context sentence ("vs. $89,498M in
Q4 FY23", "Products $69,958M; Services $24,972M",
"All-time high"). Up to ~80 chars. This is where
the prose goes.
- trend: 612 points. {label, value:number}.
- share: 35 slices. {label, value:number}.
- rows: 58 table rows. Same delta rule applies: row.delta is
SHORT ("+6%", "-3%", ""). Verbose comparisons belong elsewhere.
- scope_options: 36 chips the user can click to re-scope. Each
{label, value}. Example for an Apple earnings PDF:
[{label:"Q4 FY24", value:"q4_fy24"},
{label:"FY24", value:"fy24"},
{label:"By segment", value:"by_segment"},
{label:"By region", value:"by_region"}]
Tailor the options to what THIS document actually supports.
- scope_selected: the `value` of the currently active option.
"""
payload = {
"eyebrow": eyebrow,
"title": title,
"subtitle": subtitle,
"kpis": kpis,
"trend": trend,
"share": share,
"rows": rows,
"scope": {"options": scope_options, "selected": scope_selected},
}
return a2ui.render(
operations=[
a2ui.create_surface(SURFACE, catalog_id=CATALOG_ID),
a2ui.update_components(SURFACE, DASHBOARD_SCHEMA),
a2ui.update_data_model(SURFACE, payload),
]
)
SYSTEM_PROMPT = f"""\
You build and maintain a live dashboard from the user's PDF.
## How a turn works
The user may do three things on any turn:
A) Attach a new PDF + chat (initial render).
B) Send a chat message ("re-render focused on energy storage",
"what was operating margin?", "compare last quarter").
C) Click a scope chip on the dashboard. The runtime delivers this as a
tool result `log_a2ui_event` with content like:
User performed action "select_chip" on surface "pdf-dashboard".
Context: {{"value": "fy24", "label": "Scope"}}
In every case, decide whether to re-render the dashboard, answer in chat,
or both.
## The render contract
When you render, call `render_dashboard(...)` ONCE with structured data:
- 4 KPIs, 612 trend points, 35 share slices, 58 rows.
- `scope_options`: 36 chips tailored to THIS PDF. Examples of good
chip sets:
- Apple Q4 PDF → [Q4 FY24, FY24, By segment, By region, By category]
- Tesla Q3 PDF → [Q3 '24, By model, By region, Automotive vs Energy,
Trailing 4 quarters]
- `scope_selected`: which chip is active. Default to the most natural
starting scope for the document. After a chip click, set this to the
clicked value.
When the user (or a chip click) asks to change scope:
- Re-extract the data for the new scope from the PDF text.
- Re-call render_dashboard with the SAME surfaceId so the canvas
updates in place. The scope_selected reflects the new active chip.
## Hard rules
- Render the dashboard whenever the user attaches a PDF (initial), asks
to re-render in any way, or clicks a chip.
- Call `render_dashboard` AT MOST ONCE per turn. Never twice.
- Use ONLY numbers that actually appear in the document.
- If the user asks an analytical question that does NOT require a layout
change (e.g. "what was operating margin?"), answer in chat without
re-rendering. 13 sentences max. Cite the number.
- If the user wants to invent a brand-new visualization not covered by
the fixed schema (e.g. "show a sankey diagram"), tell them to use the
Dynamic tab.
## Chat tone
Be helpful, brief, conversational. After the first render, you can
suggest one or two follow-ups the user might click ("Tap *FY24* for the
full-year view" or "Want me to break it down by segment?"). Don't list
more than two suggestions.
{CATALOG_PROMPT}
"""
def build_fixed_agent():
return create_agent(
model="openai:gpt-5.5",
tools=[render_dashboard],
# CopilotKitMiddleware forwards frontend tools + agent context (e.g.
# useAgentContext payloads) to the LLM.
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
checkpointer=MemorySaver(),
)
graph = build_fixed_agent()
@@ -0,0 +1,112 @@
"""Make CopilotChat's native PDF attachments actually work.
## The bug we're fixing
`ag_ui_langgraph.utils.convert_agui_multimodal_to_langchain` routes ALL
multimodal content. images, audio, video, AND documents. through
LangChain's `image_url` block, because that's the only media block LangChain
exposes. It builds a data URL like::
data:text/plain;base64,<source.value>
For images this works (OpenAI knows what `data:image/png;base64,...` is).
For documents it doesn't:
- `source.value` is our raw PDF text. NOT base64. so the data URL is
malformed.
- Even if it were valid base64, OpenAI's `image_url` parser won't accept
a `text/plain` mime type. The request 400s and the agent run dies with
`INCOMPLETE_STREAM`.
## The fix
We monkey-patch `convert_agui_multimodal_to_langchain` to intercept
`DocumentInputContent` parts with text-shaped mime types BEFORE the broken
conversion runs. We inline them as plain `text` blocks (with a short
``[Document: <filename>]`` header) so the model sees normal text and the
rest of the multimodal pipeline keeps working for real images.
Call ``install()`` once at agent startup. It's idempotent.
"""
from __future__ import annotations
from typing import Any, List
from ag_ui.core import (
AudioInputContent,
BinaryInputContent,
DocumentInputContent,
ImageInputContent,
InputContentDataSource,
TextInputContent,
VideoInputContent,
)
from ag_ui_langgraph import utils as _utils
_TEXT_MIME_PREFIXES = (
"text/",
"application/json",
"application/xml",
"application/yaml",
"application/x-yaml",
)
_INSTALLED = False
def _is_text_mime(mime: str | None) -> bool:
if not mime:
return False
return any(mime.startswith(p) for p in _TEXT_MIME_PREFIXES)
def _patched(content: List[Any]) -> List[dict]:
out: List[dict] = []
passthrough: List[Any] = []
def _flush_passthrough() -> None:
if passthrough:
out.extend(_original(passthrough))
passthrough.clear()
for item in content:
# Only DocumentInputContent with a text-shaped data source needs
# rewriting. Everything else (images, video, audio, URL-sourced docs,
# text items, binary) defers to the original converter.
is_text_document = (
isinstance(item, DocumentInputContent)
and isinstance(item.source, InputContentDataSource)
and _is_text_mime(item.source.mime_type)
)
if not is_text_document:
passthrough.append(item)
continue
_flush_passthrough()
filename = None
if getattr(item, "metadata", None):
try:
filename = item.metadata.get("filename") # type: ignore[attr-defined]
except AttributeError:
filename = None
header = f"[Document: {filename}]\n" if filename else "[Document attached]\n"
out.append({"type": "text", "text": header + item.source.value})
_flush_passthrough()
return out
_original = _utils.convert_agui_multimodal_to_langchain
def install() -> None:
"""Patch the ag-ui-langgraph multimodal converter. Safe to call repeatedly."""
global _INSTALLED
if _INSTALLED:
return
_utils.convert_agui_multimodal_to_langchain = _patched
_INSTALLED = True
__all__ = ["install"]
@@ -0,0 +1,168 @@
"""Shared agent tools: PDF text → structured data for the catalog."""
from __future__ import annotations
import json
import re
from typing import TypedDict
from langchain.tools import tool
from langchain_openai import ChatOpenAI
# We keep the extractor model cheap; this is structured JSON work, not chat.
_EXTRACTOR = ChatOpenAI(model="gpt-5.5", temperature=0)
class Kpi(TypedDict):
label: str
value: str
delta: str
caption: str
class Point(TypedDict):
label: str
value: float
class Row(TypedDict, total=False):
name: str
category: str
value: str
delta: str
def _strip_to_json(text: str) -> str:
"""LLM output may be wrapped in ```json fences. Strip them."""
text = text.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
return text.strip()
@tool
def extract_dashboard_data(pdf_text: str, document_name: str) -> str:
"""Parse the supplied PDF text and return a JSON payload shaped for the
fixed dashboard schema.
The PDF text comes from the user's most recent chat attachment.
Returns a JSON string with this exact shape:
{
"eyebrow": "...short ALL-CAPS context, e.g. 'Q1 2025 · SALES REPORT'",
"title": "...short headline title (<= 8 words)",
"subtitle": "...one-sentence summary",
"kpis": [{label,value,delta,caption}, x4],
"trend": [{label,value}, x6-12],
"share": [{label,value}, x3-5],
"rows": [{name,category,value,delta}, x5-8]
}
If a field genuinely doesn't appear in the PDF, return a sensible "n/a"
string for KPI values and an empty list for series. Never invent numbers.
"""
sys = (
"You are a careful data extractor. Read the PDF text and return ONLY "
"a JSON object with the exact shape requested. No prose, no markdown "
"fences. Use only numbers that appear in the document. "
"If exact values are unclear, use 'n/a' for KPI values."
)
user = f"""\
Document name: {document_name}
PDF text (truncated to first 30k chars):
\"\"\"
{pdf_text[:30000]}
\"\"\"
Return JSON with this shape:
{{
"eyebrow": "string (short, ALL CAPS)",
"title": "string (<=8 words)",
"subtitle": "string (one sentence)",
"kpis": [{{"label": "...", "value": "...", "delta": "+X%|-X%|", "caption": "..."}}, ...], // exactly 4
"trend": [{{"label": "Jan", "value": 12.3}}, ...], // 6-12 points
"share": [{{"label": "Region", "value": 42}}, ...], // 3-5 slices
"rows": [{{"name": "...", "category": "...", "value": "...", "delta": "+X%"}}, ...] // 5-8 rows
}}
Return ONLY the JSON object.
"""
out = _EXTRACTOR.invoke([("system", sys), ("user", user)])
raw = _strip_to_json(
out.content if isinstance(out.content, str) else str(out.content)
)
# Validate. fall back to a tiny placeholder if the LLM produced invalid JSON.
try:
data = json.loads(raw)
except json.JSONDecodeError:
data = {
"eyebrow": "DOCUMENT",
"title": document_name or "Untitled",
"subtitle": "Could not extract structured data from this document.",
"kpis": [
{
"label": "Status",
"value": "n/a",
"delta": "",
"caption": "extraction failed",
}
]
* 4,
"trend": [],
"share": [],
"rows": [],
}
return json.dumps(data)
@tool
def query_pdf(pdf_text: str, question: str) -> str:
"""Answer a user question about the PDF and return ONLY structured data
that the dynamic agent can then render as a UI surface.
Returns a JSON object: { "shape_hint": "stat|trend|share|table|text",
"title": "...", "summary": "...",
"data": <shape-appropriate payload> }
The shape_hint is advice. The agent makes the final layout decision.
"""
sys = (
"You are an analyst answering a question about a PDF. Return ONLY a "
"JSON object describing the answer as structured data. No prose, no "
"markdown fences. Pick the most natural shape for the answer:\n"
"- 'stat'{ value, delta?, caption? } for single-metric answers\n"
"- 'trend' → [{label, value}, ...] for time-series\n"
"- 'share' → [{label, value}, ...] for breakdowns / shares\n"
"- 'table'{ columns:[{key,label}], rows:[{...}] } for lists\n"
"- 'text' → string for narrative answers\n"
)
user = f"""\
Question: {question}
PDF text (truncated):
\"\"\"
{pdf_text[:30000]}
\"\"\"
Return JSON shaped like:
{{
"shape_hint": "stat|trend|share|table|text",
"title": "...",
"summary": "...",
"data": <payload above>
}}
"""
out = _EXTRACTOR.invoke([("system", sys), ("user", user)])
raw = _strip_to_json(
out.content if isinstance(out.content, str) else str(out.content)
)
try:
json.loads(raw) # validate
return raw
except json.JSONDecodeError:
return json.dumps(
{
"shape_hint": "text",
"title": "Answer",
"summary": "Could not produce structured output.",
"data": "",
}
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
@@ -0,0 +1,38 @@
{
"name": "a2ui-pdf-analyst",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently --kill-others-on-fail --names \"web,agent\" --prefix-colors \"magenta,cyan\" \"pnpm:dev:web\" \"pnpm:dev:agent\"",
"dev:web": "next dev",
"dev:agent": "cd agent && uv run uvicorn main:app --port 8123 --reload",
"build": "next build",
"start": "next start",
"install:agent": "cd agent && uv sync",
"postinstall": "pnpm run install:agent"
},
"dependencies": {
"@ag-ui/client": "^0.0.53",
"@ag-ui/core": "^0.0.54",
"@copilotkit/a2ui-renderer": "^1.57.4",
"@copilotkit/react-core": "^1.57.4",
"@copilotkit/react-ui": "^1.57.4",
"@copilotkit/runtime": "^1.57.4",
"clsx": "^2.1.1",
"next": "16.2.6",
"pdfjs-dist": "^4.10.38",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,46 @@
<svg viewBox="0 0 1044.21 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(7.74 31.74)">
<g id="CopilotKit">
<path d="M111.689 53.5173H78.8802C78.647 50.8028 78.0267 48.3403 77.0185 46.1299C76.0487 43.9195 74.6918 42.0192 72.9466 40.4291C71.2403 38.8006 69.1654 37.5591 66.7223 36.7063C64.2791 35.8143 61.5062 35.3683 58.4038 35.3683C52.9745 35.3683 48.379 36.6867 44.6173 39.3235C40.8943 41.9611 38.0633 45.742 36.1243 50.6669C34.224 55.5919 33.2739 61.5063 33.2739 68.4092C33.2739 75.7001 34.2434 81.8084 36.1825 86.7334C38.1602 91.6199 41.0106 95.3042 44.7336 97.7855C48.4566 100.229 52.9358 101.45 58.1711 101.45C61.1573 101.45 63.8331 101.082 66.1988 100.345C68.5644 99.5695 70.6198 98.4647 72.3649 97.0293C74.11 95.5947 75.5258 93.8687 76.6117 91.8523C77.7362 89.7965 78.4923 87.4895 78.8802 84.9297L111.689 85.1629C111.301 90.2041 109.885 95.3426 107.442 100.578C104.999 105.774 101.528 110.583 97.0293 115.004C92.57 119.386 87.0435 122.916 80.4507 125.591C73.8579 128.267 66.1988 129.605 57.4731 129.605C46.5369 129.605 36.7254 127.259 28.0384 122.567C19.3904 117.874 12.5456 110.971 7.50404 101.858C2.50134 92.7443 0 81.5948 0 68.4092C0 55.1467 2.55952 43.9776 7.6786 34.9026C12.7977 25.7893 19.7006 18.9057 28.3875 14.252C37.0744 9.55957 46.7696 7.2133 57.4731 7.2133C64.9965 7.2133 71.9189 8.24101 78.2403 10.2964C84.5614 12.3518 90.1067 15.3572 94.877 19.3129C99.6472 23.2297 103.486 28.0578 106.395 33.7978C109.303 39.5371 111.068 46.1102 111.689 53.5173Z" fill="#010507"/>
<path d="M169.874 129.606C160.179 129.606 151.88 127.686 144.977 123.847C138.074 119.968 132.781 114.578 129.096 107.675C125.412 100.733 123.57 92.6864 123.57 83.5337C123.57 74.3818 125.412 66.3544 129.096 59.4515C132.781 52.5092 138.074 47.1186 144.977 43.2796C151.88 39.4014 160.179 37.4626 169.874 37.4626C179.569 37.4626 187.868 39.4014 194.771 43.2796C201.674 47.1186 206.968 52.5092 210.652 59.4515C214.336 66.3544 216.178 74.3818 216.178 83.5337C216.178 92.6864 214.336 100.733 210.652 107.675C206.968 114.578 201.674 119.968 194.771 123.847C187.868 127.686 179.569 129.606 169.874 129.606ZM170.107 105.872C172.822 105.872 175.168 104.96 177.145 103.138C179.123 101.315 180.655 98.7169 181.741 95.3428C182.827 91.9686 183.37 87.9553 183.37 83.3013C183.37 78.6087 182.827 74.5946 181.741 71.2597C180.655 67.8856 179.123 65.2873 177.145 63.4648C175.168 61.6422 172.822 60.7306 170.107 60.7306C167.237 60.7306 164.775 61.6422 162.719 63.4648C160.664 65.2873 159.093 67.8856 158.007 71.2597C156.922 74.5946 156.378 78.6087 156.378 83.3013C156.378 87.9553 156.922 91.9686 158.007 95.3428C159.093 98.7169 160.664 101.315 162.719 103.138C164.775 104.96 167.237 105.872 170.107 105.872Z" fill="#010507"/>
<path d="M229.208 161.484V38.6256H261.086V54.2155H261.784C262.948 51.1908 264.655 48.4182 266.903 45.8976C269.153 43.3377 271.945 41.3016 275.28 39.7893C278.615 38.2376 282.493 37.4626 286.914 37.4626C292.809 37.4626 298.413 39.0331 303.726 42.174C309.077 45.3157 313.421 50.2603 316.756 57.0078C320.13 63.7561 321.817 72.52 321.817 83.3013C321.817 93.6168 320.208 102.168 316.989 108.955C313.809 115.741 309.543 120.802 304.191 124.137C298.878 127.473 293.041 129.14 286.682 129.14C282.493 129.14 278.751 128.461 275.455 127.104C272.197 125.708 269.405 123.827 267.078 121.462C264.79 119.057 263.025 116.342 261.784 113.317H261.319V161.484H229.208ZM260.621 83.3013C260.621 87.6444 261.183 91.4064 262.308 94.5866C263.471 97.7275 265.101 100.171 267.195 101.916C269.327 103.622 271.868 104.476 274.815 104.476C277.762 104.476 280.263 103.642 282.319 101.974C284.413 100.268 286.003 97.8437 287.089 94.7028C288.214 91.5226 288.776 87.7221 288.776 83.3013C288.776 78.8804 288.214 75.0995 287.089 71.9578C286.003 68.7776 284.413 66.3544 282.319 64.6866C280.263 62.9803 277.762 62.1267 274.815 62.1267C271.868 62.1267 269.327 62.9803 267.195 64.6866C265.101 66.3544 263.471 68.7776 262.308 71.9578C261.183 75.0995 260.621 78.8804 260.621 83.3013Z" fill="#010507"/>
<path d="M335.269 127.977V38.6253H367.38V127.977H335.269ZM351.324 29.318C346.981 29.318 343.258 27.8834 340.156 25.0136C337.054 22.1438 335.502 18.6923 335.502 14.6592C335.502 10.6259 337.054 7.17442 340.156 4.30463C343.258 1.43485 346.981 0 351.324 0C355.707 0 359.429 1.43485 362.493 4.30463C365.596 7.17442 367.147 10.6259 367.147 14.6592C367.147 18.6923 365.596 22.1438 362.493 25.0136C359.429 27.8834 355.707 29.318 351.324 29.318Z" fill="#010507"/>
<path d="M415.851 8.84214V127.977H383.74V8.84214H415.851Z" fill="#010507"/>
<path d="M475.258 129.606C465.563 129.606 457.264 127.686 450.361 123.847C443.458 119.968 438.164 114.578 434.48 107.675C430.796 100.733 428.954 92.6864 428.954 83.5337C428.954 74.3818 430.796 66.3544 434.48 59.4515C438.164 52.5092 443.458 47.1186 450.361 43.2796C457.264 39.4014 465.563 37.4626 475.258 37.4626C484.954 37.4626 493.253 39.4014 500.156 43.2796C507.059 47.1186 512.352 52.5092 516.036 59.4515C519.72 66.3544 521.563 74.3818 521.563 83.5337C521.563 92.6864 519.72 100.733 516.036 107.675C512.352 114.578 507.059 119.968 500.156 123.847C493.253 127.686 484.954 129.606 475.258 129.606ZM475.491 105.872C478.205 105.872 480.552 104.96 482.53 103.138C484.508 101.315 486.039 98.7169 487.125 95.3428C488.211 91.9686 488.754 87.9553 488.754 83.3013C488.754 78.6087 488.211 74.5946 487.125 71.2597C486.039 67.8856 484.508 65.2873 482.53 63.4648C480.552 61.6422 478.205 60.7306 475.491 60.7306C472.621 60.7306 470.158 61.6422 468.103 63.4648C466.048 65.2873 464.477 67.8856 463.391 71.2597C462.305 74.5946 461.763 78.6087 461.763 83.3013C461.763 87.9553 462.305 91.9686 463.391 95.3428C464.477 98.7169 466.048 101.315 468.103 103.138C470.158 104.96 472.621 105.872 475.491 105.872Z" fill="#010507"/>
<path d="M587.877 38.6253V61.8941H529.008V38.6253H587.877ZM540.41 17.2186H572.52V99.2396C572.52 100.481 572.734 101.528 573.16 102.381C573.587 103.196 574.246 103.816 575.138 104.242C576.03 104.63 577.174 104.824 578.57 104.824C579.539 104.824 580.664 104.708 581.944 104.476C583.262 104.242 584.232 104.048 584.852 103.894L589.506 126.464C588.072 126.891 586.016 127.415 583.34 128.035C580.703 128.655 577.561 129.063 573.916 129.256C566.548 129.644 560.363 128.888 555.36 126.987C550.357 125.049 546.595 122.004 544.075 117.854C541.554 113.705 540.332 108.508 540.41 102.265V17.2186Z" fill="#010507"/>
<path d="M602.435 127.977V8.84214H634.778V57.0077H636.406L672.24 8.84214H709.935L669.681 61.8942L710.866 127.977H672.24L645.481 83.3012L634.778 97.2626V127.977H602.435Z" fill="#010507"/>
<path d="M725.597 127.977V38.6253H757.708V127.977H725.597ZM741.653 29.318C737.309 29.318 733.586 27.8834 730.484 25.0136C727.381 22.1438 725.831 18.6923 725.831 14.6592C725.831 10.6259 727.381 7.17442 730.484 4.30463C733.586 1.43485 737.309 0 741.653 0C746.035 0 749.758 1.43485 752.821 4.30463C755.924 7.17442 757.475 10.6259 757.475 14.6592C757.475 18.6923 755.924 22.1438 752.821 25.0136C749.758 27.8834 746.035 29.318 741.653 29.318Z" fill="#010507"/>
<path d="M827.354 38.6253V61.8941H768.484V38.6253H827.354ZM779.886 17.2186H811.994V99.2396C811.994 100.481 812.206 101.528 812.632 102.381C813.066 103.196 813.72 103.816 814.612 104.242C815.504 104.63 816.65 104.824 818.05 104.824C819.015 104.824 820.136 104.708 821.421 104.476C822.739 104.242 823.704 104.048 824.326 103.894L828.983 126.464C827.551 126.891 825.489 127.415 822.812 128.035C820.177 128.655 817.035 129.063 813.393 129.256C806.024 129.644 799.838 128.888 794.836 126.987C789.833 125.049 786.071 122.004 783.55 117.854C781.03 113.705 779.808 108.508 779.886 102.265V17.2186Z" fill="#010507"/>
</g>
</g>
<svg x="859.58" y="7.1" width="176.717" height="186.745" viewBox="0 0 176.717 186.745" fill="none">
<path d="M60.2013 59.6493C76.4214 38.4331 89.8836 17.4532 95.0639 0.510877C95.203 0.0498883 95.7431 -0.145948 96.1441 0.120187C114.156 12.0483 146.965 19.8996 175.984 20.0839C176.484 20.0871 176.827 20.5795 176.647 21.045C166.999 45.5233 155.214 89.384 154.756 139.471C154.756 140.215 153.708 140.482 153.34 139.835C136.825 110.934 83.9258 70.3234 60.4713 60.7386C60.0376 60.5602 59.9148 60.0242 60.2013 59.6493Z" fill="url(#paint0_linear)"/>
<path d="M114.926 46.6717C89.5724 54.6988 66.4044 59.1614 60.8722 60.1754C60.5203 60.24 60.4466 60.7269 60.7822 60.8644C84.4167 70.6906 137.054 111.183 153.414 139.967C153.446 140.03 153.528 140.052 153.594 140.024C153.659 139.993 153.692 139.911 153.667 139.841L114.926 46.6717Z" fill="url(#paint1_linear)"/>
<path d="M96.218 0.0677872C117.921 11.9064 143.004 17.2235 176.279 20.0252C176.484 20.043 176.558 20.3231 176.369 20.4204C172.114 22.6076 147.734 35.0132 129.632 41.6575C124.779 43.4376 119.902 45.089 115.098 46.6126C114.991 46.6459 114.877 46.5934 114.836 46.4919L95.7188 0.516173C95.5878 0.206583 95.9234 -0.0927773 96.218 0.0677872Z" fill="url(#paint2_linear)"/>
<path d="M95.5597 0.114978C95.875 -0.0172013 96.2317 0.0959082 96.417 0.367856L96.4849 0.493267L154.721 139.416L154.762 139.554C154.825 139.876 154.657 140.209 154.343 140.341C154.027 140.473 153.671 140.36 153.485 140.088L153.415 139.963L95.1814 1.04014L95.1383 0.902396C95.075 0.580014 95.2449 0.247049 95.5597 0.114978Z" fill="#513C9F"/>
<path d="M175.661 20.1472C176 19.9546 176.432 20.0729 176.625 20.4124C176.818 20.7522 176.697 21.1839 176.357 21.3767V21.3787H176.353C176.351 21.3803 176.346 21.3839 176.341 21.3869C176.33 21.3933 176.312 21.4033 176.29 21.4157C176.246 21.4405 176.18 21.4768 176.094 21.5247C175.921 21.6215 175.662 21.7648 175.323 21.9503C174.646 22.3215 173.645 22.8639 172.351 23.5456C169.761 24.9093 165.995 26.8363 161.283 29.0884C151.862 33.592 138.655 39.4006 123.512 44.625C108.365 49.8495 92.7416 53.9066 80.9093 56.6563C74.9919 58.0314 70.0192 59.0801 66.5261 59.7854C64.7796 60.138 63.4024 60.4044 62.4615 60.5831C61.9916 60.6723 61.6304 60.7395 61.3863 60.7845C61.2644 60.807 61.1711 60.8246 61.1087 60.8359C61.0782 60.8415 61.0547 60.8454 61.0388 60.8483C61.0311 60.8497 61.0244 60.8517 61.0203 60.8524H61.0142L60.8702 60.8647C60.5418 60.8565 60.2523 60.6188 60.1918 60.2829C60.1228 59.8986 60.3791 59.5297 60.7633 59.4605H60.7695C60.7732 59.4599 60.7786 59.4578 60.786 59.4564C60.8013 59.4537 60.8252 59.4497 60.8559 59.4441C60.9171 59.4329 61.0088 59.417 61.1293 59.3947C61.3711 59.3501 61.7305 59.2821 62.1984 59.1933C63.1343 59.0155 64.506 58.7511 66.2465 58.3997C69.7284 57.6967 74.6866 56.6503 80.5886 55.2788C92.3949 52.5352 107.968 48.4914 123.052 43.2887C138.13 38.0862 151.286 32.2987 160.673 27.8117C165.366 25.5684 169.116 23.6497 171.691 22.2936C172.978 21.6156 173.972 21.0783 174.643 20.7105C174.978 20.527 175.233 20.3863 175.404 20.2911C175.489 20.2435 175.554 20.2062 175.597 20.1822C175.618 20.1704 175.634 20.1614 175.644 20.1554C175.649 20.1525 175.654 20.1507 175.656 20.1493L175.658 20.1472H175.661Z" fill="#513C9F"/>
<path d="M2.18131 186.308C1.73888 186.829 0.956885 186.893 0.435837 186.45C-0.0843773 186.008 -0.147949 185.227 0.293978 184.707L2.18131 186.308ZM104.178 17.1327C104.832 17.3295 105.202 18.0203 105.006 18.6746L83.9124 88.8658H133.52L133.768 88.8905C134.333 89.0058 134.757 89.5053 134.757 90.1035C134.757 90.7016 134.333 91.2011 133.768 91.3164L133.52 91.3411H82.8207L2.18131 186.308L1.23765 185.506L0.293978 184.707L81.1369 89.499L102.636 17.9632C102.832 17.3086 103.523 16.9359 104.178 17.1327Z" fill="#ABABAB"/>
<path d="M57.4516 165.866L47.9012 167.209C52.8524 180.303 63.0083 186.023 75.1284 186.023C104.835 186.023 95.7677 152.43 112.978 152.43C125.466 152.43 120.393 179.658 147.26 179.658C163.66 179.658 165.297 163.136 162.498 156.029C162.481 155.985 162.465 155.946 162.44 155.907L158.046 149.177C157.759 148.73 157.064 148.898 157.015 149.43L156.196 157.587C156.139 158.154 156.155 158.72 156.221 159.286C156.892 164.921 157.326 178.597 147.26 178.597C136.645 178.597 134.092 151.722 112.978 151.722C88.2142 151.722 91.3977 184.962 76.1923 184.962C66.1591 184.962 58.5073 173.647 57.4516 165.866Z" fill="url(#paint3_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="135.295" y1="10.8932" x2="107.004" y2="88.6892" gradientUnits="userSpaceOnUse">
<stop stop-color="#6430AB"/>
<stop offset="1" stop-color="#AA89D8"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="113.371" y1="54.7414" x2="76.9532" y2="125.111" gradientUnits="userSpaceOnUse">
<stop stop-color="#005DBB"/>
<stop offset="1" stop-color="#3D92E8"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="129.632" y1="10.8927" x2="118.666" y2="45.1937" gradientUnits="userSpaceOnUse">
<stop stop-color="#1B70C4"/>
<stop offset="1" stop-color="#54A4F2"/>
</linearGradient>
<linearGradient id="paint3_linear" x1="47.9012" y1="168.165" x2="163.603" y2="168.165" gradientUnits="userSpaceOnUse">
<stop stop-color="#4497EA"/>
<stop offset="0.254755" stop-color="#1463B2"/>
<stop offset="0.498725" stop-color="#0A437D"/>
<stop offset="0.666667" stop-color="#2476C8"/>
<stop offset="0.972542" stop-color="#0C549A"/>
</linearGradient>
</defs>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,54 @@
"use client";
import { useEffect } from "react";
import { z } from "zod";
import type { ReactActivityMessageRenderer } from "@copilotkit/react-core/v2";
import { surfaceBus } from "./surface-bus";
import type { A2UIOp } from "./surface-bus";
const ContentSchema = z.object({
a2ui_operations: z.array(z.record(z.string(), z.unknown())),
});
type Content = z.infer<typeof ContentSchema>;
function MirrorPill({ ops, agentId }: { ops: A2UIOp[]; agentId?: string }) {
useEffect(() => {
if (!ops?.length) return;
console.log(
`[mirror-renderer] forwarding ${ops.length} ops for agent=${agentId ?? "default"}`,
);
surfaceBus.push(agentId ?? "default", ops);
}, [ops, agentId]);
return (
<div className="my-1.5 inline-flex items-center gap-2 max-w-fit px-3 py-2 rounded-full border border-[var(--line)] bg-[color-mix(in_oklab,var(--lilac)_8%,var(--surface))] text-[12.5px] text-[var(--ink)] font-medium leading-none">
<span className="w-2 h-2 rounded-full bg-[var(--lilac)] shadow-[0_0_0_3px_color-mix(in_oklab,var(--lilac)_30%,transparent)]" />
<span className="mono uppercase tracking-[0.1em] text-[10.5px] text-[var(--ink)]">
surface
</span>
<span aria-hidden className="text-[var(--ink)]/40">
</span>
<span>rendered in the canvas</span>
</div>
);
}
/** Custom activity renderer: captures A2UI surfaces from the chat stream
* and forwards them to the workspace canvas. In place of the inline render
* we leave a small pill in chat so the user has a clear handoff signal. */
export function createMirrorActivityRenderer(
agentId?: string,
): ReactActivityMessageRenderer<Content> {
return {
activityType: "a2ui-surface",
agentId,
content: ContentSchema,
render: ({ content }) => (
<MirrorPill
ops={(content.a2ui_operations as A2UIOp[]) ?? []}
agentId={agentId}
/>
),
};
}
@@ -0,0 +1,268 @@
/**
* A2UI custom catalog. platform-agnostic component definitions.
*
* These are the components the agent is allowed to use. Each entry pairs a
* Zod prop schema with a description. The same definitions are shipped to:
* - the frontend renderer (paired with React renderers in renderers.tsx)
* - the backend agent (the prompt builder reads the JSON-shape via extractSchema)
*
* Catalog ID is constant and shared with the Python tools so createSurface
* resolves to the right component map on the client.
*/
import { z } from "zod";
export const CATALOG_ID = "https://cpk-a2ui.local/catalogs/copilotkit/v1";
/* `child` and `children` refer to component IDs (resolved at render time). */
const childRef = z.string();
const childrenRef = z.union([
z.array(z.string()),
z.object({ componentId: z.string(), path: z.string() }),
]);
/* Helpers for "may be a literal or a path binding". */
const stringOrPath = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
Stack: {
description:
"Vertical layout. Children stack top→bottom with consistent gap. Use as the default page/section container.",
props: z.object({
children: childrenRef,
gap: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
align: z.enum(["start", "center", "end", "stretch"]).optional(),
}),
},
Row: {
description:
"Horizontal layout. Children sit side-by-side; wraps on small screens. Use for toolbars, metric rows, badge groups.",
props: z.object({
children: childrenRef,
gap: z.enum(["xs", "sm", "md", "lg"]).optional(),
justify: z.enum(["start", "center", "end", "spaceBetween"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
},
Grid: {
description:
"Responsive grid. Children fill columns left→right. Use for stat-card rows, chart pairs, card galleries.",
props: z.object({
children: childrenRef,
columns: z.number().int().min(1).max(6).optional(),
gap: z.enum(["xs", "sm", "md", "lg"]).optional(),
}),
},
Section: {
description:
"Titled section with optional eyebrow + actions row. Use to group dashboard regions (e.g. 'Revenue', 'Top customers').",
props: z.object({
title: z.string(),
eyebrow: z.string().optional(),
child: childRef,
}),
},
Card: {
description:
"Bordered, rounded surface with padding. Pass a child layout (Stack/Row/Grid) as `child`.",
props: z.object({
child: childRef,
tone: z.enum(["default", "lilac", "mint", "warning"]).optional(),
}),
},
Divider: {
description: "A 1px line. No props.",
props: z.object({}),
},
Heading: {
description:
"Page or section title. Use level 1 once per surface; 2 for major sections; 3 for sub-blocks.",
props: z.object({
text: stringOrPath,
level: z.enum(["1", "2", "3"]).optional(),
}),
},
Text: {
description:
"Body copy. Use tone='muted' for secondary text. Use size='sm' for captions.",
props: z.object({
text: stringOrPath,
tone: z.enum(["default", "muted"]).optional(),
size: z.enum(["sm", "md", "lg"]).optional(),
weight: z.enum(["regular", "medium", "semibold"]).optional(),
}),
},
Overline: {
description:
"Tiny ALL-CAPS mono label that sits above a heading. Common typography pattern (Material Design calls this 'Overline'). Use for section categories like 'OVERVIEW · Q1 2025'.",
props: z.object({ text: stringOrPath }),
},
Badge: {
description:
"Small inline status pill. Use tone to imply meaning (positive=green, warning=amber, neutral=lilac).",
props: z.object({
label: stringOrPath,
tone: z
.enum(["neutral", "positive", "warning", "danger", "info"])
.optional(),
}),
},
Callout: {
description:
"Block-level highlight for a key insight, definition, or warning. Use for 'the takeaway' moments inside an explanation. Tone picks the accent color (info=lilac, positive=green, warning=amber, neutral=grey).",
props: z.object({
body: stringOrPath,
title: stringOrPath.optional(),
tone: z.enum(["info", "positive", "warning", "neutral"]).optional(),
}),
},
BulletList: {
description:
"Bulleted or numbered list. Use for short enumerations like 'three key contributions' or 'steps to reproduce'. Pass items as a literal string array or a {path} binding.",
props: z.object({
items: z.union([z.array(z.string()), z.object({ path: z.string() })]),
ordered: z.boolean().optional(),
}),
},
StatCard: {
description:
"Single big-number metric. Always include label + value. Use delta (e.g. '+12.4%') with deltaTone for trend.",
props: z.object({
label: stringOrPath,
value: stringOrPath,
delta: stringOrPath.optional(),
deltaTone: z.enum(["positive", "negative", "neutral"]).optional(),
caption: stringOrPath.optional(),
}),
},
BarChart: {
description:
"Vertical bars. `data` must be an inline array of {label, value} objects (or a path that resolves to one). Use when labels are short (months, regions, < 7 chars). For long labels (customer names, country names), use HorizontalBarChart instead.",
props: z.object({
data: z.union([
z.array(z.object({ label: z.string(), value: z.number() })),
z.object({ path: z.string() }),
]),
height: z.number().int().min(120).max(480).optional(),
}),
},
HorizontalBarChart: {
description:
"Horizontal bars (rows). Same `data` shape as BarChart: [{label, value}]. Use for ranked lists where labels are long (e.g. 'Top 10 customers by ARR'). Height auto-sizes from row count.",
props: z.object({
data: z.union([
z.array(z.object({ label: z.string(), value: z.number() })),
z.object({ path: z.string() }),
]),
height: z.number().int().min(120).max(640).optional(),
}),
},
LineChart: {
description:
"Time-series line. `data` is [{label, value}, ...]. Use for trends where you want the direction of change to be the main signal.",
props: z.object({
data: z.union([
z.array(z.object({ label: z.string(), value: z.number() })),
z.object({ path: z.string() }),
]),
height: z.number().int().min(120).max(480).optional(),
}),
},
DonutChart: {
description:
"Donut / segment chart. `data` is [{label, value}, ...]. Use for share-of-total breakdowns with 3-6 slices.",
props: z.object({
data: z.union([
z.array(z.object({ label: z.string(), value: z.number() })),
z.object({ path: z.string() }),
]),
height: z.number().int().min(120).max(480).optional(),
}),
},
ScatterChart: {
description:
"X/Y scatter plot for correlation questions. `data` is [{x: number, y: number, label?: string}]. Use when the user asks 'is X correlated with Y' or 'plot A against B'. Provide xLabel and yLabel so the user knows what each axis represents.",
props: z.object({
data: z.union([
z.array(
z.object({
x: z.number(),
y: z.number(),
label: z.string().optional(),
}),
),
z.object({ path: z.string() }),
]),
xLabel: z.string().optional(),
yLabel: z.string().optional(),
height: z.number().int().min(160).max(560).optional(),
}),
},
DataTable: {
description:
"Rows × columns table. `columns` is a list of {key, label}; `rows` is a list of records keyed by column key.",
props: z.object({
columns: z.array(
z.object({
key: z.string(),
label: z.string(),
align: z.enum(["left", "right"]).optional(),
}),
),
rows: z.union([
z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
z.object({ path: z.string() }),
]),
}),
},
Button: {
description:
"Action button. Variant 'primary' is the main CTA (dark). 'secondary' is outlined. 'ghost' is borderless.",
props: z.object({
label: stringOrPath,
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
action: z.object({
event: z.object({
name: z.string(),
context: z.record(z.string(), z.unknown()).optional(),
}),
}),
}),
},
ChoiceChips: {
description:
"Horizontal pills bound to a data-model path. Use for scope filters " +
"and quick switches. `options` is path-bindable so the agent can " +
"populate the chips dynamically from data it just extracted.",
props: z.object({
label: z.string(),
options: z.union([
z.array(z.object({ label: z.string(), value: z.string() })),
z.object({ path: z.string() }),
]),
value: z.object({ path: z.string() }),
multi: z.boolean().optional(),
}),
},
};
export type Definitions = typeof definitions;
@@ -0,0 +1,34 @@
/**
* The CopilotKit A2UI custom catalog.
*
* `catalog` is what we hand to the A2UI renderer on the frontend.
* `schema` is what the agent's prompt cites so the LLM knows the
* components + their props.
*
* Note: includeBasicCatalog is intentionally off. our catalog is the
* complete design system. If you want Text/Button/Row from the basic
* catalog for free, flip the flag.
*/
import { createCatalog, extractSchema } from "@copilotkit/a2ui-renderer";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import { CATALOG_ID, definitions } from "./definitions";
import { renderers } from "./renderers";
/* The runtime's GenericBinder inspects these Zod schemas to decide which
* props are DYNAMIC (auto-resolved against the data model). Use the same
* Zod major version as @copilotkit/a2ui-renderer (zod@^3.25) or it
* silently classifies everything as STATIC and `{path}` objects leak
* through to the renderers.
*
* The renderers cast: definitions express props as `string | { path }`
* (the wide pre-binding shape) but the binder hands the renderer the
* resolved shape (`string`). Narrow at the boundary. */
export const catalog = createCatalog(
definitions,
renderers as unknown as CatalogRenderers<typeof definitions>,
{ catalogId: CATALOG_ID, includeBasicCatalog: false },
);
export const catalogSchema = extractSchema(definitions);
export { CATALOG_ID, definitions };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
/**
* A tiny event bus that mirrors A2UI surface ops from the chat's
* activity-message stream into the workspace `<SurfaceCanvas>`.
*
* Activity messages live inside CopilotChat's renderer scope; the canvas
* lives outside it. The bus lets us forward ops between the two without
* coupling React contexts.
*
* Per-thread (agentId) so /fixed and /dynamic don't fight over the same
* canvas state.
*/
export type A2UIOp = Record<string, unknown> & { version?: string };
type Snapshot = {
surfaceId: string | null;
ops: A2UIOp[];
};
type Listener = (snap: Snapshot) => void;
const buffers = new Map<string, A2UIOp[]>();
const surfaceIds = new Map<string, string | null>();
const listeners = new Map<string, Set<Listener>>();
function getSurfaceIdFromOp(op: A2UIOp): string | undefined {
const cs = (op.createSurface as { surfaceId?: string } | undefined)
?.surfaceId;
const uc = (op.updateComponents as { surfaceId?: string } | undefined)
?.surfaceId;
const ud = (op.updateDataModel as { surfaceId?: string } | undefined)
?.surfaceId;
return cs ?? uc ?? ud;
}
const DEBUG = typeof window !== "undefined";
function opSummary(op: A2UIOp): string {
const kind =
"createSurface" in op
? "createSurface"
: "updateComponents" in op
? "updateComponents"
: "updateDataModel" in op
? "updateDataModel"
: "deleteSurface" in op
? "deleteSurface"
: "?";
const sid = getSurfaceIdFromOp(op) ?? "?";
return `${kind}(${sid})`;
}
export const surfaceBus = {
push(channel: string, ops: A2UIOp[]) {
const buf = buffers.get(channel) ?? [];
const before = buf.length;
buf.push(...ops);
buffers.set(channel, buf);
for (const op of ops) {
const sid = getSurfaceIdFromOp(op);
if (sid) surfaceIds.set(channel, sid);
}
const subCount = listeners.get(channel)?.size ?? 0;
if (DEBUG) {
console.log(
`[surface-bus] push channel=${channel} +${ops.length} ops ` +
`(buf ${before}${buf.length}, subs=${subCount}) [${ops.map(opSummary).join(", ")}]`,
);
}
const snap = this.snapshot(channel);
listeners.get(channel)?.forEach((fn) => fn(snap));
},
reset(channel: string) {
buffers.set(channel, []);
surfaceIds.set(channel, null);
if (DEBUG) console.log(`[surface-bus] reset channel=${channel}`);
const snap = this.snapshot(channel);
listeners.get(channel)?.forEach((fn) => fn(snap));
},
snapshot(channel: string): Snapshot {
return {
surfaceId: surfaceIds.get(channel) ?? null,
ops: buffers.get(channel) ?? [],
};
},
subscribe(channel: string, fn: Listener) {
if (!listeners.has(channel)) listeners.set(channel, new Set());
listeners.get(channel)!.add(fn);
if (DEBUG)
console.log(
`[surface-bus] subscribe channel=${channel} (subs=${listeners.get(channel)!.size})`,
);
return () => {
listeners.get(channel)?.delete(fn);
if (DEBUG)
console.log(
`[surface-bus] unsubscribe channel=${channel} (subs=${listeners.get(channel)?.size ?? 0})`,
);
};
},
};
@@ -0,0 +1,31 @@
/* A2UI surface theme — mapped to verified CopilotKit brand tokens.
Scoped to .a2ui-surface and consumed by @copilotkit/a2ui-renderer. */
.a2ui-surface {
--background: #fafafc;
--foreground: #010507;
--card: #ffffff;
--primary: #010507;
--primary-foreground: #ffffff;
--border: #dbdbe5;
--input: #dbdbe5;
--muted: #f0f0f4;
--muted-foreground: #57575b;
--accent: #bec2ff;
--accent-foreground: #010507;
--radius: 14px;
font-family:
var(--font-plus-jakarta),
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif !important;
letter-spacing: -0.01em;
line-height: 1.5;
color: var(--foreground);
}
.a2ui-surface img {
max-height: 32px;
border-radius: 6px;
}
@@ -0,0 +1,43 @@
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
const FIXED_AGENT_URL =
process.env.FIXED_AGENT_URL ?? "http://localhost:8123/fixed";
const DYNAMIC_AGENT_URL =
process.env.DYNAMIC_AGENT_URL ?? "http://localhost:8123/dynamic";
const fixedAgent = new HttpAgent({ url: FIXED_AGENT_URL });
const dynamicAgent = new HttpAgent({ url: DYNAMIC_AGENT_URL });
const runtime = new CopilotRuntime({
agents: {
// CopilotKit's V2 client expects an agent named "default" for any hook
// that doesn't pass an explicit agentId (e.g. our root provider mounted
// on pages that don't render a chat). We alias it to the fixed wizard.
default: fixedAgent,
fixed_agent: fixedAgent,
dynamic_agent: dynamicAgent,
},
// The A2UI middleware intercepts tool results that contain a2ui_operations
// and turns them into rendered surfaces. We deliberately set
// `injectA2UITool: false` so the runtime does NOT register `render_a2ui`
// as a frontend tool. instead, the dynamic agent has a Python
// `generate_a2ui` tool that calls a secondary LLM and returns operations
// as a normal tool result. This avoids the CopilotKitMiddleware
// strip-and-restore lifecycle that leaves orphan tool_calls in agent
// state (which was crashing turn 2 with INCOMPLETE_STREAM).
a2ui: {
injectA2UITool: false,
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
mode: "single-route",
});
export { handler as POST };
@@ -0,0 +1,734 @@
"use client";
import { useEffect, useState } from "react";
import { SiteNav, PageHeader } from "@/components/Brand";
import {
A2UIProvider,
A2UIRenderer,
useA2UIActions,
} from "@copilotkit/a2ui-renderer";
import { catalog, CATALOG_ID } from "@/a2ui/catalog";
/* Each example renders a complete tiny A2UI surface. The shape mirrors
* how the agent emits surfaces over the wire so the showcase doubles as
* a sanity check on the catalog. */
type Example = {
group: "layout" | "content" | "data" | "interactive";
name: string;
blurb: string;
surface: {
components: unknown[];
data?: Record<string, unknown>;
};
};
const sampleSeries = [
{ label: "Jan", value: 24 },
{ label: "Feb", value: 32 },
{ label: "Mar", value: 38 },
{ label: "Apr", value: 44 },
{ label: "May", value: 41 },
{ label: "Jun", value: 52 },
];
const sampleShare = [
{ label: "NA", value: 480 },
{ label: "EMEA", value: 320 },
{ label: "APAC", value: 200 },
{ label: "LATAM", value: 80 },
];
const sampleRows = [
{ name: "Acme", category: "Mid-market", value: "$48k", delta: "+8%" },
{ name: "Northwind", category: "Enterprise", value: "$112k", delta: "+12%" },
{ name: "Globex", category: "SMB", value: "$22k", delta: "-3%" },
{ name: "Initech", category: "Mid-market", value: "$61k", delta: "+5%" },
];
const sampleRanked = [
{ label: "Atlas Industries Pte Ltd", value: 1280 },
{ label: "Northwind Logistics", value: 940 },
{ label: "Globex International", value: 720 },
{ label: "Initech Holdings", value: 510 },
{ label: "Stark Manufacturing", value: 380 },
];
const sampleScatter = [
{ x: 1, y: 22 },
{ x: 2, y: 28 },
{ x: 3, y: 31 },
{ x: 4, y: 35 },
{ x: 5, y: 41 },
{ x: 6, y: 39 },
{ x: 7, y: 47 },
{ x: 8, y: 52 },
{ x: 9, y: 56 },
{ x: 10, y: 61 },
{ x: 11, y: 64 },
{ x: 12, y: 70 },
];
const EXAMPLES: Example[] = [
{
group: "layout",
name: "Stack",
blurb:
"Vertical layout container. Arranges any children top to bottom with consistent gap. The default container for surfaces and sections.",
surface: {
components: [
{
id: "root",
component: "Stack",
gap: "sm",
children: ["c1", "c2", "c3"],
},
{ id: "c1", component: "Card", child: "c1-text", tone: "default" },
{
id: "c1-text",
component: "Text",
text: "First card.",
weight: "medium",
},
{ id: "c2", component: "Card", child: "c2-text", tone: "lilac" },
{
id: "c2-text",
component: "Text",
text: "Second card.",
weight: "medium",
},
{ id: "c3", component: "Card", child: "c3-text", tone: "default" },
{
id: "c3-text",
component: "Text",
text: "Third card.",
weight: "medium",
},
],
},
},
{
group: "layout",
name: "Row",
blurb:
"Horizontal layout. One Row = one horizontal line of children. Stack multiple Rows for table-like layouts; use a single Row for toolbars, metadata strips, and action bars. Shown here: two Rows inside a Stack so the unit of horizontal layout is visible.",
surface: {
components: [
{ id: "root", component: "Stack", gap: "xs", children: ["r1", "r2"] },
{
id: "r1",
component: "Row",
gap: "sm",
align: "center",
children: ["r1-name", "r1-sep", "r1-owner", "r1-badge"],
},
{
id: "r1-name",
component: "Text",
text: "Atlas migration",
weight: "medium",
},
{ id: "r1-sep", component: "Text", text: "·", tone: "muted" },
{
id: "r1-owner",
component: "Text",
text: "Priya",
tone: "muted",
size: "sm",
},
{
id: "r1-badge",
component: "Badge",
label: "In progress",
tone: "warning",
},
{
id: "r2",
component: "Row",
gap: "sm",
align: "center",
children: ["r2-name", "r2-sep", "r2-owner", "r2-badge"],
},
{
id: "r2-name",
component: "Text",
text: "Phoenix launch",
weight: "medium",
},
{ id: "r2-sep", component: "Text", text: "·", tone: "muted" },
{
id: "r2-owner",
component: "Text",
text: "Sam",
tone: "muted",
size: "sm",
},
{ id: "r2-badge", component: "Badge", label: "Done", tone: "positive" },
],
},
},
{
group: "layout",
name: "Grid",
blurb: "Responsive grid (cols 1 to 6).",
surface: {
components: [
{
id: "root",
component: "Grid",
columns: 3,
gap: "sm",
children: ["s1", "s2", "s3"],
},
{
id: "s1",
component: "StatCard",
label: "Revenue",
value: "$1.2M",
delta: "+18%",
deltaTone: "positive",
},
{
id: "s2",
component: "StatCard",
label: "Customers",
value: "2,940",
delta: "+7%",
deltaTone: "positive",
},
{
id: "s3",
component: "StatCard",
label: "Churn",
value: "4.4%",
delta: "+0.4%",
deltaTone: "negative",
},
],
},
},
{
group: "layout",
name: "Card",
blurb: "Bordered, padded surface. Tone variants for emphasis.",
surface: {
components: [
{ id: "root", component: "Row", gap: "sm", children: ["c1", "c2"] },
{ id: "c1", component: "Card", child: "c1-text", tone: "default" },
{ id: "c1-text", component: "Text", text: "Default tone." },
{ id: "c2", component: "Card", child: "c2-text", tone: "lilac" },
{
id: "c2-text",
component: "Text",
text: "Lilac tone.",
weight: "medium",
},
],
},
},
{
group: "layout",
name: "Section",
blurb:
"Overline + title + a single child component. Use to label a region of the surface.",
surface: {
components: [
{
id: "root",
component: "Section",
eyebrow: "OVERVIEW · Q1",
title: "Quarterly revenue",
child: "card",
},
{ id: "card", component: "Card", child: "text" },
{
id: "text",
component: "Text",
text: "Total revenue grew 18% QoQ, driven by mid-market expansion.",
tone: "muted",
},
],
},
},
{
group: "layout",
name: "Divider",
blurb: "1px line between sections.",
surface: {
components: [
{
id: "root",
component: "Stack",
gap: "sm",
children: ["t1", "d", "t2"],
},
{ id: "t1", component: "Text", text: "Above the line." },
{ id: "d", component: "Divider" },
{ id: "t2", component: "Text", text: "Below the line.", tone: "muted" },
],
},
},
{
group: "content",
name: "Heading",
blurb: "Level 1/2/3 for page, section, sub-block.",
surface: {
components: [
{
id: "root",
component: "Stack",
gap: "xs",
children: ["h1", "h2", "h3"],
},
{ id: "h1", component: "Heading", level: "1", text: "Heading 1" },
{ id: "h2", component: "Heading", level: "2", text: "Heading 2" },
{ id: "h3", component: "Heading", level: "3", text: "Heading 3" },
],
},
},
{
group: "content",
name: "Text",
blurb: "Body copy. Tone, size, weight.",
surface: {
components: [
{
id: "root",
component: "Stack",
gap: "xs",
children: ["t1", "t2", "t3"],
},
{
id: "t1",
component: "Text",
text: "Default body copy.",
weight: "medium",
},
{
id: "t2",
component: "Text",
text: "Muted secondary text.",
tone: "muted",
},
{
id: "t3",
component: "Text",
text: "Small caption.",
tone: "muted",
size: "sm",
},
],
},
},
{
group: "content",
name: "Overline",
blurb:
"Tiny ALL-CAPS mono label that sits above a heading. The 'overline' typography pattern (Material Design term). Use for category labels.",
surface: {
components: [
{ id: "root", component: "Stack", gap: "xs", children: ["e", "h"] },
{ id: "e", component: "Overline", text: "DEMO · 03" },
{ id: "h", component: "Heading", level: "2", text: "Section heading" },
],
},
},
{
group: "content",
name: "Badge",
blurb: "Inline status pill. 5 tones.",
surface: {
components: [
{
id: "root",
component: "Row",
gap: "xs",
children: ["b1", "b2", "b3", "b4", "b5"],
},
{ id: "b1", component: "Badge", label: "Neutral", tone: "neutral" },
{ id: "b2", component: "Badge", label: "Info", tone: "info" },
{ id: "b3", component: "Badge", label: "Positive", tone: "positive" },
{ id: "b4", component: "Badge", label: "Warning", tone: "warning" },
{ id: "b5", component: "Badge", label: "Danger", tone: "danger" },
],
},
},
{
group: "content",
name: "Callout",
blurb: "Block-level highlight for a key insight or definition.",
surface: {
components: [
{ id: "root", component: "Stack", gap: "sm", children: ["c1", "c2"] },
{
id: "c1",
component: "Callout",
tone: "info",
title: "Key insight",
body: "Transformers replaced recurrence with attention, so every token can read every other token in one step.",
},
{
id: "c2",
component: "Callout",
tone: "warning",
title: "Caveat",
body: "The attention layer scales quadratically with sequence length. Long contexts get expensive fast.",
},
],
},
},
{
group: "content",
name: "BulletList",
blurb:
"Short bulleted or numbered enumerations. Pass `ordered: true` for a numbered list.",
surface: {
components: [
{
id: "root",
component: "Stack",
gap: "md",
children: ["h1", "ul", "h2", "ol"],
},
{ id: "h1", component: "Overline", text: "BULLETED" },
{
id: "ul",
component: "BulletList",
items: [
"A new attention mechanism that scales near-linearly.",
"A training recipe that halves wall-clock time.",
"An evaluation benchmark released alongside the paper.",
],
},
{ id: "h2", component: "Overline", text: "NUMBERED" },
{
id: "ol",
component: "BulletList",
ordered: true,
items: ["Read the abstract.", "Skim Table 3.", "Run the colab."],
},
],
},
},
{
group: "data",
name: "StatCard",
blurb: "Single-metric card with delta + caption.",
surface: {
components: [
{
id: "root",
component: "StatCard",
label: "MRR",
value: "$48,200",
delta: "+12.4%",
deltaTone: "positive",
caption: "vs. prev month",
},
],
},
},
{
group: "data",
name: "BarChart",
blurb: "Vertical bars from [{label,value}]. Use when labels are short.",
surface: {
components: [
{ id: "root", component: "BarChart", height: 220, data: sampleSeries },
],
},
},
{
group: "data",
name: "HorizontalBarChart",
blurb: "Bars rendered as rows. Use for ranked lists where labels are long.",
surface: {
components: [
{
id: "root",
component: "HorizontalBarChart",
height: 240,
data: sampleRanked,
},
],
},
},
{
group: "data",
name: "LineChart",
blurb: "Time-series line for trends where direction matters.",
surface: {
components: [
{ id: "root", component: "LineChart", height: 220, data: sampleSeries },
],
},
},
{
group: "data",
name: "DonutChart",
blurb: "Share-of-total donut. 3 to 6 slices.",
surface: {
components: [
{ id: "root", component: "DonutChart", height: 220, data: sampleShare },
],
},
},
{
group: "data",
name: "ScatterChart",
blurb:
"X/Y dots for correlation. Always pass xLabel and yLabel so people know what each axis is.",
surface: {
components: [
{
id: "root",
component: "ScatterChart",
height: 240,
xLabel: "Months on platform",
yLabel: "Weekly active hours",
data: sampleScatter,
},
],
},
},
{
group: "data",
name: "DataTable",
blurb: "Rows × columns with right-aligned numerics.",
surface: {
components: [
{
id: "root",
component: "DataTable",
columns: [
{ key: "name", label: "Customer", align: "left" },
{ key: "category", label: "Segment", align: "left" },
{ key: "value", label: "ARR", align: "right" },
{ key: "delta", label: "Δ", align: "right" },
],
rows: sampleRows,
},
],
},
},
{
group: "interactive",
name: "Button",
blurb: "Primary / secondary / ghost.",
surface: {
components: [
{ id: "root", component: "Row", gap: "sm", children: ["p", "s", "g"] },
{
id: "p",
component: "Button",
label: "Primary",
variant: "primary",
action: { event: { name: "noop" } },
},
{
id: "s",
component: "Button",
label: "Secondary",
variant: "secondary",
action: { event: { name: "noop" } },
},
{
id: "g",
component: "Button",
label: "Ghost",
variant: "ghost",
action: { event: { name: "noop" } },
},
],
},
},
{
group: "interactive",
name: "ChoiceChips",
blurb: "Multi-select pills bound to a data path.",
surface: {
components: [
{
id: "root",
component: "ChoiceChips",
label: "Regions",
options: [
{ label: "NA", value: "na" },
{ label: "EMEA", value: "emea" },
{ label: "APAC", value: "apac" },
{ label: "LATAM", value: "latam" },
],
value: { path: "/regions" },
},
],
data: { regions: ["na", "emea"] },
},
},
];
const GROUPS: { key: Example["group"]; label: string }[] = [
{ key: "layout", label: "Layout" },
{ key: "content", label: "Content" },
{ key: "data", label: "Data viz" },
{ key: "interactive", label: "Interactive" },
];
export default function CatalogPage() {
const [filter, setFilter] = useState<Example["group"] | "all">("all");
const items =
filter === "all" ? EXAMPLES : EXAMPLES.filter((e) => e.group === filter);
return (
<>
<SiteNav active="catalog" />
<PageHeader
eyebrow="THE DESIGN SYSTEM"
meta={
<span className="pill">
<span className="dot" /> {EXAMPLES.length} components
</span>
}
title={
<>
Every component the agent <br className="hidden md:inline" />
<span className="text-[var(--muted)]">is allowed to draw.</span>
</>
}
subtitle="One catalog, one set of React renderers, one set of brand tokens. Both demos compose from this. The fixed dashboard via a pre-authored layout, the dynamic Q&A by inventing one per question."
/>
<main className="flex-1 max-w-[1320px] mx-auto px-6 py-8 w-full">
<div className="flex items-center gap-2 mb-6">
<FilterBtn
label="All"
active={filter === "all"}
onClick={() => setFilter("all")}
/>
{GROUPS.map((g) => (
<FilterBtn
key={g.key}
label={g.label}
active={filter === g.key}
onClick={() => setFilter(g.key)}
/>
))}
<span className="ml-auto mono text-[11px] text-[var(--muted-2)] uppercase tracking-wider">
showing {items.length} / {EXAMPLES.length}
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{items.map((ex) => (
<ShowcaseTile key={ex.name} example={ex} />
))}
</div>
</main>
<footer className="border-t border-[var(--line)] py-6 mt-10">
<div className="max-w-[1320px] mx-auto px-6 text-xs text-[var(--muted)] flex items-center justify-between">
<span>
Definitions:{" "}
<code className="mono px-1.5 py-0.5 rounded bg-[var(--surface-soft)] border border-[var(--line)] text-[11px]">
web/src/a2ui/catalog/definitions.ts
</code>{" "}
· Renderers:{" "}
<code className="mono px-1.5 py-0.5 rounded bg-[var(--surface-soft)] border border-[var(--line)] text-[11px]">
web/src/a2ui/catalog/renderers.tsx
</code>
</span>
<span className="mono">v0.2</span>
</div>
</footer>
</>
);
}
function FilterBtn({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`px-3 py-1.5 rounded-lg text-sm mono transition ${
active
? "bg-[var(--ink)] text-white border border-[var(--ink)]"
: "bg-[var(--surface)] text-[var(--ink-2)] border border-[var(--line)] hover:border-[var(--ink-2)]"
}`}
>
{label}
</button>
);
}
function ShowcaseTile({ example }: { example: Example }) {
return (
<div className="surface overflow-hidden flex flex-col">
<header className="px-5 py-3 border-b border-[var(--line)] flex items-center justify-between">
<div>
<div className="font-semibold text-[15px] text-[var(--ink)]">
{example.name}
</div>
<div className="text-[12.5px] text-[var(--muted)]">
{example.blurb}
</div>
</div>
<span className="mono text-[10.5px] uppercase tracking-wider text-[var(--muted-2)] px-2 py-0.5 rounded-full border border-[var(--line)]">
{example.group}
</span>
</header>
<div className="p-5 bg-[var(--surface-soft)] flex-1">
<SurfacePreview surface={example.surface} />
</div>
</div>
);
}
function SurfacePreview({
surface,
}: {
surface: { components: unknown[]; data?: Record<string, unknown> };
}) {
return (
<div className="a2ui-surface rounded-[var(--radius)]">
<A2UIProvider catalog={catalog}>
<PreviewInner surface={surface} />
</A2UIProvider>
</div>
);
}
function PreviewInner({
surface,
}: {
surface: { components: unknown[]; data?: Record<string, unknown> };
}) {
const actions = useA2UIActions();
useEffect(() => {
const messages: Array<Record<string, unknown>> = [
{
createSurface: { surfaceId: "preview", catalogId: CATALOG_ID },
},
{
updateComponents: {
surfaceId: "preview",
components: surface.components,
},
},
];
if (surface.data) {
messages.push({
updateDataModel: { surfaceId: "preview", value: surface.data },
});
}
actions.processMessages(messages);
}, [actions, surface]);
return <A2UIRenderer surfaceId="preview" />;
}
@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { z } from "zod";
import {
CopilotChat,
useAgent,
useRenderTool,
} from "@copilotkit/react-core/v2";
import { SiteNav } from "@/components/Brand";
import { SurfaceCanvas, CanvasEmptyState } from "@/components/SurfaceCanvas";
import { FilteredUserMessage } from "@/components/FilteredUserMessage";
import { FilteredAssistantMessage } from "@/components/FilteredAssistantMessage";
import { Split } from "@/components/Split";
import { extractPdfText } from "@/lib/pdf";
const AGENT_ID = "dynamic_agent";
export default function DynamicPage() {
const { agent: _agent } = useAgent({ agentId: AGENT_ID });
const [loaded, setLoaded] = useState<{
filename: string;
pages: number;
chars: number;
} | null>(null);
// generate_a2ui (the Python tool) is now the surface producer. Show a
// small pill while it streams, hide on complete (the rendered surface
// appears in the canvas. chat doesn't need a record of it).
useRenderTool({
name: "generate_a2ui",
parameters: z.any(),
render: ({ status }) => {
if (status === "complete") return <></>;
return (
<div className="surface-soft px-3 py-2 my-1 flex items-center gap-3 text-[13px] text-[var(--ink-2)]">
<span className="relative inline-flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-[var(--lilac)] opacity-75 animate-ping" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-[var(--lilac)]" />
</span>
<span>Composing a surface</span>
</div>
);
},
});
// query_pdf: render nothing, ever. The "Composing a surface…" pill
// from generate_a2ui is the only chat signal we want. We override the
// default tool card here (instead of leaving it) for two reasons:
// 1) the default tool card keeps args/result in the DOM and our args
// are the full PDF body, which is noisy.
// 2) when the agent calls query_pdf more than once per turn, the
// default would render multiple pills back to back.
useRenderTool({
name: "query_pdf",
parameters: z.any(),
render: () => <></>,
});
return (
<div className="h-screen flex flex-col bg-[var(--bg)]">
<SiteNav active="dynamic" />
<Split
persistKey="dynamic.split"
initialLeftFraction={0.32}
left={
<div className="h-full flex flex-col copilot-chat-wrapper">
{loaded && (
<div className="shrink-0 px-4 py-2 border-b border-[var(--line)] flex items-center gap-2 bg-[color-mix(in_oklab,var(--lilac)_8%,var(--surface))]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--lilac)]" />
<span className="mono text-[10.5px] uppercase tracking-[0.12em] text-[var(--ink)]">
loaded
</span>
<span className="text-[12.5px] font-medium text-[var(--ink)] truncate">
{loaded.filename}
</span>
<span className="text-[11px] text-[var(--ink)] ml-auto">
{loaded.pages} pg · {Math.round(loaded.chars / 1000)}k chars
</span>
</div>
)}
<div className="flex-1 min-h-0">
<CopilotChat
agentId={AGENT_ID}
chatView={{
messageView: {
userMessage: FilteredUserMessage,
assistantMessage: FilteredAssistantMessage,
},
}}
attachments={{
enabled: true,
accept: "application/pdf",
maxSize: 20 * 1024 * 1024,
onUpload: async (file) => {
const { text, pages } = await extractPdfText(file);
setLoaded({
filename: file.name,
pages,
chars: text.length,
});
return {
type: "data",
value: text.slice(0, 60_000),
mimeType: "text/plain",
metadata: {
filename: file.name,
pages,
originalMime: "application/pdf",
},
};
},
onUploadFailed: (err) =>
console.warn("[pdf upload failed]", err),
}}
labels={{
chatInputPlaceholder: "Attach a PDF (📎), then ask anything…",
welcomeMessageText:
"Attach a PDF using the 📎 button, then ask any question.",
}}
/>
</div>
</div>
}
right={
<SurfaceCanvas
channel={AGENT_ID}
emptyState={
<CanvasEmptyState
title="Canvas is empty"
subtitle="Attach a PDF in the chat and ask anything. The agent will compose a UI surface using the catalog and render it here."
hint={
<span className="mono text-[11px] uppercase tracking-[0.14em] text-[var(--ink)]">
try: Show me the revenue trend.
</span>
}
/>
}
/>
}
/>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { CopilotChat, useAgent } from "@copilotkit/react-core/v2";
import { SiteNav } from "@/components/Brand";
import { SurfaceCanvas, CanvasEmptyState } from "@/components/SurfaceCanvas";
import { FilteredUserMessage } from "@/components/FilteredUserMessage";
import { FilteredAssistantMessage } from "@/components/FilteredAssistantMessage";
import { Split } from "@/components/Split";
import { extractPdfText } from "@/lib/pdf";
const AGENT_ID = "fixed_agent";
export default function FixedPage() {
const { agent: _agent } = useAgent({ agentId: AGENT_ID });
const [loaded, setLoaded] = useState<{
filename: string;
pages: number;
chars: number;
} | null>(null);
return (
<div className="h-screen flex flex-col bg-[var(--bg)]">
<SiteNav active="fixed" />
<Split
persistKey="fixed.split"
initialLeftFraction={0.32}
left={
<div className="h-full flex flex-col copilot-chat-wrapper">
{loaded && (
<div className="shrink-0 px-4 py-2 border-b border-[var(--line)] flex items-center gap-2 bg-[color-mix(in_oklab,var(--mint)_8%,var(--surface))]">
<span className="w-1.5 h-1.5 rounded-full bg-[#0d6b4f]" />
<span className="mono text-[10.5px] uppercase tracking-[0.12em] text-[var(--ink)]">
loaded
</span>
<span className="text-[12.5px] font-medium text-[var(--ink)] truncate">
{loaded.filename}
</span>
<span className="text-[11px] text-[var(--ink)] ml-auto">
{loaded.pages} pg · {Math.round(loaded.chars / 1000)}k chars
</span>
</div>
)}
<div className="flex-1 min-h-0">
<CopilotChat
agentId={AGENT_ID}
chatView={{
messageView: {
userMessage: FilteredUserMessage,
assistantMessage: FilteredAssistantMessage,
},
}}
attachments={{
enabled: true,
accept: "application/pdf",
maxSize: 20 * 1024 * 1024,
onUpload: async (file) => {
const { text, pages } = await extractPdfText(file);
setLoaded({
filename: file.name,
pages,
chars: text.length,
});
return {
type: "data",
value: text.slice(0, 60_000),
mimeType: "text/plain",
metadata: {
filename: file.name,
pages,
originalMime: "application/pdf",
},
};
},
onUploadFailed: (err) =>
console.warn("[pdf upload failed]", err),
}}
labels={{
chatInputPlaceholder:
"Attach a PDF (📎), then ask to render the dashboard…",
welcomeMessageText:
"Attach a PDF using the 📎 button, then ask: “Render the dashboard.”",
}}
/>
</div>
</div>
}
right={
<SurfaceCanvas
channel={AGENT_ID}
emptyState={
<CanvasEmptyState
title="Canvas is empty"
subtitle="Attach a PDF in the chat (📎 in the input toolbar) and ask the agent to render the dashboard. The rendered A2UI surface will fill this canvas."
hint={
<span className="mono text-[11px] uppercase tracking-[0.14em] text-[var(--ink)]">
try: Render the dashboard.
</span>
}
/>
}
/>
}
/>
</div>
);
}
@@ -0,0 +1,163 @@
@import "tailwindcss";
@import "@copilotkit/react-ui/v2/styles.css";
@import "../a2ui/theme.css";
/* ── CopilotKit brand tokens (from verified brand-rules) ──────── */
:root {
/* Surfaces */
--bg: #fafafc;
--bg-alt: #ededf5;
--surface: #ffffff;
--surface-soft: #f7f7f9;
--ink: #010507;
--ink-2: #2b2b2b;
/* WCAG-AA-safe contrast on white. --muted-2 must still be readable as
* an eyebrow / caption — the previous #838389 fails contrast. */
--muted: #45454a;
--muted-2: #5a5a60;
--line: #dbdbe5;
--line-2: #e9e9ef;
/* Accents */
--lilac: #bec2ff;
--lilac-soft: #bec2ff33; /* 20% */
--lilac-softer: #bec2ff1a; /* 10% */
--mint: #85ecce;
--mint-soft: #85ecce4d; /* 30% */
--orange: #ffac4d;
--orange-soft: #ffac4d33;
--yellow: #fff388;
--red: #fa5f67;
/* Brand gradient */
--brand-gradient: linear-gradient(
90deg,
#bec2ff 0%,
#85ecce 45.67%,
#ffac4d 100%
);
/* Type tokens */
--radius: 14px;
--radius-lg: 20px;
}
@theme inline {
--color-bg: var(--bg);
--color-bg-alt: var(--bg-alt);
--color-surface: var(--surface);
--color-surface-soft: var(--surface-soft);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-muted-2: var(--muted-2);
--color-line: var(--line);
--color-line-2: var(--line-2);
--color-lilac: var(--lilac);
--color-mint: var(--mint);
--color-orange: var(--orange);
--color-yellow: var(--yellow);
--color-red: var(--red);
--font-sans:
var(--font-plus-jakarta), -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--font-mono:
var(--font-spline-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
}
html,
body {
height: 100%;
background: var(--bg);
color: var(--ink);
}
body {
font-family:
var(--font-plus-jakarta),
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
letter-spacing: -0.01em;
font-feature-settings: "ss01", "cv11";
}
/* Mono pills, code-like UI labels, button text per brand */
.mono {
font-family:
var(--font-spline-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* ── Brand chrome ────────────────────────────────────────────── */
.brand-gradient {
background: var(--brand-gradient);
}
/* Ambient kiss-of-color — peripheral, never behind reading content. */
.brand-gradient-soft {
background: var(--brand-gradient);
opacity: 0.1;
filter: blur(40px);
}
/* Subtle dotted texture, faded toward the centre. */
.dot-grid {
background-image: radial-gradient(
circle,
color-mix(in oklab, var(--ink) 5%, transparent) 1px,
transparent 1.5px
);
background-size: 22px 22px;
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 90%);
mask-image: linear-gradient(180deg, #000 0%, transparent 90%);
}
/* ── Pills (mono per brand) ──────────────────────────────────── */
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
letter-spacing: 0.04em;
font-family:
var(--font-spline-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
text-transform: uppercase;
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
}
.pill .dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--lilac);
box-shadow: 0 0 0 3px var(--lilac-soft);
}
/* ── Cards / surfaces ────────────────────────────────────────── */
.surface {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.surface-soft {
background: var(--surface-soft);
border: 1px solid var(--line-2);
border-radius: var(--radius);
}
/* ── CopilotChat surface fixes (match brand) ─────────────────── */
.copilot-chat-wrapper [class*="bg-white"] {
background: var(--surface) !important;
}
/* Breathing room around the chat input. CopilotChat wraps the input bar
* in its `bottomAnchored` container; without padding it sits flush
* against the panel edges and the rounded border touches the wall. */
.copilot-chat-wrapper > div:last-child > :last-child {
padding-left: 12px;
padding-right: 12px;
padding-bottom: 10px;
}
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Plus_Jakarta_Sans, Spline_Sans_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/Providers";
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-plus-jakarta",
subsets: ["latin"],
display: "swap",
});
const splineMono = Spline_Sans_Mono({
variable: "--font-spline-mono",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "CopilotKit × A2UI. Agents that compose your design system",
description:
"Build agents that compose UI from your own design system via A2UI v0.9. Fixed schemas for predictable layouts, dynamic schemas when the agent should pick the shape.",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang="en"
className={`${plusJakarta.variable} ${splineMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>
</html>
);
}
@@ -0,0 +1,207 @@
import Link from "next/link";
import { SiteNav, PageHeader } from "@/components/Brand";
export default function Home() {
return (
<>
<SiteNav active="home" />
<PageHeader
eyebrow="CopilotKit × A2UI v0.9"
meta={
<span className="pill">
<span className="dot" /> reference build
</span>
}
title={
<>
Agents that compose UI from your <br className="hidden md:inline" />
<span
className="bg-clip-text text-transparent"
style={{ backgroundImage: "var(--brand-gradient)" }}
>
own design system.
</span>
</>
}
subtitle="Drop a PDF. Watch one agent paint a fixed dashboard from a hand-authored layout, and another agent invent the UI for any follow-up question. Both using the same 21-component catalog."
/>
<main className="flex-1 max-w-[1320px] mx-auto px-6 py-12 w-full">
<div className="grid md:grid-cols-2 gap-5">
<ModeCard
href="/fixed"
badge="01 · FIXED SCHEMA"
title="Same layout, your data"
blurb="You author the dashboard once. The agent only fills in the numbers. Fast, predictable, brand-locked."
bullets={[
"One JSON file is the dashboard layout",
"Agent extracts KPIs, trend, share, table rows from the PDF",
"Surface streams in instantly. No second LLM call to render",
]}
cta="Open the fixed demo"
/>
<ModeCard
href="/dynamic"
badge="02 · DYNAMIC SCHEMA"
title="Agent picks the shape"
blurb="No pre-written layout. The agent answers your question, then a second LLM pass invents the UI from the catalog."
bullets={[
"Pick any of the 21 catalog components, in any combination",
"Stat for single numbers · LineChart for trends · DataTable for lists",
"Same brand tokens. The agent never sees CSS",
]}
cta="Open the dynamic demo"
/>
</div>
<section className="mt-14">
<div className="flex items-end justify-between mb-4">
<div>
<span className="mono text-[11px] uppercase tracking-[0.14em] text-[var(--muted-2)]">
The design system
</span>
<h2 className="text-[22px] font-semibold tracking-tight mt-1">
21 components, one catalog
</h2>
</div>
<Link
href="/catalog"
className="mono text-[12px] text-[var(--ink)] hover:text-[var(--lilac)] transition"
>
See them all
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{CATALOG_GROUPS.flatMap((g) =>
g.items.map((name) => (
<div
key={name}
className="surface px-3 py-3 text-[13px] flex items-center justify-between"
>
<span className="mono uppercase tracking-wider text-[11px] text-[var(--muted-2)]">
{g.short}
</span>
<span className="font-medium text-[var(--ink)]">{name}</span>
</div>
)),
)}
</div>
</section>
<section className="mt-14 grid md:grid-cols-3 gap-3">
<Spec
k="Frontend"
v="Next.js 16 · React 19 · Tailwind v4 · @copilotkit/react-core/v2"
/>
<Spec
k="Bridge"
v="@copilotkit/runtime (v2) · @ag-ui/client · a2ui middleware"
/>
<Spec
k="Backend"
v="Python · LangChain · LangGraph · FastAPI · ag-ui-langgraph"
/>
</section>
</main>
<footer className="border-t border-[var(--line)] py-6 mt-10">
<div className="max-w-[1320px] mx-auto px-6 text-xs text-[var(--muted)] flex items-center justify-between">
<span>
Drop your design tokens into{" "}
<code className="mono px-1.5 py-0.5 rounded bg-[var(--surface-soft)] border border-[var(--line)] text-[11px]">
web/src/a2ui/theme.css
</code>{" "}
to re-skin every surface.
</span>
<span className="mono">v0.2</span>
</div>
</footer>
</>
);
}
const CATALOG_GROUPS = [
{
short: "LAY",
items: ["Stack", "Row", "Grid", "Card", "Section", "Divider"],
},
{
short: "TXT",
items: ["Heading", "Text", "Overline", "Badge", "Callout", "BulletList"],
},
{
short: "DATA",
items: [
"StatCard",
"BarChart",
"HorizontalBarChart",
"LineChart",
"DonutChart",
"ScatterChart",
"DataTable",
],
},
{ short: "ACT", items: ["Button", "ChoiceChips"] },
];
function ModeCard({
href,
badge,
title,
blurb,
bullets,
cta,
}: {
href: string;
badge: string;
title: string;
blurb: string;
bullets: string[];
cta: string;
}) {
return (
<Link
href={href}
className="group surface p-7 hover:border-[var(--lilac)] transition relative overflow-hidden"
>
<div className="absolute -top-20 -right-20 w-[260px] h-[260px] rounded-full brand-gradient-soft opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative">
<span className="mono text-[11px] uppercase tracking-[0.14em] text-[var(--muted-2)]">
{badge}
</span>
<h3 className="text-[24px] font-semibold tracking-tight mt-2">
{title}
</h3>
<p className="mt-3 text-[var(--muted)] leading-relaxed text-[15px]">
{blurb}
</p>
<ul className="mt-5 space-y-2">
{bullets.map((b) => (
<li
key={b}
className="flex items-start gap-2.5 text-[13.5px] text-[var(--ink-2)]"
>
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-[var(--lilac)] flex-none" />
<span>{b}</span>
</li>
))}
</ul>
<span className="mt-6 inline-flex items-center gap-2 text-sm font-medium text-[var(--ink)] group-hover:text-[var(--ink)] transition mono">
{cta} <span aria-hidden></span>
</span>
</div>
</Link>
);
}
function Spec({ k, v }: { k: string; v: string }) {
return (
<div className="surface-soft p-4">
<div className="mono text-[11px] uppercase tracking-[0.14em] text-[var(--muted-2)]">
{k}
</div>
<div className="mt-1 text-[13px] text-[var(--ink-2)]">{v}</div>
</div>
);
}
@@ -0,0 +1,129 @@
import Image from "next/image";
import Link from "next/link";
export function Logo({ size = 22 }: { size?: number }) {
return (
<Image
src="/brand/logo-full.svg"
alt="CopilotKit"
width={size * 5}
height={size}
priority
style={{ height: size, width: "auto" }}
/>
);
}
export function SiteNav({
active,
}: {
active?: "home" | "fixed" | "dynamic" | "catalog";
}) {
const links: Array<{ href: string; label: string; key: typeof active }> = [
{ href: "/", label: "Overview", key: "home" },
{ href: "/fixed", label: "Fixed schema", key: "fixed" },
{ href: "/dynamic", label: "Dynamic schema", key: "dynamic" },
{ href: "/catalog", label: "Catalog", key: "catalog" },
];
return (
<header className="shrink-0 border-b border-[var(--line)] bg-[var(--surface)]">
<div className="max-w-[1480px] mx-auto px-5 h-14 flex items-center justify-between">
<Link href="/" className="flex items-center gap-3">
<Logo size={22} />
<span className="hidden sm:inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-[var(--line)] bg-[var(--surface-soft)] text-[10.5px] uppercase tracking-[0.12em] mono text-[var(--muted)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--lilac)]" />
A2UI
</span>
</Link>
<nav className="flex items-center gap-1">
{links.map((l) => (
<Link
key={l.key}
href={l.href}
className={`px-3 py-1.5 rounded-lg text-[13.5px] transition ${
active === l.key
? "bg-[var(--surface-soft)] text-[var(--ink)] border border-[var(--line)]"
: "text-[var(--muted)] hover:text-[var(--ink)]"
}`}
>
{l.label}
</Link>
))}
<a
href="https://docs.copilotkit.ai"
target="_blank"
rel="noopener noreferrer"
className="ml-2 px-3 py-1.5 rounded-lg text-[13.5px] text-[var(--muted)] hover:text-[var(--ink)]"
>
Docs
</a>
</nav>
</div>
</header>
);
}
/** Used only on overview & catalog pages. never on demo pages where the
* whole viewport is workspace. Compact, no atmosphere, no gradient. */
export function PageHeader({
eyebrow,
title,
subtitle,
meta,
}: {
eyebrow: string;
title: React.ReactNode;
subtitle: React.ReactNode;
meta?: React.ReactNode;
}) {
return (
<section className="border-b border-[var(--line)] bg-[var(--bg)]">
<div className="max-w-[1480px] mx-auto px-5 py-8">
<div className="flex items-center gap-3 mb-3">
<span className="mono text-[11px] uppercase tracking-[0.14em] text-[var(--muted-2)]">
{eyebrow}
</span>
{meta}
</div>
<h1 className="text-[28px] md:text-[34px] font-semibold tracking-tight leading-[1.1] text-[var(--ink)]">
{title}
</h1>
<p className="mt-2 text-[var(--muted)] max-w-2xl text-[15px] leading-relaxed">
{subtitle}
</p>
</div>
</section>
);
}
/** Used by the demo pages. A thin one-row title strip. no hero, no gradient,
* no overflow. Sits between the nav and the workspace split. */
export function WorkspaceHeader({
eyebrow,
title,
agentId,
status,
}: {
eyebrow: string;
title: string;
agentId: string;
status?: React.ReactNode;
}) {
return (
<div className="shrink-0 border-b border-[var(--line)] bg-[var(--bg)]">
<div className="max-w-[1480px] mx-auto px-5 py-3 flex items-center gap-4">
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-[var(--muted-2)]">
{eyebrow}
</span>
<span className="text-[14px] font-semibold tracking-tight text-[var(--ink)]">
{title}
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-[var(--line)] bg-[var(--surface)] text-[10.5px] uppercase tracking-[0.12em] mono text-[var(--muted)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--lilac)]" />
agent: {agentId}
</span>
<div className="ml-auto flex items-center gap-3">{status}</div>
</div>
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
/**
* Defensive sanitizer for assistant messages.
*
* The dynamic agent's system prompt instructs it to never emit prose
* (the rendered surface is the answer), but if a model decides to dump
* a verbatim PDF chunk into its chat reply anyway, we don't want a wall
* of legalese surfacing in the chat. This slot strips any text that
* looks like a PDF body. long uninterrupted paragraphs, repeated
* disclaimer phrases, or content prefixed with a `[Document: …]`
* header. and replaces it with a small note.
*
* The wire message is untouched; this only affects display.
*/
import { CopilotChatAssistantMessage } from "@copilotkit/react-core/v2";
import type { ComponentProps } from "react";
type Props = ComponentProps<typeof CopilotChatAssistantMessage>;
const DOC_HEADER = /\[Document:\s*[^\]]+\]\s*/;
/* Phrases that effectively only show up when the model has dumped raw
* PDF text (boilerplate disclaimers, footers, SEC fine-print). If any
* of these appear in a chat reply, it's near-certainly a PDF quote and
* not a legitimate assistant message. suppress it regardless of length.
* Keep this list narrow and PDF-flavored so we don't accidentally swallow
* real model output. */
const PDF_BOILERPLATE = [
/forward[- ]looking statement/i,
/disclaims any obligation/i,
/all rights reserved/i,
/this (?:report|presentation) (?:contains|may contain)/i,
/\bgaap\b.*\bnon[- ]?gaap\b/i,
/safe harbor/i,
/pursuant to (?:section|the)/i,
];
/* For longer messages without those phrases, fall back to a generic
* "wall of unstructured prose" heuristic. */
const PROSE_MAX_CHARS = 600;
function looksLikePdfDump(text: string): boolean {
if (DOC_HEADER.test(text)) return true;
if (PDF_BOILERPLATE.some((re) => re.test(text))) return true;
if (text.length < PROSE_MAX_CHARS) return false;
// No markdown + very long → probably a PDF body the model echoed.
const hasMarkdown = /[#`*_>-]/.test(text);
return !hasMarkdown;
}
/* When the dynamic agent slips and echoes a tool's JSON return value
* into chat (most often the query_pdf result, shape
* { shape_hint, title, summary, data }), we don't want that JSON
* showing up as the assistant's message. The surface in the canvas IS
* the answer; chat should stay empty. Detect a message whose trimmed
* content is a JSON object (optionally fenced in ```json) and suppress. */
function looksLikeJsonDump(text: string): boolean {
const trimmed = text
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/```$/i, "")
.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false;
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === "object" && parsed !== null;
} catch {
return false;
}
}
function sanitize(content: unknown): string {
if (typeof content !== "string")
return content == null ? "" : String(content);
if (looksLikePdfDump(content)) return "";
if (looksLikeJsonDump(content)) return "";
// Even if not a full dump, strip a stray document header if present.
return content.replace(DOC_HEADER, "").trim();
}
function FilteredAssistantMessageImpl(props: Props) {
const original = props.message;
const cleanedContent = sanitize(original.content);
if (cleanedContent === original.content) {
return <CopilotChatAssistantMessage {...props} />;
}
return (
<CopilotChatAssistantMessage
{...props}
message={{ ...original, content: cleanedContent }}
/>
);
}
export const FilteredAssistantMessage = Object.assign(
FilteredAssistantMessageImpl,
CopilotChatAssistantMessage,
) as typeof CopilotChatAssistantMessage;
@@ -0,0 +1,115 @@
"use client";
/**
* Hides the body of attached PDFs from the chat history.
*
* When CopilotChat ships a multimodal user message, the user message renderer
* concatenates every text-shaped part (including `document` parts whose
* `source.value` is the extracted PDF text). The result is a wall of raw PDF
* text below the user's typed prompt. We rewrite the message so each
* `document` part is replaced by a single line `📎 <filename>` placeholder.
*
* The wire message that actually reaches the agent is untouched. this
* only affects display.
*/
import { CopilotChatUserMessage } from "@copilotkit/react-core/v2";
import type { ComponentProps } from "react";
import type {
UserMessage,
InputContentPart,
TextInputContent,
} from "@ag-ui/core";
type Props = ComponentProps<typeof CopilotChatUserMessage>;
function isDocumentPart(
p: InputContentPart,
): p is Extract<InputContentPart, { type: "document" }> {
return (
typeof p === "object" &&
p !== null &&
(p as { type?: string }).type === "document"
);
}
function filename(p: Extract<InputContentPart, { type: "document" }>): string {
const meta = (p as { metadata?: { filename?: string } }).metadata;
return meta?.filename ?? "attached document";
}
/* The Python multimodal middleware rewrites the original `document` part
* into a `text` part prefixed with `[Document: <filename>]\n`. That string
* round-trips back through the agent's messages_snapshot and lands here as
* `message.content: string`. So we have to handle BOTH the array case (raw
* message before it round-trips) AND the string case (after). */
const DOC_HEADER = /\[Document:\s*([^\]]+)\]\s*/;
function rewriteString(content: string): string {
const m = content.match(DOC_HEADER);
if (!m) return content;
const before = content.slice(0, m.index ?? 0).trim();
const fname = m[1]?.trim() || "attached document";
return before ? `${before}\n📎 ${fname}` : `📎 ${fname}`;
}
function isInlinedDocText(p: InputContentPart): boolean {
// The Python multimodal middleware turns text-shaped documents into
// a TEXT input part prefixed with `[Document: filename]\n<text>`.
// After a state round-trip that part lands here as a text part .
// not a document part. so we have to sniff its text content.
if (typeof p !== "object" || p === null) return false;
if ((p as { type?: string }).type !== "text") return false;
const text = (p as { text?: unknown }).text;
return typeof text === "string" && DOC_HEADER.test(text);
}
function extractFilenameFromText(text: string): string {
const m = text.match(DOC_HEADER);
return m?.[1]?.trim() || "attached document";
}
function rewrite(message: UserMessage): UserMessage {
// Case 1: original multimodal array. replace document parts with a
// small placeholder text part. Also handle text parts that have
// already been inlined as `[Document: ...]\n<body>` by the agent
// round-trip. those leak the PDF body otherwise.
if (Array.isArray(message.content)) {
const rewritten: InputContentPart[] = [];
for (const part of message.content) {
if (isDocumentPart(part)) {
rewritten.push({
type: "text",
text: `📎 ${filename(part)}`,
} satisfies TextInputContent);
} else if (isInlinedDocText(part)) {
const text = (part as { text: string }).text;
rewritten.push({
type: "text",
text: `📎 ${extractFilenameFromText(text)}`,
} satisfies TextInputContent);
} else {
rewritten.push(part);
}
}
return { ...message, content: rewritten };
}
// Case 2: stringified (after agent round-trip). strip the
// `[Document: <fname>]\n<text…>` blob and keep just the user's prompt.
if (typeof message.content === "string") {
const next = rewriteString(message.content);
if (next === message.content) return message;
return { ...message, content: next };
}
return message;
}
function FilteredUserMessageImpl(props: Props) {
return <CopilotChatUserMessage {...props} message={rewrite(props.message)} />;
}
// Slot expects `typeof CopilotChatUserMessage` (a component + its static
// namespace). We only override the render. the namespace is the same.
export const FilteredUserMessage = Object.assign(
FilteredUserMessageImpl,
CopilotChatUserMessage,
) as typeof CopilotChatUserMessage;
@@ -0,0 +1,23 @@
"use client";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { createMirrorActivityRenderer } from "@/a2ui/MirrorRenderer";
/* Both agents send A2UI surfaces via activity messages. We intercept those
* with our mirror renderer and forward them to the page-level SurfaceCanvas,
* so the dashboard renders at full canvas size instead of as a chat bubble.
*
* The pill the renderer leaves behind in chat is the user-visible breadcrumb
* ("surface → rendered in the canvas"). */
const RENDERERS = [
createMirrorActivityRenderer("fixed_agent"),
createMirrorActivityRenderer("dynamic_agent"),
];
export function Providers({ children }: { children: React.ReactNode }) {
return (
<CopilotKit runtimeUrl="/api/copilotkit" renderActivityMessages={RENDERERS}>
{children}
</CopilotKit>
);
}
@@ -0,0 +1,135 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { clsx } from "clsx";
/**
* Two-pane horizontal layout with a draggable gutter (VS Code-style).
*
* - `left` and `right` each fill their half; the user drags the gutter to
* change the split.
* - Width is stored as a fraction of the container width (0..1) so the
* layout adapts when the viewport resizes.
* - Persisted to localStorage under `persistKey`.
* - Clamped to [`minFraction`, `1 - minFraction`] so neither pane can
* collapse below the configured minimum.
*
* Parent must be a fixed-height flex container; both panes fill 100% height.
*/
export function Split({
left,
right,
persistKey,
initialLeftFraction = 0.32,
minFraction = 0.3,
}: {
left: React.ReactNode;
right: React.ReactNode;
persistKey: string;
initialLeftFraction?: number;
minFraction?: number;
}) {
const [fraction, setFraction] = useState<number>(initialLeftFraction);
const [hydrated, setHydrated] = useState(false);
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Hydrate from localStorage after mount (avoid SSR mismatch).
useEffect(() => {
const raw = window.localStorage.getItem(persistKey);
if (raw) {
const n = Number(raw);
if (!Number.isNaN(n) && n >= minFraction && n <= 1 - minFraction) {
setFraction(n);
}
}
setHydrated(true);
}, [persistKey, minFraction]);
useEffect(() => {
if (!hydrated || dragging) return;
window.localStorage.setItem(persistKey, String(fraction));
}, [fraction, hydrated, dragging, persistKey]);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
setDragging(true);
}, []);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const raw = (e.clientX - rect.left) / rect.width;
const next = Math.min(1 - minFraction, Math.max(minFraction, raw));
setFraction(next);
},
[dragging, minFraction],
);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
try {
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
} catch {
/* releasePointerCapture can throw if not captured. ignore */
}
setDragging(false);
}, []);
useEffect(() => {
if (!dragging) return;
const prevCursor = document.body.style.cursor;
const prevSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
return () => {
document.body.style.cursor = prevCursor;
document.body.style.userSelect = prevSelect;
};
}, [dragging]);
const leftPct = `${fraction * 100}%`;
return (
<div ref={containerRef} className="flex-1 min-h-0 flex">
<div
style={{ width: leftPct }}
className="shrink-0 h-full overflow-hidden border-r border-[var(--line)] bg-[var(--surface)]"
>
{left}
</div>
{/* Draggable gutter. 6px wide, with a centered 1px visual rail */}
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize chat sidebar"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={clsx(
"group relative -ml-[3px] w-[6px] shrink-0 cursor-col-resize select-none touch-none",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2",
"before:bg-[var(--line)] before:transition-colors",
dragging
? "before:bg-[var(--lilac)]"
: "hover:before:bg-[var(--lilac)]",
)}
>
<span
aria-hidden
className={clsx(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-8 w-[3px] rounded-full transition-opacity",
dragging
? "opacity-100 bg-[var(--lilac)]"
: "opacity-0 group-hover:opacity-100 bg-[var(--ink)]",
)}
/>
</div>
<div className="flex-1 min-w-0 h-full overflow-hidden">{right}</div>
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
A2UIProvider,
A2UIRenderer,
useA2UIActions,
} from "@copilotkit/a2ui-renderer";
import { useAgent } from "@copilotkit/react-core/v2";
import { catalog } from "@/a2ui/catalog";
import { surfaceBus } from "@/a2ui/surface-bus";
/* The big workspace pane. A page-level A2UIProvider subscribes to the
* surface bus so any surface produced by chat renders here at canvas size.
*
* Critically, the provider's `onAction` callback forwards every chip / button
* click in a rendered surface back to the agent as
* forwardedProps.a2uiAction.userAction = { name, surfaceId, context, ... }
* The A2UI middleware on the backend sees this on the next run and injects
* a `log_a2ui_event` tool result so the agent's reasoning step can react. */
export function SurfaceCanvas({
channel,
emptyState,
}: {
channel: string;
emptyState: React.ReactNode;
}) {
const { agent } = useAgent({ agentId: channel });
return (
<A2UIProvider
catalog={catalog}
onAction={(message) => {
console.log(
`[surface-canvas] chip dispatch channel=${channel}`,
message,
);
// `message` shape: { userAction: { name, surfaceId, context, ... } }
// 1. Add a visible user message so the chat reflects the click .
// otherwise the action travels silently via forwardedProps and
// the user sees the agent respond without context.
// 2. Run the agent with the action carried in forwardedProps so the
// A2UI middleware can inject the log_a2ui_event tool result.
const ua = message?.userAction;
const labelHint = readContextLabel(ua?.context);
if (ua?.name) {
agent.addMessage({
id: crypto.randomUUID(),
role: "user",
content: humanizeAction(ua.name, labelHint),
});
}
void agent
.runAgent({
forwardedProps: { a2uiAction: message },
})
.then(() =>
console.log(`[surface-canvas] runAgent resolved for ${channel}`),
)
.catch((err) => {
console.warn("[surface-canvas] runAgent failed", err);
});
}}
>
<CanvasInner channel={channel} emptyState={emptyState} />
</A2UIProvider>
);
}
function CanvasInner({
channel,
emptyState,
}: {
channel: string;
emptyState: React.ReactNode;
}) {
const actions = useA2UIActions();
const [surfaceId, setSurfaceId] = useState<string | null>(null);
const seenRef = useRef(0);
const createdSurfacesRef = useRef<Set<string>>(new Set());
/* The MessageProcessor THROWS on duplicate createSurface. Each agent call
* to render_dashboard emits a fresh createSurface + updateComponents +
* updateDataModel batch. the second batch's createSurface would crash
* the batch and the data update never lands. Track which surfaceIds
* we've already created and strip duplicate createSurface ops. */
function applyOps(
ops: typeof seenRef extends never ? never : Array<Record<string, unknown>>,
) {
if (!ops.length) return;
const out = ops.filter((op) => {
const cs = op.createSurface as { surfaceId?: string } | undefined;
if (cs?.surfaceId) {
if (createdSurfacesRef.current.has(cs.surfaceId)) {
console.log(
`[surface-canvas] skip duplicate createSurface(${cs.surfaceId})`,
);
return false;
}
createdSurfacesRef.current.add(cs.surfaceId);
}
return true;
});
console.log(
`[surface-canvas] processMessages channel=${channel} ` +
`(${out.length} ops after dedupe, ${ops.length} raw)`,
);
try {
actions.processMessages(out);
} catch (err) {
console.warn("[surface-canvas] processMessages threw:", err);
}
}
useEffect(() => {
const initial = surfaceBus.snapshot(channel);
if (initial.ops.length) {
applyOps(initial.ops as never);
seenRef.current = initial.ops.length;
setSurfaceId(initial.surfaceId);
}
return surfaceBus.subscribe(channel, (snap) => {
const tail = snap.ops.slice(seenRef.current);
console.log(
`[surface-canvas] bus notify channel=${channel} ` +
`(snap=${snap.ops.length} seen=${seenRef.current} tail=${tail.length} ` +
`surfaceId=${snap.surfaceId ?? "null"})`,
);
if (tail.length) applyOps(tail as never);
seenRef.current = snap.ops.length;
if (snap.surfaceId) setSurfaceId(snap.surfaceId);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actions, channel]);
if (!surfaceId) {
return (
<div className="h-full flex items-center justify-center p-8">
{emptyState}
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="a2ui-surface p-6 md:p-8">
<A2UIRenderer surfaceId={surfaceId} />
</div>
</div>
);
}
function readContextLabel(ctx: unknown): string | undefined {
if (!ctx || typeof ctx !== "object") return undefined;
const c = ctx as Record<string, unknown>;
const v = c.value ?? c.label;
return typeof v === "string" ? v : undefined;
}
function humanizeAction(name: string, hint?: string): string {
if (name === "select_chip" && hint) return `Switch scope → ${prettify(hint)}`;
if (hint) return `${prettify(name)}${prettify(hint)}`;
return prettify(name);
}
function prettify(s: string): string {
return s
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/(^|\s)\w/g, (m) => m.toUpperCase());
}
export function CanvasEmptyState({
title,
subtitle,
hint,
}: {
title: string;
subtitle: string;
hint?: React.ReactNode;
}) {
return (
<div className="max-w-md text-center flex flex-col items-center gap-3">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center"
style={{ background: "var(--brand-gradient)", opacity: 0.85 }}
aria-hidden
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="#0a0a0b"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" />
<rect x="3" y="16" width="7" height="5" rx="1.5" />
</svg>
</div>
<h2 className="text-[20px] font-semibold tracking-tight text-[var(--ink)]">
{title}
</h2>
<p className="text-[14px] text-[var(--ink)] leading-relaxed">
{subtitle}
</p>
{hint && <div className="mt-2">{hint}</div>}
</div>
);
}
@@ -0,0 +1,25 @@
/** Client-side PDF → text extraction. Loaded lazily so the page bundle
* stays small. Used inside CopilotChat's `attachments.onUpload`. */
export async function extractPdfText(file: File): Promise<{
text: string;
pages: number;
}> {
const buf = await file.arrayBuffer();
// @ts-expect-error pdfjs ships its own types via a custom export
const pdfjs = await import("pdfjs-dist/build/pdf.mjs");
pdfjs.GlobalWorkerOptions.workerSrc =
"https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/pdf.worker.min.mjs";
const doc = await pdfjs.getDocument({ data: buf }).promise;
const chunks: string[] = [];
for (let p = 1; p <= doc.numPages; p++) {
const page = await doc.getPage(p);
const content = await page.getTextContent();
chunks.push(
content.items
.map((it: { str?: string }) => it.str ?? "")
.filter(Boolean)
.join(" "),
);
}
return { text: chunks.join("\n"), pages: doc.numPages };
}
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
@@ -0,0 +1,53 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.mastra/
# lock files
package-lock.json
yarn.lock
bun.lockb
# python
agent/venv/
agent/__pycache__/
agent/.venv/
@@ -0,0 +1,59 @@
# Repository Guidelines
## Project Structure & Module Organization
- Frontend (Next.js + TypeScript): `src/app/**` (pages: `page.tsx`, `layout.tsx`, styles: `globals.css`). API route for CopilotKit: `src/app/api/copilotkit/route.ts`.
- Agent (ADK/Python): `agent/agent.py`, virtual env in `agent/.venv`, deps in `agent/requirements.txt`.
- Public assets: `public/`. Config: `next.config.ts`, `tsconfig.json`, `eslint.config.mjs`.
- Scripts: `scripts/run-agent.sh`, `scripts/setup-agent.sh`.
## Build, Test, and Development
- `npm run dev` — runs UI (`next dev --turbopack`) and the Python agent concurrently.
- `npm run dev:ui` — frontend only; useful for UI iteration.
- `npm run dev:agent` — agent only; activates `.venv` and runs `agent.py`.
- `npm run build` — production build for the Next.js app.
- `npm start` — serve the built app.
- `npm run lint` — lint the frontend with Next/ESLint.
- First-time setup installs the agent via `postinstall` (creates `.venv` and installs Python deps).
## Coding Style & Naming Conventions
- TypeScript/React: 2-space indent, PascalCase components, camelCase variables, file-based routing under `src/app/**`.
- Python agent: follow PEP 8; keep modules small and composable.
- Linting: Next.js ESLint config (`npm run lint`). Prefer explicit types in exported APIs.
- Components: colocate with usage; export from an `index.ts` when creating reusable modules.
## Testing Guidelines
- Currently no test harness. When adding tests:
- Frontend: Jest/Vitest in `src/__tests__/` with `*.test.ts(x)`.
- Agent: `pytest` in `agent/tests/` with `test_*.py`.
- Aim for high coverage on data shaping (dashboard spec generation, adapters).
## Commit & Pull Request Guidelines
- Conventional Commits: `type(scope): message`.
- Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`.
- Example: `feat(charts): support pie charts`.
- PRs: clear description, linked issue, before/after screenshots or JSON spec samples, and testing notes.
- Keep PRs focused; call out env/config changes explicitly.
## Environment, Security & Config
- Place secrets in `.env.local` (frontend) and `agent/.env` (agent). Never commit secrets.
- Example keys (adjust to your provider):
- Frontend: `NEXT_PUBLIC_CPK_ENDPOINT=/api/copilotkit`.
- Agent: `GOOGLE_API_KEY=...` (Gemini), or `OPENAI_API_KEY=...` if applicable.
- Validate/sanitize prompts; avoid logging PII. Prefer `INFO` logs with redaction.
## Charts & CopilotKit Tips
- Dashboard spec (example): `{ "type": "line", "title": "Revenue", "x": "date", "y": "revenue" }`.
- Supported types to target in UI: `line`, `bar`, `pie`
- Naming: use singular `x`/`y` for series
- Recharts via CPK: map spec→props; e.g., `LineChart` with `dataKey={spec.y}` and `XAxis dataKey={spec.x}`;
## Architecture Overview
- Next.js app hosts CopilotKit UI and API route; Python agent performs ADK/Gemini orchestration. `npm run dev` runs both together.
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Atai Barkai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,91 @@
# CopilotKit + ADK Generative Canvas
https://github.com/user-attachments/assets/9201d528-573f-43cc-9d31-571c362318a7
---
This project provides a **Canvas UI** for building and running agents with [Googles ADK](https://google.github.io/adk-docs/), [AG-UI](https://docs.ag-ui.com/introduction), and [CopilotKit](https://github.com/CopilotKit/CopilotKit).
Instead of plain text, the agent can **populate metrics, charts, and real-time data** into the Canvas dashboard.
---
## 🔧 Quickstart
```bash
git clone https://github.com/your-org/copilotkit-adk-canvas
cd copilotkit-adk-canvas
# install JS deps + agent
pnpm install # or npm/yarn/bun
# install Python deps separately for the ADK agent
pnpm install:agent
# set your Google API key
export GOOGLE_API_KEY="your-google-api-key-here"
# start UI + agent together
pnpm run dev
```
### 📦 Prerequisites
- Node.js 18+
- Python 3.8+
- Google Makersuite API Key → get one [here](https://makersuite.google.com/)
- Any package manager (pnpm recommended)
💡 Lockfiles (`package-lock.json`, `yarn.lock`, etc.) are gitignored — each dev manages their own.
---
### 🛠 Available Scripts
- `dev` → Start UI + agent (default)
- `dev:debug` → Start with debug logging
- `dev:ui` → Run just the Next.js app
- `dev:agent` → Run just the ADK agent
- `build / start` → Production build + server
- `lint` → Run ESLint
- `install:agent` → Install Python deps inside `agent/.venv`
---
### 🎨 Customization
- **Main UI** → `src/app/page.tsx`
- Change theme/colors and sidebar appearance
- Add new visualization components
- Extend agent logic in `/agent`
---
### 📚 Docs
- [ADK](https://google.github.io/adk-docs/)
- [CopilotKit](https://github.com/CopilotKit/CopilotKit)
- [AG-UI](https://docs.ag-ui.com/introduction)
---
### 🐛 Troubleshooting
**Agent connection issues?**
- Ensure ADK agent runs on port `8000`
- Double-check `GOOGLE_API_KEY`
- Confirm both servers boot without errors
---
### 🤝 Contributing
PRs and issues welcome — this Canvas is meant to be hacked on.
---
### 📄 License
MIT — see [LICENSE](./LICENSE) for details.
@@ -0,0 +1,52 @@
from __future__ import annotations
# ADK imports
from ag_ui_adk import ADKAgent
from google.adk.agents import Agent
from google.adk.agents.callback_context import CallbackContext
from google.adk.tools import google_search, FunctionTool
from google.adk.tools.agent_tool import AgentTool
# Local imports
from modifiers import before_model, after_model
from tools import tools
from instructions import instruction_provider
def on_before_agent(callback_context: CallbackContext):
"""
Initialize dashboard state if it doesn't exist.
"""
return None
search_agent = Agent(
model="gemini-2.0-flash",
name="SearchAgent",
instruction="""
You're a specialist in Google Search
""",
tools=[google_search],
)
dashboard_agent = Agent(
name="DashboardAgent",
model="gemini-2.5-flash",
tools=tools + [AgentTool(agent=search_agent)],
# run-loop modifiers
before_agent_callback=on_before_agent,
before_model_callback=before_model,
after_model_callback=after_model,
# system instructions
instruction=instruction_provider,
)
# Create ADK middleware agent instance
dashboard_agent = ADKAgent(
adk_agent=dashboard_agent,
app_name="dashboard_app",
user_id="demo_user",
session_timeout_seconds=3600,
use_in_memory_services=True,
)
@@ -0,0 +1,32 @@
from google.adk.agents.readonly_context import ReadonlyContext
import json
from datetime import datetime
# This is an InstructionProvider
def instruction_provider(context: ReadonlyContext) -> str:
return f"""
You are an intelligent dashboard agent designed to assist users in building and managing interactive dashboards.
Current dashboard state: {context.state}
The current date is {datetime.now().strftime("%Y-%m-%d")}.
**Key Guidelines:**
- Your primary source of truth is the `context.state` and information retrieved via the `SearchAgent`. Do not infer data from message history.
- When asked to build or edit the dashboard, always use the `SearchAgent` to gather necessary data.
- For new dashboards or "rebuild" requests, ensure all old data is cleared before populating with new information.
- Always provide responses grounded in real data. Only generate speculative content if explicitly instructed by the user.
**Dashboard Elements & Tools:**
- Supported chart types:
- `line`: requires `x` (category) and `y` (value)
- `bar`: requires `x` (category) and `y` (value)
- `pie`: requires `x` (category) and `y` (value)
- UI state keys: `pinnedMetrics`, `dashboard.charts`, and `chartData` (a map keyed by chart title).
- Use `add_charts` to provide chart specifications and `set_chart_data` to provide datasets.
**Engagement & Quality:**
- Act as a domain expert, making informed decisions with minimal user guidance when asked to perform dashboard actions.
- When constructing dashboards, aim for a rich and informative display, typically including at least 3 metrics and 2 charts.
"""
@@ -0,0 +1,62 @@
from typing import Optional
# ADK imports
from google.adk.agents.callback_context import CallbackContext
from google.adk.models import LlmResponse, LlmRequest
from google.genai import types
def before_model(
callback_context: CallbackContext, llm_request: LlmRequest
) -> Optional[LlmResponse]:
"""
Inspects/modifies the LLM request or skips the call.
"""
agent_name = callback_context.agent_name
if agent_name == "DashboardAgent":
original_instruction = llm_request.config.system_instruction or types.Content(
role="system", parts=[]
)
prefix = f"""
You manage a dashboard for a user.
Current dashboard: {callback_context.state}
When asked for the current dashboard, pinned metrics or chart please reference the current dashboard and respond.
"""
if not isinstance(original_instruction, types.Content):
original_instruction = types.Content(
role="system", parts=[types.Part(text=str(original_instruction))]
)
if not original_instruction.parts:
original_instruction.parts.append(types.Part(text=""))
modified_text = prefix + (original_instruction.parts[0].text or "")
original_instruction.parts[0].text = modified_text
llm_request.config.system_instruction = original_instruction
return None
# --- Define the Callback Function ---
def after_model(
callback_context: CallbackContext, llm_response: LlmResponse
) -> Optional[LlmResponse]:
"""Stop the consecutive tool calling of the agent"""
agent_name = callback_context.agent_name
# --- Inspection ---
if agent_name == "DashboardAgent":
if llm_response.content and llm_response.content.parts:
# Assuming simple text response for this example
if (
llm_response.content.role == "model"
and llm_response.content.parts[0].text
):
callback_context._invocation_context.end_invocation = True
elif llm_response.error_message:
return None
else:
return None # Nothing to modify
return None
@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
python-dotenv
pydantic
google-adk
google-genai
ag-ui-adk
tavily-python
@@ -0,0 +1,29 @@
from fastapi import FastAPI
from ag_ui_adk import add_adk_fastapi_endpoint
from agent import dashboard_agent
from dotenv import load_dotenv
load_dotenv()
# Create FastAPI app
app = FastAPI(title="ADK Middleware Dashboard Agent")
# Add the ADK endpoint
add_adk_fastapi_endpoint(app, dashboard_agent, path="/")
if __name__ == "__main__":
import os
import uvicorn
if not os.getenv("GOOGLE_API_KEY"):
print("⚠️ Warning: GOOGLE_API_KEY environment variable not set!")
print(" Set it with: export GOOGLE_API_KEY='your-key-here'")
print(" Get a key from: https://makersuite.google.com/app/apikey")
print()
port = int(os.getenv("PORT", 8000))
should_reload = not (os.getenv("AGENT_RELOAD", "true").lower() == "false")
uvicorn.run("server:app", host="0.0.0.0", port=port, reload=should_reload)
@@ -0,0 +1,46 @@
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Union, Literal
from enum import Enum
# class IconType(str, Enum):
# users = "users"
# mrr = "mrr"
# conversion = "conversion"
# churn = "churn"
# custom = "custom"
# A single metric description in the dashboard spec, matching src/lib/types.ts
class Metric(BaseModel):
"""A single metric in the dashboard state, should always have a unique id."""
id: str
title: str
value: str
hint: Optional[str] = None
# icon: Optional[IconType] = None
# Flexible record for chart data rows: keys are column names, values are string or number
ChartDataRecord = Dict[str, Union[str, float, int]]
ChartDataMap = Dict[str, List[ChartDataRecord]]
class Chart(BaseModel):
"""A single chart description in the dashboard spec."""
type: Literal["line", "bar", "pie"] = Field(
description='Chart type: "line" | "bar" | "pie"'
)
title: str
x: Optional[str] = None
y: Optional[str] = None
data: List[ChartDataRecord] = Field(default_factory=list)
class Dashboard(BaseModel):
"""A dashboard spec matching the UI shape."""
title: str
pinnedMetrics: List[Metric] = Field(default_factory=list)
charts: List[Chart] = Field(default_factory=list)
@@ -0,0 +1,51 @@
# ADK imports
import os
from google.adk.tools import ToolContext, FunctionTool
from typing import List, Dict
# Local imports
from state import Metric
def add_pinned_metrics(
tool_context: ToolContext, new_pinned_metrics: List[Metric]
) -> Dict[str, str]:
"""
Add a list of new metrics to the dashboard.
Make sure the metrics all have a unique id.
"""
try:
current_metrics = tool_context.state.get("pinnedMetrics", [])
tool_context.state["pinnedMetrics"] = current_metrics + new_pinned_metrics
return {"status": "success", "message": "Pinned metrics set successfully"}
except Exception as e:
return {
"status": "error",
"message": f"Error updating pinned metrics: {str(e)}",
}
def update_pinned_metrics(
tool_context: ToolContext, updated_pinned_metrics: List[Metric]
) -> Dict[str, str]:
"""
Update a list of new metrics to the dashboard. Use for updates and removals. You must include the previously
pinned metrics in the list, the end result of this will be the complete list of pinned metrics.
"""
try:
tool_context.state["pinnedMetrics"] = updated_pinned_metrics
return {"status": "success", "message": "Pinned metrics set successfully"}
except Exception as e:
return {
"status": "error",
"message": f"Error updating pinned metrics: {str(e)}",
}
tools = [
# UI owns charts. Agent can provide pinned metrics and do searches.
FunctionTool(func=add_pinned_metrics),
FunctionTool(func=update_pinned_metrics),
]
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
@@ -0,0 +1,53 @@
{
"name": "adk-starter",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
"dev:debug": "LOG_LEVEL=debug npm run dev",
"dev:agent": "./scripts/run-agent.sh || scripts/run-agent.bat",
"dev:ui": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat",
"postinstall": "npm run install:agent"
},
"dependencies": {
"@ag-ui/client": "^0.0.38",
"@ai-sdk/openai": "^1.3.22",
"@copilotkit/react-core": "1.10.4",
"@copilotkit/react-ui": "1.10.4",
"@copilotkit/runtime": "1.10.4",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"flowtoken": "^1.0.40",
"geist": "^1.5.1",
"lucide-react": "^0.544.0",
"next": "15.5.15",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.4",
"tailwind-merge": "^3.3.1",
"zod": "^3.24.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.8",
"typescript": "^5"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,9 @@
@echo off
REM Navigate to the agent directory
cd /d %~dp0\..\agent
REM Activate the virtual environment
call .venv\Scripts\activate.bat
REM Run the agent
.venv\Scripts\python.exe server.py
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Navigate to the agent directory
cd "$(dirname "$0")/../agent" || exit 1
# Activate the virtual environment
source .venv/bin/activate
# Run the agent
.venv/bin/python server.py
@@ -0,0 +1,14 @@
@echo off
REM Navigate to the agent directory
cd /d "%~dp0\..\agent" || exit /b 1
REM Create virtual environment if it doesn't exist
if not exist ".venv" (
python -m venv .venv
)
REM Activate the virtual environment
call .venv\Scripts\activate.bat
REM Install requirements using pip
pip install -r requirements.txt
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Navigate to the agent directory
cd "$(dirname "$0")/../agent" || exit 1
# Create virtual environment if it doesn't exist
if [ ! -d ".venv" ]; then
python3 -m venv .venv || python -m venv .venv
fi
# Activate the virtual environment
source .venv/bin/activate
# Install requirements using pip3 or pip
(pip3 install -r requirements.txt || pip install -r requirements.txt)

Some files were not shown because too many files have changed in this diff Show More