Merge branch 'main' into sharath/suna-406-refactor-pricing-system

This commit is contained in:
sharath 2025-06-26 20:04:22 +00:00
commit 7b10ca6478
No known key found for this signature in database
34 changed files with 1915 additions and 386 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -288,6 +288,9 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
subscriptionStatus={subscriptionStatus}
canAccessModel={canAccessModel}
refreshCustomModels={refreshCustomModels}
selectedAgentId={selectedAgentId}
onAgentSelect={onAgentSelect}
/>
</CardContent>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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