Merge pull request #481 from Shubhamsaboo/ai-negotiation-simulator

Add New Multi-Agent App (AI Negotiation Similator)
This commit is contained in:
Shubham Saboo
2026-02-08 21:42:59 -08:00
committed by GitHub
23 changed files with 15582 additions and 0 deletions

View File

@@ -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/

View File

@@ -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!* 🏆

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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,
)

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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",
]

View File

@@ -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.
"""

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,4 @@
node_modules/
.next/
.env
.env.local

View 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.

View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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);
};

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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: [],
}

View File

@@ -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"
]
}