mirror of
https://github.com/Shubhamsaboo/awesome-llm-apps.git
synced 2026-03-11 17:48:31 -05:00
Merge pull request #481 from Shubhamsaboo/ai-negotiation-simulator
Add New Multi-Agent App (AI Negotiation Similator)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Backend (Python)
|
||||
GOOGLE_API_KEY="your_google_api_key_here"
|
||||
|
||||
# Frontend (Next.js) - optional, defaults to localhost:8000
|
||||
AGENT_URL=http://localhost:8000/
|
||||
@@ -0,0 +1,216 @@
|
||||
# 🎮 AI Negotiation Battle Simulator
|
||||
|
||||
### A Real-Time Agent vs Agent Showdown with AG-UI!
|
||||
|
||||
Watch two AI agents battle it out in an epic used car negotiation! Built with **Google ADK** for the backend agents and **AG-UI + CopilotKit** for a jaw-dropping reactive frontend.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🤖 Dual AI Agents**: Buyer vs Seller with distinct personalities and negotiation strategies
|
||||
- **🔄 AG-UI Protocol**: Real-time streaming of agent actions, tool calls, and state changes
|
||||
- **💅 Jaw-Dropping UI**: Animated battle arena with live negotiation timeline
|
||||
- **🎭 8 Unique Personalities**: 4 buyers + 4 sellers with different negotiation styles
|
||||
- **📊 Generative UI**: Custom React components render tool calls in real-time
|
||||
- **🔗 Shared State**: Agent state syncs bidirectionally with the frontend
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Next.js + CopilotKit Frontend │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||
│ │ Battle Arena│ │ VS Display │ │Chat Sidebar │ │
|
||||
│ │ Timeline │ │ Buyer/Seller │ │ (AG-UI) │ │
|
||||
│ └──────┬──────┘ └──────────────┘ └──────┬──────┘ │
|
||||
└──────────┼────────────────────────────────────────┼─────────────┘
|
||||
│ AG-UI Events │
|
||||
└────────────────────┬───────────────────┘
|
||||
│
|
||||
┌───────────▼───────────┐
|
||||
│ CopilotKit Runtime │
|
||||
│ (/api/copilotkit) │
|
||||
└───────────┬───────────┘
|
||||
│ HTTP/SSE
|
||||
┌───────────▼───────────┐
|
||||
│ FastAPI + AG-UI │
|
||||
│ ADK Middleware │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌───────────▼─────────────┐
|
||||
│ ADK Negotiation Agent │
|
||||
│ (Battle Master) │
|
||||
│ │
|
||||
│ Tools: │
|
||||
│ • configure_negotiation│
|
||||
│ • start_negotiation │
|
||||
│ • buyer_make_offer │
|
||||
│ • seller_respond │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- Google AI API Key ([Get one here](https://aistudio.google.com/))
|
||||
|
||||
### 1. Clone and Navigate
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Shubhamsaboo/awesome-llm-apps.git
|
||||
cd advanced_ai_agents/multi_agent_apps/ai_negotiation_battle_simulator
|
||||
```
|
||||
|
||||
### 2. Set Up Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create .env file
|
||||
echo "GOOGLE_API_KEY=your_api_key_here" > .env
|
||||
|
||||
# Start the backend
|
||||
python agent.py
|
||||
```
|
||||
|
||||
The backend will start on `http://localhost:8000`
|
||||
|
||||
### 3. Set Up Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# Start the frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will start on `http://localhost:3000`
|
||||
|
||||
### 4. Start Negotiating! 🎮
|
||||
|
||||
Open `http://localhost:3000` and tell the Battle Master:
|
||||
- "Start a negotiation for a used car"
|
||||
- "Show me available scenarios"
|
||||
- "Use Desperate Dan as buyer and Shark Steve as seller"
|
||||
|
||||
## 🎭 Personalities
|
||||
|
||||
### Buyers
|
||||
| Personality | Emoji | Style |
|
||||
|-------------|-------|-------|
|
||||
| Desperate Dan | 😰 | Needs car TODAY, weak poker face |
|
||||
| Analytical Alex | 🧮 | Cites every data point, very logical |
|
||||
| Cool-Hand Casey | 😎 | Master of the walkaway bluff |
|
||||
| Fair-Deal Fran | 🤝 | Just wants a win-win |
|
||||
|
||||
### Sellers
|
||||
| Personality | Emoji | Style |
|
||||
|-------------|-------|-------|
|
||||
| Shark Steve | 🦈 | Never drops more than 5% |
|
||||
| By-The-Book Beth | 📊 | Goes strictly by KBB |
|
||||
| Motivated Mike | 😅 | Really needs to sell |
|
||||
| Drama Queen Diana | 🎭 | Everything is "final offer" |
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
ai_negotiation_battle_simulator/
|
||||
├── README.md
|
||||
├── .env.example
|
||||
│
|
||||
├── backend/ # Python ADK + AG-UI
|
||||
│ ├── agent.py # Main agent with tools
|
||||
│ ├── requirements.txt
|
||||
│ ├── config/
|
||||
│ │ ├── personalities.py # 8 unique personalities
|
||||
│ │ └── scenarios.py # 3 negotiation scenarios
|
||||
│ └── agents/
|
||||
│ ├── buyer_agent.py
|
||||
│ ├── seller_agent.py
|
||||
│ └── orchestrator.py
|
||||
│
|
||||
└── frontend/ # Next.js + CopilotKit
|
||||
├── package.json
|
||||
├── src/
|
||||
│ └── app/
|
||||
│ ├── layout.tsx # CopilotKit provider
|
||||
│ ├── page.tsx # Battle Arena UI
|
||||
│ ├── globals.css # Battle animations
|
||||
│ └── api/
|
||||
│ └── copilotkit/
|
||||
│ └── route.ts # CopilotKit runtime
|
||||
└── tailwind.config.js
|
||||
```
|
||||
|
||||
## 🎬 Sample Battle
|
||||
|
||||
```
|
||||
🔔 NEGOTIATION BEGINS: 2019 Honda Civic EX
|
||||
|
||||
📋 ASKING PRICE: $15,500
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
😎 COOL-HAND CASEY (Round 1):
|
||||
"I've seen similar Civics go for less. $11,500 seems fair
|
||||
given the market. Cash in hand today."
|
||||
|
||||
🦈 SHARK STEVE (Round 1):
|
||||
"$15,000. This car is pristine. I've got two other
|
||||
interested buyers coming this weekend."
|
||||
|
||||
😎 COOL-HAND CASEY (Round 2):
|
||||
"$12,500 is my limit. Take it or I walk."
|
||||
|
||||
🦈 SHARK STEVE (Round 2):
|
||||
*considers* "$14,000. Final offer."
|
||||
|
||||
😎 COOL-HAND CASEY (Round 3):
|
||||
"$13,000. Meet me in the middle."
|
||||
|
||||
🦈 SHARK STEVE (Round 3):
|
||||
"...$13,500 and you've got a deal."
|
||||
|
||||
😎 COOL-HAND CASEY (Round 4):
|
||||
"$13,250. Final answer."
|
||||
|
||||
🦈 SHARK STEVE (Round 4):
|
||||
"Deal. 🤝"
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
✅ DEAL CLOSED AT $13,250 🎉
|
||||
Buyer saved: $2,250 (14.5% off asking)
|
||||
```
|
||||
|
||||
## 🧠 How It Works
|
||||
|
||||
1. **User Request**: You tell the Battle Master what kind of negotiation to run
|
||||
2. **Configuration**: The agent sets up the scenario and personalities
|
||||
3. **Tool Calls**: The agent alternates between `buyer_make_offer` and `seller_respond` tools
|
||||
4. **AG-UI Streaming**: Each tool call streams to the frontend via AG-UI protocol
|
||||
5. **Generative UI**: Custom React components render each offer/response beautifully
|
||||
6. **Shared State**: The negotiation timeline updates in real-time
|
||||
7. **Outcome**: Deal or no-deal is celebrated with animations!
|
||||
|
||||
## 📚 Learn More
|
||||
|
||||
- [Google ADK Documentation](https://google.github.io/adk-docs/)
|
||||
- [AG-UI Protocol Docs](https://docs.ag-ui.com/)
|
||||
- [CopilotKit Documentation](https://docs.copilotkit.ai/)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Feel free to add:
|
||||
- New negotiation scenarios (salary, apartment, contracts)
|
||||
- Additional personality types
|
||||
- More dramatic UI effects
|
||||
- Cross-framework agents (LangChain, CrewAI via A2A)
|
||||
|
||||
---
|
||||
|
||||
*May the best negotiator win!* 🏆
|
||||
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
AI Negotiation Battle Simulator - Backend Agent
|
||||
|
||||
This module creates an ADK agent wrapped with AG-UI middleware for
|
||||
real-time negotiation between AI buyer and seller agents.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.tools import FunctionTool, ToolContext
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint
|
||||
|
||||
from config.scenarios import SCENARIOS, get_scenario
|
||||
from config.personalities import (
|
||||
BUYER_PERSONALITIES,
|
||||
SELLER_PERSONALITIES,
|
||||
get_personality_prompt
|
||||
)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NEGOTIATION STATE (shared between tools)
|
||||
# ============================================================================
|
||||
|
||||
class NegotiationState:
|
||||
"""Tracks the state of the negotiation."""
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.scenario_id = "craigslist_civic"
|
||||
self.buyer_personality = "cool_hand_casey"
|
||||
self.seller_personality = "by_the_book_beth"
|
||||
self.rounds = []
|
||||
self.current_round = 0
|
||||
self.status = "setup" # setup, negotiating, deal, no_deal
|
||||
self.final_price = None
|
||||
self.buyer_budget = 13000
|
||||
self.seller_minimum = 12500
|
||||
self.asking_price = 15500
|
||||
|
||||
# Global state
|
||||
negotiation_state = NegotiationState()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NEGOTIATION TOOLS
|
||||
# ============================================================================
|
||||
|
||||
def configure_negotiation(
|
||||
scenario_id: str = "craigslist_civic",
|
||||
buyer_personality: str = "cool_hand_casey",
|
||||
seller_personality: str = "by_the_book_beth",
|
||||
tool_context: Optional[ToolContext] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Configure the negotiation scenario and personalities.
|
||||
|
||||
Args:
|
||||
scenario_id: The scenario to use (craigslist_civic, vintage_guitar, apartment_sublet)
|
||||
buyer_personality: Buyer's personality (desperate_dan, analytical_alex, cool_hand_casey, fair_deal_fran)
|
||||
seller_personality: Seller's personality (shark_steve, by_the_book_beth, motivated_mike, drama_queen_diana)
|
||||
|
||||
Returns:
|
||||
Configuration summary
|
||||
"""
|
||||
scenario = get_scenario(scenario_id)
|
||||
|
||||
negotiation_state.reset()
|
||||
negotiation_state.scenario_id = scenario_id
|
||||
negotiation_state.buyer_personality = buyer_personality
|
||||
negotiation_state.seller_personality = seller_personality
|
||||
negotiation_state.asking_price = scenario["asking_price"]
|
||||
negotiation_state.buyer_budget = scenario["buyer_budget"]
|
||||
negotiation_state.seller_minimum = scenario["seller_minimum"]
|
||||
negotiation_state.status = "ready"
|
||||
|
||||
buyer_p = BUYER_PERSONALITIES[buyer_personality]
|
||||
seller_p = SELLER_PERSONALITIES[seller_personality]
|
||||
|
||||
return {
|
||||
"status": "configured",
|
||||
"scenario": {
|
||||
"title": scenario["title"],
|
||||
"item": scenario["item"]["name"],
|
||||
"asking_price": scenario["asking_price"],
|
||||
"fair_market_value": scenario["fair_market_value"]
|
||||
},
|
||||
"buyer": {
|
||||
"name": buyer_p["name"],
|
||||
"emoji": buyer_p["emoji"],
|
||||
"budget": scenario["buyer_budget"]
|
||||
},
|
||||
"seller": {
|
||||
"name": seller_p["name"],
|
||||
"emoji": seller_p["emoji"],
|
||||
"minimum": scenario["seller_minimum"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def start_negotiation(tool_context: Optional[ToolContext] = None) -> dict:
|
||||
"""
|
||||
Start the negotiation battle!
|
||||
|
||||
Returns:
|
||||
Initial negotiation state and instructions
|
||||
"""
|
||||
if negotiation_state.status != "ready":
|
||||
return {"error": "Please configure the negotiation first using configure_negotiation"}
|
||||
|
||||
scenario = get_scenario(negotiation_state.scenario_id)
|
||||
negotiation_state.status = "negotiating"
|
||||
negotiation_state.current_round = 1
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"round": 1,
|
||||
"scenario": scenario["title"],
|
||||
"item": scenario["item"]["name"],
|
||||
"asking_price": scenario["asking_price"],
|
||||
"message": f"🔔 NEGOTIATION BEGINS! The battle for the {scenario['item']['name']} is ON!"
|
||||
}
|
||||
|
||||
|
||||
def buyer_make_offer(
|
||||
offer_amount: int,
|
||||
message: str,
|
||||
reasoning: str = "",
|
||||
tool_context: Optional[ToolContext] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Buyer makes an offer to the seller.
|
||||
|
||||
Args:
|
||||
offer_amount: The dollar amount being offered
|
||||
message: What the buyer says to the seller
|
||||
reasoning: Internal reasoning for this offer
|
||||
|
||||
Returns:
|
||||
Offer details and status
|
||||
"""
|
||||
if negotiation_state.status != "negotiating":
|
||||
return {"error": "Negotiation not in progress"}
|
||||
|
||||
scenario = get_scenario(negotiation_state.scenario_id)
|
||||
buyer_p = BUYER_PERSONALITIES[negotiation_state.buyer_personality]
|
||||
|
||||
round_data = {
|
||||
"round": negotiation_state.current_round,
|
||||
"type": "buyer_offer",
|
||||
"offer_amount": offer_amount,
|
||||
"message": message,
|
||||
"reasoning": reasoning,
|
||||
"buyer_name": buyer_p["name"],
|
||||
"buyer_emoji": buyer_p["emoji"]
|
||||
}
|
||||
|
||||
negotiation_state.rounds.append(round_data)
|
||||
|
||||
return {
|
||||
"status": "offer_made",
|
||||
"round": negotiation_state.current_round,
|
||||
"offer": offer_amount,
|
||||
"message": message,
|
||||
"buyer": f"{buyer_p['emoji']} {buyer_p['name']}",
|
||||
"percent_of_asking": round(offer_amount / scenario["asking_price"] * 100, 1)
|
||||
}
|
||||
|
||||
|
||||
def seller_respond(
|
||||
action: str,
|
||||
counter_amount: Optional[int] = None,
|
||||
message: str = "",
|
||||
reasoning: str = "",
|
||||
tool_context: Optional[ToolContext] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Seller responds to the buyer's offer.
|
||||
|
||||
Args:
|
||||
action: One of 'accept', 'counter', 'reject', 'walk'
|
||||
counter_amount: If countering, the counter offer amount
|
||||
message: What the seller says to the buyer
|
||||
reasoning: Internal reasoning for this decision
|
||||
|
||||
Returns:
|
||||
Response details and updated negotiation status
|
||||
"""
|
||||
if negotiation_state.status != "negotiating":
|
||||
return {"error": "Negotiation not in progress"}
|
||||
|
||||
scenario = get_scenario(negotiation_state.scenario_id)
|
||||
seller_p = SELLER_PERSONALITIES[negotiation_state.seller_personality]
|
||||
|
||||
round_data = {
|
||||
"round": negotiation_state.current_round,
|
||||
"type": "seller_response",
|
||||
"action": action,
|
||||
"counter_amount": counter_amount,
|
||||
"message": message,
|
||||
"reasoning": reasoning,
|
||||
"seller_name": seller_p["name"],
|
||||
"seller_emoji": seller_p["emoji"]
|
||||
}
|
||||
|
||||
negotiation_state.rounds.append(round_data)
|
||||
|
||||
result = {
|
||||
"status": "response_given",
|
||||
"round": negotiation_state.current_round,
|
||||
"action": action,
|
||||
"message": message,
|
||||
"seller": f"{seller_p['emoji']} {seller_p['name']}"
|
||||
}
|
||||
|
||||
if action == "accept":
|
||||
# Get the last buyer offer
|
||||
last_offer = None
|
||||
for r in reversed(negotiation_state.rounds):
|
||||
if r["type"] == "buyer_offer":
|
||||
last_offer = r["offer_amount"]
|
||||
break
|
||||
|
||||
negotiation_state.status = "deal"
|
||||
negotiation_state.final_price = last_offer
|
||||
|
||||
result["outcome"] = "DEAL"
|
||||
result["final_price"] = last_offer
|
||||
result["savings"] = scenario["asking_price"] - last_offer
|
||||
result["percent_off"] = round((scenario["asking_price"] - last_offer) / scenario["asking_price"] * 100, 1)
|
||||
|
||||
elif action == "walk":
|
||||
negotiation_state.status = "no_deal"
|
||||
result["outcome"] = "SELLER_WALKED"
|
||||
|
||||
elif action == "counter":
|
||||
result["counter_amount"] = counter_amount
|
||||
negotiation_state.current_round += 1
|
||||
|
||||
else: # reject
|
||||
negotiation_state.current_round += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_negotiation_state(tool_context: Optional[ToolContext] = None) -> dict:
|
||||
"""
|
||||
Get the current state of the negotiation.
|
||||
|
||||
Returns:
|
||||
Full negotiation state including history
|
||||
"""
|
||||
scenario = get_scenario(negotiation_state.scenario_id)
|
||||
buyer_p = BUYER_PERSONALITIES[negotiation_state.buyer_personality]
|
||||
seller_p = SELLER_PERSONALITIES[negotiation_state.seller_personality]
|
||||
|
||||
return {
|
||||
"status": negotiation_state.status,
|
||||
"scenario": scenario["title"],
|
||||
"item": scenario["item"]["name"],
|
||||
"asking_price": scenario["asking_price"],
|
||||
"current_round": negotiation_state.current_round,
|
||||
"buyer": {
|
||||
"name": buyer_p["name"],
|
||||
"emoji": buyer_p["emoji"],
|
||||
"budget": negotiation_state.buyer_budget
|
||||
},
|
||||
"seller": {
|
||||
"name": seller_p["name"],
|
||||
"emoji": seller_p["emoji"],
|
||||
"minimum": negotiation_state.seller_minimum
|
||||
},
|
||||
"rounds": negotiation_state.rounds,
|
||||
"final_price": negotiation_state.final_price
|
||||
}
|
||||
|
||||
|
||||
def get_available_scenarios(tool_context: Optional[ToolContext] = None) -> dict:
|
||||
"""
|
||||
Get all available negotiation scenarios.
|
||||
|
||||
Returns:
|
||||
List of available scenarios with details
|
||||
"""
|
||||
scenarios = []
|
||||
for key, s in SCENARIOS.items():
|
||||
scenarios.append({
|
||||
"id": key,
|
||||
"title": s["title"],
|
||||
"emoji": s["emoji"],
|
||||
"description": s["description"],
|
||||
"item": s["item"]["name"],
|
||||
"asking_price": s["asking_price"]
|
||||
})
|
||||
return {"scenarios": scenarios}
|
||||
|
||||
|
||||
def get_available_personalities(tool_context: Optional[ToolContext] = None) -> dict:
|
||||
"""
|
||||
Get all available buyer and seller personalities.
|
||||
|
||||
Returns:
|
||||
Available personalities for both buyer and seller
|
||||
"""
|
||||
buyers = []
|
||||
for key, p in BUYER_PERSONALITIES.items():
|
||||
buyers.append({
|
||||
"id": key,
|
||||
"name": p["name"],
|
||||
"emoji": p["emoji"],
|
||||
"description": p["description"]
|
||||
})
|
||||
|
||||
sellers = []
|
||||
for key, p in SELLER_PERSONALITIES.items():
|
||||
sellers.append({
|
||||
"id": key,
|
||||
"name": p["name"],
|
||||
"emoji": p["emoji"],
|
||||
"description": p["description"]
|
||||
})
|
||||
|
||||
return {"buyers": buyers, "sellers": sellers}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADK AGENT DEFINITION
|
||||
# ============================================================================
|
||||
|
||||
negotiation_agent = LlmAgent(
|
||||
name="NegotiationBattleAgent",
|
||||
model="gemini-3-flash-preview",
|
||||
description="AI Negotiation Battle Simulator - orchestrates dramatic negotiations between buyer and seller agents",
|
||||
instruction="""
|
||||
You are the NEGOTIATION BATTLE MASTER! 🎮 You orchestrate epic negotiations between AI buyer and seller agents.
|
||||
|
||||
YOUR ROLE:
|
||||
You manage a negotiation battle where a Buyer agent and a Seller agent negotiate over an item.
|
||||
You play BOTH roles, switching between them to create a dramatic back-and-forth negotiation.
|
||||
|
||||
AVAILABLE TOOLS:
|
||||
- get_available_scenarios: See what scenarios are available
|
||||
- get_available_personalities: See buyer/seller personality options
|
||||
- configure_negotiation: Set up the scenario and personalities
|
||||
- start_negotiation: Begin the battle!
|
||||
- buyer_make_offer: Make an offer as the buyer
|
||||
- seller_respond: Respond as the seller (accept/counter/reject/walk)
|
||||
- get_negotiation_state: Check current negotiation status
|
||||
|
||||
HOW TO RUN A NEGOTIATION:
|
||||
1. First, use configure_negotiation to set up the scenario and personalities
|
||||
2. Use start_negotiation to begin
|
||||
3. Alternate between buyer_make_offer and seller_respond
|
||||
4. Play each role authentically based on their personality!
|
||||
5. Continue until a deal is reached or someone walks away
|
||||
|
||||
PERSONALITY GUIDELINES:
|
||||
- Each personality has distinct traits - embody them fully!
|
||||
- Buyers have budgets they shouldn't exceed
|
||||
- Sellers have minimums they won't go below
|
||||
- Create DRAMA and tension in the negotiation
|
||||
- Make the dialogue feel real and entertaining
|
||||
|
||||
IMPORTANT:
|
||||
- When the user asks to start a negotiation, first show them the options
|
||||
- Let them pick scenario and personalities, or use defaults
|
||||
- Once configured, run the full negotiation automatically
|
||||
- Provide colorful commentary between rounds
|
||||
- Celebrate deals and mourn walkways dramatically!
|
||||
|
||||
Be entertaining, dramatic, and make this feel like a real negotiation showdown! 🎭
|
||||
""",
|
||||
tools=[
|
||||
FunctionTool(get_available_scenarios),
|
||||
FunctionTool(get_available_personalities),
|
||||
FunctionTool(configure_negotiation),
|
||||
FunctionTool(start_negotiation),
|
||||
FunctionTool(buyer_make_offer),
|
||||
FunctionTool(seller_respond),
|
||||
FunctionTool(get_negotiation_state),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AG-UI + FASTAPI SETUP
|
||||
# ============================================================================
|
||||
|
||||
# Create ADK middleware agent
|
||||
adk_negotiation_agent = ADKAgent(
|
||||
adk_agent=negotiation_agent,
|
||||
app_name="negotiation_battle",
|
||||
user_id="battle_user",
|
||||
session_timeout_seconds=3600,
|
||||
use_in_memory_services=True
|
||||
)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="AI Negotiation Battle Simulator",
|
||||
description="Watch AI agents battle it out in epic negotiations!",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Add CORS middleware for frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Add AG-UI endpoint
|
||||
add_adk_fastapi_endpoint(app, adk_negotiation_agent, path="/")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
if not os.getenv("GOOGLE_API_KEY"):
|
||||
print("⚠️ Warning: GOOGLE_API_KEY not set!")
|
||||
print(" Get your key from: https://aistudio.google.com/")
|
||||
print()
|
||||
|
||||
port = int(os.getenv("PORT", 8000))
|
||||
print(f"🎮 AI Negotiation Battle Simulator starting on port {port}")
|
||||
print(f" AG-UI endpoint: http://localhost:{port}/")
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Negotiation agents module."""
|
||||
|
||||
from .buyer_agent import create_buyer_agent
|
||||
from .seller_agent import create_seller_agent
|
||||
from .orchestrator import NegotiationOrchestrator
|
||||
|
||||
__all__ = [
|
||||
"create_buyer_agent",
|
||||
"create_seller_agent",
|
||||
"NegotiationOrchestrator",
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Buyer agent for the negotiation battle simulator."""
|
||||
|
||||
from google.adk.agents import LlmAgent
|
||||
|
||||
|
||||
def create_buyer_agent(
|
||||
scenario: dict,
|
||||
personality_prompt: str = "",
|
||||
model: str = "gemini-3-flash-preview"
|
||||
) -> LlmAgent:
|
||||
"""Create a buyer agent configured for a specific negotiation scenario.
|
||||
|
||||
Args:
|
||||
scenario: The negotiation scenario configuration
|
||||
personality_prompt: Optional personality traits to inject
|
||||
model: The LLM model to use
|
||||
|
||||
Returns:
|
||||
Configured LlmAgent for the buyer role
|
||||
"""
|
||||
|
||||
item = scenario["item"]
|
||||
|
||||
base_instruction = f"""You are a BUYER in a negotiation for a {item['name']}.
|
||||
|
||||
=== THE SITUATION ===
|
||||
{scenario['buyer_context']}
|
||||
|
||||
=== WHAT YOU'RE BUYING ===
|
||||
Item: {item['name']}
|
||||
Asking Price: ${scenario['asking_price']:,}
|
||||
Your Budget: ${scenario['buyer_budget']:,}
|
||||
Fair Market Value: ~${scenario['fair_market_value']:,}
|
||||
|
||||
Positive aspects:
|
||||
{chr(10).join(f' + {p}' for p in item.get('positives', []))}
|
||||
|
||||
Issues you can leverage:
|
||||
{chr(10).join(f' - {i}' for i in item.get('issues', []))}
|
||||
|
||||
=== YOUR STAKES ===
|
||||
{scenario['buyer_stakes']}
|
||||
|
||||
=== YOUR SECRET (influences your behavior, but never state directly) ===
|
||||
{scenario['buyer_secret']}
|
||||
|
||||
=== NEGOTIATION RULES ===
|
||||
1. NEVER exceed your budget of ${scenario['buyer_budget']:,} unless absolutely necessary
|
||||
2. Start lower than you're willing to pay - leave room to negotiate
|
||||
3. Use the item's issues to justify lower offers
|
||||
4. Stay in character throughout
|
||||
5. React authentically to the seller's counteroffers
|
||||
6. Know when to walk away (you have other options)
|
||||
|
||||
=== YOUR GOAL ===
|
||||
Get the {item['name']} for the best possible price, ideally under ${scenario['fair_market_value']:,}.
|
||||
But you really want this item, so find the balance between value and closing the deal.
|
||||
|
||||
{personality_prompt}
|
||||
|
||||
=== RESPONSE FORMAT ===
|
||||
When making an offer, respond with a JSON object like this:
|
||||
{{
|
||||
"offer_amount": 12000,
|
||||
"message": "What you say to the seller - be in character, conversational!",
|
||||
"reasoning": "Brief internal reasoning for this offer",
|
||||
"confidence": 7,
|
||||
"willing_to_walk": false
|
||||
}}
|
||||
|
||||
Always respond with valid JSON. Make your message sound natural and in-character!
|
||||
"""
|
||||
|
||||
return LlmAgent(
|
||||
name="buyer_agent",
|
||||
model=model,
|
||||
description=f"Buyer negotiating for {item['name']}",
|
||||
instruction=base_instruction,
|
||||
)
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Orchestrator for managing negotiation flow between buyer and seller agents."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Literal, Generator
|
||||
from datetime import datetime
|
||||
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.runners import InMemoryRunner
|
||||
from google.genai import types
|
||||
|
||||
from .buyer_agent import create_buyer_agent
|
||||
from .seller_agent import create_seller_agent
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NEGOTIATION STATE
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class NegotiationRound:
|
||||
"""A single round of negotiation."""
|
||||
round_number: int
|
||||
buyer_offer: Optional[int] = None
|
||||
buyer_message: str = ""
|
||||
buyer_reasoning: str = ""
|
||||
seller_action: Optional[str] = None # "counter", "accept", "reject", "walk"
|
||||
seller_counter: Optional[int] = None
|
||||
seller_message: str = ""
|
||||
seller_reasoning: str = ""
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NegotiationState:
|
||||
"""Full state of the negotiation."""
|
||||
scenario_id: str
|
||||
status: Literal["ongoing", "deal", "buyer_walked", "seller_walked", "no_deal"] = "ongoing"
|
||||
asking_price: int = 0
|
||||
current_offer: int = 0
|
||||
rounds: list[NegotiationRound] = field(default_factory=list)
|
||||
final_price: Optional[int] = None
|
||||
max_rounds: int = 10
|
||||
|
||||
@property
|
||||
def round_count(self) -> int:
|
||||
return len(self.rounds)
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
return self.status != "ongoing"
|
||||
|
||||
def get_negotiation_history(self) -> str:
|
||||
"""Format the negotiation history for agent context."""
|
||||
if not self.rounds:
|
||||
return "No offers yet. This is the opening round."
|
||||
|
||||
lines = []
|
||||
for r in self.rounds:
|
||||
lines.append(f"Round {r.round_number}:")
|
||||
if r.buyer_offer:
|
||||
lines.append(f" Buyer offered: ${r.buyer_offer:,}")
|
||||
lines.append(f" Buyer said: \"{r.buyer_message}\"")
|
||||
if r.seller_action:
|
||||
if r.seller_action == "accept":
|
||||
lines.append(f" Seller ACCEPTED!")
|
||||
elif r.seller_action == "counter" and r.seller_counter:
|
||||
lines.append(f" Seller countered: ${r.seller_counter:,}")
|
||||
elif r.seller_action == "reject":
|
||||
lines.append(f" Seller rejected the offer")
|
||||
elif r.seller_action == "walk":
|
||||
lines.append(f" Seller walked away!")
|
||||
if r.seller_message:
|
||||
lines.append(f" Seller said: \"{r.seller_message}\"")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RESPONSE PARSERS
|
||||
# ============================================================================
|
||||
|
||||
def parse_buyer_response(response_text: str) -> dict:
|
||||
"""Parse buyer agent response into structured data."""
|
||||
# Try to extract JSON from the response
|
||||
try:
|
||||
# Look for JSON in the response
|
||||
if "{" in response_text and "}" in response_text:
|
||||
start = response_text.find("{")
|
||||
end = response_text.rfind("}") + 1
|
||||
json_str = response_text[start:end]
|
||||
data = json.loads(json_str)
|
||||
return {
|
||||
"offer_amount": data.get("offer_amount", 0),
|
||||
"message": data.get("message", ""),
|
||||
"reasoning": data.get("reasoning", ""),
|
||||
"confidence": data.get("confidence", 5),
|
||||
"willing_to_walk": data.get("willing_to_walk", False)
|
||||
}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Fallback: extract offer from text
|
||||
import re
|
||||
amount_match = re.search(r'\$?([\d,]+)', response_text)
|
||||
offer = int(amount_match.group(1).replace(",", "")) if amount_match else 0
|
||||
|
||||
return {
|
||||
"offer_amount": offer,
|
||||
"message": response_text[:500],
|
||||
"reasoning": "Extracted from response",
|
||||
"confidence": 5,
|
||||
"willing_to_walk": False
|
||||
}
|
||||
|
||||
|
||||
def parse_seller_response(response_text: str) -> dict:
|
||||
"""Parse seller agent response into structured data."""
|
||||
try:
|
||||
if "{" in response_text and "}" in response_text:
|
||||
start = response_text.find("{")
|
||||
end = response_text.rfind("}") + 1
|
||||
json_str = response_text[start:end]
|
||||
data = json.loads(json_str)
|
||||
return {
|
||||
"action": data.get("action", "counter").lower(),
|
||||
"counter_amount": data.get("counter_amount"),
|
||||
"message": data.get("message", ""),
|
||||
"reasoning": data.get("reasoning", ""),
|
||||
"firmness": data.get("firmness", 5)
|
||||
}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Fallback parsing
|
||||
response_lower = response_text.lower()
|
||||
action = "counter"
|
||||
if "accept" in response_lower or "deal" in response_lower:
|
||||
action = "accept"
|
||||
elif "walk" in response_lower or "goodbye" in response_lower:
|
||||
action = "walk"
|
||||
elif "reject" in response_lower:
|
||||
action = "reject"
|
||||
|
||||
import re
|
||||
amount_match = re.search(r'\$?([\d,]+)', response_text)
|
||||
counter = int(amount_match.group(1).replace(",", "")) if amount_match else None
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"counter_amount": counter,
|
||||
"message": response_text[:500],
|
||||
"reasoning": "Extracted from response",
|
||||
"firmness": 5
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NEGOTIATION ORCHESTRATOR
|
||||
# ============================================================================
|
||||
|
||||
class NegotiationOrchestrator:
|
||||
"""Manages the negotiation between buyer and seller agents."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scenario: dict,
|
||||
buyer_personality: str = "",
|
||||
seller_personality: str = "",
|
||||
max_rounds: int = 10,
|
||||
model: str = "gemini-3-flash-preview"
|
||||
):
|
||||
self.scenario = scenario
|
||||
self.model = model
|
||||
self.max_rounds = max_rounds
|
||||
|
||||
# Create agents
|
||||
self.buyer_agent = create_buyer_agent(scenario, buyer_personality, model)
|
||||
self.seller_agent = create_seller_agent(scenario, seller_personality, model)
|
||||
|
||||
# Create runners
|
||||
self.buyer_runner = InMemoryRunner(
|
||||
agent=self.buyer_agent,
|
||||
app_name="negotiation_buyer"
|
||||
)
|
||||
self.seller_runner = InMemoryRunner(
|
||||
agent=self.seller_agent,
|
||||
app_name="negotiation_seller"
|
||||
)
|
||||
|
||||
# Session IDs
|
||||
self.buyer_session_id = "buyer_session"
|
||||
self.seller_session_id = "seller_session"
|
||||
|
||||
# Initialize state
|
||||
self.state = NegotiationState(
|
||||
scenario_id=scenario["id"],
|
||||
asking_price=scenario["asking_price"],
|
||||
max_rounds=max_rounds
|
||||
)
|
||||
|
||||
async def _run_agent(self, runner: InMemoryRunner, session_id: str, prompt: str) -> str:
|
||||
"""Run an agent and return its response."""
|
||||
user_content = types.Content(
|
||||
role='user',
|
||||
parts=[types.Part(text=prompt)]
|
||||
)
|
||||
|
||||
response_text = ""
|
||||
async for event in runner.run_async(
|
||||
user_id="negotiation",
|
||||
session_id=session_id,
|
||||
new_message=user_content
|
||||
):
|
||||
if event.is_final_response() and event.content:
|
||||
response_text = event.content.parts[0].text.strip()
|
||||
|
||||
return response_text
|
||||
|
||||
async def _get_buyer_offer(self, context: str) -> dict:
|
||||
"""Get an offer from the buyer agent."""
|
||||
prompt = f"""
|
||||
=== CURRENT NEGOTIATION STATUS ===
|
||||
Asking Price: ${self.state.asking_price:,}
|
||||
Rounds so far: {self.state.round_count}
|
||||
Max rounds before walkaway: {self.max_rounds}
|
||||
|
||||
=== HISTORY ===
|
||||
{self.state.get_negotiation_history()}
|
||||
|
||||
=== YOUR TURN ===
|
||||
{context}
|
||||
|
||||
Respond with a JSON object containing:
|
||||
- "offer_amount": your offer in dollars (integer)
|
||||
- "message": what you say to the seller (be in character!)
|
||||
- "reasoning": brief explanation of your strategy
|
||||
- "confidence": how confident you are 1-10
|
||||
- "willing_to_walk": true/false if you'd walk away if rejected
|
||||
"""
|
||||
|
||||
response = await self._run_agent(self.buyer_runner, self.buyer_session_id, prompt)
|
||||
return parse_buyer_response(response)
|
||||
|
||||
async def _get_seller_response(self, offer: int, buyer_message: str) -> dict:
|
||||
"""Get a response from the seller agent."""
|
||||
prompt = f"""
|
||||
=== CURRENT NEGOTIATION STATUS ===
|
||||
Your Asking Price: ${self.state.asking_price:,}
|
||||
Rounds so far: {self.state.round_count}
|
||||
Max rounds: {self.max_rounds}
|
||||
|
||||
=== HISTORY ===
|
||||
{self.state.get_negotiation_history()}
|
||||
|
||||
=== BUYER'S CURRENT OFFER ===
|
||||
Amount: ${offer:,}
|
||||
What they said: "{buyer_message}"
|
||||
|
||||
=== YOUR TURN ===
|
||||
Decide how to respond. You can ACCEPT, COUNTER, REJECT, or WALK.
|
||||
|
||||
Respond with a JSON object containing:
|
||||
- "action": one of "accept", "counter", "reject", "walk"
|
||||
- "counter_amount": if countering, your counter price (integer)
|
||||
- "message": what you say to the buyer (be in character!)
|
||||
- "reasoning": brief explanation of your decision
|
||||
- "firmness": how firm you are on this position 1-10
|
||||
"""
|
||||
|
||||
response = await self._run_agent(self.seller_runner, self.seller_session_id, prompt)
|
||||
return parse_seller_response(response)
|
||||
|
||||
def run_negotiation_sync(self) -> Generator[dict, None, None]:
|
||||
"""Run the full negotiation synchronously, yielding events.
|
||||
|
||||
Yields dicts with:
|
||||
- type: "start", "buyer_offer", "seller_response", "deal", "no_deal", "walk"
|
||||
- data: relevant data for the event
|
||||
"""
|
||||
|
||||
async def _run():
|
||||
events = []
|
||||
|
||||
events.append({
|
||||
"type": "start",
|
||||
"data": {
|
||||
"scenario": self.scenario["title"],
|
||||
"item": self.scenario["item"]["name"],
|
||||
"asking_price": self.state.asking_price,
|
||||
"max_rounds": self.max_rounds
|
||||
}
|
||||
})
|
||||
|
||||
# Initial context for buyer
|
||||
buyer_context = "Make your opening offer for this item. Start strong but leave room to negotiate."
|
||||
|
||||
while not self.state.is_complete and self.state.round_count < self.max_rounds:
|
||||
round_num = self.state.round_count + 1
|
||||
current_round = NegotiationRound(round_number=round_num)
|
||||
|
||||
# Get buyer's offer
|
||||
try:
|
||||
buyer_data = await self._get_buyer_offer(buyer_context)
|
||||
current_round.buyer_offer = buyer_data["offer_amount"]
|
||||
current_round.buyer_message = buyer_data["message"]
|
||||
current_round.buyer_reasoning = buyer_data["reasoning"]
|
||||
self.state.current_offer = buyer_data["offer_amount"]
|
||||
|
||||
events.append({
|
||||
"type": "buyer_offer",
|
||||
"data": {
|
||||
"round": round_num,
|
||||
"offer": buyer_data["offer_amount"],
|
||||
"message": buyer_data["message"],
|
||||
"reasoning": buyer_data["reasoning"],
|
||||
"confidence": buyer_data["confidence"],
|
||||
"willing_to_walk": buyer_data["willing_to_walk"]
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
events.append({"type": "error", "data": {"agent": "buyer", "error": str(e)}})
|
||||
break
|
||||
|
||||
# Get seller's response
|
||||
try:
|
||||
seller_data = await self._get_seller_response(
|
||||
buyer_data["offer_amount"],
|
||||
buyer_data["message"]
|
||||
)
|
||||
|
||||
current_round.seller_action = seller_data["action"]
|
||||
current_round.seller_message = seller_data["message"]
|
||||
current_round.seller_reasoning = seller_data["reasoning"]
|
||||
if seller_data["counter_amount"]:
|
||||
current_round.seller_counter = seller_data["counter_amount"]
|
||||
|
||||
events.append({
|
||||
"type": "seller_response",
|
||||
"data": {
|
||||
"round": round_num,
|
||||
"action": seller_data["action"],
|
||||
"counter": seller_data["counter_amount"],
|
||||
"message": seller_data["message"],
|
||||
"reasoning": seller_data["reasoning"],
|
||||
"firmness": seller_data["firmness"]
|
||||
}
|
||||
})
|
||||
|
||||
# Handle seller's decision
|
||||
if seller_data["action"] == "accept":
|
||||
self.state.status = "deal"
|
||||
self.state.final_price = buyer_data["offer_amount"]
|
||||
self.state.rounds.append(current_round)
|
||||
|
||||
events.append({
|
||||
"type": "deal",
|
||||
"data": {
|
||||
"final_price": buyer_data["offer_amount"],
|
||||
"rounds": round_num,
|
||||
"savings": self.state.asking_price - buyer_data["offer_amount"],
|
||||
"percent_off": round((self.state.asking_price - buyer_data["offer_amount"]) / self.state.asking_price * 100, 1)
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
elif seller_data["action"] == "walk":
|
||||
self.state.status = "seller_walked"
|
||||
self.state.rounds.append(current_round)
|
||||
|
||||
events.append({
|
||||
"type": "walk",
|
||||
"data": {
|
||||
"who": "seller",
|
||||
"round": round_num,
|
||||
"last_offer": buyer_data["offer_amount"]
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
elif seller_data["action"] == "reject":
|
||||
buyer_context = f"Your offer of ${buyer_data['offer_amount']:,} was rejected. The seller said: \"{seller_data['message']}\". Make a new offer or walk away."
|
||||
|
||||
else: # counter
|
||||
buyer_context = f"The seller countered with ${seller_data['counter_amount']:,}. They said: \"{seller_data['message']}\". Make your next move."
|
||||
|
||||
except Exception as e:
|
||||
events.append({"type": "error", "data": {"agent": "seller", "error": str(e)}})
|
||||
break
|
||||
|
||||
self.state.rounds.append(current_round)
|
||||
|
||||
# Max rounds reached
|
||||
if self.state.status == "ongoing":
|
||||
self.state.status = "no_deal"
|
||||
events.append({
|
||||
"type": "no_deal",
|
||||
"data": {
|
||||
"reason": "max_rounds",
|
||||
"rounds": self.state.round_count,
|
||||
"last_offer": self.state.current_offer
|
||||
}
|
||||
})
|
||||
|
||||
return events
|
||||
|
||||
# Run the async function and yield events
|
||||
events = asyncio.run(_run())
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""Get a summary of the negotiation."""
|
||||
return {
|
||||
"scenario": self.scenario["title"],
|
||||
"item": self.scenario["item"]["name"],
|
||||
"status": self.state.status,
|
||||
"asking_price": self.state.asking_price,
|
||||
"final_price": self.state.final_price,
|
||||
"rounds": self.state.round_count,
|
||||
"savings": (self.state.asking_price - self.state.final_price) if self.state.final_price else None
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Seller agent for the negotiation battle simulator."""
|
||||
|
||||
from google.adk.agents import LlmAgent
|
||||
|
||||
|
||||
def create_seller_agent(
|
||||
scenario: dict,
|
||||
personality_prompt: str = "",
|
||||
model: str = "gemini-3-flash-preview"
|
||||
) -> LlmAgent:
|
||||
"""Create a seller agent configured for a specific negotiation scenario.
|
||||
|
||||
Args:
|
||||
scenario: The negotiation scenario configuration
|
||||
personality_prompt: Optional personality traits to inject
|
||||
model: The LLM model to use
|
||||
|
||||
Returns:
|
||||
Configured LlmAgent for the seller role
|
||||
"""
|
||||
|
||||
item = scenario["item"]
|
||||
|
||||
base_instruction = f"""You are a SELLER in a negotiation for your {item['name']}.
|
||||
|
||||
=== THE SITUATION ===
|
||||
{scenario['seller_context']}
|
||||
|
||||
=== WHAT YOU'RE SELLING ===
|
||||
Item: {item['name']}
|
||||
Your Asking Price: ${scenario['asking_price']:,}
|
||||
Your Minimum (walk away below this): ${scenario['seller_minimum']:,}
|
||||
Fair Market Value: ~${scenario['fair_market_value']:,}
|
||||
|
||||
Why it's worth the price:
|
||||
{chr(10).join(f' + {p}' for p in item.get('positives', []))}
|
||||
|
||||
Issues you may need to address:
|
||||
{chr(10).join(f' - {i}' for i in item.get('issues', []))}
|
||||
|
||||
=== YOUR STAKES ===
|
||||
{scenario['seller_stakes']}
|
||||
|
||||
=== YOUR SECRET (influences your behavior, but never state directly) ===
|
||||
{scenario['seller_secret']}
|
||||
|
||||
=== NEGOTIATION RULES ===
|
||||
1. NEVER go below your minimum of ${scenario['seller_minimum']:,}
|
||||
2. Start firm - you've priced it fairly
|
||||
3. Counter lowball offers with smaller concessions
|
||||
4. Highlight the positives to justify your price
|
||||
5. Stay in character throughout
|
||||
6. Create urgency when appropriate ("I have other interested buyers")
|
||||
7. Know when to stand firm vs. when to close the deal
|
||||
|
||||
=== YOUR GOAL ===
|
||||
Sell the {item['name']} for the best possible price, ideally at or above ${scenario['fair_market_value']:,}.
|
||||
But you do need to sell, so find the balance between maximizing value and closing the deal.
|
||||
|
||||
{personality_prompt}
|
||||
|
||||
=== RESPONSE FORMAT ===
|
||||
When responding to an offer, respond with a JSON object like this:
|
||||
{{
|
||||
"action": "counter",
|
||||
"counter_amount": 14500,
|
||||
"message": "What you say to the buyer - be in character, conversational!",
|
||||
"reasoning": "Brief internal reasoning for this decision",
|
||||
"firmness": 7
|
||||
}}
|
||||
|
||||
For "action", use one of:
|
||||
- "accept" - You accept the offer (no counter_amount needed)
|
||||
- "counter" - You make a counteroffer (include counter_amount)
|
||||
- "reject" - You reject outright but don't walk away
|
||||
- "walk" - You're done negotiating
|
||||
|
||||
Always respond with valid JSON. Make your message sound natural and in-character!
|
||||
"""
|
||||
|
||||
return LlmAgent(
|
||||
name="seller_agent",
|
||||
model=model,
|
||||
description=f"Seller of {item['name']}",
|
||||
instruction=base_instruction,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
from .personalities import BUYER_PERSONALITIES, SELLER_PERSONALITIES
|
||||
from .scenarios import SCENARIOS, get_scenario
|
||||
|
||||
__all__ = [
|
||||
"BUYER_PERSONALITIES",
|
||||
"SELLER_PERSONALITIES",
|
||||
"SCENARIOS",
|
||||
"get_scenario",
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Agent personality configurations for the negotiation simulator."""
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class PersonalityConfig(TypedDict):
|
||||
"""Configuration for an agent personality."""
|
||||
name: str
|
||||
emoji: str
|
||||
description: str
|
||||
traits: list[str]
|
||||
opening_style: str
|
||||
concession_rate: str # How quickly they give ground
|
||||
walkaway_threshold: str # When they'll walk away
|
||||
secret_motivation: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BUYER PERSONALITIES
|
||||
# ============================================================================
|
||||
|
||||
BUYER_PERSONALITIES: dict[str, PersonalityConfig] = {
|
||||
"desperate_dan": {
|
||||
"name": "Desperate Dan",
|
||||
"emoji": "😰",
|
||||
"description": "Needs the car TODAY. Terrible poker face.",
|
||||
"traits": [
|
||||
"Reveals too much about urgency",
|
||||
"Makes emotional appeals",
|
||||
"Caves quickly under pressure",
|
||||
"Genuinely nice but easily manipulated"
|
||||
],
|
||||
"opening_style": "Start at 75% of asking, mention time pressure early",
|
||||
"concession_rate": "Fast - will increase offer by 5-8% each round",
|
||||
"walkaway_threshold": "Very high - will go up to 95% of budget before walking",
|
||||
"secret_motivation": "New job starts Monday, public transit is 2 hours each way"
|
||||
},
|
||||
|
||||
"analytical_alex": {
|
||||
"name": "Analytical Alex",
|
||||
"emoji": "🧮",
|
||||
"description": "Cites every data point. Very logical, somewhat robotic.",
|
||||
"traits": [
|
||||
"Quotes KBB, Edmunds, and market data constantly",
|
||||
"Breaks down value into itemized components",
|
||||
"Unemotional, focused on fair market value",
|
||||
"Respects logic, immune to emotional manipulation"
|
||||
],
|
||||
"opening_style": "Start at exactly market value minus depreciation factors",
|
||||
"concession_rate": "Slow and calculated - only moves when given new data",
|
||||
"walkaway_threshold": "Firm - walks if price exceeds data-backed value by 10%",
|
||||
"secret_motivation": "Has analyzed 47 similar listings, knows exact fair price"
|
||||
},
|
||||
|
||||
"cool_hand_casey": {
|
||||
"name": "Cool-Hand Casey",
|
||||
"emoji": "😎",
|
||||
"description": "Master of the walkaway bluff. Ice in their veins.",
|
||||
"traits": [
|
||||
"Never shows eagerness, always seems ready to leave",
|
||||
"Uses strategic silence",
|
||||
"Mentions other options constantly",
|
||||
"Extremely patient, will wait out the seller"
|
||||
],
|
||||
"opening_style": "Lowball at 65% of asking, seem indifferent",
|
||||
"concession_rate": "Glacial - small moves only after long pauses",
|
||||
"walkaway_threshold": "Will actually walk at fair value, not bluffing",
|
||||
"secret_motivation": "Has two backup cars lined up, genuinely doesn't care"
|
||||
},
|
||||
|
||||
"fair_deal_fran": {
|
||||
"name": "Fair-Deal Fran",
|
||||
"emoji": "🤝",
|
||||
"description": "Just wants everyone to win. Seeks middle ground.",
|
||||
"traits": [
|
||||
"Proposes split-the-difference solutions",
|
||||
"Acknowledges seller's perspective",
|
||||
"Builds rapport before negotiating",
|
||||
"Values relationship over small dollar amounts"
|
||||
],
|
||||
"opening_style": "Start at 85% of asking, explain reasoning kindly",
|
||||
"concession_rate": "Moderate - moves to meet in the middle",
|
||||
"walkaway_threshold": "Medium - walks if seller is unreasonable, not just expensive",
|
||||
"secret_motivation": "Believes in karma, wants seller to feel good about deal"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SELLER PERSONALITIES
|
||||
# ============================================================================
|
||||
|
||||
SELLER_PERSONALITIES: dict[str, PersonalityConfig] = {
|
||||
"shark_steve": {
|
||||
"name": "Shark Steve",
|
||||
"emoji": "🦈",
|
||||
"description": "Never drops more than 5%. Take it or leave it attitude.",
|
||||
"traits": [
|
||||
"Creates artificial scarcity",
|
||||
"Never makes the first concession",
|
||||
"Uses high-pressure tactics",
|
||||
"Dismissive of lowball offers"
|
||||
],
|
||||
"opening_style": "Price is firm, mentions multiple interested buyers",
|
||||
"concession_rate": "Minimal - 1-2% per round maximum",
|
||||
"walkaway_threshold": "Will pretend to walk to create urgency",
|
||||
"secret_motivation": "Actually has car payment due and needs to sell"
|
||||
},
|
||||
|
||||
"by_the_book_beth": {
|
||||
"name": "By-The-Book Beth",
|
||||
"emoji": "📊",
|
||||
"description": "Goes strictly by KBB. Reasonable but firm.",
|
||||
"traits": [
|
||||
"References official valuations",
|
||||
"Provides documentation for pricing",
|
||||
"Fair but won't go below market value",
|
||||
"Responds well to logical arguments"
|
||||
],
|
||||
"opening_style": "Asks KBB private party value, shows service records",
|
||||
"concession_rate": "Steady - will adjust based on condition factors",
|
||||
"walkaway_threshold": "Won't go below KBB fair condition price",
|
||||
"secret_motivation": "Has no rush, will wait for right buyer"
|
||||
},
|
||||
|
||||
"motivated_mike": {
|
||||
"name": "Motivated Mike",
|
||||
"emoji": "😅",
|
||||
"description": "Really needs to sell. More flexible than he wants to be.",
|
||||
"traits": [
|
||||
"Mentions reasons for selling",
|
||||
"Open to creative deals",
|
||||
"Shows nervousness about timeline",
|
||||
"Accepts reasonable offers quickly"
|
||||
],
|
||||
"opening_style": "Prices competitively, emphasizes quick sale",
|
||||
"concession_rate": "Fast - will drop 3-5% per round",
|
||||
"walkaway_threshold": "Low - very reluctant to lose a serious buyer",
|
||||
"secret_motivation": "Already bought the new car, paying two car payments"
|
||||
},
|
||||
|
||||
"drama_queen_diana": {
|
||||
"name": "Drama Queen Diana",
|
||||
"emoji": "🎭",
|
||||
"description": "Everything is 'my final offer' (it's never final).",
|
||||
"traits": [
|
||||
"Theatrical reactions to offers",
|
||||
"Claims emotional attachment to car",
|
||||
"Uses guilt and stories",
|
||||
"Actually negotiable despite protests"
|
||||
],
|
||||
"opening_style": "Tells the car's 'story', prices emotionally",
|
||||
"concession_rate": "Appears slow but actually moderate after drama",
|
||||
"walkaway_threshold": "Threatens to walk constantly but never does",
|
||||
"secret_motivation": "Car holds memories of ex, secretly wants it gone"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_personality_prompt(role: str, personality_key: str) -> str:
|
||||
"""Generate a personality-specific prompt addition for an agent.
|
||||
|
||||
Args:
|
||||
role: Either 'buyer' or 'seller'
|
||||
personality_key: Key from the personality dictionary
|
||||
|
||||
Returns:
|
||||
A string to append to the agent's base instructions
|
||||
"""
|
||||
personalities = BUYER_PERSONALITIES if role == "buyer" else SELLER_PERSONALITIES
|
||||
p = personalities.get(personality_key)
|
||||
|
||||
if not p:
|
||||
return ""
|
||||
|
||||
return f"""
|
||||
YOUR PERSONALITY: {p['emoji']} {p['name']}
|
||||
{p['description']}
|
||||
|
||||
YOUR TRAITS:
|
||||
{chr(10).join(f'- {trait}' for trait in p['traits'])}
|
||||
|
||||
NEGOTIATION STYLE:
|
||||
- Opening Approach: {p['opening_style']}
|
||||
- How You Concede: {p['concession_rate']}
|
||||
- When You Walk Away: {p['walkaway_threshold']}
|
||||
|
||||
SECRET (never reveal directly, but it influences your behavior):
|
||||
{p['secret_motivation']}
|
||||
|
||||
Stay in character! Your personality should come through in how you phrase offers,
|
||||
react to counteroffers, and handle pressure.
|
||||
"""
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Negotiation scenario configurations."""
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class ScenarioConfig(TypedDict):
|
||||
"""Configuration for a negotiation scenario."""
|
||||
id: str
|
||||
title: str
|
||||
emoji: str
|
||||
description: str
|
||||
|
||||
# The item being negotiated
|
||||
item: dict # name, details, condition, etc.
|
||||
|
||||
# Pricing
|
||||
asking_price: int
|
||||
fair_market_value: int
|
||||
buyer_budget: int
|
||||
seller_minimum: int
|
||||
|
||||
# Context
|
||||
buyer_context: str
|
||||
seller_context: str
|
||||
|
||||
# Stakes
|
||||
buyer_stakes: str
|
||||
seller_stakes: str
|
||||
|
||||
# Drama elements
|
||||
buyer_secret: str
|
||||
seller_secret: str
|
||||
twist: str # Something that could be revealed mid-negotiation
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCENARIOS
|
||||
# ============================================================================
|
||||
|
||||
SCENARIOS: dict[str, ScenarioConfig] = {
|
||||
"craigslist_civic": {
|
||||
"id": "craigslist_civic",
|
||||
"title": "The Craigslist Showdown",
|
||||
"emoji": "🚗",
|
||||
"description": "A classic used car negotiation with secrets on both sides.",
|
||||
|
||||
"item": {
|
||||
"name": "2019 Honda Civic EX",
|
||||
"year": 2019,
|
||||
"make": "Honda",
|
||||
"model": "Civic EX",
|
||||
"mileage": 45000,
|
||||
"color": "Lunar Silver Metallic",
|
||||
"condition": "Excellent",
|
||||
"issues": ["Minor scratch on rear bumper", "Small chip in windshield"],
|
||||
"positives": [
|
||||
"Single owner",
|
||||
"Full service records",
|
||||
"New tires (6 months ago)",
|
||||
"No accidents",
|
||||
"Garage kept"
|
||||
]
|
||||
},
|
||||
|
||||
"asking_price": 15500,
|
||||
"fair_market_value": 14000,
|
||||
"buyer_budget": 13000,
|
||||
"seller_minimum": 12500,
|
||||
|
||||
"buyer_context": """
|
||||
You're a recent college graduate who just landed your first real job.
|
||||
The commute is 25 miles each way, and public transit would take 2 hours.
|
||||
You've saved up $13,000 over the past year, with a secret $500 emergency buffer.
|
||||
You've been looking for 3 weeks and this is the best car you've seen.
|
||||
""",
|
||||
|
||||
"seller_context": """
|
||||
You bought this Civic new for $24,000 and it's been your daily driver.
|
||||
You're upgrading to an SUV because your family is growing.
|
||||
KBB says private party value is $14,000-$15,000 for excellent condition.
|
||||
You've already put a deposit on the new car and want to close this sale.
|
||||
""",
|
||||
|
||||
"buyer_stakes": "Job starts Monday. No car means either decline the job or brutal commute.",
|
||||
"seller_stakes": "New SUV deposit is non-refundable. Need this money for the down payment.",
|
||||
|
||||
"buyer_secret": "You could technically go up to $13,500 using your emergency fund, but you really don't want to.",
|
||||
"seller_secret": "The 'other interested buyer' you might mention? They're very flaky and probably won't show.",
|
||||
|
||||
"twist": "The seller's spouse mentioned at the test drive that they 'need this gone before the baby comes in 2 weeks.'"
|
||||
},
|
||||
|
||||
"vintage_guitar": {
|
||||
"id": "vintage_guitar",
|
||||
"title": "The Vintage Axe",
|
||||
"emoji": "🎸",
|
||||
"description": "A musician hunts for their dream guitar at a local shop.",
|
||||
|
||||
"item": {
|
||||
"name": "1978 Fender Stratocaster",
|
||||
"year": 1978,
|
||||
"make": "Fender",
|
||||
"model": "Stratocaster",
|
||||
"condition": "Very Good",
|
||||
"issues": ["Some fret wear", "Non-original tuning pegs", "Case shows age"],
|
||||
"positives": [
|
||||
"All original electronics",
|
||||
"Original pickups (legendary CBS-era)",
|
||||
"Great neck feel",
|
||||
"Authentic relic'd finish",
|
||||
"Plays beautifully"
|
||||
]
|
||||
},
|
||||
|
||||
"asking_price": 8500,
|
||||
"fair_market_value": 7500,
|
||||
"buyer_budget": 7000,
|
||||
"seller_minimum": 6500,
|
||||
|
||||
"buyer_context": """
|
||||
You're a professional session musician who's been searching for a late-70s Strat
|
||||
with the right feel. You've played through dozens and this one just speaks to you.
|
||||
Your budget is $7,000 but this is The One.
|
||||
""",
|
||||
|
||||
"seller_context": """
|
||||
You run a vintage guitar shop. This Strat came from an estate sale where you
|
||||
paid $4,500. It's been in the shop for 3 months and floor space is money.
|
||||
You need at least $6,500 to keep margins healthy.
|
||||
""",
|
||||
|
||||
"buyer_stakes": "You have a big studio session next week. The right guitar could define your sound.",
|
||||
"seller_stakes": "Rent is due and you've got two more estate buys coming in that need funding.",
|
||||
|
||||
"buyer_secret": "You could stretch to $7,500 by selling your backup amp, but you'd really rather not.",
|
||||
"seller_secret": "You've had this for 90 days and need to move inventory. Might take $6,200.",
|
||||
|
||||
"twist": "The buyer mentions they're recording with a famous producer who loves vintage gear."
|
||||
},
|
||||
|
||||
"apartment_sublet": {
|
||||
"id": "apartment_sublet",
|
||||
"title": "The Sublet Standoff",
|
||||
"emoji": "🏠",
|
||||
"description": "Negotiating rent for a 3-month summer sublet in a hot market.",
|
||||
|
||||
"item": {
|
||||
"name": "Studio Apartment Sublet",
|
||||
"type": "Studio apartment",
|
||||
"location": "Downtown, 5 min walk to transit",
|
||||
"duration": "3 months (June-August)",
|
||||
"condition": "Recently renovated",
|
||||
"issues": ["Street noise", "No dishwasher", "Coin laundry"],
|
||||
"positives": [
|
||||
"Great location",
|
||||
"In-unit washer/dryer",
|
||||
"Rooftop access",
|
||||
"Utilities included",
|
||||
"Furnished"
|
||||
]
|
||||
},
|
||||
|
||||
"asking_price": 2200, # per month
|
||||
"fair_market_value": 2000,
|
||||
"buyer_budget": 1800,
|
||||
"seller_minimum": 1700,
|
||||
|
||||
"buyer_context": """
|
||||
You're a summer intern at a tech company. They're paying $5,000/month stipend.
|
||||
Housing eats into that significantly. You need something walkable to the office.
|
||||
""",
|
||||
|
||||
"seller_context": """
|
||||
You're leaving for a 3-month work trip and need to cover rent while gone.
|
||||
Your rent is $1,600/month. You're hoping to make a small profit or at least break even.
|
||||
""",
|
||||
|
||||
"buyer_stakes": "Your internship starts in 2 weeks. You need housing locked down.",
|
||||
"seller_stakes": "You leave in 10 days. Empty apartment means paying double rent.",
|
||||
|
||||
"buyer_secret": "Company will reimburse up to $2,000/month but you'd love to pocket the difference.",
|
||||
"seller_secret": "You've had two other inquiries but they fell through. Getting nervous.",
|
||||
|
||||
"twist": "The sublet includes your neighbor's cat-sitting duties (easy, cat is chill)."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_scenario(scenario_id: str) -> ScenarioConfig:
|
||||
"""Get a scenario by ID.
|
||||
|
||||
Args:
|
||||
scenario_id: The scenario identifier
|
||||
|
||||
Returns:
|
||||
The scenario configuration
|
||||
|
||||
Raises:
|
||||
KeyError: If scenario not found
|
||||
"""
|
||||
if scenario_id not in SCENARIOS:
|
||||
raise KeyError(f"Unknown scenario: {scenario_id}. Available: {list(SCENARIOS.keys())}")
|
||||
return SCENARIOS[scenario_id]
|
||||
|
||||
|
||||
def format_item_description(scenario: ScenarioConfig) -> str:
|
||||
"""Format the item being negotiated into a readable description."""
|
||||
item = scenario["item"]
|
||||
|
||||
lines = [
|
||||
f"**{item['name']}**",
|
||||
"",
|
||||
"Condition: " + item.get("condition", "Good"),
|
||||
"",
|
||||
"Positives:",
|
||||
]
|
||||
|
||||
for positive in item.get("positives", []):
|
||||
lines.append(f" ✓ {positive}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Issues to Note:")
|
||||
|
||||
for issue in item.get("issues", []):
|
||||
lines.append(f" • {issue}")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,7 @@
|
||||
google-adk>=1.5.0
|
||||
google-genai>=1.0.0
|
||||
ag-ui-adk>=0.1.0
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.34.0
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
4
advanced_ai_agents/multi_agent_apps/ai_negotiation_battle_simulator/frontend/.gitignore
vendored
Normal file
4
advanced_ai_agents/multi_agent_apps/ai_negotiation_battle_simulator/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
6
advanced_ai_agents/multi_agent_apps/ai_negotiation_battle_simulator/frontend/next-env.d.ts
vendored
Normal file
6
advanced_ai_agents/multi_agent_apps/ai_negotiation_battle_simulator/frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
12887
advanced_ai_agents/multi_agent_apps/ai_negotiation_battle_simulator/frontend/package-lock.json
generated
Normal file
12887
advanced_ai_agents/multi_agent_apps/ai_negotiation_battle_simulator/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "negotiation-battle-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "^0.0.43",
|
||||
"@copilotkit/react-core": "^1.8.0",
|
||||
"@copilotkit/react-ui": "^1.8.0",
|
||||
"@copilotkit/runtime": "^1.8.0",
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"framer-motion": "^11.0.0",
|
||||
"lucide-react": "^0.460.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Service adapter for CopilotKit runtime
|
||||
const serviceAdapter = new ExperimentalEmptyAdapter();
|
||||
|
||||
// Create the CopilotRuntime with our ADK agent
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
negotiation_agent: new HttpAgent({
|
||||
url: process.env.AGENT_URL || "http://localhost:8000/",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Export POST handler for the API route
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter,
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(req);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Warm Editorial Palette - Stone & Paper */
|
||||
:root {
|
||||
/* Core Colors */
|
||||
--bg-app: #FAF8F5;
|
||||
/* Warm cream paper background */
|
||||
--bg-card: #FFFFFF;
|
||||
/* Clean white card surface */
|
||||
--bg-subtle: #F3F0EB;
|
||||
/* Subtle separation background */
|
||||
|
||||
--text-primary: #1C1917;
|
||||
/* Warm black (Stone 900) */
|
||||
--text-secondary: #44403C;
|
||||
/* Dark stone (Stone 700) */
|
||||
--text-tertiary: #78716C;
|
||||
/* Medium stone (Stone 500) */
|
||||
|
||||
--border-light: #E7E5E4;
|
||||
/* Light stone border */
|
||||
--border-medium: #D6D3D1;
|
||||
/* Medium stone border */
|
||||
|
||||
/* Accents - Muted & Sophisticated */
|
||||
--accent-gold: #D97706;
|
||||
/* Editorial Gold */
|
||||
--accent-emerald: #059669;
|
||||
/* Editorial Green */
|
||||
--accent-rose: #E11D48;
|
||||
/* Editorial Rose */
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
body {
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography Utilities */
|
||||
.font-serif {
|
||||
font-family: 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Component Utilities */
|
||||
@layer components {
|
||||
|
||||
/* Minimal Editorial Card */
|
||||
.editorial-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
@apply rounded-xl transition-all duration-300;
|
||||
}
|
||||
|
||||
.editorial-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Chat Bubble Base */
|
||||
.chat-bubble {
|
||||
@apply flex items-start gap-4 max-w-2xl p-4 rounded-2xl relative mb-2;
|
||||
}
|
||||
|
||||
.chat-bubble.buyer {
|
||||
@apply ml-auto bg-white border border-stone-200 text-stone-900 rounded-tr-sm;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.chat-bubble.seller {
|
||||
@apply mr-auto bg-stone-50 border border-stone-200 text-stone-900 rounded-tl-sm;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import "@copilotkit/react-ui/styles.css";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "🎮 AI Negotiation Battle Simulator",
|
||||
description: "Watch AI agents battle it out in epic negotiations!",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit"
|
||||
agent="negotiation_agent"
|
||||
>
|
||||
{children}
|
||||
</CopilotKit>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCoAgent, useCopilotAction } from "@copilotkit/react-core";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Swords,
|
||||
DollarSign,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Handshake,
|
||||
XCircle,
|
||||
Trophy,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
|
||||
// Types for agent state
|
||||
type NegotiationRound = {
|
||||
round: number;
|
||||
type: "buyer_offer" | "seller_response";
|
||||
offer_amount?: number;
|
||||
action?: string;
|
||||
counter_amount?: number;
|
||||
message: string;
|
||||
buyer_name?: string;
|
||||
buyer_emoji?: string;
|
||||
seller_name?: string;
|
||||
seller_emoji?: string;
|
||||
};
|
||||
|
||||
type Scenario = {
|
||||
id: string;
|
||||
title: string;
|
||||
emoji: string;
|
||||
item: string;
|
||||
asking_price: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type Personality = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type AgentState = {
|
||||
status: "setup" | "ready" | "negotiating" | "deal" | "no_deal";
|
||||
scenario?: {
|
||||
title: string;
|
||||
item: string;
|
||||
asking_price: number;
|
||||
};
|
||||
buyer?: {
|
||||
name: string;
|
||||
emoji: string;
|
||||
budget: number;
|
||||
};
|
||||
seller?: {
|
||||
name: string;
|
||||
emoji: string;
|
||||
minimum: number;
|
||||
};
|
||||
rounds: NegotiationRound[];
|
||||
current_round: number;
|
||||
final_price?: number;
|
||||
};
|
||||
|
||||
// Offer Card Component - Editorial Style
|
||||
function OfferCard({ round, isLatest }: { round: NegotiationRound; isLatest: boolean }) {
|
||||
const isBuyer = round.type === "buyer_offer";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: isBuyer ? -20 : 20, y: 10 }}
|
||||
animate={{ opacity: 1, x: 0, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className={`
|
||||
relative p-6 rounded-lg mb-6 max-w-lg
|
||||
${isBuyer
|
||||
? "bg-white border border-border-light ml-auto shadow-sm"
|
||||
: "bg-bg-subtle border border-border-light mr-auto shadow-sm"
|
||||
}
|
||||
${isLatest ? "ring-1 ring-text-tertiary" : ""}
|
||||
`}
|
||||
>
|
||||
{/* Round Indicator */}
|
||||
<div className={`
|
||||
absolute -top-3 ${isBuyer ? "-right-3" : "-left-3"}
|
||||
bg-text-primary text-bg-app px-2 py-1 rounded text-[10px] font-bold tracking-widest uppercase shadow-md
|
||||
`}>
|
||||
Round {round.round}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className={`flex items-center gap-3 mb-4 ${isBuyer ? "flex-row-reverse" : ""}`}>
|
||||
<span className="text-xl filter grayscale opacity-80">
|
||||
{isBuyer ? round.buyer_emoji : round.seller_emoji}
|
||||
</span>
|
||||
<span className="font-serif font-bold text-sm text-text-primary tracking-wide">
|
||||
{isBuyer ? round.buyer_name : round.seller_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Offer Content */}
|
||||
<div className={`${isBuyer ? "text-right" : "text-left"}`}>
|
||||
{isBuyer && round.offer_amount && (
|
||||
<div className="mb-3">
|
||||
<span className="block text-xs font-bold text-text-tertiary uppercase tracking-widest mb-1">Offer</span>
|
||||
<span className="font-serif text-3xl font-medium text-text-primary block">
|
||||
${round.offer_amount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBuyer && round.action && (
|
||||
<div className={`mb-3 ${isBuyer ? "text-right" : "text-left"}`}>
|
||||
<span className="block text-xs font-bold text-text-tertiary uppercase tracking-widest mb-1">Response</span>
|
||||
{round.action === "accept" && (
|
||||
<span className="text-lg font-serif font-medium text-emerald-700 flex items-center gap-2">
|
||||
<Handshake className="w-4 h-4" /> Deal Accepted
|
||||
</span>
|
||||
)}
|
||||
{round.action === "counter" && (
|
||||
<span className="font-serif text-3xl font-medium text-text-primary block">
|
||||
${round.counter_amount?.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{round.action === "reject" && (
|
||||
<span className="text-lg font-serif font-medium text-red-700 flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" /> Offer Rejected
|
||||
</span>
|
||||
)}
|
||||
{round.action === "walk" && (
|
||||
<span className="text-lg font-serif font-medium text-text-tertiary flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" /> Ended Negotiation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Body */}
|
||||
<p className="text-text-secondary text-sm leading-relaxed font-sans border-t border-border-light pt-3 mt-2">
|
||||
{round.message}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Deal Banner Component - Editorial Style
|
||||
function DealBanner({ finalPrice, askingPrice }: { finalPrice: number; askingPrice: number }) {
|
||||
const savings = askingPrice - finalPrice;
|
||||
const percentOff = ((savings / askingPrice) * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-bg-card rounded-2xl p-12 text-center shadow-lg border border-border-medium max-w-xl mx-auto"
|
||||
>
|
||||
<div className="mb-6 flex justify-center">
|
||||
<Trophy className="w-12 h-12 text-text-primary" strokeWidth={1} />
|
||||
</div>
|
||||
<h2 className="text-sm font-bold tracking-widest text-text-tertiary uppercase mb-4">Agreement Reached</h2>
|
||||
<p className="text-6xl font-serif text-text-primary mb-8 font-medium">
|
||||
${finalPrice.toLocaleString()}
|
||||
</p>
|
||||
<div className="flex justify-center gap-12 border-t border-border-light pt-8">
|
||||
<div>
|
||||
<p className="text-text-tertiary text-xs uppercase tracking-wider mb-1">Savings</p>
|
||||
<p className="font-serif text-xl text-text-secondary">${savings.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-tertiary text-xs uppercase tracking-wider mb-1">Discount</p>
|
||||
<p className="font-serif text-xl text-text-secondary">{percentOff}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== CHAT UI COMPONENTS ====================
|
||||
|
||||
// Chat Bubble Component
|
||||
const ChatBubble = ({ round, type }: { round: NegotiationRound; type: "buyer" | "seller" }) => {
|
||||
const name = type === "buyer" ? round.buyer_name : round.seller_name;
|
||||
const emoji = type === "buyer" ? round.buyer_emoji : round.seller_emoji;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: type === "buyer" ? -50 : 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={`chat-bubble ${type}`}
|
||||
>
|
||||
<div className="avatar">{emoji}</div>
|
||||
<div className="bubble-content">
|
||||
<div className="name">{name}</div>
|
||||
<div className="message">{round.message}</div>
|
||||
{round.offer_amount && (
|
||||
<div className="offer-badge">${round.offer_amount.toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// Typing Indicator Component
|
||||
const TypingIndicator = ({ emoji, name }: { emoji: string; name: string }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="typing-indicator"
|
||||
>
|
||||
<span className="avatar" style={{ fontSize: '20px' }}>{emoji}</span>
|
||||
<div className="dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span className="typing-text">{name} is thinking...</span>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// Price Tracker Component
|
||||
const PriceTracker = ({
|
||||
currentOffer,
|
||||
askingPrice,
|
||||
minimum,
|
||||
budget
|
||||
}: {
|
||||
currentOffer: number;
|
||||
askingPrice: number;
|
||||
minimum: number;
|
||||
budget: number;
|
||||
}) => {
|
||||
const range = budget - minimum;
|
||||
const position = ((currentOffer - minimum) / range) * 100;
|
||||
|
||||
return (
|
||||
<div className="price-tracker">
|
||||
<h4 style={{ fontSize: '14px', fontWeight: 600, marginBottom: '12px', color: 'var(--text-dark)' }}>
|
||||
💰 Price Convergence
|
||||
</h4>
|
||||
<div className="track">
|
||||
<div className="sweet-spot" style={{ left: `${Math.max(10, Math.min(90, position))}%` }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div className="range-marker seller">Min: ${minimum.toLocaleString()}</div>
|
||||
<div className="range-marker buyer">Max: ${budget.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Page Component
|
||||
export default function NegotiationBattle() {
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
const [selectedBuyer, setSelectedBuyer] = useState<string | null>(null);
|
||||
const [selectedSeller, setSelectedSeller] = useState<string | null>(null);
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
||||
const [buyers, setBuyers] = useState<Personality[]>([]);
|
||||
const [sellers, setSellers] = useState<Personality[]>([]);
|
||||
|
||||
// Connect to agent state
|
||||
const { state, setState } = useCoAgent<AgentState>({
|
||||
name: "negotiation_agent",
|
||||
initialState: {
|
||||
status: "setup",
|
||||
rounds: [],
|
||||
current_round: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch available scenarios and personalities on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
console.log('[DEBUG] Fetching scenarios and personalities...');
|
||||
// Fetch scenarios
|
||||
const scenariosRes = await fetch('http://localhost:8000/get_available_scenarios');
|
||||
const scenariosData = await scenariosRes.json();
|
||||
console.log('[DEBUG] Scenarios fetched:', scenariosData);
|
||||
if (scenariosData.scenarios) {
|
||||
setScenarios(scenariosData.scenarios);
|
||||
console.log('[DEBUG] Scenarios state updated:', scenariosData.scenarios.length, 'scenarios');
|
||||
}
|
||||
|
||||
// Fetch personalities
|
||||
const personalitiesRes = await fetch('http://localhost:8000/get_available_personalities');
|
||||
const personalitiesData = await personalitiesRes.json();
|
||||
console.log('[DEBUG] Personalities fetched:', personalitiesData);
|
||||
if (personalitiesData.buyers && personalitiesData.sellers) {
|
||||
setBuyers(personalitiesData.buyers);
|
||||
setSellers(personalitiesData.sellers);
|
||||
console.log('[DEBUG] Personalities state updated:', personalitiesData.buyers.length, 'buyers,', personalitiesData.sellers.length, 'sellers');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DEBUG] Error fetching data:', error);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Debug logging for state changes
|
||||
useEffect(() => {
|
||||
console.log('[DEBUG] Current agent state:', state);
|
||||
console.log('[DEBUG] Scenarios count:', scenarios.length);
|
||||
console.log('[DEBUG] Buyers count:', buyers.length);
|
||||
console.log('[DEBUG] Sellers count:', sellers.length);
|
||||
}, [state, scenarios, buyers, sellers]);
|
||||
|
||||
const handleStartBattle = async () => {
|
||||
if (!selectedScenario || !selectedBuyer || !selectedSeller) return;
|
||||
|
||||
try {
|
||||
// Configure the negotiation
|
||||
const configRes = await fetch('http://localhost:8000/configure_negotiation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scenario_id: selectedScenario,
|
||||
buyer_personality: selectedBuyer,
|
||||
seller_personality: selectedSeller,
|
||||
}),
|
||||
});
|
||||
const configData = await configRes.json();
|
||||
|
||||
// Update state with configuration
|
||||
setState({
|
||||
...state,
|
||||
status: "ready",
|
||||
scenario: configData.scenario,
|
||||
buyer: configData.buyer,
|
||||
seller: configData.seller,
|
||||
});
|
||||
|
||||
// Start the negotiation
|
||||
const startRes = await fetch('http://localhost:8000/start_negotiation', {
|
||||
method: 'POST',
|
||||
});
|
||||
const startData = await startRes.json();
|
||||
|
||||
if (startData.status === "started") {
|
||||
setState({
|
||||
...state,
|
||||
status: "negotiating",
|
||||
scenario: configData.scenario,
|
||||
buyer: configData.buyer,
|
||||
seller: configData.seller,
|
||||
});
|
||||
|
||||
// Start the automated negotiation
|
||||
runNegotiation();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting battle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const runNegotiation = async () => {
|
||||
// This will be handled by the agent automatically
|
||||
// Just need to poll for state updates
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const stateRes = await fetch('http://localhost:8000/get_negotiation_state');
|
||||
const stateData = await stateRes.json();
|
||||
|
||||
setState({
|
||||
status: stateData.status,
|
||||
scenario: {
|
||||
title: stateData.scenario,
|
||||
item: stateData.item,
|
||||
asking_price: stateData.asking_price,
|
||||
},
|
||||
buyer: stateData.buyer,
|
||||
seller: stateData.seller,
|
||||
rounds: stateData.rounds,
|
||||
current_round: stateData.current_round,
|
||||
final_price: stateData.final_price,
|
||||
});
|
||||
|
||||
if (stateData.status === "deal" || stateData.status === "no_deal") {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling state:', error);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedScenario(null);
|
||||
setSelectedBuyer(null);
|
||||
setSelectedSeller(null);
|
||||
setState({
|
||||
status: "setup",
|
||||
rounds: [],
|
||||
current_round: 0,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-bg-app">
|
||||
{/* Header - Editorial Style */}
|
||||
<header className="border-b border-border-light bg-card/50 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<motion.div
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-serif tracking-tight text-text-primary">
|
||||
The Negotiation
|
||||
</h1>
|
||||
<p
|
||||
className="text-center text-sm font-sans tracking-wide uppercase text-text-tertiary"
|
||||
>
|
||||
Autonomous Agent Simulation
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative container mx-auto px-4 py-12">
|
||||
{/* Setup Phase - Editorial Style */}
|
||||
{(state.status === "setup" || (!state.status && scenarios.length > 0)) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-5xl mx-auto space-y-16"
|
||||
>
|
||||
{/* Scenario Selection */}
|
||||
<section>
|
||||
<div className="text-center mb-10">
|
||||
<span className="text-xs font-bold tracking-widest text-text-tertiary uppercase mb-2 block">Step 1</span>
|
||||
<h2 className="text-3xl font-serif text-text-primary">
|
||||
Select Context
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{scenarios.map((scenario) => (
|
||||
<motion.div
|
||||
key={scenario.id}
|
||||
whileHover={{ y: -4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setSelectedScenario(scenario.id)}
|
||||
className={`
|
||||
cursor-pointer p-8 rounded-xl border transition-all duration-200
|
||||
${selectedScenario === scenario.id
|
||||
? 'bg-bg-card border-text-primary shadow-md ring-1 ring-text-primary'
|
||||
: 'bg-bg-subtle border-transparent hover:border-border-medium hover:bg-bg-card'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="text-4xl mb-6 text-center filter grayscale opacity-90">{scenario.emoji}</div>
|
||||
<h3 className="text-lg font-serif font-medium text-center mb-2 text-text-primary">{scenario.title}</h3>
|
||||
<p className="text-text-secondary text-sm text-center mb-4 line-clamp-2">{scenario.item}</p>
|
||||
<p className="text-center text-text-primary font-bold font-serif">
|
||||
${scenario.asking_price.toLocaleString()}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Character Selection */}
|
||||
{selectedScenario && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-16">
|
||||
{/* Buyer Selection */}
|
||||
<section>
|
||||
<div className="text-center mb-8">
|
||||
<span className="text-xs font-bold tracking-widest text-text-tertiary uppercase mb-2 block">Step 2</span>
|
||||
<h2 className="text-2xl font-serif text-text-primary">
|
||||
Choose Buyer
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{buyers.map((buyer) => (
|
||||
<motion.div
|
||||
key={buyer.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setSelectedBuyer(buyer.id)}
|
||||
className={`
|
||||
cursor-pointer p-5 rounded-lg border transition-all
|
||||
${selectedBuyer === buyer.id
|
||||
? 'bg-bg-card border-text-primary shadow-sm'
|
||||
: 'bg-bg-subtle border-transparent hover:border-border-medium'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="text-3xl mb-3 text-center filter grayscale opacity-90">{buyer.emoji}</div>
|
||||
<h4 className="text-sm font-medium text-center text-text-primary">{buyer.name}</h4>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Seller Selection */}
|
||||
<section>
|
||||
<div className="text-center mb-8">
|
||||
<span className="text-xs font-bold tracking-widest text-text-tertiary uppercase mb-2 block">Step 3</span>
|
||||
<h2 className="text-2xl font-serif text-text-primary">
|
||||
Choose Seller
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{sellers.map((seller) => (
|
||||
<motion.div
|
||||
key={seller.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setSelectedSeller(seller.id)}
|
||||
className={`
|
||||
cursor-pointer p-5 rounded-lg border transition-all
|
||||
${selectedSeller === seller.id
|
||||
? 'bg-bg-card border-text-primary shadow-sm'
|
||||
: 'bg-bg-subtle border-transparent hover:border-border-medium'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="text-3xl mb-3 text-center filter grayscale opacity-90">{seller.emoji}</div>
|
||||
<h4 className="text-sm font-medium text-center text-text-primary">{seller.name}</h4>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Start Button */}
|
||||
{selectedBuyer && selectedSeller && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex justify-center pt-8"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleStartBattle}
|
||||
className="px-10 py-4 bg-text-primary text-bg-app rounded-full font-serif font-medium text-xl shadow-lg hover:shadow-xl transition-all flex items-center gap-3 tracking-wide"
|
||||
>
|
||||
<Play className="w-5 h-5 fill-current" />
|
||||
Begin Negotiation
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Battle Phase */}
|
||||
{(state.status === "ready" || state.status === "negotiating" || state.status === "deal" || state.status === "no_deal") && (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Scenario Header - Editorial Style */}
|
||||
{state.scenario && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-16 bg-bg-card border border-border-light rounded-2xl p-8 shadow-sm"
|
||||
>
|
||||
<div className="inline-block px-3 py-1 mb-4 border border-border-medium rounded-full text-xs font-semibold tracking-wider text-text-tertiary uppercase">
|
||||
Negotiation Item
|
||||
</div>
|
||||
<h2 className="text-4xl font-serif text-text-primary mb-2">
|
||||
{state.scenario.title}
|
||||
</h2>
|
||||
<p className="text-xl text-text-secondary mb-6 font-light">{state.scenario.item}</p>
|
||||
<div className="inline-flex items-center gap-2 text-lg border-t border-b border-border-light py-2 px-6">
|
||||
<span className="text-text-tertiary uppercase text-xs font-bold tracking-widest">Asking Price</span>
|
||||
<span className="text-text-primary font-serif font-bold text-xl">
|
||||
${state.scenario.asking_price?.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Comparison Display - Editorial Two-Column */}
|
||||
{state.buyer && state.seller && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16 items-start">
|
||||
|
||||
{/* Buyer Column */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-right pr-6 border-r border-border-light"
|
||||
>
|
||||
<span className="text-xs font-bold tracking-widest text-text-tertiary uppercase mb-4 block">Buyer</span>
|
||||
<div className="text-5xl mb-6 grayscale hover:grayscale-0 transition-all duration-500 opacity-90 hover:opacity-100">{state.buyer.emoji}</div>
|
||||
<h3 className="text-3xl font-serif text-text-primary mb-2">{state.buyer.name}</h3>
|
||||
<p className="font-sans text-text-secondary mb-1">Budget: <span className="font-semibold text-text-primary">${state.buyer.budget?.toLocaleString()}</span></p>
|
||||
</motion.div>
|
||||
|
||||
{/* Seller Column */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-left pl-6"
|
||||
>
|
||||
<span className="text-xs font-bold tracking-widest text-text-tertiary uppercase mb-4 block">Seller</span>
|
||||
<div className="text-5xl mb-6 grayscale hover:grayscale-0 transition-all duration-500 opacity-90 hover:opacity-100">{state.seller.emoji}</div>
|
||||
<h3 className="text-3xl font-serif text-text-primary mb-2">{state.seller.name}</h3>
|
||||
<p className="font-sans text-text-secondary mb-1">Minimum: <span className="font-semibold text-text-primary">${state.seller.minimum?.toLocaleString()}</span></p>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Negotiation Chat */}
|
||||
{state.rounds && state.rounds.length > 0 && (
|
||||
<div className="max-w-3xl mx-auto mb-12">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<span className="h-px w-8 bg-border-medium"></span>
|
||||
<span className="text-xs font-bold tracking-widest text-text-tertiary uppercase">Live Transcript</span>
|
||||
<span className="h-px w-8 bg-border-medium"></span>
|
||||
</div>
|
||||
|
||||
{/* Price Tracker */}
|
||||
{state.buyer && state.seller && state.rounds.length > 0 && state.rounds[state.rounds.length - 1].offer_amount && (
|
||||
<div className="mb-10 px-8">
|
||||
<PriceTracker
|
||||
currentOffer={state.rounds[state.rounds.length - 1].offer_amount || state.scenario?.asking_price || 0}
|
||||
askingPrice={state.scenario?.asking_price || 0}
|
||||
minimum={state.seller.minimum || 0}
|
||||
budget={state.buyer.budget || 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Bubbles */}
|
||||
<div className="space-y-6">
|
||||
<AnimatePresence>
|
||||
{state.rounds.map((round, index) => {
|
||||
const isBuyer = round.type === "buyer_offer" || round.buyer_name;
|
||||
return (
|
||||
<OfferCard
|
||||
key={`${round.type}-${round.round}-${index}`}
|
||||
round={round}
|
||||
isLatest={index === state.rounds.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{(state.status === "negotiating" || state.status === "ready") && (
|
||||
<div className="flex justify-center py-4">
|
||||
<TypingIndicator
|
||||
emoji={state.current_round % 2 === 0 ? state.buyer?.emoji || "🔵" : state.seller?.emoji || "🔴"}
|
||||
name={state.current_round % 2 === 0 ? state.buyer?.name || "Buyer" : state.seller?.name || "Seller"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deal Banner */}
|
||||
{state.status === "deal" && state.final_price && state.scenario && (
|
||||
<div className="max-w-2xl mx-auto mb-8">
|
||||
<DealBanner
|
||||
finalPrice={state.final_price}
|
||||
askingPrice={state.scenario.asking_price}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Deal Banner */}
|
||||
{state.status === "no_deal" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-xl mx-auto mb-12 bg-bg-card border border-border-medium rounded-xl p-10 text-center shadow-lg"
|
||||
>
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="h-16 w-16 bg-bg-subtle rounded-full flex items-center justify-center">
|
||||
<XCircle className="w-8 h-8 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-serif text-text-primary mb-3">Negotiation Ended</h2>
|
||||
<p className="text-text-secondary font-sans leading-relaxed">No agreement could be reached between the parties.</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Reset Button */}
|
||||
{(state.status === "deal" || state.status === "no_deal") && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-center mt-12 mb-12"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleReset}
|
||||
className="px-8 py-3 bg-text-primary text-bg-app rounded-full font-serif font-medium text-lg shadow-lg hover:shadow-xl transition-all flex items-center gap-3 tracking-wide"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Start New Negotiation
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'battle-blue': '#1e3a5f',
|
||||
'battle-red': '#5a1e1e',
|
||||
'battle-gold': '#ffd700',
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'bounce-slow': 'bounce 2s infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"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": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user