mirror of https://github.com/kortix-ai/suna.git
Merge branch 'main' into sharath/suna-406-refactor-pricing-system
This commit is contained in:
commit
7b10ca6478
|
@ -529,13 +529,10 @@ async def start_agent(
|
|||
logger.error(f"Error loading workflow {workflow_id} for thread {thread_id}: {e}")
|
||||
# Continue with existing agent config if workflow loading fails
|
||||
|
||||
# Update thread's agent_id if a different agent was explicitly requested
|
||||
# Don't update thread's agent_id since threads are now agent-agnostic
|
||||
# The agent selection is handled per message/agent run
|
||||
if body.agent_id and body.agent_id != thread_agent_id and agent_config:
|
||||
try:
|
||||
await client.table('threads').update({"agent_id": agent_config['agent_id']}).eq('thread_id', thread_id).execute()
|
||||
logger.info(f"Updated thread {thread_id} to use agent {agent_config['agent_id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update thread agent_id: {e}")
|
||||
logger.info(f"Using agent {agent_config['agent_id']} for this agent run (thread remains agent-agnostic)")
|
||||
|
||||
can_use, model_message, allowed_models = await can_use_model(client, account_id, model_name)
|
||||
if not can_use:
|
||||
|
@ -570,7 +567,9 @@ async def start_agent(
|
|||
|
||||
agent_run = await client.table('agent_runs').insert({
|
||||
"thread_id": thread_id, "status": "running",
|
||||
"started_at": datetime.now(timezone.utc).isoformat()
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"agent_id": agent_config.get('agent_id') if agent_config else None,
|
||||
"agent_version_id": agent_config.get('current_version_id') if agent_config else None
|
||||
}).execute()
|
||||
agent_run_id = agent_run.data[0]['id']
|
||||
structlog.contextvars.bind_contextvars(
|
||||
|
@ -648,7 +647,8 @@ async def get_agent_run(agent_run_id: str, user_id: str = Depends(get_current_us
|
|||
|
||||
@router.get("/thread/{thread_id}/agent", response_model=ThreadAgentResponse)
|
||||
async def get_thread_agent(thread_id: str, user_id: str = Depends(get_current_user_id_from_jwt)):
|
||||
"""Get the agent details for a specific thread."""
|
||||
"""Get the agent details for a specific thread. Since threads are now agent-agnostic,
|
||||
this returns the most recently used agent or the default agent."""
|
||||
structlog.contextvars.bind_contextvars(
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
@ -667,47 +667,79 @@ async def get_thread_agent(thread_id: str, user_id: str = Depends(get_current_us
|
|||
thread_agent_id = thread_data.get('agent_id')
|
||||
account_id = thread_data.get('account_id')
|
||||
|
||||
# If no agent_id is set in the thread, try to get the default agent
|
||||
effective_agent_id = thread_agent_id
|
||||
agent_source = "thread"
|
||||
effective_agent_id = None
|
||||
agent_source = "none"
|
||||
|
||||
if not effective_agent_id:
|
||||
# No agent set in thread, get default agent for the account
|
||||
# First, try to get the most recently used agent from agent_runs
|
||||
recent_agent_result = await client.table('agent_runs').select('agent_id', 'agent_version_id').eq('thread_id', thread_id).not_.is_('agent_id', 'null').order('created_at', desc=True).limit(1).execute()
|
||||
if recent_agent_result.data:
|
||||
effective_agent_id = recent_agent_result.data[0]['agent_id']
|
||||
recent_version_id = recent_agent_result.data[0].get('agent_version_id')
|
||||
agent_source = "recent"
|
||||
logger.info(f"Found most recently used agent: {effective_agent_id} (version: {recent_version_id})")
|
||||
|
||||
# If no recent agent, fall back to thread default agent
|
||||
elif thread_agent_id:
|
||||
effective_agent_id = thread_agent_id
|
||||
agent_source = "thread"
|
||||
logger.info(f"Using thread default agent: {effective_agent_id}")
|
||||
|
||||
# If no thread agent, try to get the default agent for the account
|
||||
else:
|
||||
default_agent_result = await client.table('agents').select('agent_id').eq('account_id', account_id).eq('is_default', True).execute()
|
||||
if default_agent_result.data:
|
||||
effective_agent_id = default_agent_result.data[0]['agent_id']
|
||||
agent_source = "default"
|
||||
else:
|
||||
# No default agent found
|
||||
return {
|
||||
"agent": None,
|
||||
"source": "none",
|
||||
"message": "No agent configured for this thread"
|
||||
}
|
||||
logger.info(f"Using account default agent: {effective_agent_id}")
|
||||
|
||||
# Fetch the agent details
|
||||
agent_result = await client.table('agents').select('*').eq('agent_id', effective_agent_id).eq('account_id', account_id).execute()
|
||||
# If still no agent found
|
||||
if not effective_agent_id:
|
||||
return {
|
||||
"agent": None,
|
||||
"source": "none",
|
||||
"message": "No agent configured for this thread. Threads are agent-agnostic - you can select any agent."
|
||||
}
|
||||
|
||||
# Fetch the agent details with version information
|
||||
agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', effective_agent_id).eq('account_id', account_id).execute()
|
||||
|
||||
if not agent_result.data:
|
||||
# Agent was deleted or doesn't exist
|
||||
return {
|
||||
"agent": None,
|
||||
"source": "missing",
|
||||
"message": f"Agent {effective_agent_id} not found or was deleted"
|
||||
"message": f"Agent {effective_agent_id} not found or was deleted. You can select a different agent."
|
||||
}
|
||||
|
||||
agent_data = agent_result.data[0]
|
||||
|
||||
# Use version data if available, otherwise fall back to agent data (for backward compatibility)
|
||||
if agent_data.get('agent_versions'):
|
||||
version_data = agent_data['agent_versions']
|
||||
# Use the version data for the response
|
||||
system_prompt = version_data['system_prompt']
|
||||
configured_mcps = version_data.get('configured_mcps', [])
|
||||
custom_mcps = version_data.get('custom_mcps', [])
|
||||
agentpress_tools = version_data.get('agentpress_tools', {})
|
||||
logger.info(f"Using agent {agent_data['name']} version {version_data.get('version_name', 'v1')}")
|
||||
else:
|
||||
# Backward compatibility - use agent data directly
|
||||
system_prompt = agent_data['system_prompt']
|
||||
configured_mcps = agent_data.get('configured_mcps', [])
|
||||
custom_mcps = agent_data.get('custom_mcps', [])
|
||||
agentpress_tools = agent_data.get('agentpress_tools', {})
|
||||
logger.info(f"Using agent {agent_data['name']} - no version data (backward compatibility)")
|
||||
|
||||
return {
|
||||
"agent": AgentResponse(
|
||||
agent_id=agent_data['agent_id'],
|
||||
account_id=agent_data['account_id'],
|
||||
name=agent_data['name'],
|
||||
description=agent_data.get('description'),
|
||||
system_prompt=agent_data['system_prompt'],
|
||||
configured_mcps=agent_data.get('configured_mcps', []),
|
||||
custom_mcps=agent_data.get('custom_mcps', []),
|
||||
agentpress_tools=agent_data.get('agentpress_tools', {}),
|
||||
system_prompt=system_prompt,
|
||||
configured_mcps=configured_mcps,
|
||||
custom_mcps=custom_mcps,
|
||||
agentpress_tools=agentpress_tools,
|
||||
is_default=agent_data.get('is_default', False),
|
||||
is_public=agent_data.get('is_public', False),
|
||||
marketplace_published_at=agent_data.get('marketplace_published_at'),
|
||||
|
@ -716,10 +748,12 @@ async def get_thread_agent(thread_id: str, user_id: str = Depends(get_current_us
|
|||
avatar=agent_data.get('avatar'),
|
||||
avatar_color=agent_data.get('avatar_color'),
|
||||
created_at=agent_data['created_at'],
|
||||
updated_at=agent_data['updated_at']
|
||||
updated_at=agent_data['updated_at'],
|
||||
current_version_id=agent_data.get('current_version_id'),
|
||||
version_count=agent_data.get('version_count', 1)
|
||||
),
|
||||
"source": agent_source,
|
||||
"message": f"Using {agent_source} agent: {agent_data['name']}"
|
||||
"message": f"Using {agent_source} agent: {agent_data['name']}. Threads are agent-agnostic - you can change agents anytime."
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
@ -1091,10 +1125,10 @@ async def initiate_agent_with_files(
|
|||
account_id=account_id,
|
||||
)
|
||||
|
||||
# Store the agent_id in the thread if we have one
|
||||
# Don't store agent_id in thread since threads are now agent-agnostic
|
||||
# The agent selection will be handled per message/agent run
|
||||
if agent_config:
|
||||
thread_data["agent_id"] = agent_config['agent_id']
|
||||
logger.info(f"Storing agent_id {agent_config['agent_id']} in thread")
|
||||
logger.info(f"Using agent {agent_config['agent_id']} for this conversation (thread remains agent-agnostic)")
|
||||
structlog.contextvars.bind_contextvars(
|
||||
agent_id=agent_config['agent_id'],
|
||||
)
|
||||
|
@ -1186,7 +1220,9 @@ async def initiate_agent_with_files(
|
|||
# 6. Start Agent Run
|
||||
agent_run = await client.table('agent_runs').insert({
|
||||
"thread_id": thread_id, "status": "running",
|
||||
"started_at": datetime.now(timezone.utc).isoformat()
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"agent_id": agent_config.get('agent_id') if agent_config else None,
|
||||
"agent_version_id": agent_config.get('current_version_id') if agent_config else None
|
||||
}).execute()
|
||||
agent_run_id = agent_run.data[0]['id']
|
||||
logger.info(f"Created new agent run: {agent_run_id}")
|
||||
|
@ -1834,6 +1870,7 @@ class MarketplaceAgent(BaseModel):
|
|||
creator_name: str
|
||||
avatar: Optional[str]
|
||||
avatar_color: Optional[str]
|
||||
is_kortix_team: Optional[bool] = False
|
||||
|
||||
class MarketplaceAgentsResponse(BaseModel):
|
||||
agents: List[MarketplaceAgent]
|
||||
|
@ -1902,6 +1939,25 @@ async def get_marketplace_agents(
|
|||
if has_more:
|
||||
total_pages = page + 1
|
||||
|
||||
# Add Kortix team identification
|
||||
kortix_team_creators = [
|
||||
'kortix', 'kortix team', 'suna team', 'official', 'kortix official'
|
||||
]
|
||||
|
||||
for agent in agents_data:
|
||||
creator_name = agent.get('creator_name', '').lower()
|
||||
agent['is_kortix_team'] = any(
|
||||
kortix_creator in creator_name
|
||||
for kortix_creator in kortix_team_creators
|
||||
)
|
||||
|
||||
agents_data = sorted(agents_data, key=lambda x: (
|
||||
not x.get('is_kortix_team', False),
|
||||
-x.get('download_count', 0) if sort_by == "most_downloaded" else 0,
|
||||
x.get('name', '').lower() if sort_by == "name" else '',
|
||||
-(datetime.fromisoformat(x.get('marketplace_published_at', x.get('created_at', ''))).timestamp()) if sort_by == "newest" else 0
|
||||
))
|
||||
|
||||
logger.info(f"Found {len(agents_data)} marketplace agents (page {page}, estimated {total_pages} pages)")
|
||||
return {
|
||||
"agents": agents_data,
|
||||
|
|
|
@ -57,7 +57,7 @@ async def run_agent(
|
|||
|
||||
if not trace:
|
||||
trace = langfuse.trace(name="run_agent", session_id=thread_id, metadata={"project_id": project_id})
|
||||
thread_manager = ThreadManager(trace=trace, is_agent_builder=is_agent_builder, target_agent_id=target_agent_id)
|
||||
thread_manager = ThreadManager(trace=trace, is_agent_builder=is_agent_builder, target_agent_id=target_agent_id, agent_config=agent_config)
|
||||
|
||||
client = await thread_manager.db.client
|
||||
|
||||
|
|
|
@ -86,13 +86,14 @@ class ProcessorConfig:
|
|||
class ResponseProcessor:
|
||||
"""Processes LLM responses, extracting and executing tool calls."""
|
||||
|
||||
def __init__(self, tool_registry: ToolRegistry, add_message_callback: Callable, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None):
|
||||
def __init__(self, tool_registry: ToolRegistry, add_message_callback: Callable, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None):
|
||||
"""Initialize the ResponseProcessor.
|
||||
|
||||
Args:
|
||||
tool_registry: Registry of available tools
|
||||
add_message_callback: Callback function to add messages to the thread.
|
||||
MUST return the full saved message object (dict) or None.
|
||||
agent_config: Optional agent configuration with version information
|
||||
"""
|
||||
self.tool_registry = tool_registry
|
||||
self.add_message = add_message_callback
|
||||
|
@ -103,6 +104,7 @@ class ResponseProcessor:
|
|||
self.xml_parser = XMLToolParser(strict_mode=False)
|
||||
self.is_agent_builder = is_agent_builder
|
||||
self.target_agent_id = target_agent_id
|
||||
self.agent_config = agent_config
|
||||
|
||||
async def _yield_message(self, message_obj: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Helper to yield a message with proper formatting.
|
||||
|
@ -112,6 +114,32 @@ class ResponseProcessor:
|
|||
if message_obj:
|
||||
return format_for_yield(message_obj)
|
||||
|
||||
async def _add_message_with_agent_info(
|
||||
self,
|
||||
thread_id: str,
|
||||
type: str,
|
||||
content: Union[Dict[str, Any], List[Any], str],
|
||||
is_llm_message: bool = False,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""Helper to add a message with agent version information if available."""
|
||||
agent_id = None
|
||||
agent_version_id = None
|
||||
|
||||
if self.agent_config:
|
||||
agent_id = self.agent_config.get('agent_id')
|
||||
agent_version_id = self.agent_config.get('current_version_id')
|
||||
|
||||
return await self.add_message(
|
||||
thread_id=thread_id,
|
||||
type=type,
|
||||
content=content,
|
||||
is_llm_message=is_llm_message,
|
||||
metadata=metadata,
|
||||
agent_id=agent_id,
|
||||
agent_version_id=agent_version_id
|
||||
)
|
||||
|
||||
async def process_streaming_response(
|
||||
self,
|
||||
llm_response: AsyncGenerator,
|
||||
|
@ -492,7 +520,7 @@ class ResponseProcessor:
|
|||
"tool_calls": complete_native_tool_calls or None
|
||||
}
|
||||
|
||||
last_assistant_message_object = await self.add_message(
|
||||
last_assistant_message_object = await self._add_message_with_agent_info(
|
||||
thread_id=thread_id, type="assistant", content=message_data,
|
||||
is_llm_message=True, metadata={"thread_run_id": thread_run_id}
|
||||
)
|
||||
|
@ -879,7 +907,7 @@ class ResponseProcessor:
|
|||
|
||||
# --- SAVE and YIELD Final Assistant Message ---
|
||||
message_data = {"role": "assistant", "content": content, "tool_calls": native_tool_calls_for_message or None}
|
||||
assistant_message_object = await self.add_message(
|
||||
assistant_message_object = await self._add_message_with_agent_info(
|
||||
thread_id=thread_id, type="assistant", content=message_data,
|
||||
is_llm_message=True, metadata={"thread_run_id": thread_run_id}
|
||||
)
|
||||
|
|
|
@ -38,19 +38,21 @@ class ThreadManager:
|
|||
XML-based tool execution patterns.
|
||||
"""
|
||||
|
||||
def __init__(self, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None):
|
||||
def __init__(self, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None):
|
||||
"""Initialize ThreadManager.
|
||||
|
||||
Args:
|
||||
trace: Optional trace client for logging
|
||||
is_agent_builder: Whether this is an agent builder session
|
||||
target_agent_id: ID of the agent being built (if in agent builder mode)
|
||||
agent_config: Optional agent configuration with version information
|
||||
"""
|
||||
self.db = DBConnection()
|
||||
self.tool_registry = ToolRegistry()
|
||||
self.trace = trace
|
||||
self.is_agent_builder = is_agent_builder
|
||||
self.target_agent_id = target_agent_id
|
||||
self.agent_config = agent_config
|
||||
if not self.trace:
|
||||
self.trace = langfuse.trace(name="anonymous:thread_manager")
|
||||
self.response_processor = ResponseProcessor(
|
||||
|
@ -58,7 +60,8 @@ class ThreadManager:
|
|||
add_message_callback=self.add_message,
|
||||
trace=self.trace,
|
||||
is_agent_builder=self.is_agent_builder,
|
||||
target_agent_id=self.target_agent_id
|
||||
target_agent_id=self.target_agent_id,
|
||||
agent_config=self.agent_config
|
||||
)
|
||||
self.context_manager = ContextManager()
|
||||
|
||||
|
@ -345,7 +348,9 @@ class ThreadManager:
|
|||
type: str,
|
||||
content: Union[Dict[str, Any], List[Any], str],
|
||||
is_llm_message: bool = False,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
agent_version_id: Optional[str] = None
|
||||
):
|
||||
"""Add a message to the thread in the database.
|
||||
|
||||
|
@ -358,8 +363,10 @@ class ThreadManager:
|
|||
Defaults to False (user message).
|
||||
metadata: Optional dictionary for additional message metadata.
|
||||
Defaults to None, stored as an empty JSONB object if None.
|
||||
agent_id: Optional ID of the agent associated with this message.
|
||||
agent_version_id: Optional ID of the specific agent version used.
|
||||
"""
|
||||
logger.debug(f"Adding message of type '{type}' to thread {thread_id}")
|
||||
logger.debug(f"Adding message of type '{type}' to thread {thread_id} (agent: {agent_id}, version: {agent_version_id})")
|
||||
client = await self.db.client
|
||||
|
||||
# Prepare data for insertion
|
||||
|
@ -371,6 +378,12 @@ class ThreadManager:
|
|||
'metadata': metadata or {},
|
||||
}
|
||||
|
||||
# Add agent information if provided
|
||||
if agent_id:
|
||||
data_to_insert['agent_id'] = agent_id
|
||||
if agent_version_id:
|
||||
data_to_insert['agent_version_id'] = agent_version_id
|
||||
|
||||
try:
|
||||
# Add returning='representation' to get the inserted row data including the id
|
||||
result = await client.table('messages').insert(data_to_insert, returning='representation').execute()
|
||||
|
|
|
@ -114,6 +114,7 @@ class TemplateResponse(BaseModel):
|
|||
creator_name: Optional[str] = None
|
||||
avatar: Optional[str]
|
||||
avatar_color: Optional[str]
|
||||
is_kortix_team: Optional[bool] = False
|
||||
|
||||
class InstallationResponse(BaseModel):
|
||||
"""Response model for template installation"""
|
||||
|
|
|
@ -71,24 +71,18 @@ class TemplateManager:
|
|||
tags: Optional[List[str]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Create an agent template from an existing agent, stripping all credentials
|
||||
Create a secure template from an existing agent
|
||||
|
||||
Args:
|
||||
agent_id: ID of the existing agent
|
||||
creator_id: ID of the user creating the template
|
||||
make_public: Whether to make the template public immediately
|
||||
tags: Optional tags for the template
|
||||
|
||||
Returns:
|
||||
template_id: ID of the created template
|
||||
This extracts the agent configuration and creates a template with
|
||||
MCP requirements (without credentials) that can be safely shared
|
||||
"""
|
||||
logger.info(f"Creating template from agent {agent_id}")
|
||||
logger.info(f"Creating template from agent {agent_id} for user {creator_id}")
|
||||
|
||||
try:
|
||||
client = await db.client
|
||||
|
||||
# Get the existing agent with current version
|
||||
agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', agent_id).execute()
|
||||
# Get the agent
|
||||
agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).execute()
|
||||
if not agent_result.data:
|
||||
raise ValueError("Agent not found")
|
||||
|
||||
|
@ -96,61 +90,63 @@ class TemplateManager:
|
|||
|
||||
# Verify ownership
|
||||
if agent['account_id'] != creator_id:
|
||||
raise ValueError("Access denied: not agent owner")
|
||||
raise ValueError("Access denied - you can only create templates from your own agents")
|
||||
|
||||
# Extract MCP requirements (remove credentials)
|
||||
# Extract MCP requirements from agent configuration
|
||||
mcp_requirements = []
|
||||
|
||||
# Process configured_mcps
|
||||
for mcp_config in agent.get('configured_mcps', []):
|
||||
requirement = {
|
||||
'qualified_name': mcp_config.get('qualifiedName'),
|
||||
'display_name': mcp_config.get('name'),
|
||||
'enabled_tools': mcp_config.get('enabledTools', []),
|
||||
'required_config': list(mcp_config.get('config', {}).keys())
|
||||
}
|
||||
mcp_requirements.append(requirement)
|
||||
# Process configured_mcps (regular MCP servers)
|
||||
for mcp in agent.get('configured_mcps', []):
|
||||
if isinstance(mcp, dict) and 'qualifiedName' in mcp:
|
||||
# Extract required config keys from the config
|
||||
config_keys = list(mcp.get('config', {}).keys())
|
||||
|
||||
# Process custom_mcps
|
||||
requirement = {
|
||||
'qualified_name': mcp['qualifiedName'],
|
||||
'display_name': mcp.get('name', mcp['qualifiedName']),
|
||||
'enabled_tools': mcp.get('enabledTools', []),
|
||||
'required_config': config_keys
|
||||
}
|
||||
mcp_requirements.append(requirement)
|
||||
|
||||
# Process custom_mcps (custom MCP servers)
|
||||
for custom_mcp in agent.get('custom_mcps', []):
|
||||
custom_type = custom_mcp.get('customType', custom_mcp.get('type', 'sse'))
|
||||
requirement = {
|
||||
'qualified_name': f"custom_{custom_type}_{custom_mcp['name'].replace(' ', '_').lower()}",
|
||||
'display_name': custom_mcp['name'],
|
||||
'enabled_tools': custom_mcp.get('enabledTools', []),
|
||||
'required_config': list(custom_mcp.get('config', {}).keys()),
|
||||
'custom_type': custom_type
|
||||
}
|
||||
logger.info(f"Created custom MCP requirement: {requirement}")
|
||||
mcp_requirements.append(requirement)
|
||||
if isinstance(custom_mcp, dict) and 'name' in custom_mcp:
|
||||
# Extract required config keys from the config
|
||||
config_keys = list(custom_mcp.get('config', {}).keys())
|
||||
|
||||
# Use version data if available, otherwise fall back to agent data
|
||||
version_data = agent.get('agent_versions', {})
|
||||
if version_data:
|
||||
system_prompt = version_data.get('system_prompt', agent['system_prompt'])
|
||||
agentpress_tools = version_data.get('agentpress_tools', agent.get('agentpress_tools', {}))
|
||||
version_name = version_data.get('version_name', 'v1')
|
||||
else:
|
||||
system_prompt = agent['system_prompt']
|
||||
agentpress_tools = agent.get('agentpress_tools', {})
|
||||
version_name = 'v1'
|
||||
requirement = {
|
||||
'qualified_name': custom_mcp['name'].lower().replace(' ', '_'),
|
||||
'display_name': custom_mcp['name'],
|
||||
'enabled_tools': custom_mcp.get('enabledTools', []),
|
||||
'required_config': config_keys,
|
||||
'custom_type': custom_mcp.get('type', 'http') # Default to http
|
||||
}
|
||||
mcp_requirements.append(requirement)
|
||||
|
||||
# Create template
|
||||
kortix_team_account_ids = [
|
||||
'xxxxxxxx',
|
||||
]
|
||||
|
||||
is_kortix_team = creator_id in kortix_team_account_ids
|
||||
|
||||
# Create the template
|
||||
template_data = {
|
||||
'creator_id': creator_id,
|
||||
'name': agent['name'],
|
||||
'description': agent.get('description'),
|
||||
'system_prompt': system_prompt,
|
||||
'system_prompt': agent['system_prompt'],
|
||||
'mcp_requirements': mcp_requirements,
|
||||
'agentpress_tools': agentpress_tools,
|
||||
'agentpress_tools': agent.get('agentpress_tools', {}),
|
||||
'tags': tags or [],
|
||||
'is_public': make_public,
|
||||
'is_kortix_team': is_kortix_team,
|
||||
'avatar': agent.get('avatar'),
|
||||
'avatar_color': agent.get('avatar_color'),
|
||||
'metadata': {
|
||||
'source_agent_id': agent_id,
|
||||
'source_version_id': agent.get('current_version_id'),
|
||||
'source_version_name': version_name
|
||||
'source_version_name': agent.get('current_version', {}).get('version_name', 'v1.0')
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -163,7 +159,7 @@ class TemplateManager:
|
|||
raise ValueError("Failed to create template")
|
||||
|
||||
template_id = result.data[0]['template_id']
|
||||
logger.info(f"Successfully created template {template_id} from agent {agent_id}")
|
||||
logger.info(f"Successfully created template {template_id} from agent {agent_id} with is_kortix_team={is_kortix_team}")
|
||||
|
||||
return template_id
|
||||
|
||||
|
@ -554,10 +550,19 @@ class TemplateManager:
|
|||
if not template or template.creator_id != creator_id:
|
||||
raise ValueError("Template not found or access denied")
|
||||
|
||||
# Check if this is a Kortix team account
|
||||
kortix_team_account_ids = [
|
||||
'bc14b70b-2edf-473c-95be-5f5b109d6553', # Your Kortix team account ID
|
||||
# Add more Kortix team account IDs here as needed
|
||||
]
|
||||
|
||||
is_kortix_team = template.creator_id in kortix_team_account_ids
|
||||
|
||||
# Update template
|
||||
update_data = {
|
||||
'is_public': True,
|
||||
'marketplace_published_at': datetime.now(timezone.utc).isoformat()
|
||||
'marketplace_published_at': datetime.now(timezone.utc).isoformat(),
|
||||
'is_kortix_team': is_kortix_team # Set based on account
|
||||
}
|
||||
|
||||
if tags:
|
||||
|
@ -568,6 +573,7 @@ class TemplateManager:
|
|||
.eq('template_id', template_id)\
|
||||
.execute()
|
||||
|
||||
logger.info(f"Published template {template_id} with is_kortix_team={is_kortix_team}")
|
||||
return len(result.data) > 0
|
||||
|
||||
except Exception as e:
|
||||
|
@ -615,6 +621,7 @@ class TemplateManager:
|
|||
query = client.table('agent_templates')\
|
||||
.select('*')\
|
||||
.eq('is_public', True)\
|
||||
.order('is_kortix_team', desc=True)\
|
||||
.order('marketplace_published_at', desc=True)\
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
|
@ -628,6 +635,9 @@ class TemplateManager:
|
|||
|
||||
templates = []
|
||||
for template_data in result.data:
|
||||
# Use the database field for is_kortix_team
|
||||
is_kortix_team = template_data.get('is_kortix_team', False)
|
||||
|
||||
templates.append({
|
||||
'template_id': template_data['template_id'],
|
||||
'name': template_data['name'],
|
||||
|
@ -639,11 +649,13 @@ class TemplateManager:
|
|||
'download_count': template_data.get('download_count', 0),
|
||||
'marketplace_published_at': template_data.get('marketplace_published_at'),
|
||||
'created_at': template_data['created_at'],
|
||||
'creator_name': 'Anonymous',
|
||||
'creator_name': 'Kortix Team' if is_kortix_team else 'Community',
|
||||
'avatar': template_data.get('avatar'),
|
||||
'avatar_color': template_data.get('avatar_color')
|
||||
'avatar_color': template_data.get('avatar_color'),
|
||||
'is_kortix_team': is_kortix_team
|
||||
})
|
||||
|
||||
# Templates are already sorted by database query (Kortix team first, then by date)
|
||||
return templates
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
@ -6,3 +6,16 @@ CREATE INDEX IF NOT EXISTS idx_threads_metadata ON threads USING GIN (metadata);
|
|||
|
||||
-- Comment on the column
|
||||
COMMENT ON COLUMN threads.metadata IS 'Stores additional thread context like agent builder mode and target agent';
|
||||
|
||||
-- Add agent_id to messages table to support per-message agent selection
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS agent_id UUID REFERENCES agents(agent_id) ON DELETE SET NULL;
|
||||
|
||||
-- Create index for message agent queries
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_agent_id ON messages(agent_id);
|
||||
|
||||
-- Comment on the new column
|
||||
COMMENT ON COLUMN messages.agent_id IS 'ID of the agent that generated this message. For user messages, this represents the agent that should respond to this message.';
|
||||
|
||||
-- Make thread agent_id nullable to allow agent-agnostic threads
|
||||
-- This is already nullable from the existing migration, but we'll add a comment
|
||||
COMMENT ON COLUMN threads.agent_id IS 'Optional default agent for the thread. If NULL, agent can be selected per message.';
|
|
@ -0,0 +1,33 @@
|
|||
-- Migration: Make threads agent-agnostic with proper agent versioning support
|
||||
-- This migration enables per-message agent selection with version tracking
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add agent version tracking to messages table
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS agent_id UUID REFERENCES agents(agent_id) ON DELETE SET NULL;
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS agent_version_id UUID REFERENCES agent_versions(version_id) ON DELETE SET NULL;
|
||||
|
||||
-- Create indexes for message agent queries
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_agent_id ON messages(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_agent_version_id ON messages(agent_version_id);
|
||||
|
||||
-- Comments on the new columns
|
||||
COMMENT ON COLUMN messages.agent_id IS 'ID of the agent that generated this message. For user messages, this represents the agent that should respond to this message.';
|
||||
COMMENT ON COLUMN messages.agent_version_id IS 'Specific version of the agent used for this message. This is the actual configuration that was active.';
|
||||
|
||||
-- Update comment on thread agent_id to reflect new agent-agnostic approach
|
||||
COMMENT ON COLUMN threads.agent_id IS 'Optional default agent for the thread. If NULL, agent can be selected per message. Threads are now agent-agnostic.';
|
||||
|
||||
-- Add agent version tracking to agent_runs
|
||||
ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS agent_id UUID REFERENCES agents(agent_id) ON DELETE SET NULL;
|
||||
ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS agent_version_id UUID REFERENCES agent_versions(version_id) ON DELETE SET NULL;
|
||||
|
||||
-- Create indexes for agent run queries
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_agent_id ON agent_runs(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_agent_version_id ON agent_runs(agent_version_id);
|
||||
|
||||
-- Comments on the agent_runs columns
|
||||
COMMENT ON COLUMN agent_runs.agent_id IS 'ID of the agent used for this specific agent run.';
|
||||
COMMENT ON COLUMN agent_runs.agent_version_id IS 'Specific version of the agent used for this run. This tracks the exact configuration.';
|
||||
|
||||
COMMIT;
|
|
@ -0,0 +1,15 @@
|
|||
-- Migration: Add is_kortix_team field to agent_templates
|
||||
-- This migration adds support for marking templates as Kortix team templates
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add is_kortix_team column to agent_templates table
|
||||
ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS is_kortix_team BOOLEAN DEFAULT false;
|
||||
|
||||
-- Create index for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_templates_is_kortix_team ON agent_templates(is_kortix_team);
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON COLUMN agent_templates.is_kortix_team IS 'Indicates if this template is created by the Kortix team (official templates)';
|
||||
|
||||
COMMIT;
|
|
@ -170,7 +170,7 @@ class Configuration:
|
|||
return self.STRIPE_TIER_200_1000_YEARLY_ID_PROD
|
||||
|
||||
# LLM API keys
|
||||
ANTHROPIC_API_KEY: str = None
|
||||
ANTHROPIC_API_KEY: Optional[str] = None
|
||||
OPENAI_API_KEY: Optional[str] = None
|
||||
GROQ_API_KEY: Optional[str] = None
|
||||
OPENROUTER_API_KEY: Optional[str] = None
|
||||
|
|
|
@ -118,8 +118,6 @@ const AgentModal = ({ agent, isOpen, onClose, onCustomize, onChat, onPublish, on
|
|||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Marketplace Actions */}
|
||||
<div className="pt-2 border-t">
|
||||
{agent.is_public ? (
|
||||
<div className="space-y-2">
|
||||
|
@ -265,31 +263,20 @@ export const AgentsGrid = ({
|
|||
const { avatar, color } = getAgentStyling(agent);
|
||||
const isPublishing = publishingId === agent.agent_id;
|
||||
const isUnpublishing = unpublishingId === agent.agent_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden hover:bg-muted/50 transition-all duration-200 cursor-pointer group"
|
||||
onClick={() => handleAgentClick(agent)}
|
||||
>
|
||||
<div className={`h-50 flex items-center justify-center relative`} style={{ backgroundColor: color }}>
|
||||
<div className="text-4xl">
|
||||
{avatar}
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
{agent.is_default && (
|
||||
<Star className="h-4 w-4 text-white fill-white drop-shadow-sm" />
|
||||
)}
|
||||
{agent.is_public && (
|
||||
<div className="flex items-center gap-1 bg-white/20 backdrop-blur-sm px-2 py-1 rounded-full">
|
||||
<Shield className="h-3 w-3 text-white" />
|
||||
<span className="text-white text-xs font-medium">{agent.download_count || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='p-4'>
|
||||
<div className={`h-12 w-12 flex items-center justify-center rounded-lg`} style={{ backgroundColor: color }}>
|
||||
<div className="text-2xl">
|
||||
{avatar}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-4 -mt-4 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-foreground font-medium text-lg line-clamp-1 flex-1">
|
||||
{agent.name}
|
||||
|
|
|
@ -49,9 +49,9 @@ export const SearchAndFilters = ({
|
|||
allTools
|
||||
}: SearchAndFiltersProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center flex-1">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<div className="flex flex-col w-full gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col w-full gap-3 sm:flex-row sm:items-center flex-1">
|
||||
<div className="relative flex-1 w-full border rounded-xl">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search agents..."
|
||||
|
@ -71,7 +71,7 @@ export const SearchAndFilters = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Select value={sortBy} onValueChange={(value: SortOption) => setSortBy(value)}>
|
||||
{/* <Select value={sortBy} onValueChange={(value: SortOption) => setSortBy(value)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
|
@ -90,11 +90,11 @@ export const SearchAndFilters = ({
|
|||
className="px-3"
|
||||
>
|
||||
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
{/* <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="relative">
|
||||
<Filter className="h-4 w-4" />
|
||||
|
@ -137,7 +137,7 @@ export const SearchAndFilters = ({
|
|||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu> */}
|
||||
|
||||
{/* <div className="flex border rounded-md">
|
||||
<Button
|
||||
|
|
|
@ -1,10 +1,22 @@
|
|||
import { getPixelDesignFromSeed } from '@/components/ui/pixel-avatar';
|
||||
|
||||
export const getAgentAvatar = (agentId: string) => {
|
||||
const avatars = ['🤖', '🎯', '⚡', '🚀', '🔮', '🎨', '📊', '🔧', '💡', '🌟'];
|
||||
const colors = ['#06b6d4', '#22c55e', '#8b5cf6', '#3b82f6', '#ec4899', '#eab308', '#ef4444', '#6366f1'];
|
||||
const avatarIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % avatars.length;
|
||||
const colorIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length;
|
||||
return {
|
||||
avatar: avatars[avatarIndex],
|
||||
color: colors[colorIndex]
|
||||
};
|
||||
const avatars = ['🤖', '🎯', '⚡', '🚀', '🔮', '🎨', '📊', '🔧', '💡', '🌟'];
|
||||
const colors = ['#06b6d4', '#22c55e', '#8b5cf6', '#3b82f6', '#ec4899', '#eab308', '#ef4444', '#6366f1'];
|
||||
const avatarIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % avatars.length;
|
||||
const colorIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length;
|
||||
return {
|
||||
avatar: avatars[avatarIndex],
|
||||
color: colors[colorIndex]
|
||||
};
|
||||
};
|
||||
|
||||
export const getAgentPixelAvatar = (agentId: string) => {
|
||||
const colors = ['#06b6d4', '#22c55e', '#8b5cf6', '#3b82f6', '#ec4899', '#eab308', '#ef4444', '#6366f1'];
|
||||
const colorIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length;
|
||||
const pixelDesign = getPixelDesignFromSeed(agentId);
|
||||
return {
|
||||
avatar: pixelDesign,
|
||||
color: colors[colorIndex]
|
||||
};
|
||||
};
|
|
@ -1,6 +1,4 @@
|
|||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Agent | Kortix Suna',
|
||||
|
@ -17,9 +15,5 @@ export default async function NewAgentLayout({
|
|||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Plus, AlertCircle, Loader2, File } from 'lucide-react';
|
||||
import { Plus, AlertCircle, Loader2, File, Bot } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { UpdateAgentDialog } from './_components/update-agent-dialog';
|
||||
|
@ -240,60 +240,36 @@ export default function AgentsPage() {
|
|||
return (
|
||||
<div className="container mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
Your Agents
|
||||
</h1>
|
||||
<p className="text-md text-muted-foreground max-w-2xl">
|
||||
Create and manage your AI agents with custom instructions and tools
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
onClick={() => router.push('/marketplace/my-templates')}
|
||||
className="self-start sm:self-center"
|
||||
variant="outline"
|
||||
>
|
||||
<File className="h-5 w-5" />
|
||||
My Templates
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateNewAgent}
|
||||
disabled={createAgentMutation.isPending}
|
||||
className="self-start sm:self-center"
|
||||
>
|
||||
{createAgentMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-5 w-5" />
|
||||
New Agent
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className='w-full space-y-4 bg-gradient-to-b from-primary/10 to-primary/5 border rounded-xl h-60 flex items-center justify-center'>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-center">
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Bot className='h-6 w-6 text-primary' />
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
Agents
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-md text-muted-foreground max-w-2xl">
|
||||
Create and manage your agents with custom instructions and tools
|
||||
</p>
|
||||
</div>
|
||||
<SearchAndFilters
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
sortOrder={sortOrder}
|
||||
setSortOrder={setSortOrder}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
activeFiltersCount={activeFiltersCount}
|
||||
clearFilters={clearFilters}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
allTools={allTools}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAndFilters
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
sortOrder={sortOrder}
|
||||
setSortOrder={setSortOrder}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
activeFiltersCount={activeFiltersCount}
|
||||
clearFilters={clearFilters}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
allTools={allTools}
|
||||
/>
|
||||
|
||||
<ResultsInfo
|
||||
isLoading={isLoading}
|
||||
totalAgents={pagination?.total || 0}
|
||||
|
|
|
@ -25,7 +25,7 @@ import { useAccounts } from '@/hooks/use-accounts';
|
|||
import { config } from '@/lib/config';
|
||||
import { useInitiateAgentWithInvalidation } from '@/hooks/react-query/dashboard/use-initiate-agent';
|
||||
import { ModalProviders } from '@/providers/modal-providers';
|
||||
import { AgentSelector } from '@/components/dashboard/agent-selector';
|
||||
import { useAgents } from '@/hooks/react-query/agents/use-agents';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useModal } from '@/hooks/use-modal-store';
|
||||
import { Examples } from './suggestions/examples';
|
||||
|
@ -52,6 +52,20 @@ export function DashboardContent() {
|
|||
const initiateAgentMutation = useInitiateAgentWithInvalidation();
|
||||
const { onOpen } = useModal();
|
||||
|
||||
// Fetch agents to get the selected agent's name
|
||||
const { data: agentsResponse } = useAgents({
|
||||
limit: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc'
|
||||
});
|
||||
|
||||
const agents = agentsResponse?.agents || [];
|
||||
const selectedAgent = selectedAgentId
|
||||
? agents.find(agent => agent.agent_id === selectedAgentId)
|
||||
: null;
|
||||
const displayName = selectedAgent?.name || 'Suna';
|
||||
const agentAvatar = selectedAgent?.avatar;
|
||||
|
||||
const threadQuery = useThreadQuery(initiatedThreadId || '');
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -189,24 +203,25 @@ export function DashboardContent() {
|
|||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
|
||||
<div className="flex flex-col items-center text-center w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<h1 className="tracking-tight text-4xl text-muted-foreground leading-tight">
|
||||
Hey, I am
|
||||
</h1>
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={setSelectedAgentId}
|
||||
variant="heading"
|
||||
/>
|
||||
<h1 className="ml-1 tracking-tight text-4xl font-semibold leading-tight text-primary">
|
||||
{displayName}
|
||||
{agentAvatar && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{agentAvatar}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2">
|
||||
What would you like to do today?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"w-full mb-2",
|
||||
"max-w-full",
|
||||
|
@ -220,12 +235,12 @@ export function DashboardContent() {
|
|||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
hideAttachments={false}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={setSelectedAgentId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Examples onSelectPrompt={setInputValue} />
|
||||
</div>
|
||||
|
||||
<BillingErrorAlert
|
||||
message={billingError?.message}
|
||||
currentUsage={billingError?.currentUsage}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Search, Download, Star, Calendar, User, Tags, TrendingUp, Shield, CheckCircle, Loader2, Settings, Wrench, AlertTriangle, GitBranch, Plus } from 'lucide-react';
|
||||
import { Search, Download, Star, Calendar, User, Tags, TrendingUp, Shield, CheckCircle, Loader2, Settings, Wrench, AlertTriangle, GitBranch, Plus, ShoppingBag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import { getAgentAvatar } from '../agents/_utils/get-agent-style';
|
||||
|
@ -37,6 +38,7 @@ interface MarketplaceTemplate {
|
|||
avatar?: string;
|
||||
avatar_color?: string;
|
||||
template_id: string;
|
||||
is_kortix_team?: boolean;
|
||||
mcp_requirements?: Array<{
|
||||
qualified_name: string;
|
||||
display_name: string;
|
||||
|
@ -74,6 +76,14 @@ interface MissingProfile {
|
|||
required_config: string[];
|
||||
}
|
||||
|
||||
interface AgentPreviewSheetProps {
|
||||
item: MarketplaceTemplate | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onInstall: (item: MarketplaceTemplate) => void;
|
||||
isInstalling: boolean;
|
||||
}
|
||||
|
||||
interface InstallDialogProps {
|
||||
item: MarketplaceTemplate | null;
|
||||
open: boolean;
|
||||
|
@ -82,6 +92,159 @@ interface InstallDialogProps {
|
|||
isInstalling: boolean;
|
||||
}
|
||||
|
||||
const AgentPreviewSheet: React.FC<AgentPreviewSheetProps> = ({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onInstall,
|
||||
isInstalling
|
||||
}) => {
|
||||
if (!item) return null;
|
||||
|
||||
const { avatar, color } = item.avatar && item.avatar_color
|
||||
? { avatar: item.avatar, color: item.avatar_color }
|
||||
: getAgentAvatar(item.id);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="h-16 w-16 flex items-center justify-center rounded-xl shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<div className="text-3xl">{avatar}</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<SheetTitle className="text-xl font-semibold line-clamp-2">
|
||||
{item.name}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{item.creator_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>{item.download_count} downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onInstall(item)}
|
||||
disabled={isInstalling}
|
||||
size='sm'
|
||||
className='w-48'
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Add to Library
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<div className="px-4 space-y-6 py-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Description
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed">
|
||||
{item.description || 'No description available for this agent.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
|
||||
Tags
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
<Tags className="h-3 w-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.mcp_requirements && item.mcp_requirements.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Required Tools & MCPs
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{item.mcp_requirements.map((mcp, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-muted-foreground/10 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||
<Wrench className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{mcp.display_name}</div>
|
||||
{mcp.enabled_tools && mcp.enabled_tools.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mcp.enabled_tools.length} tool{mcp.enabled_tools.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{mcp.custom_type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{mcp.custom_type.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.metadata?.source_version_name && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
|
||||
Version
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{item.metadata.source_version_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.marketplace_published_at && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Published
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{formatDate(item.marketplace_published_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
const InstallDialog: React.FC<InstallDialogProps> = ({
|
||||
item,
|
||||
open,
|
||||
|
@ -301,13 +464,13 @@ const InstallDialog: React.FC<InstallDialogProps> = ({
|
|||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
<AlertTitle className="text-destructive">Missing Credential Profiles</AlertTitle>
|
||||
<AlertDescription className="text-destructive/80">
|
||||
This agent requires credential profiles for the following services:
|
||||
This agent requires profiles for the following services:
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-3">
|
||||
{missingProfiles.map((profile) => (
|
||||
<Card key={profile.qualified_name} className="border-destructive/20 shadow-none bg-transparent">
|
||||
<Card key={profile.qualified_name} className="py-0 border-destructive/20 shadow-none bg-transparent">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive/10">
|
||||
|
@ -533,6 +696,7 @@ export default function MarketplacePage() {
|
|||
const [installingItemId, setInstallingItemId] = useState<string | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<MarketplaceTemplate | null>(null);
|
||||
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
||||
const [showPreviewSheet, setShowPreviewSheet] = useState(false);
|
||||
|
||||
// Secure marketplace data (all templates are now secure)
|
||||
const secureQueryParams = useMemo(() => ({
|
||||
|
@ -546,13 +710,14 @@ export default function MarketplacePage() {
|
|||
const installTemplateMutation = useInstallTemplate();
|
||||
|
||||
// Transform secure templates data
|
||||
const marketplaceItems = useMemo(() => {
|
||||
const items: MarketplaceTemplate[] = [];
|
||||
const { kortixTeamItems, communityItems } = useMemo(() => {
|
||||
const kortixItems: MarketplaceTemplate[] = [];
|
||||
const communityItems: MarketplaceTemplate[] = [];
|
||||
|
||||
// Add secure templates (all items are now secure)
|
||||
if (secureTemplates) {
|
||||
secureTemplates.forEach(template => {
|
||||
items.push({
|
||||
const item: MarketplaceTemplate = {
|
||||
id: template.template_id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
|
@ -564,35 +729,59 @@ export default function MarketplacePage() {
|
|||
avatar: template.avatar,
|
||||
avatar_color: template.avatar_color,
|
||||
template_id: template.template_id,
|
||||
is_kortix_team: template.is_kortix_team,
|
||||
mcp_requirements: template.mcp_requirements,
|
||||
metadata: template.metadata,
|
||||
});
|
||||
};
|
||||
|
||||
if (template.is_kortix_team) {
|
||||
kortixItems.push(item);
|
||||
} else {
|
||||
communityItems.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort items
|
||||
return items.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
return new Date(b.marketplace_published_at || b.created_at).getTime() -
|
||||
new Date(a.marketplace_published_at || a.created_at).getTime();
|
||||
case 'popular':
|
||||
case 'most_downloaded':
|
||||
return b.download_count - a.download_count;
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
// Sort function
|
||||
const sortItems = (items: MarketplaceTemplate[]) => {
|
||||
return items.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
return new Date(b.marketplace_published_at || b.created_at).getTime() -
|
||||
new Date(a.marketplace_published_at || a.created_at).getTime();
|
||||
case 'popular':
|
||||
case 'most_downloaded':
|
||||
return b.download_count - a.download_count;
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
kortixTeamItems: sortItems(kortixItems),
|
||||
communityItems: sortItems(communityItems)
|
||||
};
|
||||
}, [secureTemplates, sortBy]);
|
||||
|
||||
// Combined items for tag filtering and search stats
|
||||
const allMarketplaceItems = useMemo(() => {
|
||||
return [...kortixTeamItems, ...communityItems];
|
||||
}, [kortixTeamItems, communityItems]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [searchQuery, selectedTags, sortBy]);
|
||||
|
||||
const handleItemClick = (item: MarketplaceTemplate) => {
|
||||
setSelectedItem(item);
|
||||
setShowPreviewSheet(true);
|
||||
};
|
||||
|
||||
const handlePreviewInstall = (item: MarketplaceTemplate) => {
|
||||
setShowPreviewSheet(false);
|
||||
setShowInstallDialog(true);
|
||||
};
|
||||
|
||||
|
@ -658,11 +847,11 @@ export default function MarketplacePage() {
|
|||
|
||||
const allTags = React.useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
marketplaceItems.forEach(item => {
|
||||
allMarketplaceItems.forEach(item => {
|
||||
item.tags?.forEach(tag => tags.add(tag));
|
||||
});
|
||||
return Array.from(tags);
|
||||
}, [marketplaceItems]);
|
||||
}, [allMarketplaceItems]);
|
||||
|
||||
if (flagLoading) {
|
||||
return (
|
||||
|
@ -701,54 +890,57 @@ export default function MarketplacePage() {
|
|||
return (
|
||||
<div className="container mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
Agent Marketplace
|
||||
</h1>
|
||||
<p className="text-md text-muted-foreground max-w-2xl">
|
||||
Discover and install secure AI agent templates created by the community
|
||||
</p>
|
||||
<div className='w-full space-y-4 bg-gradient-to-b from-primary/10 to-primary/5 border rounded-xl h-60 flex items-center justify-center'>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-center">
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<ShoppingBag className='h-6 w-6 text-primary' />
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
Marketplace
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-md text-muted-foreground max-w-2xl">
|
||||
Discover and install powerful agents created by the community
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 border rounded-xl">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search agents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
{/* <Select value={sortBy} onValueChange={(value) => setSortBy(value as SortOption)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newest">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Newest First
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="popular">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
Most Popular
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="most_downloaded">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Most Downloaded
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search agent templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortOption)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newest">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Newest First
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="popular">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
Most Popular
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="most_downloaded">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Most Downloaded
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Filter by tags:</p>
|
||||
|
@ -772,7 +964,7 @@ export default function MarketplacePage() {
|
|||
{isLoading ? (
|
||||
"Loading marketplace..."
|
||||
) : (
|
||||
`${marketplaceItems.length} template${marketplaceItems.length !== 1 ? 's' : ''} found`
|
||||
`${allMarketplaceItems.length} template${allMarketplaceItems.length !== 1 ? 's' : ''} found`
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
@ -792,7 +984,7 @@ export default function MarketplacePage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : marketplaceItems.length === 0 ? (
|
||||
) : allMarketplaceItems.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery || selectedTags.length > 0
|
||||
|
@ -801,95 +993,214 @@ export default function MarketplacePage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{marketplaceItems.map((item) => {
|
||||
const { avatar, color } = getItemStyling(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden hover:bg-muted/50 transition-all duration-200 cursor-pointer group flex flex-col h-full"
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className={`h-50 flex items-center justify-center relative`} style={{ backgroundColor: color }}>
|
||||
<div className="text-4xl">
|
||||
{avatar}
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
<div className="flex items-center gap-1 bg-white/20 backdrop-blur-sm px-2 py-1 rounded-full">
|
||||
<Download className="h-3 w-3 text-white" />
|
||||
<span className="text-white text-xs font-medium">{item.download_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-12">
|
||||
{kortixTeamItems.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20">
|
||||
<Shield className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-foreground font-medium text-lg line-clamp-1 flex-1">
|
||||
{item.name}
|
||||
</h3>
|
||||
{item.metadata?.source_version_name && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{item.metadata.source_version_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-3 line-clamp-2">
|
||||
{item.description || 'No description available'}
|
||||
</p>
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{item.tags.slice(0, 2).map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{item.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{item.tags.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 mb-4">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span>By {item.creator_name}</span>
|
||||
</div>
|
||||
{item.marketplace_published_at && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{new Date(item.marketplace_published_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={(e) => handleInstallClick(item, e)}
|
||||
disabled={installingItemId === item.id}
|
||||
className="w-full transition-opacity mt-auto"
|
||||
size="sm"
|
||||
>
|
||||
{installingItemId === item.id ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Install Template
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Agents from Kortix Team</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{kortixTeamItems.map((item) => {
|
||||
const { avatar, color } = getItemStyling(item);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden hover:bg-muted/50 transition-all duration-200 cursor-pointer group flex flex-col h-full"
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className='p-4'>
|
||||
<div className={`h-12 w-12 flex items-center justify-center rounded-lg`} style={{ backgroundColor: color }}>
|
||||
<div className="text-2xl">
|
||||
{avatar}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 -mt-4 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-foreground font-medium text-lg line-clamp-1 flex-1">
|
||||
{item.name}
|
||||
</h3>
|
||||
{item.metadata?.source_version_name && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{item.metadata.source_version_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-3 line-clamp-2">
|
||||
{item.description || 'No description available'}
|
||||
</p>
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{item.tags.slice(0, 2).map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{item.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{item.tags.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 w-full flex justify-between">
|
||||
<div className='space-y-1'>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span>By {item.creator_name}</span>
|
||||
</div>
|
||||
{item.marketplace_published_at && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{new Date(item.marketplace_published_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-xs font-medium">{item.download_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => handleInstallClick(item, e)}
|
||||
disabled={installingItemId === item.id}
|
||||
className="w-full transition-opacity mt-auto"
|
||||
size="sm"
|
||||
>
|
||||
{installingItemId === item.id ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Add to Library
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{communityItems.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
|
||||
<User className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">Agents from Community</h2>
|
||||
<p className="text-sm text-muted-foreground">Templates created by the community</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{communityItems.map((item) => {
|
||||
const { avatar, color } = getItemStyling(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden hover:bg-muted/50 transition-all duration-200 cursor-pointer group flex flex-col h-full"
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className={`h-50 flex items-center justify-center relative`} style={{ backgroundColor: color }}>
|
||||
<div className="text-4xl">
|
||||
{avatar}
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 flex gap-2">
|
||||
<div className="flex items-center gap-1 bg-white/20 backdrop-blur-sm px-2 py-1 rounded-full">
|
||||
<Download className="h-3 w-3 text-white" />
|
||||
<span className="text-white text-xs font-medium">{item.download_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-foreground font-medium text-lg line-clamp-1 flex-1">
|
||||
{item.name}
|
||||
</h3>
|
||||
{item.metadata?.source_version_name && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{item.metadata.source_version_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-3 line-clamp-2">
|
||||
{item.description || 'No description available'}
|
||||
</p>
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{item.tags.slice(0, 2).map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{item.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{item.tags.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 mb-4">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span>By {item.creator_name}</span>
|
||||
</div>
|
||||
{item.marketplace_published_at && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{new Date(item.marketplace_published_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={(e) => handleInstallClick(item, e)}
|
||||
disabled={installingItemId === item.id}
|
||||
className="w-full transition-opacity mt-auto"
|
||||
size="sm"
|
||||
>
|
||||
{installingItemId === item.id ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Install Template
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AgentPreviewSheet
|
||||
item={selectedItem}
|
||||
open={showPreviewSheet}
|
||||
onOpenChange={setShowPreviewSheet}
|
||||
onInstall={handlePreviewInstall}
|
||||
isInstalling={installingItemId === selectedItem?.id}
|
||||
/>
|
||||
<InstallDialog
|
||||
item={selectedItem}
|
||||
open={showInstallDialog}
|
||||
|
|
|
@ -28,7 +28,7 @@ import { UnifiedMessage, ApiMessageType, ToolCallInput, Project } from '../_type
|
|||
import { useThreadData, useToolCalls, useBilling, useKeyboardShortcuts } from '../_hooks';
|
||||
import { ThreadError, UpgradeDialog, ThreadLayout } from '../_components';
|
||||
import { useVncPreloader } from '@/hooks/useVncPreloader';
|
||||
import { useAgent } from '@/hooks/react-query/agents/use-agents';
|
||||
import { useThreadAgent } from '@/hooks/react-query/agents/use-agents';
|
||||
|
||||
export default function ThreadPage({
|
||||
params,
|
||||
|
@ -52,6 +52,7 @@ export default function ThreadPage({
|
|||
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||
const [debugMode, setDebugMode] = useState(false);
|
||||
const [initialPanelOpenAttempted, setInitialPanelOpenAttempted] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | undefined>(undefined);
|
||||
|
||||
// Refs
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
@ -124,9 +125,17 @@ export default function ThreadPage({
|
|||
const addUserMessageMutation = useAddUserMessageMutation();
|
||||
const startAgentMutation = useStartAgentMutation();
|
||||
const stopAgentMutation = useStopAgentMutation();
|
||||
const { data: agent } = useAgent(threadQuery.data?.agent_id);
|
||||
const { data: threadAgentData } = useThreadAgent(threadId);
|
||||
const agent = threadAgentData?.agent;
|
||||
const workflowId = threadQuery.data?.metadata?.workflow_id;
|
||||
|
||||
// Set initial selected agent from thread data
|
||||
useEffect(() => {
|
||||
if (threadAgentData?.agent && !selectedAgentId) {
|
||||
setSelectedAgentId(threadAgentData.agent.agent_id);
|
||||
}
|
||||
}, [threadAgentData, selectedAgentId]);
|
||||
|
||||
const { data: subscriptionData } = useSubscription();
|
||||
const subscriptionStatus: SubscriptionStatus = subscriptionData?.status === 'active'
|
||||
? 'active'
|
||||
|
@ -273,7 +282,10 @@ export default function ThreadPage({
|
|||
|
||||
const agentPromise = startAgentMutation.mutateAsync({
|
||||
threadId,
|
||||
options
|
||||
options: {
|
||||
...options,
|
||||
agent_id: selectedAgentId
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([messagePromise, agentPromise]);
|
||||
|
@ -640,7 +652,7 @@ export default function ThreadPage({
|
|||
value={newMessage}
|
||||
onChange={setNewMessage}
|
||||
onSubmit={handleSubmitMessage}
|
||||
placeholder={`Ask ${agent ? agent.name : 'Suna'} anything...`}
|
||||
placeholder={`Describe what you need help with...`}
|
||||
loading={isSending}
|
||||
disabled={isSending || agentStatus === 'running' || agentStatus === 'connecting'}
|
||||
isAgentRunning={agentStatus === 'running' || agentStatus === 'connecting'}
|
||||
|
@ -650,6 +662,8 @@ export default function ThreadPage({
|
|||
sandboxId={sandboxId || undefined}
|
||||
messages={messages}
|
||||
agentName={agent && agent.name}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={setSelectedAgentId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -156,6 +156,8 @@ export function useThreadData(threadId: string, projectId: string): UseThreadDat
|
|||
metadata: msg.metadata || '{}',
|
||||
created_at: msg.created_at || new Date().toISOString(),
|
||||
updated_at: msg.updated_at || new Date().toISOString(),
|
||||
agent_id: (msg as any).agent_id,
|
||||
agents: (msg as any).agents,
|
||||
}));
|
||||
|
||||
setMessages(unifiedMessages);
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Credentials | Kortix Suna',
|
||||
|
@ -17,9 +15,5 @@ export default async function CredentialsLayout({
|
|||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const customAgentsEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!customAgentsEnabled) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
@ -40,6 +40,12 @@ interface ApiMessageType extends BaseApiMessageType {
|
|||
metadata?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
agent_id?: string;
|
||||
agents?: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
avatar_color?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Add a simple interface for streaming tool calls
|
||||
|
@ -392,6 +398,8 @@ export default function ThreadPage({
|
|||
metadata: msg.metadata || '{}',
|
||||
created_at: msg.created_at || new Date().toISOString(),
|
||||
updated_at: msg.updated_at || new Date().toISOString(),
|
||||
agent_id: (msg as any).agent_id,
|
||||
agents: (msg as any).agents,
|
||||
}));
|
||||
|
||||
setMessages(unifiedMessages);
|
||||
|
|
|
@ -288,6 +288,9 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
subscriptionStatus={subscriptionStatus}
|
||||
canAccessModel={canAccessModel}
|
||||
refreshCustomModels={refreshCustomModels}
|
||||
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={onAgentSelect}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ModelSelector } from './model-selector';
|
||||
import { SubscriptionStatus } from './_use-model-selection';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatSettingsDialogProps {
|
||||
selectedModel: string;
|
||||
onModelChange: (model: string) => void;
|
||||
modelOptions: any[];
|
||||
subscriptionStatus: SubscriptionStatus;
|
||||
canAccessModel: (modelId: string) => boolean;
|
||||
refreshCustomModels?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChatSettingsDialog({
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
modelOptions,
|
||||
subscriptionStatus,
|
||||
canAccessModel,
|
||||
refreshCustomModels,
|
||||
disabled = false,
|
||||
className,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: ChatSettingsDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const setOpen = controlledOnOpenChange || setInternalOpen;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{controlledOpen === undefined && (
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 w-8 p-0 text-muted-foreground hover:text-foreground',
|
||||
'rounded-lg',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
Chat Settings
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-selector" className="text-sm font-medium">
|
||||
AI Model
|
||||
</Label>
|
||||
<div className="w-full">
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
modelOptions={modelOptions}
|
||||
subscriptionStatus={subscriptionStatus}
|
||||
canAccessModel={canAccessModel}
|
||||
refreshCustomModels={refreshCustomModels}
|
||||
hasBorder={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose the AI model that best fits your needs. Premium models offer better performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,314 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Settings, ChevronRight, Bot, Presentation, FileSpreadsheet, Search, Plus, User, Check, ChevronDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useAgents } from '@/hooks/react-query/agents/use-agents';
|
||||
import { ChatSettingsDialog } from './chat-settings-dialog';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn, truncateString } from '@/lib/utils';
|
||||
|
||||
interface PredefinedAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
category: 'productivity' | 'creative' | 'development';
|
||||
}
|
||||
|
||||
const PREDEFINED_AGENTS: PredefinedAgent[] = [
|
||||
// {
|
||||
// id: 'slides',
|
||||
// name: 'Slides',
|
||||
// description: 'Create stunning presentations and slide decks',
|
||||
// icon: <Presentation className="h-4 w-4" />,
|
||||
// category: 'productivity'
|
||||
// },
|
||||
// {
|
||||
// id: 'sheets',
|
||||
// name: 'Sheets',
|
||||
// description: 'Spreadsheet and data analysis expert',
|
||||
// icon: <FileSpreadsheet className="h-4 w-4" />,
|
||||
// category: 'productivity'
|
||||
// }
|
||||
];
|
||||
|
||||
interface ChatSettingsDropdownProps {
|
||||
selectedAgentId?: string;
|
||||
onAgentSelect?: (agentId: string | undefined) => void;
|
||||
selectedModel: string;
|
||||
onModelChange: (model: string) => void;
|
||||
modelOptions: any[];
|
||||
subscriptionStatus: any;
|
||||
canAccessModel: (modelId: string) => boolean;
|
||||
refreshCustomModels?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ChatSettingsDropdown: React.FC<ChatSettingsDropdownProps> = ({
|
||||
selectedAgentId,
|
||||
onAgentSelect,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
modelOptions,
|
||||
subscriptionStatus,
|
||||
canAccessModel,
|
||||
refreshCustomModels,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const { data: agentsResponse, isLoading: agentsLoading } = useAgents();
|
||||
const agents = agentsResponse?.agents || [];
|
||||
|
||||
// Combine all agents
|
||||
const allAgents = [
|
||||
{
|
||||
id: undefined,
|
||||
name: 'Suna',
|
||||
description: 'Your personal AI assistant',
|
||||
type: 'default' as const,
|
||||
icon: <User className="h-4 w-4" />
|
||||
},
|
||||
...PREDEFINED_AGENTS.map(agent => ({
|
||||
...agent,
|
||||
type: 'predefined' as const
|
||||
})),
|
||||
...agents.map((agent: any) => ({
|
||||
...agent,
|
||||
id: agent.agent_id,
|
||||
type: 'custom' as const,
|
||||
icon: agent.avatar || <Bot className="h-4 w-4" />
|
||||
}))
|
||||
];
|
||||
|
||||
// Filter agents based on search query
|
||||
const filteredAgents = allAgents.filter((agent) =>
|
||||
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
agent.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
} else {
|
||||
setSearchQuery('');
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const getAgentDisplay = () => {
|
||||
const selectedAgent = allAgents.find(agent => agent.id === selectedAgentId);
|
||||
if (selectedAgent) {
|
||||
return {
|
||||
name: selectedAgent.name,
|
||||
icon: selectedAgent.icon
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: 'Suna',
|
||||
icon: <User className="h-4 w-4" />
|
||||
};
|
||||
};
|
||||
|
||||
const handleAgentSelect = (agentId: string | undefined) => {
|
||||
onAgentSelect?.(agentId);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSearchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < filteredAgents.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredAgents.length - 1
|
||||
);
|
||||
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const selectedAgent = filteredAgents[highlightedIndex];
|
||||
if (selectedAgent) {
|
||||
handleAgentSelect(selectedAgent.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExploreAll = () => {
|
||||
setIsOpen(false);
|
||||
router.push('/agents');
|
||||
};
|
||||
|
||||
const handleMoreOptions = () => {
|
||||
setIsOpen(false);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const agentDisplay = getAgentDisplay();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-2 text-xs font-medium"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{agentDisplay.icon}
|
||||
<span className="hidden sm:inline-block truncate max-w-[80px]">
|
||||
{agentDisplay.name}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Select Agent</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-80 p-0" sideOffset={4}>
|
||||
<div className="p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search agents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchInputKeyDown}
|
||||
className="w-full pl-8 pr-3 py-2 text-sm bg-transparent border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent">
|
||||
{agentsLoading ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
Loading agents...
|
||||
</div>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
No agents found
|
||||
</div>
|
||||
) : (
|
||||
filteredAgents.map((agent, index) => {
|
||||
const isSelected = agent.id === selectedAgentId;
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={agent.id || 'default'}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="w-full">
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"text-sm mx-2 my-0.5 flex items-center justify-between cursor-pointer",
|
||||
isHighlighted && "bg-accent",
|
||||
)}
|
||||
onClick={() => handleAgentSelect(agent.id)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
{agent.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.type === 'custom' && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0 h-4">
|
||||
custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs max-w-xs">
|
||||
<p className="truncate">{truncateString(agent.description, 35)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExploreAll}
|
||||
className="text-xs"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
Explore All
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMoreOptions}
|
||||
className="text-xs"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
More Options
|
||||
<ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ChatSettingsDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
modelOptions={modelOptions}
|
||||
subscriptionStatus={subscriptionStatus}
|
||||
canAccessModel={canAccessModel}
|
||||
refreshCustomModels={refreshCustomModels}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -261,7 +261,6 @@ export const FileUploadHandler = forwardRef<
|
|||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm sm:block hidden">Attachments</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
|
|
|
@ -7,8 +7,10 @@ import { UploadedFile } from './chat-input';
|
|||
import { FileUploadHandler } from './file-upload-handler';
|
||||
import { VoiceRecorder } from './voice-recorder';
|
||||
import { ModelSelector } from './model-selector';
|
||||
import { ChatSettingsDropdown } from './chat-settings-dropdown';
|
||||
import { SubscriptionStatus } from './_use-model-selection';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { useFeatureFlag } from '@/lib/feature-flags';
|
||||
import { TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import { TooltipProvider, TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||
|
@ -41,6 +43,8 @@ interface MessageInputProps {
|
|||
subscriptionStatus: SubscriptionStatus;
|
||||
canAccessModel: (modelId: string) => boolean;
|
||||
refreshCustomModels?: () => void;
|
||||
selectedAgentId?: string;
|
||||
onAgentSelect?: (agentId: string | undefined) => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||
|
@ -73,10 +77,15 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
subscriptionStatus,
|
||||
canAccessModel,
|
||||
refreshCustomModels,
|
||||
|
||||
selectedAgentId,
|
||||
onAgentSelect,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [billingModalOpen, setBillingModalOpen] = useState(false);
|
||||
const { enabled: customAgentsEnabled, loading: flagsLoading } = useFeatureFlag('custom_agents');
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = ref as React.RefObject<HTMLTextAreaElement>;
|
||||
if (!textarea.current) return;
|
||||
|
@ -148,11 +157,9 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
messages={messages}
|
||||
/>
|
||||
)}
|
||||
<VoiceRecorder
|
||||
onTranscription={onTranscription}
|
||||
disabled={loading || (disabled && !isAgentRunning)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{subscriptionStatus === 'no_subscription' && !isLocalMode() &&
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
@ -165,16 +172,38 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
}
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
modelOptions={modelOptions}
|
||||
subscriptionStatus={subscriptionStatus}
|
||||
canAccessModel={canAccessModel}
|
||||
refreshCustomModels={refreshCustomModels}
|
||||
billingModalOpenParent={billingModalOpen}
|
||||
{/* Show model selector inline if custom agents are disabled, otherwise show settings dropdown */}
|
||||
{!customAgentsEnabled || flagsLoading ? (
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
modelOptions={modelOptions}
|
||||
subscriptionStatus={subscriptionStatus}
|
||||
canAccessModel={canAccessModel}
|
||||
refreshCustomModels={refreshCustomModels}
|
||||
billingModalOpenParent={billingModalOpen}
|
||||
/>
|
||||
) : (
|
||||
<ChatSettingsDropdown
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={onAgentSelect}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
modelOptions={modelOptions}
|
||||
subscriptionStatus={subscriptionStatus}
|
||||
canAccessModel={canAccessModel}
|
||||
refreshCustomModels={refreshCustomModels}
|
||||
disabled={loading || (disabled && !isAgentRunning)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VoiceRecorder
|
||||
onTranscription={onTranscription}
|
||||
disabled={loading || (disabled && !isAgentRunning)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={isAgentRunning && onStopAgent ? onStopAgent : onSubmit}
|
||||
|
|
|
@ -47,6 +47,7 @@ interface ModelSelectorProps {
|
|||
subscriptionStatus: SubscriptionStatus;
|
||||
refreshCustomModels?: () => void;
|
||||
billingModalOpenParent: boolean;
|
||||
hasBorder?: boolean;
|
||||
}
|
||||
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
|
@ -57,6 +58,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
subscriptionStatus,
|
||||
refreshCustomModels,
|
||||
billingModalOpenParent,
|
||||
hasBorder = false,
|
||||
}) => {
|
||||
const [paywallOpen, setPaywallOpen] = useState(false);
|
||||
const [billingModalOpen, setBillingModalOpen] = useState(billingModalOpenParent);
|
||||
|
@ -514,7 +516,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant={hasBorder ? "outline" : "ghost"}
|
||||
size="default"
|
||||
className="h-8 rounded-lg text-muted-foreground shadow-none border-none focus:ring-0 px-3"
|
||||
>
|
||||
|
|
|
@ -410,7 +410,25 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
// Create a new user message group
|
||||
groupedMessages.push({ type: 'user', messages: [message], key });
|
||||
} else if (messageType === 'assistant' || messageType === 'tool' || messageType === 'browser_state') {
|
||||
if (currentGroup && currentGroup.type === 'assistant_group') {
|
||||
// Check if we can add to existing assistant group (same agent)
|
||||
const canAddToExistingGroup = currentGroup &&
|
||||
currentGroup.type === 'assistant_group' &&
|
||||
(() => {
|
||||
// For assistant messages, check if agent matches
|
||||
if (messageType === 'assistant') {
|
||||
const lastAssistantMsg = currentGroup.messages.findLast(m => m.type === 'assistant');
|
||||
if (!lastAssistantMsg) return true; // No assistant message yet, can add
|
||||
|
||||
// Compare agent info - both null/undefined should be treated as same (default agent)
|
||||
const currentAgentId = message.agent_id;
|
||||
const lastAgentId = lastAssistantMsg.agent_id;
|
||||
return currentAgentId === lastAgentId;
|
||||
}
|
||||
// For tool/browser_state messages, always add to current group
|
||||
return true;
|
||||
})();
|
||||
|
||||
if (canAddToExistingGroup) {
|
||||
// Add to existing assistant group
|
||||
currentGroup.messages.push(message);
|
||||
} else {
|
||||
|
@ -571,12 +589,37 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
return (
|
||||
<div key={group.key} ref={groupIndex === groupedMessages.length - 1 ? latestMessageRef : null}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Logo positioned above the message content - ONLY ONCE PER GROUP */}
|
||||
<div className="flex items-center">
|
||||
<div className="rounded-md flex items-center justify-center">
|
||||
{agentAvatar}
|
||||
{(() => {
|
||||
const firstAssistantWithAgent = group.messages.find(msg =>
|
||||
msg.type === 'assistant' && (msg.agents?.avatar || msg.agents?.avatar_color)
|
||||
);
|
||||
if (firstAssistantWithAgent?.agents?.avatar) {
|
||||
const avatar = firstAssistantWithAgent.agents.avatar;
|
||||
const color = firstAssistantWithAgent.agents.avatar_color;
|
||||
return (
|
||||
<div
|
||||
className="h-4 w-5 flex items-center justify-center rounded text-xs"
|
||||
>
|
||||
<span className="text-lg">{avatar}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <KortixLogo size={16} />;
|
||||
})()}
|
||||
</div>
|
||||
<p className='ml-2 text-sm text-muted-foreground'>{agentName ? agentName : 'Suna'}</p>
|
||||
<p className='ml-2 text-sm text-muted-foreground'>
|
||||
{(() => {
|
||||
const firstAssistantWithAgent = group.messages.find(msg =>
|
||||
msg.type === 'assistant' && msg.agents?.name
|
||||
);
|
||||
if (firstAssistantWithAgent?.agents?.name) {
|
||||
return firstAssistantWithAgent.agents.name;
|
||||
}
|
||||
return 'Suna';
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message content - ALL messages in the group */}
|
||||
|
@ -845,7 +888,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
<div className="rounded-md flex items-center justify-center">
|
||||
{agentAvatar}
|
||||
</div>
|
||||
<p className='ml-2 text-sm text-muted-foreground'>{agentName}</p>
|
||||
<p className='ml-2 text-sm text-muted-foreground'>{agentName || 'Suna'}</p>
|
||||
</div>
|
||||
|
||||
{/* Loader content */}
|
||||
|
@ -865,7 +908,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
<div className="rounded-md flex items-center justify-center">
|
||||
{agentAvatar}
|
||||
</div>
|
||||
<p className='ml-2 text-sm text-muted-foreground'>{agentName}</p>
|
||||
<p className='ml-2 text-sm text-muted-foreground'>{agentName || 'Suna'}</p>
|
||||
</div>
|
||||
|
||||
{/* Tool call content */}
|
||||
|
@ -890,7 +933,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|||
<div className="rounded-md flex items-center justify-center">
|
||||
{agentAvatar}
|
||||
</div>
|
||||
<p className='ml-2 text-sm text-muted-foreground'>{agentName}</p>
|
||||
<p className='ml-2 text-sm text-muted-foreground'>{agentName || 'Suna'}</p>
|
||||
</div>
|
||||
|
||||
{/* Streaming indicator content */}
|
||||
|
|
|
@ -19,6 +19,12 @@ export interface UnifiedMessage {
|
|||
metadata: string; // ALWAYS a JSON string from the backend
|
||||
created_at: string; // ISO timestamp string
|
||||
updated_at: string; // ISO timestamp string
|
||||
agent_id?: string; // ID of the agent associated with this message
|
||||
agents?: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
avatar_color?: string;
|
||||
}; // Agent information from join
|
||||
}
|
||||
|
||||
// Helper type for parsed content - structure depends on message.type
|
||||
|
|
|
@ -0,0 +1,262 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Eraser, Palette, RotateCcw, Save } from 'lucide-react';
|
||||
|
||||
interface PixelArtEditorProps {
|
||||
onSave: (pixelArt: string) => void;
|
||||
onCancel: () => void;
|
||||
initialPixelArt?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const GRID_SIZE = 16;
|
||||
const COLORS = [
|
||||
'#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
|
||||
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080', '#C0C0C0', '#808080',
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#48DBFB'
|
||||
];
|
||||
|
||||
export const PixelArtEditor: React.FC<PixelArtEditorProps> = ({
|
||||
onSave,
|
||||
onCancel,
|
||||
initialPixelArt,
|
||||
size = 400
|
||||
}) => {
|
||||
const [grid, setGrid] = useState<string[][]>(() => {
|
||||
// Initialize grid with transparent pixels
|
||||
const initialGrid = Array(GRID_SIZE).fill(null).map(() =>
|
||||
Array(GRID_SIZE).fill('transparent')
|
||||
);
|
||||
|
||||
// If there's initial pixel art, try to parse it
|
||||
if (initialPixelArt) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(initialPixelArt, 'image/svg+xml');
|
||||
const rects = doc.querySelectorAll('rect');
|
||||
|
||||
rects.forEach(rect => {
|
||||
const x = parseInt(rect.getAttribute('x') || '0');
|
||||
const y = parseInt(rect.getAttribute('y') || '0');
|
||||
const width = parseInt(rect.getAttribute('width') || '1');
|
||||
const height = parseInt(rect.getAttribute('height') || '1');
|
||||
const fill = rect.getAttribute('fill') || 'currentColor';
|
||||
|
||||
// Fill the grid based on the rect
|
||||
for (let row = y; row < y + height && row < GRID_SIZE; row++) {
|
||||
for (let col = x; col < x + width && col < GRID_SIZE; col++) {
|
||||
if (row >= 0 && col >= 0) {
|
||||
initialGrid[row][col] = fill;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse initial pixel art:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return initialGrid;
|
||||
});
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isErasing, setIsErasing] = useState(false);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
|
||||
const handlePixelClick = useCallback((row: number, col: number) => {
|
||||
setGrid(prev => {
|
||||
const newGrid = prev.map(r => [...r]);
|
||||
newGrid[row][col] = isErasing ? 'transparent' : selectedColor;
|
||||
return newGrid;
|
||||
});
|
||||
}, [selectedColor, isErasing]);
|
||||
|
||||
const handleMouseDown = useCallback((row: number, col: number) => {
|
||||
setIsDrawing(true);
|
||||
handlePixelClick(row, col);
|
||||
}, [handlePixelClick]);
|
||||
|
||||
const handleMouseEnter = useCallback((row: number, col: number) => {
|
||||
if (isDrawing) {
|
||||
handlePixelClick(row, col);
|
||||
}
|
||||
}, [isDrawing, handlePixelClick]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDrawing(false);
|
||||
}, []);
|
||||
|
||||
const clearGrid = useCallback(() => {
|
||||
setGrid(Array(GRID_SIZE).fill(null).map(() =>
|
||||
Array(GRID_SIZE).fill('transparent')
|
||||
));
|
||||
}, []);
|
||||
|
||||
const generateSVG = useCallback(() => {
|
||||
const rects: string[] = [];
|
||||
const visited = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(false));
|
||||
for (let row = 0; row < GRID_SIZE; row++) {
|
||||
for (let col = 0; col < GRID_SIZE; col++) {
|
||||
if (!visited[row][col] && grid[row][col] !== 'transparent') {
|
||||
const color = grid[row][col];
|
||||
let width = 1;
|
||||
let height = 1;
|
||||
while (col + width < GRID_SIZE &&
|
||||
grid[row][col + width] === color &&
|
||||
!visited[row][col + width]) {
|
||||
width++;
|
||||
}
|
||||
let canExtendHeight = true;
|
||||
while (row + height < GRID_SIZE && canExtendHeight) {
|
||||
for (let c = col; c < col + width; c++) {
|
||||
if (grid[row + height][c] !== color || visited[row + height][c]) {
|
||||
canExtendHeight = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (canExtendHeight) height++;
|
||||
}
|
||||
for (let r = row; r < row + height; r++) {
|
||||
for (let c = col; c < col + width; c++) {
|
||||
visited[r][c] = true;
|
||||
}
|
||||
}
|
||||
const fill = color === 'currentColor' ? 'currentColor' : color;
|
||||
rects.push(`<rect x="${col}" y="${row}" width="${width}" height="${height}" fill="${fill}"/>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `<svg viewBox="0 0 ${GRID_SIZE} ${GRID_SIZE}" xmlns="http://www.w3.org/2000/svg">\n ${rects.join('\n ')}\n</svg>`;
|
||||
}, [grid]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const svg = generateSVG();
|
||||
onSave(svg);
|
||||
}, [generateSVG, onSave]);
|
||||
|
||||
const pixelSize = size / GRID_SIZE;
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Pixel Art Editor
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Colors:</span>
|
||||
<Button
|
||||
variant={isErasing ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setIsErasing(!isErasing)}
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
{isErasing ? "Erasing" : "Eraser"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
className={`w-8 h-8 rounded border-2 ${
|
||||
selectedColor === color && !isErasing
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'border-border'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
setSelectedColor(color);
|
||||
setIsErasing(false);
|
||||
}}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
className={`w-8 h-8 rounded border-2 ${
|
||||
selectedColor === 'currentColor' && !isErasing
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'border-border'
|
||||
} bg-gradient-to-br from-blue-500 to-purple-500`}
|
||||
onClick={() => {
|
||||
setSelectedColor('currentColor');
|
||||
setIsErasing(false);
|
||||
}}
|
||||
title="Theme Color"
|
||||
>
|
||||
<span className="text-white text-xs font-bold">T</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="grid border-2 border-border bg-white dark:bg-gray-900 relative"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${GRID_SIZE}, 1fr)`,
|
||||
width: size,
|
||||
height: size
|
||||
}}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{grid.map((row, rowIndex) =>
|
||||
row.map((pixel, colIndex) => (
|
||||
<div
|
||||
key={`${rowIndex}-${colIndex}`}
|
||||
className="border border-gray-200 dark:border-gray-700 cursor-pointer hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: pixel === 'transparent' ? 'transparent' :
|
||||
pixel === 'currentColor' ? '#3b82f6' : pixel,
|
||||
width: pixelSize,
|
||||
height: pixelSize
|
||||
}}
|
||||
onMouseDown={() => handleMouseDown(rowIndex, colIndex)}
|
||||
onMouseEnter={() => handleMouseEnter(rowIndex, colIndex)}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Preview:</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 border rounded"
|
||||
style={{ backgroundColor: '#3b82f6', color: 'white' }}
|
||||
dangerouslySetInnerHTML={{ __html: generateSVG() }}
|
||||
/>
|
||||
<div
|
||||
className="w-12 h-12 border rounded"
|
||||
style={{ backgroundColor: '#ef4444', color: 'white' }}
|
||||
dangerouslySetInnerHTML={{ __html: generateSVG() }}
|
||||
/>
|
||||
<div
|
||||
className="w-12 h-12 border rounded"
|
||||
style={{ backgroundColor: '#10b981', color: 'white' }}
|
||||
dangerouslySetInnerHTML={{ __html: generateSVG() }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<Button variant="outline" onClick={clearGrid}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,266 @@
|
|||
import React from 'react';
|
||||
|
||||
export const PIXEL_ART_DESIGNS = {
|
||||
robot: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="2" width="8" height="6" fill="currentColor"/>
|
||||
<rect x="3" y="3" width="1" height="4" fill="currentColor"/>
|
||||
<rect x="12" y="3" width="1" height="4" fill="currentColor"/>
|
||||
<rect x="5" y="4" width="2" height="2" fill="white"/>
|
||||
<rect x="9" y="4" width="2" height="2" fill="white"/>
|
||||
<rect x="6" y="5" width="1" height="1" fill="currentColor"/>
|
||||
<rect x="9" y="5" width="1" height="1" fill="currentColor"/>
|
||||
<rect x="2" y="9" width="12" height="4" fill="currentColor"/>
|
||||
<rect x="4" y="10" width="8" height="2" fill="white"/>
|
||||
<rect x="5" y="11" width="6" height="1" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
cat: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="2" width="4" height="6" fill="currentColor"/>
|
||||
<rect x="5" y="1" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="9" y="1" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="9" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="7" y="6" width="2" height="1" fill="white"/>
|
||||
<rect x="6" y="7" width="4" height="1" fill="white"/>
|
||||
</svg>`,
|
||||
|
||||
wizard: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="7" y="1" width="2" height="1" fill="currentColor"/>
|
||||
<rect x="6" y="2" width="4" height="1" fill="currentColor"/>
|
||||
<rect x="5" y="3" width="6" height="1" fill="currentColor"/>
|
||||
<rect x="4" y="4" width="8" height="4" fill="currentColor"/>
|
||||
<rect x="6" y="5" width="1" height="1" fill="white"/>
|
||||
<rect x="9" y="5" width="1" height="1" fill="white"/>
|
||||
<rect x="7" y="7" width="2" height="1" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
</svg>`,
|
||||
|
||||
knight: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5" y="2" width="6" height="6" fill="currentColor"/>
|
||||
<rect x="4" y="3" width="8" height="4" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="2" fill="white"/>
|
||||
<rect x="9" y="4" width="1" height="2" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
<rect x="7" y="1" width="2" height="2" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
ninja: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="9" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="5" y="6" width="6" height="1" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
<rect x="2" y="2" width="12" height="1" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
pirate: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="8" y="4" width="3" height="1" fill="white"/>
|
||||
<rect x="7" y="6" width="2" height="1" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
<rect x="3" y="2" width="10" height="1" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
alien: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5" y="2" width="6" height="6" fill="currentColor"/>
|
||||
<rect x="4" y="3" width="8" height="4" fill="currentColor"/>
|
||||
<rect x="3" y="4" width="10" height="2" fill="currentColor"/>
|
||||
<rect x="5" y="5" width="2" height="2" fill="white"/>
|
||||
<rect x="9" y="5" width="2" height="2" fill="white"/>
|
||||
<rect x="2" y="8" width="12" height="4" fill="currentColor"/>
|
||||
<rect x="4" y="10" width="8" height="2" fill="white"/>
|
||||
</svg>`,
|
||||
|
||||
dragon: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="2" width="4" height="6" fill="currentColor"/>
|
||||
<rect x="5" y="3" width="6" height="4" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="9" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="4" y="1" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="10" y="1" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="3" y="8" width="10" height="4" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
</svg>`,
|
||||
|
||||
ghost: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="3" width="8" height="10" fill="currentColor"/>
|
||||
<rect x="5" y="2" width="6" height="1" fill="currentColor"/>
|
||||
<rect x="6" y="1" width="4" height="1" fill="currentColor"/>
|
||||
<rect x="6" y="5" width="1" height="2" fill="white"/>
|
||||
<rect x="9" y="5" width="1" height="2" fill="white"/>
|
||||
<rect x="4" y="13" width="2" height="1" fill="transparent"/>
|
||||
<rect x="7" y="13" width="2" height="1" fill="transparent"/>
|
||||
<rect x="10" y="13" width="2" height="1" fill="transparent"/>
|
||||
</svg>`,
|
||||
|
||||
bear: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="2" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="11" y="2" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="4" y="3" width="8" height="6" fill="currentColor"/>
|
||||
<rect x="6" y="5" width="1" height="1" fill="white"/>
|
||||
<rect x="9" y="5" width="1" height="1" fill="white"/>
|
||||
<rect x="7" y="7" width="2" height="1" fill="white"/>
|
||||
<rect x="3" y="9" width="10" height="4" fill="currentColor"/>
|
||||
<rect x="5" y="11" width="6" height="2" fill="white"/>
|
||||
</svg>`,
|
||||
|
||||
astronaut: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="2" width="8" height="6" fill="currentColor"/>
|
||||
<rect x="3" y="3" width="10" height="4" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="2" fill="white"/>
|
||||
<rect x="9" y="4" width="1" height="2" fill="white"/>
|
||||
<rect x="7" y="6" width="2" height="1" fill="white"/>
|
||||
<rect x="2" y="8" width="12" height="5" fill="currentColor"/>
|
||||
<rect x="4" y="10" width="8" height="2" fill="white"/>
|
||||
<rect x="1" y="9" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="13" y="9" width="2" height="2" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
viking: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="9" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="7" y="6" width="2" height="1" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
<rect x="2" y="1" width="3" height="3" fill="currentColor"/>
|
||||
<rect x="11" y="1" width="3" height="3" fill="currentColor"/>
|
||||
<rect x="7" y="0" width="2" height="2" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
demon: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="4" width="2" height="2" fill="white"/>
|
||||
<rect x="9" y="4" width="2" height="2" fill="white"/>
|
||||
<rect x="6" y="5" width="1" height="1" fill="currentColor"/>
|
||||
<rect x="9" y="5" width="1" height="1" fill="currentColor"/>
|
||||
<rect x="7" y="7" width="2" height="1" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
<rect x="3" y="1" width="2" height="3" fill="currentColor"/>
|
||||
<rect x="11" y="1" width="2" height="3" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
samurai: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="9" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="5" y="6" width="6" height="1" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
<rect x="6" y="1" width="4" height="2" fill="currentColor"/>
|
||||
<rect x="5" y="2" width="6" height="1" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
witch: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="9" y="4" width="1" height="1" fill="white"/>
|
||||
<rect x="7" y="6" width="2" height="1" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
<rect x="6" y="0" width="4" height="3" fill="currentColor"/>
|
||||
<rect x="10" y="1" width="2" height="2" fill="currentColor"/>
|
||||
</svg>`,
|
||||
|
||||
cyborg: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="2" width="8" height="6" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="1" height="2" fill="white"/>
|
||||
<rect x="9" y="4" width="2" height="2" fill="white"/>
|
||||
<rect x="10" y="4" width="1" height="1" fill="currentColor"/>
|
||||
<rect x="7" y="6" width="2" height="1" fill="white"/>
|
||||
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
|
||||
<rect x="5" y="10" width="6" height="2" fill="white"/>
|
||||
<rect x="12" y="3" width="2" height="2" fill="currentColor"/>
|
||||
</svg>`
|
||||
};
|
||||
|
||||
export const PIXEL_ART_CATEGORIES = {
|
||||
fantasy: {
|
||||
name: "Fantasy",
|
||||
designs: ["wizard", "knight", "dragon", "witch", "demon"]
|
||||
},
|
||||
scifi: {
|
||||
name: "Sci-Fi",
|
||||
designs: ["robot", "alien", "astronaut", "cyborg"]
|
||||
},
|
||||
animals: {
|
||||
name: "Animals",
|
||||
designs: ["cat", "bear", "ghost"]
|
||||
},
|
||||
warriors: {
|
||||
name: "Warriors",
|
||||
designs: ["ninja", "pirate", "viking", "samurai"]
|
||||
}
|
||||
};
|
||||
|
||||
interface PixelAvatarProps {
|
||||
design: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
customPixels?: string;
|
||||
}
|
||||
|
||||
export const PixelAvatar: React.FC<PixelAvatarProps> = ({
|
||||
design,
|
||||
size = 32,
|
||||
className = "",
|
||||
customPixels
|
||||
}) => {
|
||||
const pixelArt = customPixels || PIXEL_ART_DESIGNS[design];
|
||||
|
||||
if (!pixelArt) {
|
||||
return (
|
||||
<div
|
||||
className={`inline-block ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
|
||||
<rect x="4" y="2" width="8" height="6" fill="currentColor"/>
|
||||
<rect x="6" y="4" width="2" height="2" fill="white"/>
|
||||
<rect x="9" y="4" width="2" height="2" fill="white"/>
|
||||
<rect x="2" y="9" width="12" height="4" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-block ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
dangerouslySetInnerHTML={{ __html: pixelArt }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const getAllPixelDesigns = (): string[] => {
|
||||
return Object.keys(PIXEL_ART_DESIGNS);
|
||||
};
|
||||
|
||||
export const getPixelDesignsByCategory = (category: string): string[] => {
|
||||
return PIXEL_ART_CATEGORIES[category]?.designs || [];
|
||||
};
|
||||
|
||||
export const getRandomPixelDesign = (): string => {
|
||||
const designs = getAllPixelDesigns();
|
||||
return designs[Math.floor(Math.random() * designs.length)];
|
||||
};
|
||||
|
||||
export const getPixelDesignFromSeed = (seed: string): string => {
|
||||
const designs = getAllPixelDesigns();
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const char = seed.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
const designIndex = Math.abs(hash) % designs.length;
|
||||
return designs[designIndex];
|
||||
};
|
|
@ -44,6 +44,7 @@ export interface AgentTemplate {
|
|||
creator_name?: string;
|
||||
avatar?: string;
|
||||
avatar_color?: string;
|
||||
is_kortix_team?: boolean;
|
||||
metadata?: {
|
||||
source_agent_id?: string;
|
||||
source_version_id?: string;
|
||||
|
|
|
@ -23,6 +23,12 @@ interface ApiMessageType {
|
|||
metadata?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
agent_id?: string;
|
||||
agents?: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
avatar_color?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Define the structure returned by the hook
|
||||
|
@ -62,6 +68,8 @@ const mapApiMessagesToUnified = (
|
|||
metadata: msg.metadata || '{}',
|
||||
created_at: msg.created_at || new Date().toISOString(),
|
||||
updated_at: msg.updated_at || new Date().toISOString(),
|
||||
agent_id: (msg as any).agent_id,
|
||||
agents: (msg as any).agents,
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
|
@ -68,6 +68,12 @@ export type Message = {
|
|||
role: string;
|
||||
content: string;
|
||||
type: string;
|
||||
agent_id?: string;
|
||||
agents?: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
avatar_color?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentRun = {
|
||||
|
@ -579,7 +585,14 @@ export const getMessages = async (threadId: string): Promise<Message[]> => {
|
|||
while (hasMore) {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.select(`
|
||||
*,
|
||||
agents:agent_id (
|
||||
name,
|
||||
avatar,
|
||||
avatar_color
|
||||
)
|
||||
`)
|
||||
.eq('thread_id', threadId)
|
||||
.neq('type', 'cost')
|
||||
.neq('type', 'summary')
|
||||
|
|
Loading…
Reference in New Issue