mirror of https://github.com/kortix-ai/suna.git
Compare commits
14 Commits
8d0955af53
...
b4bf745103
Author | SHA1 | Date |
---|---|---|
|
b4bf745103 | |
|
7ed528400c | |
|
47238cba3b | |
|
70df974720 | |
|
a7df37b0ba | |
|
dad49f8a50 | |
|
2e31dd4593 | |
|
e4189c74a3 | |
|
f55cc025d7 | |
|
ab32d5040f | |
|
7af07582ff | |
|
ce98bb8a99 | |
|
9576936bdd | |
|
8be8b5face |
|
@ -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:
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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;
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue