diff --git a/backend/agent/api.py b/backend/agent/api.py index 0d290e66..a3db33ea 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -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, diff --git a/backend/agent/run.py b/backend/agent/run.py index 8d396cde..5fe6cd76 100644 --- a/backend/agent/run.py +++ b/backend/agent/run.py @@ -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 diff --git a/backend/agentpress/response_processor.py b/backend/agentpress/response_processor.py index 62c118e5..68cd6bdc 100644 --- a/backend/agentpress/response_processor.py +++ b/backend/agentpress/response_processor.py @@ -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} ) diff --git a/backend/agentpress/thread_manager.py b/backend/agentpress/thread_manager.py index db1e08e6..2eb963af 100644 --- a/backend/agentpress/thread_manager.py +++ b/backend/agentpress/thread_manager.py @@ -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 @@ -370,6 +377,12 @@ class ThreadManager: 'is_llm_message': is_llm_message, '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 diff --git a/backend/mcp_local/secure_api.py b/backend/mcp_local/secure_api.py index 67d63a7e..82d35a87 100644 --- a/backend/mcp_local/secure_api.py +++ b/backend/mcp_local/secure_api.py @@ -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""" diff --git a/backend/mcp_local/template_manager.py b/backend/mcp_local/template_manager.py index 262b731b..4b270049 100644 --- a/backend/mcp_local/template_manager.py +++ b/backend/mcp_local/template_manager.py @@ -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()) + + 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 + # 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()) + + 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) - # 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' + kortix_team_account_ids = [ + 'xxxxxxxx', + ] - # Create template + 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: diff --git a/backend/supabase/migrations/20250601000000_add_thread_metadata.sql b/backend/supabase/migrations/20250601000000_add_thread_metadata.sql index d86e7c12..84d15923 100644 --- a/backend/supabase/migrations/20250601000000_add_thread_metadata.sql +++ b/backend/supabase/migrations/20250601000000_add_thread_metadata.sql @@ -5,4 +5,17 @@ ALTER TABLE threads ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb; 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'; \ No newline at end of file +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.'; \ No newline at end of file diff --git a/backend/supabase/migrations/20250626092143_agent_agnostic_thread.sql b/backend/supabase/migrations/20250626092143_agent_agnostic_thread.sql new file mode 100644 index 00000000..0af214ff --- /dev/null +++ b/backend/supabase/migrations/20250626092143_agent_agnostic_thread.sql @@ -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; \ No newline at end of file diff --git a/backend/supabase/migrations/20250626114642_kortix_team_agents.sql b/backend/supabase/migrations/20250626114642_kortix_team_agents.sql new file mode 100644 index 00000000..25f4a4be --- /dev/null +++ b/backend/supabase/migrations/20250626114642_kortix_team_agents.sql @@ -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; \ No newline at end of file diff --git a/backend/utils/config.py b/backend/utils/config.py index bca8d582..1bc42f81 100644 --- a/backend/utils/config.py +++ b/backend/utils/config.py @@ -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 diff --git a/frontend/src/app/(dashboard)/agents/_components/agents-grid.tsx b/frontend/src/app/(dashboard)/agents/_components/agents-grid.tsx index 67fde4c2..021bdd7a 100644 --- a/frontend/src/app/(dashboard)/agents/_components/agents-grid.tsx +++ b/frontend/src/app/(dashboard)/agents/_components/agents-grid.tsx @@ -118,8 +118,6 @@ const AgentModal = ({ agent, isOpen, onClose, onCustomize, onChat, onPublish, on Chat - - {/* Marketplace Actions */}
{agent.is_public ? (
@@ -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 (
handleAgentClick(agent)} > -
-
- {avatar} -
-
- {agent.is_default && ( - - )} - {agent.is_public && ( -
- - {agent.download_count || 0} -
- )} +
+
+
+ {avatar} +
- -
+

{agent.name} diff --git a/frontend/src/app/(dashboard)/agents/_components/search-and-filters.tsx b/frontend/src/app/(dashboard)/agents/_components/search-and-filters.tsx index c25fa576..4c740a59 100644 --- a/frontend/src/app/(dashboard)/agents/_components/search-and-filters.tsx +++ b/frontend/src/app/(dashboard)/agents/_components/search-and-filters.tsx @@ -49,9 +49,9 @@ export const SearchAndFilters = ({ allTools }: SearchAndFiltersProps) => { return ( -
-
-
+
+
+
- setSortBy(value)}> @@ -90,11 +90,11 @@ export const SearchAndFilters = ({ className="px-3" > {sortOrder === 'asc' ? : } - + */}
- + {/* - +
+
+
+
+ +

+ Agents +

+
+

+ Create and manage your agents with custom instructions and tools +

+
+
- - - 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() {
)} -

Hey, I am

- +

+ {displayName} + {agentAvatar && ( + + {agentAvatar} + + )} +

What would you like to do today?

-
-
- 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 = ({ + 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 ( + + + +
+
+
{avatar}
+
+
+
+ + {item.name} + +
+
+
+ + {item.creator_name} +
+
+ + {item.download_count} downloads +
+
+
+
+ +
+
+
+

+ Description +

+

+ {item.description || 'No description available for this agent.'} +

+
+ + {item.tags && item.tags.length > 0 && ( +
+

+ Tags +

+
+ {item.tags.map(tag => ( + + + {tag} + + ))} +
+
+ )} + {item.mcp_requirements && item.mcp_requirements.length > 0 && ( +
+

+ Required Tools & MCPs +

+
+ {item.mcp_requirements.map((mcp, index) => ( +
+
+
+ +
+
+
{mcp.display_name}
+ {mcp.enabled_tools && mcp.enabled_tools.length > 0 && ( +
+ {mcp.enabled_tools.length} tool{mcp.enabled_tools.length !== 1 ? 's' : ''} +
+ )} +
+
+ {mcp.custom_type && ( + + {mcp.custom_type.toUpperCase()} + + )} +
+ ))} +
+
+ )} + {item.metadata?.source_version_name && ( +
+

+ Version +

+
+ + {item.metadata.source_version_name} +
+
+ )} + {item.marketplace_published_at && ( +
+

+ Published +

+
+ + {formatDate(item.marketplace_published_at)} +
+
+ )} +
+
+
+ ); +}; + const InstallDialog: React.FC = ({ item, open, @@ -301,13 +464,13 @@ const InstallDialog: React.FC = ({ Missing Credential Profiles - This agent requires credential profiles for the following services: + This agent requires profiles for the following services:
{missingProfiles.map((profile) => ( - +
@@ -533,6 +696,7 @@ export default function MarketplacePage() { const [installingItemId, setInstallingItemId] = useState(null); const [selectedItem, setSelectedItem] = useState(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(); - 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 (
-
-
-

- Agent Marketplace -

-

- Discover and install secure AI agent templates created by the community -

+
+
+
+
+ +

+ Marketplace +

+
+

+ Discover and install powerful agents created by the community +

+
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ {/* */} +
- -
-
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
- -
- {allTags.length > 0 && (

Filter by tags:

@@ -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` )}
@@ -792,7 +984,7 @@ export default function MarketplacePage() {
))}
- ) : marketplaceItems.length === 0 ? ( + ) : allMarketplaceItems.length === 0 ? (

{searchQuery || selectedTags.length > 0 @@ -801,95 +993,214 @@ export default function MarketplacePage() {

) : ( -
- {marketplaceItems.map((item) => { - const { avatar, color } = getItemStyling(item); - - return ( -
handleItemClick(item)} - > -
-
- {avatar} -
-
-
- - {item.download_count} -
-
+
+ {kortixTeamItems.length > 0 && ( +
+
+
+
-
-
-

- {item.name} -

- {item.metadata?.source_version_name && ( - - - {item.metadata.source_version_name} - - )} -
-

- {item.description || 'No description available'} -

- {item.tags && item.tags.length > 0 && ( -
- {item.tags.slice(0, 2).map(tag => ( - - {tag} - - ))} - {item.tags.length > 2 && ( - - +{item.tags.length - 2} - - )} -
- )} -
-
- - By {item.creator_name} -
- {item.marketplace_published_at && ( -
- - {new Date(item.marketplace_published_at).toLocaleDateString()} -
- )} -
- - +
+

Agents from Kortix Team

- ); - })} +
+ {kortixTeamItems.map((item) => { + const { avatar, color } = getItemStyling(item); + return ( +
handleItemClick(item)} + > +
+
+
+ {avatar} +
+
+
+
+
+

+ {item.name} +

+ {item.metadata?.source_version_name && ( + + + {item.metadata.source_version_name} + + )} +
+

+ {item.description || 'No description available'} +

+ {item.tags && item.tags.length > 0 && ( +
+ {item.tags.slice(0, 2).map(tag => ( + + {tag} + + ))} + {item.tags.length > 2 && ( + + +{item.tags.length - 2} + + )} +
+ )} +
+
+
+ + By {item.creator_name} +
+ {item.marketplace_published_at && ( +
+ + {new Date(item.marketplace_published_at).toLocaleDateString()} +
+ )} +
+
+ + {item.download_count} +
+
+ +
+
+ ); + })} +
+
+ )} + {communityItems.length > 0 && ( +
+
+
+ +
+
+

Agents from Community

+

Templates created by the community

+
+
+
+ {communityItems.map((item) => { + const { avatar, color } = getItemStyling(item); + + return ( +
handleItemClick(item)} + > +
+
+ {avatar} +
+
+
+ + {item.download_count} +
+
+
+
+
+

+ {item.name} +

+ {item.metadata?.source_version_name && ( + + + {item.metadata.source_version_name} + + )} +
+

+ {item.description || 'No description available'} +

+ {item.tags && item.tags.length > 0 && ( +
+ {item.tags.slice(0, 2).map(tag => ( + + {tag} + + ))} + {item.tags.length > 2 && ( + + +{item.tags.length - 2} + + )} +
+ )} +
+
+ + By {item.creator_name} +
+ {item.marketplace_published_at && ( +
+ + {new Date(item.marketplace_published_at).toLocaleDateString()} +
+ )} +
+ + +
+
+ ); + })} +
+
+ )}
)}
- + (undefined); // Refs const messagesEndRef = useRef(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} />
diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/_hooks/useThreadData.ts b/frontend/src/app/(dashboard)/projects/[projectId]/thread/_hooks/useThreadData.ts index c5cad93c..7b387633 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/_hooks/useThreadData.ts +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/_hooks/useThreadData.ts @@ -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); diff --git a/frontend/src/app/(dashboard)/settings/credentials/layout.tsx b/frontend/src/app/(dashboard)/settings/credentials/layout.tsx index 3e8d0869..4802ab7f 100644 --- a/frontend/src/app/(dashboard)/settings/credentials/layout.tsx +++ b/frontend/src/app/(dashboard)/settings/credentials/layout.tsx @@ -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}; } diff --git a/frontend/src/app/share/[threadId]/page.tsx b/frontend/src/app/share/[threadId]/page.tsx index 6d0c563b..677c87c4 100644 --- a/frontend/src/app/share/[threadId]/page.tsx +++ b/frontend/src/app/share/[threadId]/page.tsx @@ -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); diff --git a/frontend/src/components/thread/chat-input/chat-input.tsx b/frontend/src/components/thread/chat-input/chat-input.tsx index c9a46c65..f5096852 100644 --- a/frontend/src/components/thread/chat-input/chat-input.tsx +++ b/frontend/src/components/thread/chat-input/chat-input.tsx @@ -288,6 +288,9 @@ export const ChatInput = forwardRef( subscriptionStatus={subscriptionStatus} canAccessModel={canAccessModel} refreshCustomModels={refreshCustomModels} + + selectedAgentId={selectedAgentId} + onAgentSelect={onAgentSelect} />
diff --git a/frontend/src/components/thread/chat-input/chat-settings-dialog.tsx b/frontend/src/components/thread/chat-input/chat-settings-dialog.tsx new file mode 100644 index 00000000..75628ab1 --- /dev/null +++ b/frontend/src/components/thread/chat-input/chat-settings-dialog.tsx @@ -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 ( + + {controlledOpen === undefined && ( + + + + )} + + + + + + Chat Settings + + + +
+
+ +
+ +
+

+ Choose the AI model that best fits your needs. Premium models offer better performance. +

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/thread/chat-input/chat-settings-dropdown.tsx b/frontend/src/components/thread/chat-input/chat-settings-dropdown.tsx new file mode 100644 index 00000000..bfd52076 --- /dev/null +++ b/frontend/src/components/thread/chat-input/chat-settings-dropdown.tsx @@ -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: , + // category: 'productivity' + // }, + // { + // id: 'sheets', + // name: 'Sheets', + // description: 'Spreadsheet and data analysis expert', + // icon: , + // 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 = ({ + selectedAgentId, + onAgentSelect, + selectedModel, + onModelChange, + modelOptions, + subscriptionStatus, + canAccessModel, + refreshCustomModels, + disabled = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [dialogOpen, setDialogOpen] = useState(false); + const searchInputRef = useRef(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: + }, + ...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 || + })) + ]; + + // 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: + }; + }; + + const handleAgentSelect = (agentId: string | undefined) => { + onAgentSelect?.(agentId); + setIsOpen(false); + }; + + const handleSearchInputKeyDown = (e: React.KeyboardEvent) => { + 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 ( + <> + + + + + + + + + +

Select Agent

+
+
+
+ + +
+
+ + 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" + /> +
+
+
+ {agentsLoading ? ( +
+ Loading agents... +
+ ) : filteredAgents.length === 0 ? ( +
+ No agents found +
+ ) : ( + filteredAgents.map((agent, index) => { + const isSelected = agent.id === selectedAgentId; + const isHighlighted = index === highlightedIndex; + + return ( + + + +
+ handleAgentSelect(agent.id)} + onMouseEnter={() => setHighlightedIndex(index)} + > +
+
+ {agent.icon} +
+
+
+ + {agent.name} + + {agent.type === 'custom' && ( + + custom + + )} +
+
+
+ {isSelected && ( + + )} +
+
+
+ +

{truncateString(agent.description, 35)}

+
+
+
+ ); + }) + )} +
+
+
+ + + +
+
+
+
+ + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/thread/chat-input/file-upload-handler.tsx b/frontend/src/components/thread/chat-input/file-upload-handler.tsx index 4485f231..951a17fc 100644 --- a/frontend/src/components/thread/chat-input/file-upload-handler.tsx +++ b/frontend/src/components/thread/chat-input/file-upload-handler.tsx @@ -261,7 +261,6 @@ export const FileUploadHandler = forwardRef< ) : ( )} - Attachments diff --git a/frontend/src/components/thread/chat-input/message-input.tsx b/frontend/src/components/thread/chat-input/message-input.tsx index 00bfbacd..33e04090 100644 --- a/frontend/src/components/thread/chat-input/message-input.tsx +++ b/frontend/src/components/thread/chat-input/message-input.tsx @@ -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( @@ -73,10 +77,15 @@ export const MessageInput = forwardRef( 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; if (!textarea.current) return; @@ -148,11 +157,9 @@ export const MessageInput = forwardRef( messages={messages} /> )} - +
+ {subscriptionStatus === 'no_subscription' && !isLocalMode() && @@ -165,16 +172,38 @@ export const MessageInput = forwardRef( } +
- + ) : ( + + )} + + +
{/* Streaming indicator content */} diff --git a/frontend/src/components/thread/types.ts b/frontend/src/components/thread/types.ts index 577f593f..1e26dbe9 100644 --- a/frontend/src/components/thread/types.ts +++ b/frontend/src/components/thread/types.ts @@ -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 diff --git a/frontend/src/components/ui/pixel-art-editor.tsx b/frontend/src/components/ui/pixel-art-editor.tsx new file mode 100644 index 00000000..d171ae94 --- /dev/null +++ b/frontend/src/components/ui/pixel-art-editor.tsx @@ -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 = ({ + onSave, + onCancel, + initialPixelArt, + size = 400 +}) => { + const [grid, setGrid] = useState(() => { + // 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(``); + } + } + } + return `\n ${rects.join('\n ')}\n`; + }, [grid]); + + const handleSave = useCallback(() => { + const svg = generateSVG(); + onSave(svg); + }, [generateSVG, onSave]); + + const pixelSize = size / GRID_SIZE; + + return ( + + + + + Pixel Art Editor + + + +
+
+ Colors: + +
+
+ {COLORS.map(color => ( + +
+
+
+
+ {grid.map((row, rowIndex) => + row.map((pixel, colIndex) => ( +
handleMouseDown(rowIndex, colIndex)} + onMouseEnter={() => handleMouseEnter(rowIndex, colIndex)} + onMouseUp={handleMouseUp} + /> + )) + )} +
+
+
+ Preview: +
+
+
+
+
+
+
+ +
+ + +
+
+ + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/pixel-avatar.tsx b/frontend/src/components/ui/pixel-avatar.tsx new file mode 100644 index 00000000..60a4da63 --- /dev/null +++ b/frontend/src/components/ui/pixel-avatar.tsx @@ -0,0 +1,266 @@ +import React from 'react'; + +export const PIXEL_ART_DESIGNS = { + robot: ` + + + + + + + + + + + `, + + cat: ` + + + + + + + + + `, + + wizard: ` + + + + + + + + + + `, + + knight: ` + + + + + + + + `, + + ninja: ` + + + + + + + + `, + + pirate: ` + + + + + + + + `, + + alien: ` + + + + + + + + `, + + dragon: ` + + + + + + + + + `, + + ghost: ` + + + + + + + + + `, + + bear: ` + + + + + + + + + `, + + astronaut: ` + + + + + + + + + + `, + + viking: ` + + + + + + + + + + `, + + demon: ` + + + + + + + + + + + `, + + samurai: ` + + + + + + + + + `, + + witch: ` + + + + + + + + + `, + + cyborg: ` + + + + + + + + + ` +}; + +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 = ({ + design, + size = 32, + className = "", + customPixels +}) => { + const pixelArt = customPixels || PIXEL_ART_DESIGNS[design]; + + if (!pixelArt) { + return ( +
+ + + + + + +
+ ); + } + + return ( +
+ ); +}; + +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]; +}; \ No newline at end of file diff --git a/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts b/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts index 9b548178..18173285 100644 --- a/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts +++ b/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts @@ -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; diff --git a/frontend/src/hooks/useAgentStream.ts b/frontend/src/hooks/useAgentStream.ts index 1e0153df..965cc977 100644 --- a/frontend/src/hooks/useAgentStream.ts +++ b/frontend/src/hooks/useAgentStream.ts @@ -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, })); }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a4171fc9..260cef8b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => { 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')