Compare commits

...

15 Commits

Author SHA1 Message Date
kubet 7a67ebe746
Merge pull request #1446 from kubet/fix/csv-improvements
Fix/csv improvements
2025-08-24 21:40:16 +02:00
Vukasin 87d25e3634 Merge remote-tracking branch 'upstream/main' into fix/csv-improvements 2025-08-24 21:38:24 +02:00
Vukasin 85abdf7698 fix: styling 2025-08-24 21:38:15 +02:00
Bobbie 7ed528400c
Merge pull request #1445 from escapade-mckv/agent-icons
debug agent icons
2025-08-25 00:48:39 +05:30
Saumya 47238cba3b debug agent icons 2025-08-25 00:47:41 +05:30
Bobbie 70df974720
Merge pull request #1442 from escapade-mckv/agent-icons
create agent with a default icon
2025-08-24 22:45:25 +05:30
Saumya a7df37b0ba create agent with a default icon 2025-08-24 22:44:29 +05:30
Bobbie dad49f8a50
Merge pull request #1441 from escapade-mckv/agent-icons
fix agent creation error
2025-08-24 22:35:27 +05:30
Saumya 2e31dd4593 fix agent creation error 2025-08-24 22:34:32 +05:30
Bobbie e4189c74a3
Merge pull request #1440 from escapade-mckv/agent-icons
agents use icons and bg color
2025-08-24 20:49:30 +05:30
Saumya f55cc025d7 agents use icons and bg color 2025-08-24 20:48:04 +05:30
Bobbie ab32d5040f
Merge pull request #1439 from escapade-mckv/agent-icons
Agent icons
2025-08-24 17:17:52 +05:30
Saumya 7af07582ff agents use icons and bg color 2025-08-24 17:16:50 +05:30
Saumya ce98bb8a99 agents use icons and bg color 2025-08-24 17:16:21 +05:30
Vukasin 9ae810683d fix: csv improvements 2025-08-24 00:13:21 +02:00
32 changed files with 1260 additions and 505 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { useCreateTemplate, useUnpublishTemplate } from '@/hooks/react-query/sec
import { toast } from 'sonner';
import { AgentCard } from './custom-agents-page/agent-card';
import { KortixLogo } from '../sidebar/kortix-logo';
import { DynamicIcon } from 'lucide-react/dynamic';
interface Agent {
agent_id: string;
@ -43,6 +44,10 @@ interface Agent {
};
};
profile_image_url?: string;
// Icon system fields
icon_name?: string | null;
icon_color?: string | null;
icon_background?: string | null;
}
interface AgentsGridProps {
@ -98,6 +103,17 @@ const AgentModal: React.FC<AgentModalProps> = ({
<div className="p-6">
<KortixLogo size={48} />
</div>
) : 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" />
) : (

View File

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

View File

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

View File

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

View File

@ -1,22 +1,34 @@
'use client';
import React, { useState } from 'react';
import { Upload, Link2, X, Image as ImageIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import React, { useState, useCallback, useEffect } from 'react';
import { Sparkles } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { toast } from 'sonner';
import { createClient } from '@/lib/supabase/client';
import { IconPicker } from './icon-picker';
import { AgentIconAvatar } from './agent-icon-avatar';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface ProfilePictureDialogProps {
isOpen: boolean;
onClose: () => void;
currentImageUrl?: string;
agentName: string;
agentName?: string;
onImageUpdate: (url: string | null) => void;
currentIconName?: string;
currentIconColor?: string;
currentBackgroundColor?: string;
onIconUpdate?: (iconName: string | null, iconColor: string, backgroundColor: string) => void;
}
export function ProfilePictureDialog({
@ -25,271 +37,187 @@ 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');
}
}, [isOpen, currentIconName, currentIconColor, currentBackgroundColor]);
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!');
onClose();
} catch (err) {
toast.error('Failed to update profile image URL');
} finally {
setIsUrlSubmitting(false);
}
};
const handleUrlPreview = () => {
if (customUrl) {
try {
new URL(customUrl);
setPreviewUrl(customUrl);
} catch {
toast.error('Please enter a valid URL');
}
}
};
const handleRemoveImage = () => {
const handleIconSave = useCallback(() => {
if (onIconUpdate) {
onIconUpdate(selectedIcon, iconColor, backgroundColor);
onImageUpdate(null);
toast.success('Profile image removed');
toast.success('Agent icon updated!');
onClose();
};
}
}, [selectedIcon, iconColor, backgroundColor, onIconUpdate, onImageUpdate, onClose]);
const handleClose = () => {
setCustomUrl('');
setPreviewUrl(null);
setDragActive(false);
onClose();
};
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">
<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={handleClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader className="pb-2">
<DialogTitle className="text-center">Profile Picture</DialogTitle>
<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="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"
<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"
/>
) : (
<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>
<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="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 value="customize" className="flex-1 min-h-0 mt-4">
<ScrollArea className="h-[400px]">
<ColorControls />
</ScrollArea>
</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"
<TabsContent value="icons" className="flex-1 min-h-0 mt-4">
<IconPicker
selectedIcon={selectedIcon}
onIconSelect={setSelectedIcon}
iconColor={iconColor}
backgroundColor={backgroundColor}
className="h-[400px]"
/>
{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 ? (
</div>
<DialogFooter className="px-6 py-4 shrink-0 border-t">
<Button
variant="ghost"
onClick={handleRemoveImage}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
size="sm"
variant="outline"
onClick={onClose}
>
Remove
Cancel
</Button>
) : (
<div />
)}
<Button variant="outline" onClick={handleClose} size="sm">
Close
<Button
onClick={handleIconSave}
>
Save Icon
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

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

View File

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

View File

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

View File

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

View File

@ -4,14 +4,12 @@ import React, { useState, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CsvTable } from '@/components/ui/csv-table';
import Papa from 'papaparse';
import { cn } from '@/lib/utils';
import {
Search,
ChevronUp,
ChevronDown,
FileSpreadsheet,
ArrowUpDown,
Filter,
} from 'lucide-react';
import {
@ -139,36 +137,6 @@ export function CsvRenderer({
});
};
const getSortIcon = (column: string) => {
if (sortConfig.column !== column) {
return <ArrowUpDown className="h-3 w-3 text-muted-foreground" />;
}
return sortConfig.direction === 'asc' ?
<ChevronUp className="h-3 w-3 text-primary" /> :
<ChevronDown className="h-3 w-3 text-primary" />;
};
const formatCellValue = (value: any) => {
if (value == null) return '';
if (typeof value === 'number') {
return value.toLocaleString();
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
};
const getCellClassName = (value: any) => {
if (typeof value === 'number') {
return 'text-right font-mono';
}
if (typeof value === 'boolean') {
return value ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
}
return '';
};
if (isEmpty) {
return (
<div className={cn('w-full h-full flex items-center justify-center', className)}>
@ -245,73 +213,14 @@ export function CsvRenderer({
<div className="flex-1 overflow-hidden">
<div className="w-full h-full overflow-auto scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent">
<table className="w-full border-collapse table-fixed" style={{ minWidth: `${visibleHeaders.length * 150}px` }}>
<thead className="bg-muted/50 sticky top-0 z-10">
<tr>
{visibleHeaders.map((header, index) => (
<th
key={header}
className="px-4 py-3 text-left font-medium border-b border-border bg-muted/50 backdrop-blur-sm"
style={{ width: '150px', minWidth: '150px' }}
>
<button
onClick={() => handleSort(header)}
className="flex items-center gap-2 hover:text-primary transition-colors group w-full text-left"
>
<span className="truncate">{header}</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{getSortIcon(header)}
</div>
</button>
</th>
))}
</tr>
</thead>
<tbody>
{paginatedData.map((row: any, rowIndex) => (
<tr
key={startIndex + rowIndex}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
{visibleHeaders.map((header, cellIndex) => {
const value = row[header];
return (
<td
key={`${startIndex + rowIndex}-${cellIndex}`}
className={cn(
"px-4 py-3 text-sm border-r border-border last:border-r-0",
getCellClassName(value)
)}
style={{ width: '150px', minWidth: '150px' }}
>
<div className="truncate" title={String(value || '')}>
{formatCellValue(value)}
</div>
</td>
);
})}
</tr>
))}
{/* Empty state for current page */}
{paginatedData.length === 0 && searchTerm && (
<tr>
<td colSpan={visibleHeaders.length} className="py-8 text-center text-muted-foreground">
<div className="space-y-2">
<p>No results found for "{searchTerm}"</p>
<Button
variant="outline"
size="sm"
onClick={() => setSearchTerm('')}
>
Clear search
</Button>
</div>
</td>
</tr>
)}
</tbody>
</table>
<CsvTable
headers={visibleHeaders}
data={paginatedData}
sortConfig={sortConfig}
onSort={handleSort}
searchTerm={searchTerm}
onClearSearch={() => setSearchTerm('')}
/>
</div>
</div>

View File

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

View File

@ -453,6 +453,7 @@ export function FileAttachment({
"rounded-xl border bg-card overflow-hidden pt-10", // Consistent card styling with header space
isPdf ? "!min-h-[200px] sm:min-h-0 sm:h-[400px] max-h-[500px] sm:!min-w-[300px]" :
isHtmlOrMd ? "!min-h-[200px] sm:min-h-0 sm:h-[400px] max-h-[600px] sm:!min-w-[300px]" :
isCsv ? "min-h-[300px] h-full" : // Let CSV take full height
standalone ? "min-h-[300px] h-auto" : "h-[300px]", // Better height handling for standalone
className
)}
@ -553,16 +554,16 @@ export function FileAttachment({
</div>
{/* Header with filename */}
<div className="absolute top-0 left-0 right-0 bg-accent p-2 z-10 flex items-center justify-between">
<div className="absolute top-0 left-0 right-0 bg-accent p-2 h-[40px] z-10 flex items-center justify-between">
<div className="text-sm font-medium truncate">{filename}</div>
<div className="flex items-center gap-1">
<button
{/* <button
onClick={handleDownload}
className="cursor-pointer p-1 rounded-full hover:bg-black/10 dark:hover:bg-white/10"
title="Download file"
>
<Download size={14} />
</button>
</button> */}
{onClick && (
<button
onClick={handleClick}

View File

@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import React, { useState } from 'react';
import { CsvTable } from '@/components/ui/csv-table';
import Papa from 'papaparse';
import { cn } from '@/lib/utils';
@ -35,49 +35,75 @@ function parseCSV(content: string) {
}
/**
* CSV/TSV renderer that presents data in a table format
* Minimal CSV renderer for thread previews using the CsvTable component
*/
export function CsvRenderer({
content,
className
}: CsvRendererProps) {
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' | null }>({
column: '',
direction: null
});
const parsedData = parseCSV(content);
const isEmpty = parsedData.data.length === 0;
const handleSort = (column: string) => {
setSortConfig(prev => {
if (prev.column === column) {
const newDirection = prev.direction === 'asc' ? 'desc' : prev.direction === 'desc' ? null : 'asc';
return { column: newDirection ? column : '', direction: newDirection };
} else {
return { column, direction: 'asc' };
}
});
};
// Sort the data based on sortConfig
const sortedData = React.useMemo(() => {
if (!sortConfig.column || !sortConfig.direction) {
return parsedData.data;
}
return [...parsedData.data].sort((a: any, b: any) => {
const aVal = a[sortConfig.column];
const bVal = b[sortConfig.column];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return sortConfig.direction === 'asc' ? -1 : 1;
if (bVal == null) return sortConfig.direction === 'asc' ? 1 : -1;
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
}
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [parsedData.data, sortConfig]);
if (isEmpty) {
return (
<div className={cn('w-full h-full overflow-hidden', className)}>
<ScrollArea className="w-full h-full">
<div className="p-0">
<table className="w-full border-collapse text-sm">
<thead className="bg-muted sticky top-0">
<tr>
{parsedData.headers.map((header, index) => (
<th key={index} className="px-3 py-2 text-left font-medium border border-border">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{!isEmpty ? parsedData.data.map((row: any, rowIndex) => (
<tr key={rowIndex} className="border-b border-border hover:bg-muted/50">
{parsedData.headers.map((header, cellIndex) => (
<td key={cellIndex} className="px-3 py-2 border border-border">
{row[header] || ''}
</td>
))}
</tr>
)) : (
<tr>
<td colSpan={parsedData.headers.length || 1} className="py-4 text-center text-muted-foreground">
No data available
</td>
</tr>
)}
</tbody>
</table>
</div>
</ScrollArea>
<div className={cn('w-full h-full flex items-center justify-center', className)}>
<div className="text-muted-foreground text-sm">No data</div>
</div>
);
}
return (
<div className={cn('w-full h-full', className)}>
<CsvTable
headers={parsedData.headers}
data={sortedData}
sortConfig={sortConfig}
onSort={handleSort}
containerHeight={300} // Fixed height for thread preview
/>
</div>
);
}

View File

@ -0,0 +1,208 @@
'use client';
import React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useTheme } from 'next-themes';
import {
ChevronUp,
ChevronDown,
ArrowUpDown,
} from 'lucide-react';
interface CsvTableProps {
headers: string[];
data: any[];
sortConfig: { column: string; direction: 'asc' | 'desc' | null };
onSort: (column: string) => void;
searchTerm?: string;
onClearSearch?: () => void;
className?: string;
containerHeight?: number; // Add container height prop
}
export function CsvTable({
headers,
data,
sortConfig,
onSort,
searchTerm,
onClearSearch,
className,
containerHeight = 500
}: CsvTableProps) {
const { resolvedTheme } = useTheme();
const ROW_HEIGHT = 48; // Height of each row in pixels
const HEADER_HEIGHT = 48; // Height of header row
const COL_WIDTH = 150; // Fixed column width in pixels
// Calculate offset to handle parent containers with fractional heights like calc(100vh-17rem)
// 17rem = 272px (16px base * 17), so we need to offset the grid to align properly
const parentOffset = 272; // 17rem in pixels
const gridOffsetY = parentOffset % ROW_HEIGHT; // Get remainder to align to 48px grid
// Theme-aware grid colors with subtle monochrome grays
const getGridColors = () => {
const isDark = resolvedTheme === 'dark';
return {
vertical: isDark ? 'rgb(64 64 64)' : 'rgb(229 229 229)', // Subtle gray for each theme
horizontal: isDark ? 'rgb(64 64 64)' : 'rgb(229 229 229)', // Same for consistency
background: 'hsl(var(--muted))'
};
};
const gridColors = getGridColors();
const getSortIcon = (column: string) => {
if (sortConfig.column !== column) {
return <ArrowUpDown className="h-3 w-3 text-muted-foreground" />;
}
return sortConfig.direction === 'asc' ?
<ChevronUp className="h-3 w-3 text-primary" /> :
<ChevronDown className="h-3 w-3 text-primary" />;
};
const formatCellValue = (value: any) => {
if (value == null) return '';
if (typeof value === 'number') {
return value.toLocaleString();
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
};
const getCellClassName = (value: any) => {
if (typeof value === 'number') {
return 'text-right font-mono';
}
if (typeof value === 'boolean') {
return value ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
}
return '';
};
return (
<div className={cn("w-full relative h-full !bg-card", className)}>
{/* Use CSS Grid for perfect alignment */}
<div
className="w-full h-full overflow-auto relative min-h-full"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${headers.length}, 150px)`,
gridAutoRows: '48px',
minHeight: '100%',
backgroundColor: gridColors.background,
backgroundImage: `
repeating-linear-gradient(
to right,
transparent 0px,
transparent 149px,
${gridColors.vertical} 149px,
${gridColors.vertical} 150px
),
repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 47px,
${gridColors.horizontal} 47px,
${gridColors.horizontal} 48px
)
`,
backgroundSize: `${(headers.length - 1) * 150 + 149}px 48px, 150px 48px`,
backgroundPosition: '0 0',
backgroundAttachment: 'local'
} as React.CSSProperties}
>
{/* Header background extension for infinite grid */}
<div
className={cn(
"sticky top-0 z-10 pointer-events-none",
resolvedTheme === 'dark' ? 'bg-muted' : 'bg-background'
)}
style={{
position: 'absolute',
left: `${headers.length * 150}px`,
right: 0,
height: '48px',
top: 0
}}
/>
{/* Header row */}
{headers.map((header, index) => (
<div
key={`header-${index}`}
className={cn(
"sticky top-0 z-20 flex items-center px-4 font-medium",
resolvedTheme === 'dark' ? 'bg-muted' : 'bg-background'
)}
style={{
gridColumn: index + 1,
gridRow: 1,
height: '48px'
}}
>
<button
onClick={() => onSort(header)}
className="flex items-center gap-2 hover:text-primary transition-colors group w-full text-left"
>
<span className="truncate">{header}</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{getSortIcon(header)}
</div>
</button>
</div>
))}
{/* Data rows */}
{data.map((row: any, rowIndex) => (
headers.map((header, cellIndex) => {
const value = row[header];
return (
<div
key={`${rowIndex}-${cellIndex}`}
className={cn(
"flex items-center px-4 text-sm hover:bg-muted/30 transition-colors",
getCellClassName(value)
)}
style={{
gridColumn: cellIndex + 1,
gridRow: rowIndex + 2,
height: '48px'
}}
>
<div className="truncate w-full" title={String(value || '')}>
{formatCellValue(value)}
</div>
</div>
);
})
))}
{/* Empty state for search */}
{data.length === 0 && searchTerm && (
<div
className="col-span-full py-8 text-center text-muted-foreground"
style={{
gridColumn: `1 / ${headers.length + 1}`,
gridRow: 2
}}
>
<div className="space-y-2">
<p>No results found for "{searchTerm}"</p>
{onClearSearch && (
<Button
variant="outline"
size="sm"
onClick={onClearSearch}
>
Clear search
</Button>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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