Compare commits

...

14 Commits

Author SHA1 Message Date
Artem Semyanov b4bf745103
Merge 9576936bdd into 7ed528400c 2025-08-25 01:34:30 +06:00
Bobbie 7ed528400c
Merge pull request #1445 from escapade-mckv/agent-icons
debug agent icons
2025-08-25 00:48:39 +05:30
Saumya 47238cba3b debug agent icons 2025-08-25 00:47:41 +05:30
Bobbie 70df974720
Merge pull request #1442 from escapade-mckv/agent-icons
create agent with a default icon
2025-08-24 22:45:25 +05:30
Saumya a7df37b0ba create agent with a default icon 2025-08-24 22:44:29 +05:30
Bobbie dad49f8a50
Merge pull request #1441 from escapade-mckv/agent-icons
fix agent creation error
2025-08-24 22:35:27 +05:30
Saumya 2e31dd4593 fix agent creation error 2025-08-24 22:34:32 +05:30
Bobbie e4189c74a3
Merge pull request #1440 from escapade-mckv/agent-icons
agents use icons and bg color
2025-08-24 20:49:30 +05:30
Saumya f55cc025d7 agents use icons and bg color 2025-08-24 20:48:04 +05:30
Bobbie ab32d5040f
Merge pull request #1439 from escapade-mckv/agent-icons
Agent icons
2025-08-24 17:17:52 +05:30
Saumya 7af07582ff agents use icons and bg color 2025-08-24 17:16:50 +05:30
Saumya ce98bb8a99 agents use icons and bg color 2025-08-24 17:16:21 +05:30
asemyanov 9576936bdd fix 2025-08-21 14:33:57 +02:00
asemyanov 8be8b5face Add Claude Code integration support in API and frontend. Updated allowed origins for CORS, added OAuth endpoints, and enhanced API keys page with integration instructions and connection command. 2025-08-21 14:32:50 +02:00
31 changed files with 1932 additions and 353 deletions

View File

@ -18,7 +18,7 @@ from services import redis
from utils.auth_utils import get_current_user_id_from_jwt, get_user_id_from_stream_auth, verify_thread_access, verify_admin_api_key
from utils.logger import logger, structlog
from services.billing import check_billing_status, can_use_model
from utils.config import config
from utils.config import config, EnvMode
from sandbox.sandbox import create_sandbox, delete_sandbox, get_or_start_sandbox
from services.llm import make_llm_api_call
from run_agent_background import run_agent_background, _cleanup_redis_response_list, update_agent_run_status
@ -71,16 +71,17 @@ class MessageCreateRequest(BaseModel):
class AgentCreateRequest(BaseModel):
name: str
description: Optional[str] = None
system_prompt: Optional[str] = None # Make optional to allow defaulting to Suna's system prompt
system_prompt: Optional[str] = None
configured_mcps: Optional[List[Dict[str, Any]]] = []
custom_mcps: Optional[List[Dict[str, Any]]] = []
agentpress_tools: Optional[Dict[str, Any]] = {}
is_default: Optional[bool] = False
# Deprecated, kept for backward-compat
avatar: Optional[str] = None
avatar_color: Optional[str] = None
# New profile image url (can be external or Supabase storage URL)
profile_image_url: Optional[str] = None
icon_name: Optional[str] = None
icon_color: Optional[str] = None
icon_background: Optional[str] = None
class AgentVersionResponse(BaseModel):
version_id: str
@ -88,7 +89,7 @@ class AgentVersionResponse(BaseModel):
version_number: int
version_name: str
system_prompt: str
model: Optional[str] = None # Add model field
model: Optional[str] = None
configured_mcps: List[Dict[str, Any]]
custom_mcps: List[Dict[str, Any]]
agentpress_tools: Dict[str, Any]
@ -102,8 +103,8 @@ class AgentVersionCreateRequest(BaseModel):
configured_mcps: Optional[List[Dict[str, Any]]] = []
custom_mcps: Optional[List[Dict[str, Any]]] = []
agentpress_tools: Optional[Dict[str, Any]] = {}
version_name: Optional[str] = None # Custom version name
description: Optional[str] = None # Version description
version_name: Optional[str] = None
description: Optional[str] = None
class AgentUpdateRequest(BaseModel):
name: Optional[str] = None
@ -113,11 +114,12 @@ class AgentUpdateRequest(BaseModel):
custom_mcps: Optional[List[Dict[str, Any]]] = None
agentpress_tools: Optional[Dict[str, Any]] = None
is_default: Optional[bool] = None
# Deprecated, kept for backward-compat
avatar: Optional[str] = None
avatar_color: Optional[str] = None
# New profile image url
profile_image_url: Optional[str] = None
icon_name: Optional[str] = None
icon_color: Optional[str] = None
icon_background: Optional[str] = None
class AgentResponse(BaseModel):
agent_id: str
@ -128,15 +130,15 @@ class AgentResponse(BaseModel):
custom_mcps: List[Dict[str, Any]]
agentpress_tools: Dict[str, Any]
is_default: bool
# Deprecated
avatar: Optional[str] = None
avatar_color: Optional[str] = None
# New
profile_image_url: Optional[str] = None
icon_name: Optional[str] = None
icon_color: Optional[str] = None
icon_background: Optional[str] = None
created_at: str
updated_at: Optional[str] = None
is_public: Optional[bool] = False
tags: Optional[List[str]] = []
current_version_id: Optional[str] = None
version_count: Optional[int] = 1
@ -155,7 +157,7 @@ class AgentsResponse(BaseModel):
class ThreadAgentResponse(BaseModel):
agent: Optional[AgentResponse]
source: str # "thread", "default", "none", "missing"
source: str
message: str
class AgentExportData(BaseModel):
@ -1544,7 +1546,7 @@ async def get_agents(
custom_mcps = agent_config['custom_mcps']
agentpress_tools = agent_config['agentpress_tools']
agent_list.append(AgentResponse(
agent_response = AgentResponse(
agent_id=agent['agent_id'],
name=agent['name'],
description=agent.get('description'),
@ -1558,13 +1560,19 @@ async def get_agents(
avatar=agent_config.get('avatar'),
avatar_color=agent_config.get('avatar_color'),
profile_image_url=agent_config.get('profile_image_url'),
icon_name=agent_config.get('icon_name'),
icon_color=agent_config.get('icon_color'),
icon_background=agent_config.get('icon_background'),
created_at=agent['created_at'],
updated_at=agent['updated_at'],
current_version_id=agent.get('current_version_id'),
version_count=agent.get('version_count', 1),
current_version=current_version,
metadata=agent.get('metadata')
))
)
print(f"[DEBUG] get_agents RESPONSE item {agent['name']}: icon_name={agent_response.icon_name}, icon_color={agent_response.icon_color}, icon_background={agent_response.icon_background}")
agent_list.append(agent_response)
total_pages = (total_count + limit - 1) // limit
@ -1593,6 +1601,11 @@ async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_fr
)
logger.debug(f"Fetching agent {agent_id} for user: {user_id}")
# Debug logging
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] get_agent: Starting to fetch agent {agent_id}")
client = await db.client
try:
@ -1604,6 +1617,11 @@ async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_fr
agent_data = agent.data[0]
# Debug logging for fetched agent data
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] get_agent: Fetched agent from DB - icon_name={agent_data.get('icon_name')}, icon_color={agent_data.get('icon_color')}, icon_background={agent_data.get('icon_background')}")
print(f"[DEBUG] get_agent: Also has - profile_image_url={agent_data.get('profile_image_url')}, avatar={agent_data.get('avatar')}, avatar_color={agent_data.get('avatar_color')}")
# Check ownership - only owner can access non-public agents
if agent_data['account_id'] != user_id and not agent_data.get('is_public', False):
raise HTTPException(status_code=403, detail="Access denied")
@ -1662,14 +1680,24 @@ async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_fr
}
from agent.config_helper import extract_agent_config
# Debug logging before extract_agent_config
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] get_agent: Before extract_agent_config - agent_data has icon_name={agent_data.get('icon_name')}, icon_color={agent_data.get('icon_color')}, icon_background={agent_data.get('icon_background')}")
agent_config = extract_agent_config(agent_data, version_data)
# Debug logging after extract_agent_config
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] get_agent: After extract_agent_config - agent_config has icon_name={agent_config.get('icon_name')}, icon_color={agent_config.get('icon_color')}, icon_background={agent_config.get('icon_background')}")
print(f"[DEBUG] get_agent: Final response will use icon fields from agent_config")
system_prompt = agent_config['system_prompt']
configured_mcps = agent_config['configured_mcps']
custom_mcps = agent_config['custom_mcps']
agentpress_tools = agent_config['agentpress_tools']
return AgentResponse(
response = AgentResponse(
agent_id=agent_data['agent_id'],
name=agent_data['name'],
description=agent_data.get('description'),
@ -1683,6 +1711,9 @@ async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_fr
avatar=agent_config.get('avatar'),
avatar_color=agent_config.get('avatar_color'),
profile_image_url=agent_config.get('profile_image_url'),
icon_name=agent_config.get('icon_name'),
icon_color=agent_config.get('icon_color'),
icon_background=agent_config.get('icon_background'),
created_at=agent_data['created_at'],
updated_at=agent_data.get('updated_at', agent_data['created_at']),
current_version_id=agent_data.get('current_version_id'),
@ -1691,6 +1722,15 @@ async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_fr
metadata=agent_data.get('metadata')
)
# Debug logging for the actual response
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] get_agent FINAL RESPONSE: agent_id={response.agent_id}")
print(f"[DEBUG] get_agent FINAL RESPONSE: icon_name={response.icon_name}, icon_color={response.icon_color}, icon_background={response.icon_background}")
print(f"[DEBUG] get_agent FINAL RESPONSE: profile_image_url={response.profile_image_url}, avatar={response.avatar}, avatar_color={response.avatar_color}")
print(f"[DEBUG] get_agent FINAL RESPONSE: Response being sent to frontend with icon fields from agent_config")
return response
except HTTPException:
raise
except Exception as e:
@ -1923,15 +1963,19 @@ async def create_agent(
"account_id": user_id,
"name": agent_data.name,
"description": agent_data.description,
# Deprecated fields still populated if sent by older clients
"avatar": agent_data.avatar,
"avatar_color": agent_data.avatar_color,
# New profile image url field
"profile_image_url": agent_data.profile_image_url,
"icon_name": agent_data.icon_name or "bot",
"icon_color": agent_data.icon_color or "#000000",
"icon_background": agent_data.icon_background or "#F3F4F6",
"is_default": agent_data.is_default or False,
"version_count": 1
}
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] create_agent: Creating with icon_name={insert_data.get('icon_name')}, icon_color={insert_data.get('icon_color')}, icon_background={insert_data.get('icon_background')}")
new_agent = await client.table('agents').insert(insert_data).execute()
if not new_agent.data:
@ -1990,7 +2034,8 @@ async def create_agent(
await Cache.invalidate(f"agent_count_limit:{user_id}")
logger.debug(f"Created agent {agent['agent_id']} with v1 for user: {user_id}")
return AgentResponse(
response = AgentResponse(
agent_id=agent['agent_id'],
name=agent['name'],
description=agent.get('description'),
@ -2005,6 +2050,9 @@ async def create_agent(
avatar=agent.get('avatar'),
avatar_color=agent.get('avatar_color'),
profile_image_url=agent.get('profile_image_url'),
icon_name=agent.get('icon_name'),
icon_color=agent.get('icon_color'),
icon_background=agent.get('icon_background'),
created_at=agent['created_at'],
updated_at=agent.get('updated_at', agent['created_at']),
current_version_id=agent.get('current_version_id'),
@ -2013,6 +2061,12 @@ async def create_agent(
metadata=agent.get('metadata')
)
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] create_agent RESPONSE: Returning icon_name={response.icon_name}, icon_color={response.icon_color}, icon_background={response.icon_background}")
print(f"[DEBUG] create_agent RESPONSE: Also returning profile_image_url={response.profile_image_url}, avatar={response.avatar}, avatar_color={response.avatar_color}")
return response
except HTTPException:
raise
except Exception as e:
@ -2053,6 +2107,12 @@ async def update_agent(
detail="Custom agent currently disabled. This feature is not available at the moment."
)
logger.debug(f"Updating agent {agent_id} for user: {user_id}")
# Debug logging for icon fields
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] update_agent: Received icon fields - icon_name={agent_data.icon_name}, icon_color={agent_data.icon_color}, icon_background={agent_data.icon_background}")
print(f"[DEBUG] update_agent: Also received - profile_image_url={agent_data.profile_image_url}, avatar={agent_data.avatar}, avatar_color={agent_data.avatar_color}")
client = await db.client
try:
@ -2252,6 +2312,17 @@ async def update_agent(
update_data["avatar_color"] = agent_data.avatar_color
if agent_data.profile_image_url is not None:
update_data["profile_image_url"] = agent_data.profile_image_url
# Handle new icon system fields
if agent_data.icon_name is not None:
update_data["icon_name"] = agent_data.icon_name
if agent_data.icon_color is not None:
update_data["icon_color"] = agent_data.icon_color
if agent_data.icon_background is not None:
update_data["icon_background"] = agent_data.icon_background
# Debug logging for update_data
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] update_agent: Prepared update_data with icon fields - icon_name={update_data.get('icon_name')}, icon_color={update_data.get('icon_color')}, icon_background={update_data.get('icon_background')}")
current_system_prompt = agent_data.system_prompt if agent_data.system_prompt is not None else current_version_data.get('system_prompt', '')
current_configured_mcps = agent_data.configured_mcps if agent_data.configured_mcps is not None else current_version_data.get('configured_mcps', [])
@ -2296,12 +2367,24 @@ async def update_agent(
if update_data:
try:
print(f"[DEBUG] update_agent DB UPDATE: About to update agent {agent_id} with data: {update_data}")
update_result = await client.table('agents').update(update_data).eq("agent_id", agent_id).eq("account_id", user_id).execute()
# Debug logging after DB update
if config.ENV_MODE == EnvMode.STAGING:
if update_result.data:
print(f"[DEBUG] update_agent DB UPDATE SUCCESS: Updated {len(update_result.data)} row(s)")
print(f"[DEBUG] update_agent DB UPDATE RESULT: {update_result.data[0] if update_result.data else 'No data'}")
else:
print(f"[DEBUG] update_agent DB UPDATE FAILED: No rows affected")
if not update_result.data:
raise HTTPException(status_code=500, detail="Failed to update agent - no rows affected")
except Exception as e:
logger.error(f"Error updating agent {agent_id}: {str(e)}")
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] update_agent DB UPDATE ERROR: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}")
updated_agent = await client.table('agents').select('*').eq("agent_id", agent_id).eq("account_id", user_id).maybe_single().execute()
@ -2311,6 +2394,11 @@ async def update_agent(
agent = updated_agent.data
print(f"[DEBUG] update_agent AFTER UPDATE FETCH: agent_id={agent.get('agent_id')}")
print(f"[DEBUG] update_agent AFTER UPDATE FETCH: icon_name={agent.get('icon_name')}, icon_color={agent.get('icon_color')}, icon_background={agent.get('icon_background')}")
print(f"[DEBUG] update_agent AFTER UPDATE FETCH: profile_image_url={agent.get('profile_image_url')}, avatar={agent.get('avatar')}, avatar_color={agent.get('avatar_color')}")
print(f"[DEBUG] update_agent AFTER UPDATE FETCH: All keys in agent: {agent.keys()}")
current_version = None
if agent.get('current_version_id'):
try:
@ -2359,14 +2447,23 @@ async def update_agent(
}
from agent.config_helper import extract_agent_config
# Debug logging before extract_agent_config
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] update_agent: Before extract_agent_config - agent has icon_name={agent.get('icon_name')}, icon_color={agent.get('icon_color')}, icon_background={agent.get('icon_background')}")
agent_config = extract_agent_config(agent, version_data)
# Debug logging after extract_agent_config
if config.ENV_MODE == EnvMode.STAGING:
print(f"[DEBUG] update_agent: After extract_agent_config - agent_config has icon_name={agent_config.get('icon_name')}, icon_color={agent_config.get('icon_color')}, icon_background={agent_config.get('icon_background')}")
system_prompt = agent_config['system_prompt']
configured_mcps = agent_config['configured_mcps']
custom_mcps = agent_config['custom_mcps']
agentpress_tools = agent_config['agentpress_tools']
return AgentResponse(
response = AgentResponse(
agent_id=agent['agent_id'],
name=agent['name'],
description=agent.get('description'),
@ -2380,6 +2477,9 @@ async def update_agent(
avatar=agent_config.get('avatar'),
avatar_color=agent_config.get('avatar_color'),
profile_image_url=agent_config.get('profile_image_url'),
icon_name=agent_config.get('icon_name'),
icon_color=agent_config.get('icon_color'),
icon_background=agent_config.get('icon_background'),
created_at=agent['created_at'],
updated_at=agent.get('updated_at', agent['created_at']),
current_version_id=agent.get('current_version_id'),
@ -2388,6 +2488,14 @@ async def update_agent(
metadata=agent.get('metadata')
)
print(f"[DEBUG] update_agent FINAL RESPONSE: agent_id={response.agent_id}")
print(f"[DEBUG] update_agent FINAL RESPONSE: icon_name={response.icon_name}, icon_color={response.icon_color}, icon_background={response.icon_background}")
print(f"[DEBUG] update_agent FINAL RESPONSE: profile_image_url={response.profile_image_url}, avatar={response.avatar}, avatar_color={response.avatar_color}")
print(f"[DEBUG] update_agent FINAL RESPONSE: Full response dict keys: {response.dict().keys()}")
return response
except HTTPException:
raise
except Exception as e:

View File

@ -1,5 +1,6 @@
from typing import Dict, Any, Optional, List
from utils.logger import logger
import os
def extract_agent_config(agent_data: Dict[str, Any], version_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@ -8,6 +9,11 @@ def extract_agent_config(agent_data: Dict[str, Any], version_data: Optional[Dict
metadata = agent_data.get('metadata', {})
is_suna_default = metadata.get('is_suna_default', False)
# Debug logging
if os.getenv("ENV_MODE", "").upper() == "STAGING":
print(f"[DEBUG] extract_agent_config: Called for agent {agent_id}, is_suna_default={is_suna_default}")
print(f"[DEBUG] extract_agent_config: Input agent_data has icon_name={agent_data.get('icon_name')}, icon_color={agent_data.get('icon_color')}, icon_background={agent_data.get('icon_background')}")
# Handle Suna agents with special logic
if is_suna_default:
return _extract_suna_agent_config(agent_data, version_data)
@ -49,9 +55,7 @@ def _extract_suna_agent_config(agent_data: Dict[str, Any], version_data: Optiona
}
}
# Add user customizations from version or agent data
if version_data:
# Get customizations from version data
if version_data.get('config'):
version_config = version_data['config']
tools = version_config.get('tools', {})
@ -60,13 +64,11 @@ def _extract_suna_agent_config(agent_data: Dict[str, Any], version_data: Optiona
config['workflows'] = version_config.get('workflows', [])
config['triggers'] = version_config.get('triggers', [])
else:
# Legacy version format
config['configured_mcps'] = version_data.get('configured_mcps', [])
config['custom_mcps'] = version_data.get('custom_mcps', [])
config['workflows'] = []
config['triggers'] = []
else:
# Fallback to agent data or empty
config['configured_mcps'] = agent_data.get('configured_mcps', [])
config['custom_mcps'] = agent_data.get('custom_mcps', [])
config['workflows'] = []
@ -76,13 +78,15 @@ def _extract_suna_agent_config(agent_data: Dict[str, Any], version_data: Optiona
def _extract_custom_agent_config(agent_data: Dict[str, Any], version_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Extract config for custom agents using versioning system."""
agent_id = agent_data.get('agent_id', 'Unknown')
# Debug logging for icon fields
if os.getenv("ENV_MODE", "").upper() == "STAGING":
print(f"[DEBUG] _extract_custom_agent_config: Input agent_data has icon_name={agent_data.get('icon_name')}, icon_color={agent_data.get('icon_color')}, icon_background={agent_data.get('icon_background')}")
if version_data:
logger.debug(f"Using version data for custom agent {agent_id} (version: {version_data.get('version_name', 'unknown')})")
# Extract from version data
if version_data.get('config'):
config = version_data['config'].copy()
system_prompt = config.get('system_prompt', '')
@ -94,7 +98,6 @@ def _extract_custom_agent_config(agent_data: Dict[str, Any], version_data: Optio
workflows = config.get('workflows', [])
triggers = config.get('triggers', [])
else:
# Legacy version format
system_prompt = version_data.get('system_prompt', '')
model = version_data.get('model')
configured_mcps = version_data.get('configured_mcps', [])
@ -103,7 +106,7 @@ def _extract_custom_agent_config(agent_data: Dict[str, Any], version_data: Optio
workflows = []
triggers = []
return {
config = {
'agent_id': agent_data['agent_id'],
'name': agent_data['name'],
'description': agent_data.get('description'),
@ -117,6 +120,9 @@ def _extract_custom_agent_config(agent_data: Dict[str, Any], version_data: Optio
'avatar': agent_data.get('avatar'),
'avatar_color': agent_data.get('avatar_color'),
'profile_image_url': agent_data.get('profile_image_url'),
'icon_name': agent_data.get('icon_name'),
'icon_color': agent_data.get('icon_color'),
'icon_background': agent_data.get('icon_background'),
'is_default': agent_data.get('is_default', False),
'is_suna_default': False,
'centrally_managed': False,
@ -125,11 +131,16 @@ def _extract_custom_agent_config(agent_data: Dict[str, Any], version_data: Optio
'version_name': version_data.get('version_name', 'v1'),
'restrictions': {}
}
# Debug logging for returned config
if os.getenv("ENV_MODE", "").upper() == "STAGING":
print(f"[DEBUG] _extract_custom_agent_config: Returning config with icon_name={config.get('icon_name')}, icon_color={config.get('icon_color')}, icon_background={config.get('icon_background')}")
return config
# Fallback: create default config for custom agents without version data
logger.warning(f"No version data found for custom agent {agent_id}, creating default configuration")
return {
fallback_config = {
'agent_id': agent_data['agent_id'],
'name': agent_data.get('name', 'Unnamed Agent'),
'description': agent_data.get('description', ''),
@ -143,6 +154,9 @@ def _extract_custom_agent_config(agent_data: Dict[str, Any], version_data: Optio
'avatar': agent_data.get('avatar'),
'avatar_color': agent_data.get('avatar_color'),
'profile_image_url': agent_data.get('profile_image_url'),
'icon_name': agent_data.get('icon_name'),
'icon_color': agent_data.get('icon_color'),
'icon_background': agent_data.get('icon_background'),
'is_default': agent_data.get('is_default', False),
'is_suna_default': False,
'centrally_managed': False,
@ -151,6 +165,12 @@ def _extract_custom_agent_config(agent_data: Dict[str, Any], version_data: Optio
'version_name': 'v1',
'restrictions': {}
}
# Debug logging for fallback config
if os.getenv("ENV_MODE", "").upper() == "STAGING":
print(f"[DEBUG] _extract_custom_agent_config: Fallback config with icon_name={fallback_config.get('icon_name')}, icon_color={fallback_config.get('icon_color')}, icon_background={fallback_config.get('icon_background')}")
return fallback_config
def build_unified_config(

View File

@ -54,11 +54,12 @@ class JsonImportService:
agent_info = {
'name': json_data.get('name', 'Imported Agent'),
'description': json_data.get('description', ''),
# Deprecated fields
'avatar': json_data.get('avatar'),
'avatar_color': json_data.get('avatar_color'),
# New field
'profile_image_url': json_data.get('profile_image_url') or json_data.get('metadata', {}).get('profile_image_url')
'profile_image_url': json_data.get('profile_image_url') or json_data.get('metadata', {}).get('profile_image_url'),
'icon_name': json_data.get('icon_name', 'brain'),
'icon_color': json_data.get('icon_color', '#000000'),
'icon_background': json_data.get('icon_background', '#F3F4F6')
}
return JsonImportAnalysis(
@ -93,11 +94,12 @@ class JsonImportService:
agent_info={
'name': json_data.get('name', 'Imported Agent'),
'description': json_data.get('description', ''),
# Deprecated
'avatar': json_data.get('avatar'),
'avatar_color': json_data.get('avatar_color'),
# New
'profile_image_url': json_data.get('profile_image_url') or json_data.get('metadata', {}).get('profile_image_url')
'profile_image_url': json_data.get('profile_image_url') or json_data.get('metadata', {}).get('profile_image_url'),
'icon_name': json_data.get('icon_name', 'brain'),
'icon_color': json_data.get('icon_color', '#000000'),
'icon_background': json_data.get('icon_background', '#F3F4F6')
}
)
@ -299,6 +301,10 @@ class JsonImportService:
"description": json_data.get('description', ''),
"avatar": json_data.get('avatar'),
"avatar_color": json_data.get('avatar_color'),
"profile_image_url": json_data.get('profile_image_url'),
"icon_name": json_data.get('icon_name', 'brain'),
"icon_color": json_data.get('icon_color', '#000000'),
"icon_background": json_data.get('icon_background', '#F3F4F6'),
"is_default": False,
"tags": json_data.get('tags', []),
"version_count": 1,

View File

@ -133,7 +133,20 @@ async def log_requests_middleware(request: Request, call_next):
allowed_origins = ["https://www.suna.so", "https://suna.so"]
allow_origin_regex = None
# Add staging-specific origins
# Add Claude Code origins for MCP
allowed_origins.extend([
"https://claude.ai",
"https://www.claude.ai",
"https://app.claude.ai",
"http://localhost",
"http://127.0.0.1",
"http://192.168.1.1"
])
# Add wildcard for local development and Claude Code CLI
allow_origin_regex = r"https://.*\.claude\.ai|http://localhost.*|http://127\.0\.0\.1.*|http://192\.168\..*|http://10\..*"
# Add local environment origins
if config.ENV_MODE == EnvMode.LOCAL:
allowed_origins.append("http://localhost:3000")
@ -149,7 +162,7 @@ app.add_middleware(
allow_origin_regex=allow_origin_regex,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers", "X-Refresh-Token", "X-API-Key"],
allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers", "X-Refresh-Token", "X-API-Key", "Mcp-Session-Id"],
)
# Create a main API router
@ -191,6 +204,41 @@ api_router.include_router(admin_api.router)
from composio_integration import api as composio_api
api_router.include_router(composio_api.router)
# Include MCP Kortix Layer
from mcp_kortix_layer import mcp_router
api_router.include_router(mcp_router)
# Add OAuth discovery endpoints at root level for Claude Code MCP
@api_router.get("/.well-known/oauth-authorization-server")
async def oauth_authorization_server():
"""OAuth authorization server metadata for Claude Code MCP"""
return {
"issuer": "https://api2.restoned.app",
"authorization_endpoint": "https://api2.restoned.app/api/mcp/oauth/authorize",
"token_endpoint": "https://api2.restoned.app/api/mcp/oauth/token",
"registration_endpoint": "https://api2.restoned.app/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"token_endpoint_auth_methods_supported": ["none"]
}
@api_router.get("/.well-known/oauth-protected-resource")
async def oauth_protected_resource():
"""OAuth protected resource metadata for Claude Code MCP"""
return {
"resource": "https://api2.restoned.app/api/mcp",
"authorization_servers": ["https://api2.restoned.app"]
}
@api_router.post("/register")
async def oauth_register():
"""OAuth client registration for Claude Code MCP"""
return {
"client_id": "claude-code-mcp-client",
"client_secret": "not-required-for-api-key-auth",
"message": "AgentPress MCP uses API key authentication - provide your key via Authorization header"
}
@api_router.get("/health")
async def health_check():
logger.debug("Health check endpoint called")

867
backend/mcp_kortix_layer.py Normal file
View File

@ -0,0 +1,867 @@
"""
MCP Layer for AgentPress Agent Invocation
Allows Claude Code to discover and invoke your custom AgentPress agents and workflows
through MCP (Model Context Protocol) instead of using generic capabilities.
🚀 ADD TO CLAUDE CODE:
```bash
claude mcp add --transport http "AgentPress" "https://your-backend-domain.com/api/mcp?key=pk_your_key:sk_your_secret"
```
claude mcp add AgentPress https://api2.restoned.app/api/mcp --header
"Authorization=Bearer pk_your_key:sk_your_secret"
📋 SETUP STEPS:
1. Deploy your AgentPress backend with this MCP layer
2. Get your API key from your-frontend-domain.com/settings/api-keys
3. Replace your-backend-domain.com and API key in the command above
4. Run the command in Claude Code
🎯 BENEFITS:
Claude Code uses YOUR specialized agents instead of generic ones
Real execution of your custom prompts and workflows
Uses existing AgentPress authentication and infrastructure
Transforms your agents into Claude Code tools
📡 TOOLS PROVIDED:
- get_agent_list: List your agents
- get_agent_workflows: List agent workflows
- run_agent: Execute agents with prompts or workflows
"""
from fastapi import APIRouter, Request, Response
from typing import Dict, Any, Union, Optional
from pydantic import BaseModel
import asyncio
from datetime import datetime
from utils.logger import logger
from services.supabase import DBConnection
# Create MCP router that wraps existing endpoints
mcp_router = APIRouter(prefix="/mcp", tags=["MCP Kortix Layer"])
# Initialize database connection
db = DBConnection()
class JSONRPCRequest(BaseModel):
"""JSON-RPC 2.0 request format"""
jsonrpc: str = "2.0"
method: str
params: Optional[Dict[str, Any]] = None
id: Union[str, int, None] = None
class JSONRPCSuccessResponse(BaseModel):
"""JSON-RPC 2.0 success response format"""
jsonrpc: str = "2.0"
result: Any
id: Union[str, int, None]
class JSONRPCError(BaseModel):
"""JSON-RPC 2.0 error object"""
code: int
message: str
data: Optional[Any] = None
class JSONRPCErrorResponse(BaseModel):
"""JSON-RPC 2.0 error response format"""
jsonrpc: str = "2.0"
error: JSONRPCError
id: Union[str, int, None]
# JSON-RPC error codes
class JSONRPCErrorCodes:
PARSE_ERROR = -32700
INVALID_REQUEST = -32600
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
INTERNAL_ERROR = -32603
UNAUTHORIZED = -32001 # Custom error for auth failures
def extract_api_key_from_request(request: Request) -> tuple[str, str]:
"""Extract and parse API key from request URL parameters or Authorization header."""
# Try Authorization header first (for Claude Code)
auth_header = request.headers.get("authorization")
if auth_header:
if auth_header.startswith("Bearer "):
key_param = auth_header[7:] # Remove "Bearer " prefix
else:
key_param = auth_header
if ":" in key_param:
try:
public_key, secret_key = key_param.split(":", 1)
if public_key.startswith("pk_") and secret_key.startswith("sk_"):
return public_key, secret_key
except:
pass
# Fallback to URL parameter (for curl testing)
key_param = request.query_params.get("key")
if key_param:
if ":" not in key_param:
raise ValueError("Invalid key format. Expected 'pk_xxx:sk_xxx'")
try:
public_key, secret_key = key_param.split(":", 1)
if not public_key.startswith("pk_") or not secret_key.startswith("sk_"):
raise ValueError("Invalid key format. Expected 'pk_xxx:sk_xxx'")
return public_key, secret_key
except Exception as e:
raise ValueError(f"Failed to parse API key: {str(e)}")
# No valid auth found
raise ValueError("Missing API key. Provide via Authorization header: 'Bearer pk_xxx:sk_xxx' or URL parameter: '?key=pk_xxx:sk_xxx'")
async def authenticate_api_key(public_key: str, secret_key: str) -> str:
"""Authenticate API key and return account_id."""
try:
# Use the existing API key service for validation
from services.api_keys import APIKeyService
api_key_service = APIKeyService(db)
# Validate the API key
validation_result = await api_key_service.validate_api_key(public_key, secret_key)
if not validation_result.is_valid:
raise ValueError(validation_result.error_message or "Invalid API key")
account_id = str(validation_result.account_id)
logger.info(f"API key authenticated for account_id: {account_id}")
return account_id
except Exception as e:
logger.error(f"API key authentication failed: {str(e)}")
raise ValueError(f"Authentication failed: {str(e)}")
def extract_last_message(full_output: str) -> str:
"""Extract the last meaningful message from agent output."""
if not full_output.strip():
return "No output received"
lines = full_output.strip().split('\n')
# Look for the last substantial message
for line in reversed(lines):
if line.strip() and not line.startswith('#') and not line.startswith('```'):
try:
line_index = lines.index(line)
start_index = max(0, line_index - 3)
return '\n'.join(lines[start_index:]).strip()
except ValueError:
return line.strip()
# Fallback: return last 20% of the output
return full_output[-len(full_output)//5:].strip() if len(full_output) > 100 else full_output
def truncate_from_end(text: str, max_tokens: int) -> str:
"""Truncate text from the beginning, keeping the end."""
if max_tokens <= 0:
return ""
max_chars = max_tokens * 4 # Rough token estimation
if len(text) <= max_chars:
return text
truncated = text[-max_chars:]
return f"...[truncated {len(text) - max_chars} characters]...\n{truncated}"
@mcp_router.post("/")
@mcp_router.post("") # Handle requests without trailing slash
async def mcp_handler(
request: JSONRPCRequest,
http_request: Request
):
"""Main MCP endpoint handling JSON-RPC 2.0 requests."""
try:
# Authenticate API key from URL parameters
try:
public_key, secret_key = extract_api_key_from_request(http_request)
account_id = await authenticate_api_key(public_key, secret_key)
except ValueError as auth_error:
logger.warning(f"Authentication failed: {str(auth_error)}")
return JSONRPCErrorResponse(
error=JSONRPCError(
code=JSONRPCErrorCodes.UNAUTHORIZED,
message=f"Authentication failed: {str(auth_error)}"
),
id=request.id
)
# Validate JSON-RPC format
if request.jsonrpc != "2.0":
return JSONRPCErrorResponse(
error=JSONRPCError(
code=JSONRPCErrorCodes.INVALID_REQUEST,
message="Invalid JSON-RPC version"
),
id=request.id
)
method = request.method
params = request.params or {}
logger.info(f"MCP JSON-RPC call: {method} for account: {account_id}")
# Handle different MCP methods
if method == "initialize":
result = {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "agentpress",
"version": "1.0.0"
}
}
elif method == "tools/list":
result = await handle_tools_list()
elif method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
if not tool_name:
return JSONRPCErrorResponse(
error=JSONRPCError(
code=JSONRPCErrorCodes.INVALID_PARAMS,
message="Missing 'name' parameter for tools/call"
),
id=request.id
)
result = await handle_tool_call(tool_name, arguments, account_id)
else:
return JSONRPCErrorResponse(
error=JSONRPCError(
code=JSONRPCErrorCodes.METHOD_NOT_FOUND,
message=f"Method '{method}' not found"
),
id=request.id
)
return JSONRPCSuccessResponse(
result=result,
id=request.id
)
except Exception as e:
logger.error(f"Error in MCP JSON-RPC handler: {str(e)}")
return JSONRPCErrorResponse(
error=JSONRPCError(
code=JSONRPCErrorCodes.INTERNAL_ERROR,
message=f"Internal error: {str(e)}"
),
id=getattr(request, 'id', None)
)
async def handle_tools_list():
"""Handle tools/list method."""
tools = [
{
"name": "get_agent_list",
"description": "Get a list of all available agents in your account. Always call this tool first.",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "get_agent_workflows",
"description": "Get a list of available workflows for a specific agent.",
"inputSchema": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "The ID of the agent to get workflows for"
}
},
"required": ["agent_id"]
}
},
{
"name": "run_agent",
"description": "Run a specific agent with a message and get formatted output.",
"inputSchema": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "The ID of the agent to run"
},
"message": {
"type": "string",
"description": "The message/prompt to send to the agent"
},
"execution_mode": {
"type": "string",
"enum": ["prompt", "workflow"],
"description": "Either 'prompt' for custom prompt execution or 'workflow' for workflow execution"
},
"workflow_id": {
"type": "string",
"description": "Required when execution_mode is 'workflow' - the ID of the workflow to run"
},
"output_mode": {
"type": "string",
"enum": ["last_message", "full"],
"description": "How to format output: 'last_message' (default) or 'full'"
},
"max_tokens": {
"type": "integer",
"description": "Maximum tokens in response"
},
"model_name": {
"type": "string",
"description": "Model to use for the agent execution. If not specified, uses the agent's configured model or fallback."
}
},
"required": ["agent_id", "message"]
}
}
]
return {"tools": tools}
async def handle_tool_call(tool_name: str, arguments: Dict[str, Any], account_id: str):
"""Handle tools/call method."""
try:
if tool_name == "get_agent_list":
result = await call_get_agents_endpoint(account_id)
elif tool_name == "get_agent_workflows":
agent_id = arguments.get("agent_id")
if not agent_id:
raise ValueError("agent_id is required")
result = await call_get_agent_workflows_endpoint(account_id, agent_id)
elif tool_name == "run_agent":
agent_id = arguments.get("agent_id")
message = arguments.get("message")
execution_mode = arguments.get("execution_mode", "prompt")
workflow_id = arguments.get("workflow_id")
output_mode = arguments.get("output_mode", "last_message")
max_tokens = arguments.get("max_tokens", 1000)
model_name = arguments.get("model_name")
if not agent_id or not message:
raise ValueError("agent_id and message are required")
if execution_mode == "workflow" and not workflow_id:
raise ValueError("workflow_id is required when execution_mode is 'workflow'")
result = await call_run_agent_endpoint(
account_id, agent_id, message, execution_mode, workflow_id, output_mode, max_tokens, model_name
)
else:
raise ValueError(f"Unknown tool: {tool_name}")
# Return MCP-compatible tool call result
return {
"content": [{
"type": "text",
"text": result
}]
}
except Exception as e:
logger.error(f"Error in tool call {tool_name}: {str(e)}")
raise e
async def call_get_agents_endpoint(account_id: str) -> str:
"""Call the existing /agents endpoint and format for MCP."""
try:
# Import the get_agents function from agent.api
from agent.api import get_agents
# Call the existing endpoint
response = await get_agents(
user_id=account_id,
page=1,
limit=100, # Get all agents
search=None,
sort_by="created_at",
sort_order="desc",
has_default=None,
has_mcp_tools=None,
has_agentpress_tools=None,
tools=None
)
# Handle both dict and object response formats
if hasattr(response, 'agents'):
agents = response.agents
elif isinstance(response, dict) and 'agents' in response:
agents = response['agents']
else:
logger.error(f"Unexpected response format from get_agents: {type(response)}")
return f"Error: Unexpected response format from get_agents: {response}"
if not agents:
return "No agents found in your account. Create some agents first in your frontend."
agent_list = "🤖 Available Agents in Your Account:\n\n"
for i, agent in enumerate(agents, 1):
# Handle both dict and object formats for individual agents
agent_id = agent.agent_id if hasattr(agent, 'agent_id') else agent.get('agent_id')
name = agent.name if hasattr(agent, 'name') else agent.get('name')
description = agent.description if hasattr(agent, 'description') else agent.get('description')
agent_list += f"{i}. Agent ID: {agent_id}\n"
agent_list += f" Name: {name}\n"
if description:
agent_list += f" Description: {description}\n"
agent_list += "\n"
agent_list += "📝 Use the 'run_agent' tool with the Agent ID to invoke any of these agents."
logger.info(f"Listed {len(agents)} agents via MCP")
return agent_list
except Exception as e:
logger.error(f"Error in get_agent_list: {str(e)}")
return f"Error listing agents: {str(e)}"
async def verify_agent_access(agent_id: str, account_id: str):
"""Verify account has access to the agent."""
try:
client = await db.client
result = await client.table('agents').select('agent_id').eq('agent_id', agent_id).eq('account_id', account_id).execute()
if not result.data:
raise ValueError("Agent not found or access denied")
except Exception as e:
logger.error(f"Database error in verify_agent_access: {str(e)}")
raise ValueError("Database connection error")
async def call_get_agent_workflows_endpoint(account_id: str, agent_id: str) -> str:
"""Get workflows for a specific agent."""
try:
# Verify agent access
await verify_agent_access(agent_id, account_id)
# Get workflows from database
client = await db.client
result = await client.table('agent_workflows').select('*').eq('agent_id', agent_id).order('created_at', desc=True).execute()
if not result.data:
return f"No workflows found for agent {agent_id}. This agent can only be run with custom prompts."
workflow_list = f"🔄 Available Workflows for Agent {agent_id}:\n\n"
for i, workflow in enumerate(result.data, 1):
workflow_list += f"{i}. Workflow ID: {workflow['id']}\n"
workflow_list += f" Name: {workflow['name']}\n"
if workflow.get('description'):
workflow_list += f" Description: {workflow['description']}\n"
workflow_list += f" Status: {workflow.get('status', 'unknown')}\n"
workflow_list += "\n"
workflow_list += "📝 Use the 'run_agent' tool with execution_mode='workflow' and the Workflow ID to run a workflow."
logger.info(f"Listed {len(result.data)} workflows for agent {agent_id} via MCP")
return workflow_list
except Exception as e:
logger.error(f"Error in get_agent_workflows: {str(e)}")
return f"Error listing workflows: {str(e)}"
async def call_run_agent_endpoint(
account_id: str,
agent_id: str,
message: str,
execution_mode: str = "prompt",
workflow_id: Optional[str] = None,
output_mode: str = "last_message",
max_tokens: int = 1000,
model_name: Optional[str] = None
) -> str:
"""Call the existing agent run endpoints and format for MCP."""
try:
# Validate execution mode and workflow parameters
if execution_mode not in ["prompt", "workflow"]:
return "Error: execution_mode must be either 'prompt' or 'workflow'"
if execution_mode == "workflow" and not workflow_id:
return "Error: workflow_id is required when execution_mode is 'workflow'"
# Verify agent access
await verify_agent_access(agent_id, account_id)
# Apply model fallback if no model specified
if not model_name:
# Use a reliable free-tier model as fallback
model_name = "openrouter/google/gemini-2.5-flash"
logger.info(f"No model specified for agent {agent_id}, using fallback: {model_name}")
if execution_mode == "workflow":
# Execute workflow using the existing workflow execution endpoint
result = await execute_agent_workflow_internal(agent_id, workflow_id, message, account_id, model_name)
else:
# Execute agent with prompt using existing agent endpoints
result = await execute_agent_prompt_internal(agent_id, message, account_id, model_name)
# Process the output based on the requested mode
if output_mode == "last_message":
processed_output = extract_last_message(result)
else:
processed_output = result
# Apply token limiting
final_output = truncate_from_end(processed_output, max_tokens)
logger.info(f"MCP agent run completed for agent {agent_id} in {execution_mode} mode")
return final_output
except Exception as e:
logger.error(f"Error running agent {agent_id}: {str(e)}")
return f"Error running agent: {str(e)}"
async def execute_agent_workflow_internal(agent_id: str, workflow_id: str, message: str, account_id: str, model_name: Optional[str] = None) -> str:
"""Execute an agent workflow."""
try:
client = await db.client
# Verify workflow exists and is active
workflow_result = await client.table('agent_workflows').select('*').eq('id', workflow_id).eq('agent_id', agent_id).execute()
if not workflow_result.data:
return f"Error: Workflow {workflow_id} not found for agent {agent_id}"
workflow = workflow_result.data[0]
if workflow.get('status') != 'active':
return f"Error: Workflow {workflow['name']} is not active (status: {workflow.get('status')})"
# Execute workflow through the execution service
try:
from triggers.execution_service import execute_workflow
# Execute the workflow with the provided message
execution_result = await execute_workflow(
workflow_id=workflow_id,
agent_id=agent_id,
input_data={"message": message},
user_id=account_id
)
if execution_result.get('success'):
return execution_result.get('output', f"Workflow '{workflow['name']}' executed successfully")
else:
return f"Workflow execution failed: {execution_result.get('error', 'Unknown error')}"
except ImportError:
logger.warning("Execution service not available, using fallback workflow execution")
# Fallback: Create a thread and run the agent with workflow context
from agent.api import create_thread, add_message_to_thread, start_agent, AgentStartRequest
# Create thread with workflow context
thread_response = await create_thread(
name=f"Workflow: {workflow['name']}",
user_id=account_id
)
thread_id = thread_response.get('thread_id') if isinstance(thread_response, dict) else thread_response.thread_id
# Add workflow context message
workflow_context = f"Executing workflow '{workflow['name']}'"
if workflow.get('description'):
workflow_context += f": {workflow['description']}"
workflow_context += f"\n\nUser message: {message}"
await add_message_to_thread(
thread_id=thread_id,
message=workflow_context,
user_id=account_id
)
# Start agent with workflow execution
agent_request = AgentStartRequest(
agent_id=agent_id,
enable_thinking=False,
stream=False,
model_name=model_name
)
await start_agent(
thread_id=thread_id,
body=agent_request,
user_id=account_id
)
# Wait for completion (similar to prompt execution)
client = await db.client
max_wait = 90 # Longer timeout for workflows
poll_interval = 3
elapsed = 0
while elapsed < max_wait:
messages_result = await client.table('messages').select('*').eq('thread_id', thread_id).order('created_at', desc=True).limit(5).execute()
if messages_result.data:
for msg in messages_result.data:
# Parse JSON content to check role
content = msg.get('content')
if content:
try:
import json
if isinstance(content, str):
parsed_content = json.loads(content)
else:
parsed_content = content
if parsed_content.get('role') == 'assistant':
return parsed_content.get('content', '')
except:
# If parsing fails, check if it's a direct assistant message
if msg.get('type') == 'assistant':
return content
runs_result = await client.table('agent_runs').select('status, error').eq('thread_id', thread_id).order('created_at', desc=True).limit(1).execute()
if runs_result.data:
run = runs_result.data[0]
if run['status'] in ['completed', 'failed', 'cancelled']:
if run['status'] == 'failed':
return f"Workflow execution failed: {run.get('error', 'Unknown error')}"
elif run['status'] == 'cancelled':
return "Workflow execution was cancelled"
break
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return f"Workflow '{workflow['name']}' execution timed out after {max_wait}s. Thread ID: {thread_id}"
except Exception as e:
logger.error(f"Error executing workflow {workflow_id}: {str(e)}")
return f"Error executing workflow: {str(e)}"
async def execute_agent_prompt_internal(agent_id: str, message: str, account_id: str, model_name: Optional[str] = None) -> str:
"""Execute an agent with a custom prompt."""
try:
# Import existing agent execution functions
from agent.api import create_thread, add_message_to_thread, start_agent, AgentStartRequest
# Create a new thread
thread_response = await create_thread(name="MCP Agent Run", user_id=account_id)
thread_id = thread_response.get('thread_id') if isinstance(thread_response, dict) else thread_response.thread_id
# Add the message to the thread
await add_message_to_thread(
thread_id=thread_id,
message=message,
user_id=account_id
)
# Start the agent
agent_request = AgentStartRequest(
agent_id=agent_id,
enable_thinking=False,
stream=False,
model_name=model_name
)
# Start the agent
await start_agent(
thread_id=thread_id,
body=agent_request,
user_id=account_id
)
# Wait for agent completion and get response
client = await db.client
# Poll for completion (max 60 seconds)
max_wait = 60
poll_interval = 2
elapsed = 0
while elapsed < max_wait:
# Check thread messages for agent response
messages_result = await client.table('messages').select('*').eq('thread_id', thread_id).order('created_at', desc=True).limit(5).execute()
if messages_result.data:
# Look for the most recent agent message (not user message)
for msg in messages_result.data:
# Parse JSON content to check role
content = msg.get('content')
if content:
try:
import json
if isinstance(content, str):
parsed_content = json.loads(content)
else:
parsed_content = content
if parsed_content.get('role') == 'assistant':
return parsed_content.get('content', '')
except:
# If parsing fails, check if it's a direct assistant message
if msg.get('type') == 'assistant':
return content
# Check if agent run is complete by checking agent_runs table
runs_result = await client.table('agent_runs').select('status, error').eq('thread_id', thread_id).order('created_at', desc=True).limit(1).execute()
if runs_result.data:
run = runs_result.data[0]
if run['status'] in ['completed', 'failed', 'cancelled']:
if run['status'] == 'failed':
return f"Agent execution failed: {run.get('error', 'Unknown error')}"
elif run['status'] == 'cancelled':
return "Agent execution was cancelled"
# If completed, continue to check for messages
break
await asyncio.sleep(poll_interval)
elapsed += poll_interval
# Timeout fallback - get latest messages
messages_result = await client.table('messages').select('*').eq('thread_id', thread_id).order('created_at', desc=True).limit(10).execute()
if messages_result.data:
# Return the most recent assistant message or fallback message
for msg in messages_result.data:
# Parse JSON content to check role
content = msg.get('content')
if content:
try:
import json
if isinstance(content, str):
parsed_content = json.loads(content)
else:
parsed_content = content
if parsed_content.get('role') == 'assistant':
return parsed_content.get('content', '')
except:
# If parsing fails, check if it's a direct assistant message
if msg.get('type') == 'assistant':
return content
return f"Agent execution timed out after {max_wait}s. Thread ID: {thread_id}"
return f"No response received from agent {agent_id}. Thread ID: {thread_id}"
except Exception as e:
logger.error(f"Error executing agent prompt: {str(e)}")
return f"Error executing agent: {str(e)}"
@mcp_router.get("/health")
async def mcp_health_check():
"""Health check for MCP layer"""
return {"status": "healthy", "service": "mcp-kortix-layer"}
# OAuth 2.0 endpoints for Claude Code compatibility
@mcp_router.get("/oauth/authorize")
async def oauth_authorize(
response_type: str = None,
client_id: str = None,
redirect_uri: str = None,
scope: str = None,
state: str = None,
code_challenge: str = None,
code_challenge_method: str = None
):
"""OAuth authorization endpoint - redirect with authorization code"""
from fastapi.responses import RedirectResponse
import secrets
# Generate a dummy authorization code (since we use API keys)
auth_code = f"ac_{secrets.token_urlsafe(32)}"
# Build redirect URL with authorization code and state
redirect_url = f"{redirect_uri}?code={auth_code}"
if state:
redirect_url += f"&state={state}"
logger.info(f"OAuth authorize redirecting to: {redirect_url}")
return RedirectResponse(url=redirect_url)
@mcp_router.post("/oauth/token")
async def oauth_token(
grant_type: str = None,
code: str = None,
redirect_uri: str = None,
client_id: str = None,
client_secret: str = None,
code_verifier: str = None
):
"""OAuth token endpoint - simplified for API key flow with PKCE support"""
return {
"access_token": "use_api_key_instead",
"token_type": "bearer",
"message": "AgentPress MCP Server uses API key authentication",
"instructions": "Use your API key as Bearer token: Authorization: Bearer pk_xxx:sk_xxx",
"pkce_supported": True
}
@mcp_router.options("/")
@mcp_router.options("") # Handle OPTIONS without trailing slash
async def mcp_options():
"""Handle CORS preflight for MCP endpoint"""
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id"
}
)
@mcp_router.get("/.well-known/mcp")
async def mcp_discovery():
"""MCP discovery endpoint for Claude Code"""
return {
"mcpVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"implementation": {
"name": "AgentPress MCP Server",
"version": "1.0.0"
},
"oauth": {
"authorization_endpoint": "/api/mcp/oauth/authorize",
"token_endpoint": "/api/mcp/oauth/token",
"supported_flows": ["authorization_code"]
},
"instructions": "Use API key authentication via Authorization header: Bearer pk_xxx:sk_xxx"
}
@mcp_router.get("/")
@mcp_router.get("")
async def mcp_health():
"""Health check endpoint for MCP server"""
return {
"status": "healthy",
"service": "agentpress-mcp-server",
"version": "1.0.0",
"timestamp": datetime.now().isoformat()
}

View File

@ -0,0 +1,24 @@
BEGIN;
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS icon_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS icon_color VARCHAR(7) DEFAULT '#000000',
ADD COLUMN IF NOT EXISTS icon_background VARCHAR(7) DEFAULT '#F3F4F6';
ALTER TABLE agent_templates
ADD COLUMN IF NOT EXISTS icon_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS icon_color VARCHAR(7) DEFAULT '#000000',
ADD COLUMN IF NOT EXISTS icon_background VARCHAR(7) DEFAULT '#F3F4F6';
CREATE INDEX IF NOT EXISTS idx_agents_icon_name ON agents(icon_name) WHERE icon_name IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_agent_templates_icon_name ON agent_templates(icon_name) WHERE icon_name IS NOT NULL;
COMMENT ON COLUMN agents.icon_name IS 'Optional: Lucide React icon name for agent profile (used when profile_image_url is not set)';
COMMENT ON COLUMN agents.icon_color IS 'Optional: Hex color for the icon (e.g., #000000)';
COMMENT ON COLUMN agents.icon_background IS 'Optional: Hex background color for the icon container';
COMMENT ON COLUMN agent_templates.icon_name IS 'Optional: Lucide React icon name for template profile (used when profile_image_url is not set)';
COMMENT ON COLUMN agent_templates.icon_color IS 'Optional: Hex color for the icon (e.g., #000000)';
COMMENT ON COLUMN agent_templates.icon_background IS 'Optional: Hex background color for the icon container';
COMMIT;

View File

@ -63,6 +63,9 @@ class TemplateResponse(BaseModel):
avatar: Optional[str]
avatar_color: Optional[str]
profile_image_url: Optional[str] = None
icon_name: Optional[str] = None
icon_color: Optional[str] = None
icon_background: Optional[str] = None
metadata: Dict[str, Any]
creator_name: Optional[str] = None

View File

@ -359,6 +359,9 @@ class InstallationService:
'avatar': template.avatar,
'avatar_color': template.avatar_color,
'profile_image_url': template.profile_image_url,
'icon_name': template.icon_name or 'brain',
'icon_color': template.icon_color or '#000000',
'icon_background': template.icon_background or '#F3F4F6',
'metadata': {
**template.metadata,
'created_from_template': template.template_id,

View File

@ -49,6 +49,9 @@ class AgentTemplate:
avatar: Optional[str] = None
avatar_color: Optional[str] = None
profile_image_url: Optional[str] = None
icon_name: Optional[str] = None
icon_color: Optional[str] = None
icon_background: Optional[str] = None
metadata: ConfigType = field(default_factory=dict)
creator_name: Optional[str] = None
@ -223,6 +226,9 @@ class TemplateService:
avatar=agent.get('avatar'),
avatar_color=agent.get('avatar_color'),
profile_image_url=agent.get('profile_image_url'),
icon_name=agent.get('icon_name'),
icon_color=agent.get('icon_color'),
icon_background=agent.get('icon_background'),
metadata=agent.get('metadata', {})
)
@ -629,6 +635,9 @@ class TemplateService:
'avatar': template.avatar,
'avatar_color': template.avatar_color,
'profile_image_url': template.profile_image_url,
'icon_name': template.icon_name,
'icon_color': template.icon_color,
'icon_background': template.icon_background,
'metadata': template.metadata
}
@ -653,6 +662,9 @@ class TemplateService:
avatar=data.get('avatar'),
avatar_color=data.get('avatar_color'),
profile_image_url=data.get('profile_image_url'),
icon_name=data.get('icon_name'),
icon_color=data.get('icon_color'),
icon_background=data.get('icon_background'),
metadata=data.get('metadata', {}),
creator_name=creator_name
)

View File

@ -136,6 +136,9 @@ def format_template_for_response(template: AgentTemplate) -> Dict[str, Any]:
'avatar': template.avatar,
'avatar_color': template.avatar_color,
'profile_image_url': template.profile_image_url,
'icon_name': template.icon_name,
'icon_color': template.icon_color,
'icon_background': template.icon_background,
'metadata': template.metadata,
'creator_name': template.creator_name
}

View File

@ -101,6 +101,9 @@ interface FormData {
custom_mcps: any[];
is_default: boolean;
profile_image_url?: string;
icon_name?: string | null;
icon_color: string;
icon_background: string;
}
function AgentConfigurationContent() {
@ -130,6 +133,9 @@ function AgentConfigurationContent() {
custom_mcps: [],
is_default: false,
profile_image_url: '',
icon_name: null,
icon_color: '#000000',
icon_background: '#e5e5e5',
});
const [originalData, setOriginalData] = useState<FormData>(formData);
@ -147,6 +153,9 @@ function AgentConfigurationContent() {
configured_mcps: versionData.configured_mcps,
custom_mcps: versionData.custom_mcps,
agentpress_tools: versionData.agentpress_tools,
icon_name: versionData.icon_name || agent.icon_name,
icon_color: versionData.icon_color || agent.icon_color,
icon_background: versionData.icon_background || agent.icon_background,
};
}
const newFormData: FormData = {
@ -159,6 +168,9 @@ function AgentConfigurationContent() {
custom_mcps: configSource.custom_mcps || [],
is_default: configSource.is_default || false,
profile_image_url: configSource.profile_image_url || '',
icon_name: configSource.icon_name || null,
icon_color: configSource.icon_color || '#000000',
icon_background: configSource.icon_background || '#e5e5e5',
};
setFormData(newFormData);
setOriginalData(newFormData);
@ -174,6 +186,9 @@ function AgentConfigurationContent() {
custom_mcps: versionData.custom_mcps || formData.custom_mcps,
is_default: formData.is_default,
profile_image_url: formData.profile_image_url,
icon_name: versionData.icon_name || formData.icon_name || null,
icon_color: versionData.icon_color || formData.icon_color || '#000000',
icon_background: versionData.icon_background || formData.icon_background || '#e5e5e5',
} : formData;
const handleFieldChange = useCallback((field: string, value: any) => {
@ -235,6 +250,9 @@ function AgentConfigurationContent() {
description: formData.description,
is_default: formData.is_default,
profile_image_url: formData.profile_image_url,
icon_name: formData.icon_name,
icon_color: formData.icon_color,
icon_background: formData.icon_background,
system_prompt: formData.system_prompt,
agentpress_tools: formData.agentpress_tools,
configured_mcps: formData.configured_mcps,
@ -275,12 +293,39 @@ function AgentConfigurationContent() {
setFormData(prev => ({ ...prev, profile_image_url: profileImageUrl || '' }));
setOriginalData(prev => ({ ...prev, profile_image_url: profileImageUrl || '' }));
toast.success('Profile picture updated');
} catch (error) {
toast.error('Failed to update profile picture');
throw error;
}
}, [agentId, updateAgentMutation]);
const handleIconSave = useCallback(async (iconName: string | null, iconColor: string, iconBackground: string) => {
try {
await updateAgentMutation.mutateAsync({
agentId,
icon_name: iconName,
icon_color: iconColor,
icon_background: iconBackground,
});
setFormData(prev => ({
...prev,
icon_name: iconName,
icon_color: iconColor,
icon_background: iconBackground,
}));
setOriginalData(prev => ({
...prev,
icon_name: iconName,
icon_color: iconColor,
icon_background: iconBackground,
}));
toast.success('Agent icon updated');
} catch (error) {
toast.error('Failed to update agent icon');
throw error;
}
}, [agentId, updateAgentMutation]);
const handleSystemPromptSave = useCallback(async (value: string) => {
try {
@ -300,8 +345,6 @@ function AgentConfigurationContent() {
const handleModelSave = useCallback(async (model: string) => {
try {
// Model updates might need to be handled through a different endpoint
// For now, just update the local state
setFormData(prev => ({ ...prev, model }));
setOriginalData(prev => ({ ...prev, model }));
toast.success('Model updated');
@ -403,6 +446,7 @@ function AgentConfigurationContent() {
}}
onNameSave={handleNameSave}
onProfileImageSave={handleProfileImageSave}
onIconSave={handleIconSave}
/>
</div>
</div>
@ -471,6 +515,7 @@ function AgentConfigurationContent() {
}}
onNameSave={handleNameSave}
onProfileImageSave={handleProfileImageSave}
onIconSave={handleIconSave}
/>
<Drawer>
<DrawerTrigger asChild>

View File

@ -171,13 +171,17 @@ export default function AgentsPage() {
created_at: template.created_at,
marketplace_published_at: template.marketplace_published_at,
profile_image_url: template.profile_image_url,
avatar: template.avatar,
avatar_color: template.avatar_color,
icon_name: template.icon_name,
icon_color: template.icon_color,
icon_background: template.icon_background,
template_id: template.template_id,
is_kortix_team: template.is_kortix_team,
mcp_requirements: template.mcp_requirements,
metadata: template.metadata,
};
// Backend handles search filtering, so we just transform the data
allItems.push(item);
if (user?.id === template.creator_id) {

View File

@ -241,7 +241,7 @@ export default function APIKeysPage() {
<h1 className="text-2xl font-bold">API Keys</h1>
</div>
<p className="text-muted-foreground">
Manage your API keys for programmatic access to Suna
Manage your API keys for programmatic access to your agents
</p>
</div>
@ -285,6 +285,59 @@ export default function APIKeysPage() {
</CardContent>
</Card>
{/* Claude Code Integration Notice */}
<Card className="border-purple-200/60 bg-gradient-to-br from-purple-50/80 to-violet-50/40 dark:from-purple-950/20 dark:to-violet-950/10 dark:border-purple-800/30">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="relative">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-purple-500/20 to-violet-600/10 border border-purple-500/20">
<Shield className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="absolute -top-1 -right-1">
<Badge variant="secondary" className="h-5 px-1.5 text-xs bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-700">
New
</Badge>
</div>
</div>
<div className="flex-1 space-y-3">
<div>
<h3 className="text-base font-semibold text-purple-900 dark:text-purple-100 mb-1">
Claude Code Integration
</h3>
<p className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed mb-3">
Connect your agents to Claude Code for seamless AI-powered collaboration.
Use your API key to add an MCP server in Claude Code.
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-purple-800 dark:text-purple-200 mb-1">
Connection Command:
</p>
<div className="bg-purple-900/10 dark:bg-purple-900/30 border border-purple-200/50 dark:border-purple-700/50 rounded-lg p-3">
<code className="text-xs font-mono text-purple-800 dark:text-purple-200 break-all">
claude mcp add AgentPress https://YOUR_DOMAIN/api/mcp --header "Authorization=Bearer YOUR_API_KEY"
</code>
</div>
<p className="text-xs text-purple-600 dark:text-purple-400">
Replace <code className="bg-purple-100 dark:bg-purple-900/50 px-1 rounded">YOUR_DOMAIN</code> and <code className="bg-purple-100 dark:bg-purple-900/50 px-1 rounded">YOUR_API_KEY</code> with your actual API key from below.
</p>
</div>
<div className="flex items-center gap-3">
<a
href="https://docs.anthropic.com/en/docs/claude-code/mcp"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300 transition-colors"
>
<span>Learn about Claude Code MCP</span>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Header Actions */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">

View File

@ -51,6 +51,10 @@ interface ApiMessageType extends BaseApiMessageType {
name: string;
avatar?: string;
avatar_color?: string;
profile_image_url?: string;
icon_name?: string;
icon_color?: string;
icon_background?: string;
};
}

View File

@ -39,6 +39,7 @@ import Image from 'next/image';
import { useTheme } from 'next-themes';
import ColorThief from 'colorthief';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
import { DynamicIcon } from 'lucide-react/dynamic';
interface MarketplaceTemplate {
template_id: string;
@ -58,6 +59,9 @@ interface MarketplaceTemplate {
avatar: string | null;
avatar_color: string | null;
profile_image_url: string | null;
icon_name: string | null;
icon_color: string | null;
icon_background: string | null;
metadata: Record<string, any>;
creator_name: string | null;
}
@ -256,7 +260,20 @@ export default function TemplateSharePage() {
};
useEffect(() => {
if (template?.profile_image_url && imageRef.current && imageLoaded) {
if (template?.icon_name && template?.icon_background) {
// For icons, use the icon background color as the primary color
const iconBg = template.icon_background || '#e5e5e5';
const iconColor = template.icon_color || '#000000';
// Create a palette based on the icon colors
setColorPalette([
iconBg,
iconColor,
'#6366f1',
'#8b5cf6',
'#ec4899',
'#f43f5e'
]);
} else if (template?.profile_image_url && imageRef.current && imageLoaded) {
const colorThief = new ColorThief();
try {
const palette = colorThief.getPalette(imageRef.current, 6);
@ -270,13 +287,13 @@ export default function TemplateSharePage() {
'#f43f5e', '#f97316', '#facc15'
]);
}
} else if (!template?.profile_image_url) {
} else {
setColorPalette([
'#6366f1', '#8b5cf6', '#ec4899',
'#f43f5e', '#f97316', '#facc15'
]);
}
}, [template?.profile_image_url, imageLoaded]);
}, [template?.profile_image_url, template?.icon_name, template?.icon_background, template?.icon_color, imageLoaded]);
const handleInstall = () => {
if (!template) return;
@ -450,7 +467,18 @@ export default function TemplateSharePage() {
/>
)}
<div className="relative aspect-square w-full max-w-sm mx-auto lg:mx-0 rounded-2xl overflow-hidden bg-background">
{template.profile_image_url ? (
{template.icon_name ? (
<div
className="w-full h-full flex items-center justify-center"
style={{ backgroundColor: template.icon_background || '#e5e5e5' }}
>
<DynamicIcon
name={template.icon_name as any}
size={120}
color={template.icon_color || '#000000'}
/>
</div>
) : template.profile_image_url ? (
<>
<img
ref={imageRef}

View File

@ -15,6 +15,7 @@ import { useStartAgentMutation, useStopAgentMutation } from '@/hooks/react-query
import { BillingError } from '@/lib/api';
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
import { KortixLogo } from '../sidebar/kortix-logo';
import { DynamicIcon } from 'lucide-react/dynamic';
interface Agent {
agent_id: string;
@ -27,6 +28,10 @@ interface Agent {
created_at?: string;
updated_at?: string;
profile_image_url?: string;
// Icon system fields
icon_name?: string | null;
icon_color?: string | null;
icon_background?: string | null;
}
interface AgentPreviewProps {
@ -63,6 +68,20 @@ export const AgentPreview = ({ agent, agentMetadata }: AgentPreviewProps) => {
if (isSunaAgent) {
return <KortixLogo size={16} />;
}
if (agent.icon_name) {
return (
<div
className="h-4 w-4 flex items-center justify-center rounded-sm"
style={{ backgroundColor: agent.icon_background || '#F3F4F6' }}
>
<DynamicIcon
name={agent.icon_name as any}
size={14}
color={agent.icon_color || '#000000'}
/>
</div>
);
}
if (agent.profile_image_url) {
return (
<img
@ -76,7 +95,7 @@ export const AgentPreview = ({ agent, agentMetadata }: AgentPreviewProps) => {
return <div className="text-base leading-none">{avatar}</div>;
}
return <KortixLogo size={16} />;
}, [agent.profile_image_url, agent.name, avatar, isSunaAgent]);
}, [agent.profile_image_url, agent.icon_name, agent.icon_color, agent.icon_background, agent.name, avatar, isSunaAgent]);
const initiateAgentMutation = useInitiateAgentWithInvalidation();
const addUserMessageMutation = useAddUserMessageMutation();
@ -349,9 +368,22 @@ export const AgentPreview = ({ agent, agentMetadata }: AgentPreviewProps) => {
agentData={agent}
emptyStateComponent={
<div className="flex flex-col items-center text-center text-muted-foreground/80">
<div className="flex w-20 aspect-square items-center justify-center rounded-2xl bg-muted-foreground/10 p-4 mb-4">
<div className="flex w-20 aspect-square items-center justify-center rounded-2xl bg-muted-foreground/10 mb-4">
{isSunaAgent ? (
<KortixLogo size={36} />
) : agent.icon_name ? (
<div
className="w-full h-full rounded-3xl flex items-center justify-center"
style={{
backgroundColor: agent.icon_background || '#e5e5e5'
}}
>
<DynamicIcon
name={agent.icon_name as any}
size={36}
style={{ color: agent.icon_color || '#000000' }}
/>
</div>
) : agent.profile_image_url ? (
<img
src={agent.profile_image_url}

View File

@ -9,6 +9,7 @@ import { useCreateTemplate, useUnpublishTemplate } from '@/hooks/react-query/sec
import { toast } from 'sonner';
import { AgentCard } from './custom-agents-page/agent-card';
import { KortixLogo } from '../sidebar/kortix-logo';
import { DynamicIcon } from 'lucide-react/dynamic';
interface Agent {
agent_id: string;
@ -43,6 +44,10 @@ interface Agent {
};
};
profile_image_url?: string;
// Icon system fields
icon_name?: string | null;
icon_color?: string | null;
icon_background?: string | null;
}
interface AgentsGridProps {
@ -98,7 +103,18 @@ const AgentModal: React.FC<AgentModalProps> = ({
<div className="p-6">
<KortixLogo size={48} />
</div>
) : agent.profile_image_url ? (
) : agent.icon_name ? (
<div
className="h-16 w-16 rounded-xl flex items-center justify-center"
style={{ backgroundColor: agent.icon_background || '#F3F4F6' }}
>
<DynamicIcon
name={agent.icon_name as any}
size={32}
color={agent.icon_color || '#000000'}
/>
</div>
) : agent.profile_image_url ? (
<img src={agent.profile_image_url} alt={agent.name} className="h-16 w-16 rounded-xl object-cover" />
) : (
<div className="h-16 w-16 rounded-xl bg-muted flex items-center justify-center">

View File

@ -1,15 +1,16 @@
import React, { useState, useRef, KeyboardEvent } from 'react';
import { Sparkles, Settings, Download, Image as ImageIcon } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Download } from 'lucide-react';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
import { ProfilePictureDialog } from './profile-picture-dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AgentIconAvatar } from './agent-icon-avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ProfilePictureDialog } from './profile-picture-dialog';
import { AgentVersionSwitcher } from '../agent-version-switcher';
import { UpcomingRunsDropdown } from '../upcoming-runs-dropdown';
@ -19,6 +20,9 @@ interface AgentHeaderProps {
name: string;
description?: string;
profile_image_url?: string;
icon_name?: string | null;
icon_color?: string | null;
icon_background?: string | null;
};
isViewingOldVersion: boolean;
onFieldChange: (field: string, value: any) => void;
@ -31,7 +35,6 @@ interface AgentHeaderProps {
name_editable?: boolean;
};
};
// Version control props
currentVersionId?: string;
currentFormData?: {
system_prompt: string;
@ -43,6 +46,7 @@ interface AgentHeaderProps {
onVersionCreated?: () => void;
onNameSave?: (name: string) => Promise<void>;
onProfileImageSave?: (profileImageUrl: string | null) => Promise<void>;
onIconSave?: (iconName: string | null, iconColor: string, iconBackground: string) => Promise<void>;
}
export function AgentHeader({
@ -59,6 +63,7 @@ export function AgentHeader({
onVersionCreated,
onNameSave,
onProfileImageSave,
onIconSave,
}: AgentHeaderProps) {
const [isProfileDialogOpen, setIsProfileDialogOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@ -99,7 +104,6 @@ export function AgentHeader({
return;
}
// Use dedicated save handler if available, otherwise fallback to generic onFieldChange
if (onNameSave) {
await onNameSave(editName);
} else {
@ -110,7 +114,7 @@ export function AgentHeader({
setIsEditing(false);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
saveNewName();
} else if (e.key === 'Escape') {
@ -119,18 +123,30 @@ export function AgentHeader({
};
const handleImageUpdate = (url: string | null) => {
// Use dedicated save handler if available, otherwise fallback to generic onFieldChange
if (onProfileImageSave) {
onProfileImageSave(url);
} else {
onFieldChange('profile_image_url', url);
}
};
const handleIconUpdate = async (iconName: string | null, iconColor: string, backgroundColor: string) => {
if (onIconSave) {
await onIconSave(iconName, iconColor, backgroundColor);
} else {
onFieldChange('icon_name', iconName);
onFieldChange('icon_color', iconColor);
onFieldChange('icon_background', backgroundColor);
}
if (iconName && displayData.profile_image_url) {
handleImageUpdate(null);
}
};
return (
<>
<header className="bg-background sticky top-0 flex h-14 shrink-0 items-center gap-3 z-20 w-full px-8 mb-2">
{/* Left side - Agent info */}
<div className="flex items-center gap-3 min-w-0">
<div className="relative flex-shrink-0">
{isSunaAgent ? (
@ -143,19 +159,15 @@ export function AgentHeader({
onClick={() => setIsProfileDialogOpen(true)}
type="button"
>
<Avatar className="h-9 w-9 rounded-lg ring-1 ring-black/5 hover:ring-black/10 transition-colors">
{displayData.profile_image_url ? (
<AvatarImage
src={displayData.profile_image_url}
alt={displayData.name}
className="object-cover rounded-lg"
/>
) : (
<AvatarFallback className="rounded-lg text-xs hover:bg-muted">
<ImageIcon className="h-4 w-4" />
</AvatarFallback>
)}
</Avatar>
<AgentIconAvatar
profileImageUrl={displayData.profile_image_url}
iconName={displayData.icon_name}
iconColor={displayData.icon_color}
backgroundColor={displayData.icon_background}
agentName={displayData.name}
size={36}
className="ring-1 ring-black/5 hover:ring-black/10 transition-all"
/>
</button>
)}
</div>
@ -188,10 +200,7 @@ export function AgentHeader({
</div>
<div className="flex flex-1 items-center gap-2">
{/* Spacer to push content to the right */}
</div>
{/* Right side - Version controls, tabs and actions aligned together */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{!isSunaAgent && currentFormData && (
@ -229,8 +238,12 @@ export function AgentHeader({
isOpen={isProfileDialogOpen}
onClose={() => setIsProfileDialogOpen(false)}
currentImageUrl={displayData.profile_image_url}
currentIconName={displayData.icon_name}
currentIconColor={displayData.icon_color}
currentBackgroundColor={displayData.icon_background}
agentName={displayData.name}
onImageUpdate={handleImageUpdate}
onIconUpdate={handleIconUpdate}
/>
</>
);

View File

@ -0,0 +1,112 @@
'use client';
import React from 'react';
import { DynamicIcon } from 'lucide-react/dynamic';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
import { cn } from '@/lib/utils';
interface AgentIconAvatarProps {
profileImageUrl?: string | null;
iconName?: string | null;
iconColor?: string;
backgroundColor?: string;
agentName?: string;
size?: number;
className?: string;
isSunaDefault?: boolean;
}
export function AgentIconAvatar({
profileImageUrl,
iconName,
iconColor = '#000000',
backgroundColor = '#F3F4F6',
agentName = 'Agent',
size = 40,
className,
isSunaDefault = false
}: AgentIconAvatarProps) {
if (isSunaDefault) {
return (
<div
className={cn(
"flex items-center justify-center rounded-lg bg-muted border",
className
)}
style={{ width: size, height: size }}
>
<KortixLogo size={size * 0.6} />
</div>
);
}
if (iconName) {
return (
<div
className={cn(
"flex items-center justify-center rounded-lg transition-all",
className
)}
style={{
width: size,
height: size,
backgroundColor
}}
>
<DynamicIcon
name={iconName as any}
size={size * 0.5}
color={iconColor}
/>
</div>
);
}
if (profileImageUrl) {
return (
<Avatar
className={cn("rounded-lg", className)}
style={{ width: size, height: size }}
>
<AvatarImage
src={profileImageUrl}
alt={agentName}
className="object-cover"
/>
<AvatarFallback className="rounded-lg">
<div className="w-full h-full flex items-center justify-center bg-muted">
<DynamicIcon
name="bot"
size={size * 0.5}
color="#6B7280"
/>
</div>
</AvatarFallback>
</Avatar>
);
}
return (
<div
className={cn(
"flex items-center justify-center rounded-lg bg-muted",
className
)}
style={{ width: size, height: size }}
>
<DynamicIcon
name="bot"
size={size * 0.5}
color="#6B7280"
/>
</div>
);
}
export function hasCustomProfile(agent: {
profile_image_url?: string | null;
icon_name?: string | null;
}): boolean {
return !!(agent.icon_name || agent.profile_image_url);
}

View File

@ -0,0 +1,143 @@
'use client';
import React, { useState, useMemo } from 'react';
import { DynamicIcon } from 'lucide-react/dynamic';
import { icons } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
interface IconPickerProps {
selectedIcon?: string;
onIconSelect: (iconName: string) => void;
iconColor?: string;
backgroundColor?: string;
className?: string;
}
function toKebabCase(str: string): string {
return str
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}
export function IconPicker({
selectedIcon,
onIconSelect,
iconColor = '#000000',
backgroundColor = '#F3F4F6',
className
}: IconPickerProps) {
const [searchQuery, setSearchQuery] = useState('');
const allIconNames = useMemo(() => {
return Object.keys(icons).map(name => toKebabCase(name)).sort();
}, []);
const filteredIcons = useMemo(() => {
if (!searchQuery) return allIconNames;
const query = searchQuery.toLowerCase();
return allIconNames.filter(name =>
name.includes(query) ||
name.replace(/-/g, ' ').includes(query)
);
}, [allIconNames, searchQuery]);
const popularIcons = [
'bot', 'brain', 'sparkles', 'zap', 'rocket',
'briefcase', 'code', 'database', 'globe', 'heart',
'lightbulb', 'message-circle', 'shield', 'star', 'user',
'cpu', 'terminal', 'settings', 'wand-2', 'layers'
];
return (
<div className={cn("flex flex-col", className)}>
<div className="pb-3 shrink-0">
<Input
type="text"
placeholder="Search icons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
</div>
<ScrollArea className="flex-1 rounded-lg border min-h-0">
<div className="p-4 space-y-6">
{!searchQuery && (
<div className="space-y-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Popular Icons
</p>
<div className="grid grid-cols-5 sm:grid-cols-8 gap-2">
{popularIcons.map((iconName) => (
<button
key={iconName}
onClick={() => onIconSelect(iconName)}
className={cn(
"p-2 sm:p-3 rounded-md border transition-all hover:scale-105 flex items-center justify-center aspect-square",
selectedIcon === iconName
? "border-primary bg-primary/10 ring-1 ring-primary/30"
: "border-border hover:border-primary/60 hover:bg-accent"
)}
style={{
backgroundColor: selectedIcon === iconName ? backgroundColor : undefined
}}
title={iconName}
>
<DynamicIcon
name={iconName as any}
size={18}
color={selectedIcon === iconName ? iconColor : undefined}
/>
</button>
))}
</div>
</div>
)}
<div className="space-y-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{searchQuery
? `Search Results (${filteredIcons.length})`
: `All Icons (${allIconNames.length})`
}
</p>
{filteredIcons.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground">
<p>No icons found matching "{searchQuery}"</p>
<p className="text-xs mt-2">Try a different search term</p>
</div>
) : (
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{filteredIcons.map((iconName) => (
<button
key={iconName}
onClick={() => onIconSelect(iconName)}
className={cn(
"rounded-md border transition-all hover:scale-105 flex items-center justify-center aspect-square",
selectedIcon === iconName
? "border-primary bg-primary/10 ring-1 ring-primary/30"
: "border-border hover:border-primary/60 hover:bg-accent"
)}
style={{
backgroundColor: selectedIcon === iconName ? backgroundColor : undefined
}}
title={iconName}
>
<DynamicIcon
name={iconName as any}
size={18}
color={selectedIcon === iconName ? iconColor : undefined}
/>
</button>
))}
</div>
)}
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@ -1,22 +1,34 @@
'use client';
import React, { useState } from 'react';
import { Upload, Link2, X, Image as ImageIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import React, { useState, useCallback, useEffect } from 'react';
import { Sparkles } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { toast } from 'sonner';
import { createClient } from '@/lib/supabase/client';
import { IconPicker } from './icon-picker';
import { AgentIconAvatar } from './agent-icon-avatar';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface ProfilePictureDialogProps {
isOpen: boolean;
onClose: () => void;
currentImageUrl?: string;
agentName: string;
agentName?: string;
onImageUpdate: (url: string | null) => void;
currentIconName?: string;
currentIconColor?: string;
currentBackgroundColor?: string;
onIconUpdate?: (iconName: string | null, iconColor: string, backgroundColor: string) => void;
}
export function ProfilePictureDialog({
@ -25,272 +37,188 @@ export function ProfilePictureDialog({
currentImageUrl,
agentName,
onImageUpdate,
currentIconName,
currentIconColor = '#000000',
currentBackgroundColor = '#F3F4F6',
onIconUpdate,
}: ProfilePictureDialogProps) {
const [isUploading, setIsUploading] = useState(false);
const [isUrlSubmitting, setIsUrlSubmitting] = useState(false);
const [customUrl, setCustomUrl] = useState('');
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);
const [selectedIcon, setSelectedIcon] = useState(currentIconName || 'bot');
const [iconColor, setIconColor] = useState(currentIconColor || '#000000');
const [backgroundColor, setBackgroundColor] = useState(currentBackgroundColor || '#e5e5e5');
const handleFileUpload = async (file: File) => {
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
useEffect(() => {
if (isOpen) {
setSelectedIcon(currentIconName || 'bot');
setIconColor(currentIconColor || '#000000');
setBackgroundColor(currentBackgroundColor || '#e5e5e5');
}
if (file.size > 5 * 1024 * 1024) { // 5MB limit
toast.error('File size must be less than 5MB');
return;
}
setIsUploading(true);
try {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
toast.error('You must be logged in to upload images');
return;
}
const form = new FormData();
form.append('file', file);
const res = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/agents/profile-image/upload`, {
method: 'POST',
body: form,
headers: {
'Authorization': `Bearer ${session.access_token}`,
}
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(error.message || 'Upload failed');
}
const data = await res.json();
if (data?.url) {
onImageUpdate(data.url);
toast.success('Profile image uploaded successfully!');
onClose();
}
} catch (err) {
console.error('Upload error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to upload image');
} finally {
setIsUploading(false);
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
// Reset input
e.target.value = '';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragActive(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
};
const handleUrlSubmit = async () => {
if (!customUrl.trim()) {
toast.error('Please enter a valid URL');
return;
}
// Basic URL validation
try {
new URL(customUrl);
} catch {
toast.error('Please enter a valid URL');
return;
}
setIsUrlSubmitting(true);
try {
onImageUpdate(customUrl);
toast.success('Profile image URL updated successfully!');
}, [isOpen, currentIconName, currentIconColor, currentBackgroundColor]);
const handleIconSave = useCallback(() => {
if (onIconUpdate) {
onIconUpdate(selectedIcon, iconColor, backgroundColor);
onImageUpdate(null);
toast.success('Agent icon updated!');
onClose();
} catch (err) {
toast.error('Failed to update profile image URL');
} finally {
setIsUrlSubmitting(false);
}
};
}, [selectedIcon, iconColor, backgroundColor, onIconUpdate, onImageUpdate, onClose]);
const handleUrlPreview = () => {
if (customUrl) {
try {
new URL(customUrl);
setPreviewUrl(customUrl);
} catch {
toast.error('Please enter a valid URL');
}
}
};
const presetColors = [
{ bg: '#6366F1', icon: '#FFFFFF', name: 'Indigo' },
{ bg: '#10B981', icon: '#FFFFFF', name: 'Emerald' },
{ bg: '#F59E0B', icon: '#FFFFFF', name: 'Amber' },
{ bg: '#EF4444', icon: '#FFFFFF', name: 'Red' },
{ bg: '#8B5CF6', icon: '#FFFFFF', name: 'Purple' },
];
const ColorControls = () => (
<div className="space-y-6">
const handleRemoveImage = () => {
onImageUpdate(null);
toast.success('Profile image removed');
onClose();
};
const handleClose = () => {
setCustomUrl('');
setPreviewUrl(null);
setDragActive(false);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader className="pb-2">
<DialogTitle className="text-center">Profile Picture</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Current Image Display */}
<div className="flex justify-center">
<Avatar className="h-24 w-24 rounded-xl ring-2 ring-border">
{currentImageUrl ? (
<AvatarImage
src={currentImageUrl}
alt={agentName}
className="object-cover w-full h-full"
/>
) : (
<AvatarFallback className="rounded-xl bg-muted">
<ImageIcon className="h-10 w-10 text-muted-foreground" />
</AvatarFallback>
)}
</Avatar>
</div>
<Tabs defaultValue="upload" className="w-full">
<TabsList className="grid w-full grid-cols-2 h-9">
<TabsTrigger value="upload" className="text-sm">
Upload
</TabsTrigger>
<TabsTrigger value="url" className="text-sm">
URL
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="mt-4">
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-all cursor-pointer ${
dragActive
? 'border-primary bg-primary/5 scale-[1.02]'
: 'border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/25'
} ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !isUploading && document.getElementById('file-upload')?.click()}
>
<Upload className={`mx-auto h-8 w-8 mb-3 transition-colors ${
dragActive ? 'text-primary' : 'text-muted-foreground'
}`} />
<p className="text-sm font-medium mb-1">
{isUploading ? 'Uploading...' : 'Drop image here or click to select'}
</p>
<p className="text-xs text-muted-foreground">
PNG, JPG, GIF up to 5MB
</p>
<input
type="file"
accept="image/*"
onChange={handleFileInputChange}
className="hidden"
id="file-upload"
disabled={isUploading}
/>
</div>
</TabsContent>
<TabsContent value="url" className="mt-4 space-y-4">
<div className="space-y-3">
<Input
type="url"
placeholder="Paste image URL here..."
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
onBlur={handleUrlPreview}
className="text-sm"
/>
{previewUrl && (
<div className="flex justify-center p-3 bg-muted/30 rounded-lg">
<Avatar className="h-12 w-12 rounded-lg">
<AvatarImage
src={previewUrl}
alt="Preview"
className="object-cover w-full h-full"
onError={() => {
setPreviewUrl(null);
toast.error('Unable to load image from URL');
}}
/>
<AvatarFallback className="rounded-lg">
<ImageIcon className="h-5 w-5 text-muted-foreground" />
</AvatarFallback>
</Avatar>
</div>
)}
<Button
onClick={handleUrlSubmit}
disabled={!customUrl.trim() || isUrlSubmitting}
className="w-full"
size="sm"
>
{isUrlSubmitting ? 'Updating...' : 'Set as Profile Picture'}
</Button>
</div>
</TabsContent>
</Tabs>
{/* Action Buttons */}
<div className="flex justify-between pt-2">
{currentImageUrl ? (
<Button
variant="ghost"
onClick={handleRemoveImage}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
size="sm"
>
Remove
</Button>
) : (
<div />
)}
<Button variant="outline" onClick={handleClose} size="sm">
Close
</Button>
<div className="flex flex-col items-center space-y-3 py-4">
<AgentIconAvatar
iconName={selectedIcon}
iconColor={iconColor}
backgroundColor={backgroundColor}
agentName={agentName}
size={100}
className="rounded-3xl border"
/>
<div className="text-center">
<p className="font-medium">{agentName || 'Agent'}</p>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="icon-color" className="text-sm mb-2 block">Icon Color</Label>
<div className="flex gap-2">
<Input
id="icon-color"
type="color"
value={iconColor}
onChange={(e) => setIconColor(e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={iconColor}
onChange={(e) => setIconColor(e.target.value)}
placeholder="#000000"
className="flex-1"
maxLength={7}
/>
</div>
</div>
<div>
<Label htmlFor="bg-color" className="text-sm mb-2 block">Background Color</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
placeholder="#F3F4F6"
className="flex-1"
maxLength={7}
/>
</div>
</div>
</div>
<div className="space-y-3">
<Label className="text-sm">Quick Presets</Label>
<div className="grid grid-cols-5 gap-2">
{presetColors.map((preset) => (
<button
key={preset.name}
onClick={() => {
setIconColor(preset.icon);
setBackgroundColor(preset.bg);
}}
className="group relative h-10 w-full rounded-lg border-2 border-border hover:border-primary transition-all hover:scale-105"
style={{ backgroundColor: preset.bg }}
title={preset.name}
>
<span className="sr-only">{preset.name}</span>
{backgroundColor === preset.bg && iconColor === preset.icon && (
<div className="absolute inset-0 rounded-lg ring-2 ring-primary ring-offset-2" />
)}
</button>
))}
</div>
</div>
</div>
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Customize Agent Icon
</DialogTitle>
</DialogHeader>
<div className="hidden md:flex flex-1 min-h-0 px-6">
<div className="flex gap-6 w-full">
<div className="flex-1 min-w-0">
<IconPicker
selectedIcon={selectedIcon}
onIconSelect={setSelectedIcon}
iconColor={iconColor}
backgroundColor={backgroundColor}
className="h-full"
/>
</div>
<Separator orientation="vertical" className="h-full" />
<div className="w-80 shrink-0">
<ScrollArea className="h-[500px] pr-4">
<ColorControls />
</ScrollArea>
</div>
</div>
</div>
<div className="md:hidden flex-1 min-h-0 px-6">
<Tabs defaultValue="customize" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-2 shrink-0">
<TabsTrigger value="customize">Customize</TabsTrigger>
<TabsTrigger value="icons">Icons</TabsTrigger>
</TabsList>
<TabsContent value="customize" className="flex-1 min-h-0 mt-4">
<ScrollArea className="h-[400px]">
<ColorControls />
</ScrollArea>
</TabsContent>
<TabsContent value="icons" className="flex-1 min-h-0 mt-4">
<IconPicker
selectedIcon={selectedIcon}
onIconSelect={setSelectedIcon}
iconColor={iconColor}
backgroundColor={backgroundColor}
className="h-[400px]"
/>
</TabsContent>
</Tabs>
</div>
<DialogFooter className="px-6 py-4 shrink-0 border-t">
<Button
variant="outline"
onClick={onClose}
>
Cancel
</Button>
<Button
onClick={handleIconSave}
>
Save Icon
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}

View File

@ -2,6 +2,7 @@
import React from 'react';
import { Download, CheckCircle, Loader2, Globe, GlobeLock, GitBranch, Trash2, MoreVertical, User } from 'lucide-react';
import { DynamicIcon } from 'lucide-react/dynamic';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -9,7 +10,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
@ -21,7 +21,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { MarketplaceTemplate } from '@/components/agents/installation/types';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
export type AgentCardMode = 'marketplace' | 'template' | 'agent';
@ -338,7 +337,21 @@ const TemplateActions: React.FC<{
</div>
);
const CardAvatar: React.FC<{ isSunaAgent?: boolean; profileImageUrl?: string; agentName?: string }> = ({ isSunaAgent = false, profileImageUrl, agentName }) => {
const CardAvatar: React.FC<{
isSunaAgent?: boolean;
profileImageUrl?: string;
agentName?: string;
iconName?: string;
iconColor?: string;
iconBackground?: string;
}> = ({
isSunaAgent = false,
profileImageUrl,
agentName,
iconName,
iconColor = '#000000',
iconBackground = '#F3F4F6'
}) => {
if (isSunaAgent) {
return (
<div className="h-14 w-14 bg-muted border flex items-center justify-center rounded-2xl">
@ -346,11 +359,28 @@ const CardAvatar: React.FC<{ isSunaAgent?: boolean; profileImageUrl?: string; ag
</div>
)
}
if (iconName) {
return (
<div
className="h-14 w-14 flex items-center justify-center rounded-2xl"
style={{ backgroundColor: iconBackground }}
>
<DynamicIcon
name={iconName as any}
size={28}
color={iconColor}
/>
</div>
);
}
if (profileImageUrl) {
return (
<img src={profileImageUrl} alt="Agent" className="h-14 w-14 rounded-2xl object-cover" />
);
}
return (
<div className="h-14 w-14 bg-muted border flex items-center justify-center rounded-2xl">
<span className="text-lg font-semibold">{agentName?.charAt(0).toUpperCase() || '?'}</span>
@ -454,7 +484,14 @@ export const AgentCard: React.FC<AgentCardProps> = ({
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative p-6 flex flex-col flex-1">
<div className="flex items-start justify-between mb-4">
<CardAvatar isSunaAgent={isSunaAgent} profileImageUrl={(data as any)?.profile_image_url} agentName={data.name} />
<CardAvatar
isSunaAgent={isSunaAgent}
profileImageUrl={(data as any)?.profile_image_url}
agentName={data.name}
iconName={(data as any)?.icon_name}
iconColor={(data as any)?.icon_color}
iconBackground={(data as any)?.icon_background}
/>
<div className="flex items-center gap-2">
{renderBadge()}
</div>

View File

@ -11,6 +11,9 @@ export interface MarketplaceTemplate {
profile_image_url?: string;
avatar?: string;
avatar_color?: string;
icon_name?: string;
icon_color?: string;
icon_background?: string;
template_id: string;
is_kortix_team?: boolean;
model?: string;

View File

@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Bot, Download, Wrench, Plug, Tag, User, Calendar, Loader2, Share, Cpu, Eye, Zap } from 'lucide-react';
import { DynamicIcon } from 'lucide-react/dynamic';
import { toast } from 'sonner';
import type { MarketplaceTemplate } from '@/components/agents/installation/types';
import { useComposioToolkitIcon } from '@/hooks/react-query/composio/use-composio';
@ -150,12 +151,31 @@ export const MarketplaceAgentPreviewDialog: React.FC<MarketplaceAgentPreviewDial
return qualifiedName.replace(/\b\w/g, l => l.toUpperCase());
};
console.log('agent', agent);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] p-0 overflow-hidden">
<DialogHeader className='p-6'>
<DialogTitle className='sr-only'>Agent Preview</DialogTitle>
{agent.profile_image_url ? (
{agent.icon_name ? (
<div
className='relative h-20 w-20 aspect-square rounded-2xl flex items-center justify-center shadow-lg'
style={{ backgroundColor: agent.icon_background || '#e5e5e5' }}
>
<DynamicIcon
name={agent.icon_name as any}
size={40}
color={agent.icon_color || '#000000'}
/>
<div
className="absolute inset-0 rounded-2xl pointer-events-none opacity-0 dark:opacity-100 transition-opacity"
style={{
boxShadow: `0 16px 48px -8px ${agent.icon_background || '#e5e5e5'}70, 0 8px 24px -4px ${agent.icon_background || '#e5e5e5'}50`
}}
/>
</div>
) : agent.profile_image_url ? (
<img
src={agent.profile_image_url}
alt={agent.name}

View File

@ -43,7 +43,6 @@ export function CustomAgentsSection({ onAgentSelect }: CustomAgentsSectionProps)
const [installingItemId, setInstallingItemId] = React.useState<string | null>(null);
const handleCardClick = (template: any) => {
// Map the template to MarketplaceTemplate format
const marketplaceTemplate: MarketplaceTemplate = {
id: template.template_id,
template_id: template.template_id,
@ -58,6 +57,9 @@ export function CustomAgentsSection({ onAgentSelect }: CustomAgentsSectionProps)
profile_image_url: template.profile_image_url,
avatar: template.avatar,
avatar_color: template.avatar_color,
icon_name: template.icon_name,
icon_color: template.icon_color,
icon_background: template.icon_background,
mcp_requirements: template.mcp_requirements || [],
agentpress_tools: template.agentpress_tools || {},
model: template.metadata?.model,
@ -68,7 +70,6 @@ export function CustomAgentsSection({ onAgentSelect }: CustomAgentsSectionProps)
setIsPreviewOpen(true);
};
// Handle clicking Install from the preview dialog - opens the streamlined install dialog
const handlePreviewInstall = (agent: MarketplaceTemplate) => {
setIsPreviewOpen(false);
setSelectedTemplate(agent);

View File

@ -3,7 +3,7 @@
import React from 'react';
import { useAgent } from '@/hooks/react-query/agents/use-agents';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
import { Skeleton } from '@/components/ui/skeleton';
import { DynamicIcon } from 'lucide-react/dynamic';
interface AgentAvatarProps {
agentId?: string;
@ -38,6 +38,25 @@ export const AgentAvatar: React.FC<AgentAvatarProps> = ({
return <KortixLogo size={size} />;
}
if (agent?.icon_name) {
return (
<div
className={`flex items-center justify-center rounded ${className}`}
style={{
width: size,
height: size,
backgroundColor: agent.icon_background || '#F3F4F6'
}}
>
<DynamicIcon
name={agent.icon_name as any}
size={size * 0.6}
color={agent.icon_color || '#000000'}
/>
</div>
);
}
if (agent?.profile_image_url) {
return (
<img
@ -49,7 +68,6 @@ export const AgentAvatar: React.FC<AgentAvatarProps> = ({
);
}
return <KortixLogo size={size} />;
};

View File

@ -71,6 +71,9 @@ export const useCreateNewAgent = () => {
configured_mcps: [],
agentpress_tools: DEFAULT_AGENTPRESS_TOOLS,
is_default: false,
icon_name: 'brain',
icon_color: '#000000',
icon_background: '#F3F4F6',
};
const newAgent = await createAgentMutation.mutateAsync(defaultAgentData);

View File

@ -26,8 +26,10 @@ export type Agent = {
tags?: string[];
created_at: string;
updated_at: string;
// New
profile_image_url?: string;
icon_name?: string | null;
icon_color?: string | null;
icon_background?: string | null;
current_version_id?: string | null;
version_count?: number;
current_version?: AgentVersion | null;
@ -95,6 +97,10 @@ export type AgentCreateRequest = {
is_default?: boolean;
// New
profile_image_url?: string;
// Icon system fields
icon_name?: string | null;
icon_color?: string | null;
icon_background?: string | null;
};
export type AgentVersionCreateRequest = {
@ -150,6 +156,10 @@ export type AgentUpdateRequest = {
is_default?: boolean;
// New
profile_image_url?: string;
// Icon system fields
icon_name?: string | null;
icon_color?: string | null;
icon_background?: string | null;
};
export const getAgents = async (params: AgentsParams = {}): Promise<AgentsResponse> => {

View File

@ -46,11 +46,15 @@ export interface AgentTemplate {
avatar?: string;
avatar_color?: string;
profile_image_url?: string;
icon_name?: string;
icon_color?: string;
icon_background?: string;
is_kortix_team?: boolean;
metadata?: {
source_agent_id?: string;
source_version_id?: string;
source_version_name?: string;
model?: string;
};
}

View File

@ -26,6 +26,9 @@ interface NormalizedVersionData {
updated_at: string;
created_by?: string;
change_description?: string;
icon_name?: string | null;
icon_color?: string | null;
icon_background?: string | null;
}
interface UseAgentVersionDataProps {

View File

@ -35,6 +35,10 @@ class AgentCreateRequest:
is_default: bool = False
avatar: Optional[str] = None
avatar_color: Optional[str] = None
profile_image_url: Optional[str] = None
icon_name: Optional[str] = None
icon_color: Optional[str] = None
icon_background: Optional[str] = None
@dataclass
@ -47,6 +51,10 @@ class AgentUpdateRequest:
is_default: Optional[bool] = None
avatar: Optional[str] = None
avatar_color: Optional[str] = None
profile_image_url: Optional[str] = None
icon_name: Optional[str] = None
icon_color: Optional[str] = None
icon_background: Optional[str] = None
@dataclass