Merge pull request #1214 from escapade-mckv/composio-1a

Composio integration
This commit is contained in:
Bobbie 2025-08-06 11:58:53 +05:30 committed by GitHub
commit 351f6500c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 833 additions and 360 deletions

View File

@ -11,6 +11,7 @@ class SunaConfig:
DEFAULT_TOOLS = {
"sb_shell_tool": True,
"sb_files_tool": True,
"sb_browser_tool": True,
"sb_deploy_tool": True,
"sb_expose_tool": True,

View File

@ -1,4 +1,5 @@
from typing import Optional, List
from uuid import uuid4
from agentpress.tool import ToolResult, openapi_schema, usage_example
from agentpress.thread_manager import ThreadManager
from .base_tool import AgentBuilderBaseTool
@ -110,19 +111,19 @@ class CredentialProfileTool(AgentBuilderBaseTool):
) -> ToolResult:
try:
account_id = await self._get_current_account_id()
integration_user_id = str(uuid4())
logger.info(f"Generated integration user_id: {integration_user_id} for account: {account_id}")
integration_service = get_integration_service(db_connection=self.db)
result = await integration_service.integrate_toolkit(
toolkit_slug=toolkit_slug,
account_id=account_id,
user_id=integration_user_id,
profile_name=profile_name,
display_name=display_name or profile_name,
user_id=account_id,
save_as_profile=True
)
print("[DEBUG] create_credential_profile result:", result)
response_data = {
"message": f"Successfully created credential profile '{profile_name}' for {result.toolkit.name}",
"profile": {
@ -148,90 +149,6 @@ class CredentialProfileTool(AgentBuilderBaseTool):
except Exception as e:
return self.fail_response(f"Error creating credential profile: {str(e)}")
# @openapi_schema({
# "type": "function",
# "function": {
# "name": "check_profile_connection",
# "description": "Check the connection status of a credential profile and get available tools if connected.",
# "parameters": {
# "type": "object",
# "properties": {
# "profile_id": {
# "type": "string",
# "description": "The ID of the credential profile to check"
# }
# },
# "required": ["profile_id"]
# }
# }
# })
# @usage_example('''
# <function_calls>
# <invoke name="check_profile_connection">
# <parameter name="profile_id">profile-uuid-123</parameter>
# </invoke>
# </function_calls>
# ''')
# async def check_profile_connection(self, profile_id: str) -> ToolResult:
# try:
# from uuid import UUID
# from pipedream.connection_service import ExternalUserId
# account_id = await self._get_current_account_id()
# profile_service = ComposioProfileService(self.db)
# profile = await profile_service.get_profile(UUID(account_id), UUID(profile_id))
# if not profile:
# return self.fail_response("Credential profile not found")
# external_user_id = ExternalUserId(profile.external_user_id.value if hasattr(profile.external_user_id, 'value') else str(profile.external_user_id))
# connection_service = ConnectionService(self.db)
# raw_connections = await connection_service.get_connections_for_user(external_user_id)
# connections = []
# for conn in raw_connections:
# connections.append({
# "external_user_id": conn.external_user_id.value if hasattr(conn.external_user_id, 'value') else str(conn.external_user_id),
# "app_slug": conn.app.slug.value if hasattr(conn.app.slug, 'value') else str(conn.app.slug),
# "app_name": conn.app.name,
# "created_at": conn.created_at.isoformat() if conn.created_at else None,
# "updated_at": conn.updated_at.isoformat() if conn.updated_at else None,
# "is_active": conn.is_active
# })
# response_data = {
# "profile_name": profile.display_name,
# "app_name": profile.app_name,
# "app_slug": profile.app_slug.value if hasattr(profile.app_slug, 'value') else str(profile.app_slug),
# "external_user_id": profile.external_user_id.value if hasattr(profile.external_user_id, 'value') else str(profile.external_user_id),
# "is_connected": profile.is_connected,
# "connections": connections,
# "connection_count": len(connections)
# }
# if profile.is_connected and connections:
# try:
# from pipedream.mcp_service import ConnectionStatus, ExternalUserId, AppSlug
# external_user_id = ExternalUserId(profile.external_user_id.value if hasattr(profile.external_user_id, 'value') else str(profile.external_user_id))
# app_slug = AppSlug(profile.app_slug.value if hasattr(profile.app_slug, 'value') else str(profile.app_slug))
# servers = await mcp_service.discover_servers_for_user(external_user_id, app_slug)
# connected_servers = [s for s in servers if s.status == ConnectionStatus.CONNECTED]
# if connected_servers:
# tools = [t.name for t in connected_servers[0].available_tools]
# response_data["available_tools"] = tools
# response_data["tool_count"] = len(tools)
# response_data["message"] = f"Profile '{profile.display_name}' is connected with {len(tools)} available tools"
# else:
# response_data["message"] = f"Profile '{profile.display_name}' is connected but no MCP tools are available yet"
# except Exception as mcp_error:
# logger.error(f"Error getting MCP tools for profile: {mcp_error}")
# response_data["message"] = f"Profile '{profile.display_name}' is connected but could not retrieve MCP tools"
# else:
# response_data["message"] = f"Profile '{profile.display_name}' is not connected yet"
# return self.success_response(response_data)
# except Exception as e:
# return self.fail_response(f"Error checking profile connection: {str(e)}")
@openapi_schema({
"type": "function",
"function": {

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from typing import Dict, Any, Optional, List
from pydantic import BaseModel
from uuid import uuid4
from utils.auth_utils import get_current_user_id_from_jwt
from utils.logger import logger
from services.supabase import DBConnection
@ -16,11 +17,9 @@ from .composio_profile_service import ComposioProfileService, ComposioProfile
router = APIRouter(prefix="/composio", tags=["composio"])
# Global database connection
db: Optional[DBConnection] = None
def initialize(database: DBConnection):
"""Initialize the composio API with database connection"""
global db
db = database
@ -29,7 +28,6 @@ class IntegrateToolkitRequest(BaseModel):
toolkit_slug: str
profile_name: Optional[str] = None
display_name: Optional[str] = None
user_id: Optional[str] = "default"
mcp_server_name: Optional[str] = None
save_as_profile: bool = True
@ -49,7 +47,6 @@ class CreateProfileRequest(BaseModel):
toolkit_slug: str
profile_name: str
display_name: Optional[str] = None
user_id: Optional[str] = "default"
mcp_server_name: Optional[str] = None
is_default: bool = False
@ -137,13 +134,16 @@ async def integrate_toolkit(
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> IntegrationStatusResponse:
try:
integration_user_id = str(uuid4())
logger.info(f"Generated integration user_id: {integration_user_id} for account: {current_user_id}")
service = get_integration_service(db_connection=db)
result = await service.integrate_toolkit(
toolkit_slug=request.toolkit_slug,
account_id=current_user_id,
user_id=integration_user_id,
profile_name=request.profile_name,
display_name=request.display_name,
user_id=request.user_id or current_user_id,
mcp_server_name=request.mcp_server_name,
save_as_profile=request.save_as_profile
)
@ -171,13 +171,16 @@ async def create_profile(
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> ProfileResponse:
try:
integration_user_id = str(uuid4())
logger.info(f"Generated integration user_id: {integration_user_id} for account: {current_user_id}")
service = get_integration_service(db_connection=db)
result = await service.integrate_toolkit(
toolkit_slug=request.toolkit_slug,
account_id=current_user_id,
user_id=integration_user_id,
profile_name=request.profile_name,
display_name=request.display_name,
user_id=request.user_id or current_user_id,
mcp_server_name=request.mcp_server_name,
save_as_profile=True
)

View File

@ -36,9 +36,9 @@ class ComposioIntegrationService:
self,
toolkit_slug: str,
account_id: str,
user_id: str,
profile_name: Optional[str] = None,
display_name: Optional[str] = None,
user_id: str = "default",
mcp_server_name: Optional[str] = None,
save_as_profile: bool = True
) -> ComposioIntegrationResult:

View File

@ -26,48 +26,39 @@ class ConnectedAccountService:
self.client = ComposioClient.get_client(api_key)
def _extract_deprecated_value(self, deprecated_obj) -> Optional[bool]:
"""Extract boolean value from Composio SDK's Deprecated object"""
if deprecated_obj is None:
return None
# If it's already a boolean, return it
if isinstance(deprecated_obj, bool):
return deprecated_obj
# If it's a Composio Deprecated object, try to extract meaningful info
if hasattr(deprecated_obj, '__dict__'):
# Check if it has any deprecation info - if so, consider it deprecated
deprecated_dict = deprecated_obj.__dict__
if deprecated_dict:
return True
# Default to not deprecated
return False
def _extract_val_dict(self, val_obj) -> Dict[str, Any]:
"""Extract dictionary from Composio SDK's val object"""
if val_obj is None:
return {}
# If it's already a dict, return it
if isinstance(val_obj, dict):
return val_obj
# If it's a Pydantic model, convert it to dict
if hasattr(val_obj, 'model_dump'):
return val_obj.model_dump()
elif hasattr(val_obj, 'dict'):
return val_obj.dict()
elif hasattr(val_obj, '__dict__'):
return val_obj.__dict__
# Fallback to empty dict
return {}
async def create_connected_account(
self,
auth_config_id: str,
user_id: str = "default"
user_id: str
) -> ConnectedAccount:
try:
logger.info(f"Creating connected account for auth_config: {auth_config_id}, user: {user_id}")
@ -85,16 +76,13 @@ class ConnectedAccountService:
}
)
# Access Pydantic model attributes directly
connection_data_obj = getattr(response, 'connection_data', None)
if not connection_data_obj:
# Try alternative attribute names
connection_data_obj = getattr(response, 'connectionData', None)
if connection_data_obj and hasattr(connection_data_obj, '__dict__'):
connection_data_dict = connection_data_obj.__dict__
# Extract val field properly - it might be a Pydantic object
val_obj = connection_data_dict.get('val', {})
val_dict = self._extract_val_dict(val_obj)
@ -105,7 +93,6 @@ class ConnectedAccountService:
else:
connection_data = ConnectionState()
# Handle the deprecated field properly
deprecated_obj = getattr(response, 'deprecated', None)
deprecated_value = self._extract_deprecated_value(deprecated_obj)
@ -136,7 +123,6 @@ class ConnectedAccountService:
if not response:
return None
# Access Pydantic model attributes directly
connection_data_obj = getattr(response, 'connection_data', None)
if not connection_data_obj:
connection_data_obj = getattr(response, 'connectionData', None)
@ -144,7 +130,6 @@ class ConnectedAccountService:
if connection_data_obj and hasattr(connection_data_obj, '__dict__'):
connection_data_dict = connection_data_obj.__dict__
# Extract val field properly - it might be a Pydantic object
val_obj = connection_data_dict.get('val', {})
val_dict = self._extract_val_dict(val_obj)
@ -155,7 +140,6 @@ class ConnectedAccountService:
else:
connection_data = ConnectionState()
# Handle the deprecated field properly
deprecated_obj = getattr(response, 'deprecated', None)
deprecated_value = self._extract_deprecated_value(deprecated_obj)
@ -213,7 +197,6 @@ class ConnectedAccountService:
if connection_data_obj and hasattr(connection_data_obj, '__dict__'):
connection_data_dict = connection_data_obj.__dict__
# Extract val field properly - it might be a Pydantic object
val_obj = connection_data_dict.get('val', {})
val_dict = self._extract_val_dict(val_obj)
@ -224,7 +207,6 @@ class ConnectedAccountService:
else:
connection_data = ConnectionState()
# Handle the deprecated field properly
deprecated_obj = getattr(item, 'deprecated', None)
deprecated_value = self._extract_deprecated_value(deprecated_obj)

View File

@ -142,18 +142,15 @@ class MCPServerService:
if user_ids:
request_data["user_ids"] = user_ids
# Try different possible API paths
try:
response = self.client.mcp.generate_mcp_url(**request_data)
except AttributeError:
try:
response = self.client.mcp.generate.url(**request_data)
except AttributeError:
# Fallback: try direct method call
response = self.client.generate_mcp_url(**request_data)
# Access Pydantic model attributes directly
mcp_url_response = MCPUrlResponse(
mcp_url=response.mcp_url,
connected_account_urls=getattr(response, 'connected_account_urls', []),
@ -171,17 +168,14 @@ class MCPServerService:
try:
logger.info(f"Fetching MCP server: {mcp_server_id}")
# Try different possible API paths
try:
response = self.client.mcp.get(mcp_server_id)
except AttributeError:
# Fallback: try direct method call
response = self.client.get_mcp_server(mcp_server_id)
if not response:
return None
# Access Pydantic model attributes directly
commands_obj = getattr(response, 'commands', None)
commands = MCPCommands(
@ -211,11 +205,9 @@ class MCPServerService:
try:
logger.info("Listing MCP servers")
# Try different possible API paths
try:
response = self.client.mcp.list()
except AttributeError:
# Fallback: try direct method call
response = self.client.list_mcp_servers()
mcp_servers = []

View File

@ -29,24 +29,12 @@ class ToolkitService:
popular_categories = [
{"id": "popular", "name": "Popular"},
{"id": "productivity", "name": "Productivity"},
{"id": "ai", "name": "AI"},
{"id": "crm", "name": "CRM"},
{"id": "marketing", "name": "Marketing"},
{"id": "email", "name": "Email"},
{"id": "analytics", "name": "Analytics"},
{"id": "automation", "name": "Automation"},
{"id": "communication", "name": "Communication"},
{"id": "project-management", "name": "Project Management"},
{"id": "e-commerce", "name": "E-commerce"},
{"id": "social-media", "name": "Social Media"},
{"id": "payments", "name": "Payments"},
{"id": "finance", "name": "Finance"},
{"id": "developer-tools", "name": "Developer Tools"},
{"id": "api", "name": "API"},
{"id": "notifications", "name": "Notifications"},
{"id": "scheduling", "name": "Scheduling"},
{"id": "data-analytics", "name": "Data Analytics"},
{"id": "customer-support", "name": "Customer Support"}
]
categories = [CategoryInfo(**cat) for cat in popular_categories]

View File

@ -44,7 +44,7 @@ export default function AppProfilesPage() {
<div className="container mx-auto max-w-4xl px-6 py-6">
<div className="space-y-8">
<PageHeader icon={Zap}>
<span className="text-primary">Composio Credentials</span>
<span className="text-primary">App Credentials</span>
</PageHeader>
<ComposioConnectionsSection />
</div>

View File

@ -35,8 +35,10 @@ import {
XCircle,
} from 'lucide-react';
import { useComposioCredentialsProfiles, useComposioMcpUrl } from '@/hooks/react-query/composio/use-composio-profiles';
import { ComposioRegistry } from './composio-registry';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import type { ComposioProfileSummary, ComposioToolkitGroup } from '@/hooks/react-query/composio/utils';
interface ComposioConnectionsSectionProps {
@ -367,6 +369,8 @@ export const ComposioConnectionsSection: React.FC<ComposioConnectionsSectionProp
}) => {
const { data: toolkits, isLoading, error } = useComposioCredentialsProfiles();
const [searchQuery, setSearchQuery] = useState('');
const [showRegistry, setShowRegistry] = useState(false);
const queryClient = useQueryClient();
const filteredToolkits = useMemo(() => {
if (!toolkits || !searchQuery.trim()) return toolkits || [];
@ -405,6 +409,12 @@ export const ComposioConnectionsSection: React.FC<ComposioConnectionsSectionProp
return { filteredProfilesCount, filteredToolkitsCount };
}, [filteredToolkits]);
const handleProfileCreated = (profileId: string, selectedTools: string[], appName: string, appSlug: string) => {
setShowRegistry(false);
queryClient.invalidateQueries({ queryKey: ['composio', 'profiles'] });
toast.success(`Successfully connected ${appName}!`);
};
if (isLoading) {
return (
<div className={cn("space-y-6", className)}>
@ -448,15 +458,13 @@ export const ComposioConnectionsSection: React.FC<ComposioConnectionsSectionProp
<CardContent className="py-12">
<div className="text-center">
<Settings className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Composio Connections</h3>
<h3 className="text-lg font-medium mb-2">No Connections</h3>
<p className="text-muted-foreground mb-4">
You haven't connected any Composio applications yet.
You haven't connected any applications yet.
</p>
<Button variant="outline" asChild>
<Link href="/agents" className="inline-flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
Connect Apps
</Link>
<Button variant="outline" onClick={() => setShowRegistry(true)}>
<Plus className="h-4 w-4" />
Connect Apps
</Button>
</div>
</CardContent>
@ -477,7 +485,7 @@ export const ComposioConnectionsSection: React.FC<ComposioConnectionsSectionProp
</p>
</div>
<Button
onClick={() => window.open('/agents', '_blank')}
onClick={() => setShowRegistry(true)}
size="sm"
>
<Plus className="h-4 w-4" />
@ -540,6 +548,19 @@ export const ComposioConnectionsSection: React.FC<ComposioConnectionsSectionProp
))}
</div>
)}
<Dialog open={showRegistry} onOpenChange={setShowRegistry}>
<DialogContent className="p-0 max-w-6xl h-[90vh] overflow-hidden">
<DialogHeader className="sr-only">
<DialogTitle>Connect New App</DialogTitle>
</DialogHeader>
<ComposioRegistry
mode="profile-only"
onClose={() => setShowRegistry(false)}
onToolsSelected={handleProfileCreated}
/>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -88,7 +88,7 @@ const getStepIndex = (step: Step): number => {
const StepIndicator = ({ currentStep, mode }: { currentStep: Step; mode: 'full' | 'profile-only' }) => {
const currentIndex = getStepIndex(currentStep);
const visibleSteps = mode === 'profile-only'
? stepConfigs.filter(step => step.id !== Step.ToolsSelection)
? stepConfigs.filter(step => step.id !== Step.ToolsSelection && step.id !== Step.ProfileSelect)
: stepConfigs;
const visibleCurrentIndex = visibleSteps.findIndex(step => step.id === currentStep);
@ -274,7 +274,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
useEffect(() => {
if (open) {
setStep(Step.ProfileSelect);
setStep(mode === 'profile-only' ? Step.ProfileCreate : Step.ProfileSelect);
setProfileName(`${app.name} Profile`);
setSelectedProfileId('');
setSelectedProfile(null);
@ -287,7 +287,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
setAvailableTools([]);
setToolsError(null);
}
}, [open, app.name]);
}, [open, app.name, mode]);
useEffect(() => {
if (step === Step.ToolsSelection && selectedProfile) {
@ -485,7 +485,11 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
const handleBack = () => {
switch (step) {
case Step.ProfileCreate:
navigateToStep(Step.ProfileSelect);
if (mode === 'profile-only') {
onOpenChange(false);
} else {
navigateToStep(Step.ProfileSelect);
}
break;
case Step.Connecting:
navigateToStep(Step.ProfileCreate);
@ -590,30 +594,61 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
transition={{ duration: 0.3, ease: "easeInOut" }}
className="space-y-6"
>
<div className="space-y-3">
<Label className="text-sm font-medium">Connection Profile</Label>
<Select value={selectedProfileId} onValueChange={setSelectedProfileId}>
<SelectTrigger className="w-full h-12 text-base">
<SelectValue placeholder="Select a profile..." />
</SelectTrigger>
<SelectContent className="w-full">
{existingProfiles.map((profile) => (
<SelectItem key={profile.profile_id} value={profile.profile_id}>
<div className="flex items-center justify-between w-full">
<div className="flex-1">
<div className="text-sm font-medium">{profile.profile_name}</div>
{existingProfiles.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-medium">Use Existing Profile</Label>
<Select value={selectedProfileId} onValueChange={setSelectedProfileId}>
<SelectTrigger className="w-full h-12 text-base">
<SelectValue placeholder="Select a profile..." />
</SelectTrigger>
<SelectContent className="w-full">
{existingProfiles.map((profile) => (
<SelectItem key={profile.profile_id} value={profile.profile_id}>
<div className="flex items-center gap-3 w-full">
{app.logo ? (
<img src={app.logo} alt={app.name} className="h-5 w-5 rounded-lg object-contain flex-shrink-0" />
) : (
<div className="w-5 h-5 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-primary text-xs font-semibold flex-shrink-0">
{app.name.charAt(0)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{profile.profile_name}</div>
<div className="text-xs text-muted-foreground">
Created {formatDistanceToNow(new Date(profile.created_at), { addSuffix: true })}
</div>
</div>
</div>
</div>
</SelectItem>
))}
<SelectItem value="new">
<div className="flex items-center gap-1">
<Plus className="h-4 w-4" />
<span>Create New Profile</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="h-px bg-border flex-1" />
<span className="text-xs text-muted-foreground px-2">
{existingProfiles.length > 0 ? 'or' : ''}
</span>
<div className="h-px bg-border flex-1" />
</div>
<Button
onClick={() => navigateToStep(Step.ProfileCreate)}
variant={existingProfiles.length > 0 ? "outline" : "default"}
className="w-full"
>
{app.logo ? (
<img src={app.logo} alt={app.name} className="h-4 w-4 rounded-lg object-contain" />
) : (
<div className="w-4 h-4 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-primary text-xs font-semibold mr-2">
{app.name.charAt(0)}
</div>
</SelectItem>
</SelectContent>
</Select>
)}
Connect New {app.name} Account
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-3 pt-2">
@ -624,14 +659,16 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
>
Cancel
</Button>
<Button
onClick={handleProfileSelect}
disabled={!selectedProfileId}
className="flex-1"
>
{selectedProfileId === 'new' ? 'Continue' : mode === 'full' && agentId ? 'Configure Tools' : 'Use Profile'}
<ChevronRight className="h-4 w-4" />
</Button>
{existingProfiles.length > 0 && (
<Button
onClick={handleProfileSelect}
disabled={!selectedProfileId}
className="flex-1"
>
{mode === 'full' && agentId ? 'Configure Tools' : 'Use Profile'}
<ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
</motion.div>
)}
@ -754,11 +791,11 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
className="text-center py-8"
>
<div className="space-y-6">
<div className="w-20 h-20 mx-auto rounded-2xl bg-green-50 dark:bg-green-900/20 flex items-center justify-center">
<Check className="h-10 w-10 text-green-600 dark:text-green-400" />
<div className="w-18 h-18 mx-auto rounded-full bg-green-400 dark:bg-green-600 flex items-center justify-center">
<Check className="h-10 w-10 text-white" />
</div>
<div className="space-y-2">
<div className="space-y-1">
<h3 className="font-semibold text-lg">Successfully Connected!</h3>
<p className="text-sm text-muted-foreground">
Your {app.name} integration is ready.

View File

@ -4,40 +4,41 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Search, Zap, X } from 'lucide-react';
import { Search, Zap, X, Settings, ChevronDown, ChevronUp } from 'lucide-react';
import { useComposioToolkits, useComposioCategories } from '@/hooks/react-query/composio/use-composio';
import { useComposioProfiles } from '@/hooks/react-query/composio/use-composio-profiles';
import { useAgent } from '@/hooks/react-query/agents/use-agents';
import { useAgent, useUpdateAgent } from '@/hooks/react-query/agents/use-agents';
import { ComposioConnector } from './composio-connector';
import { ComposioToolsManager } from './composio-tools-manager';
import type { ComposioToolkit, ComposioProfile } from '@/hooks/react-query/composio/utils';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { useQueryClient } from '@tanstack/react-query';
import { AgentSelector } from '../../thread/chat-input/agent-selector';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Switch } from '@/components/ui/switch';
const CATEGORY_EMOJIS: Record<string, string> = {
'popular': '🔥',
'productivity': '📊',
'ai': '🤖',
'crm': '👥',
'marketing': '📢',
'email': '📧',
'analytics': '📈',
'automation': '⚡',
'communication': '💬',
'project-management': '📋',
'e-commerce': '🛒',
'social-media': '📱',
'payments': '💳',
'finance': '💰',
'developer-tools': '🛠️',
'api': '🔌',
'notifications': '🔔',
'scheduling': '📅',
'data-analytics': '📊',
'customer-support': '🎧'
};
interface ConnectedApp {
toolkit: ComposioToolkit;
profile: ComposioProfile;
mcpConfig: {
name: string;
type: string;
config: Record<string, any>;
enabledTools: string[];
};
}
interface ComposioRegistryProps {
onToolsSelected?: (profileId: string, selectedTools: string[], appName: string, appSlug: string) => void;
@ -49,6 +50,52 @@ interface ComposioRegistryProps {
onAgentChange?: (agentId: string | undefined) => void;
}
// Helper function to get agent-specific connected apps
const getAgentConnectedApps = (
agent: any,
profiles: ComposioProfile[],
toolkits: ComposioToolkit[]
): ConnectedApp[] => {
if (!agent?.custom_mcps || !profiles?.length || !toolkits?.length) return [];
const connectedApps: ConnectedApp[] = [];
agent.custom_mcps.forEach((mcpConfig: any) => {
// Check if this is a Composio MCP by looking for profile_id in config
if (mcpConfig.config?.profile_id) {
const profile = profiles.find(p => p.profile_id === mcpConfig.config.profile_id);
const toolkit = toolkits.find(t => t.slug === profile?.toolkit_slug);
if (profile && toolkit) {
connectedApps.push({
toolkit,
profile,
mcpConfig
});
}
}
});
return connectedApps;
};
// Helper function to check if an app is connected to the agent
const isAppConnectedToAgent = (
agent: any,
appSlug: string,
profiles: ComposioProfile[]
): boolean => {
if (!agent?.custom_mcps) return false;
return agent.custom_mcps.some((mcpConfig: any) => {
if (mcpConfig.config?.profile_id) {
const profile = profiles.find(p => p.profile_id === mcpConfig.config.profile_id);
return profile?.toolkit_slug === appSlug;
}
return false;
});
};
const AppCardSkeleton = () => (
<div className="border border-border/50 rounded-xl p-4">
<div className="flex items-center gap-3 mb-3">
@ -69,16 +116,100 @@ const AppCardSkeleton = () => (
</div>
);
const AppCard = ({ app, profiles, onConnect, onConfigure }: {
const ConnectedAppSkeleton = () => (
<div className="border border-border/50 rounded-2xl p-4">
<div className="flex items-start gap-3 mb-3">
<Skeleton className="w-10 h-10 rounded-lg" />
<div className="flex-1">
<Skeleton className="w-3/4 h-4 mb-2" />
<Skeleton className="w-full h-3" />
</div>
<Skeleton className="w-8 h-8 rounded" />
</div>
<div className="flex justify-between items-center">
<Skeleton className="w-32 h-4" />
</div>
</div>
);
const ConnectedAppCard = ({
connectedApp,
onToggleTools,
onConfigure,
onManageTools,
isUpdating
}: {
connectedApp: ConnectedApp;
onToggleTools: (profileId: string, enabled: boolean) => void;
onConfigure: (app: ComposioToolkit, profile: ComposioProfile) => void;
onManageTools: (connectedApp: ConnectedApp) => void;
isUpdating: boolean;
}) => {
const { toolkit, profile, mcpConfig } = connectedApp;
const hasEnabledTools = mcpConfig.enabledTools && mcpConfig.enabledTools.length > 0;
return (
<div
className="group border bg-card rounded-2xl p-4 transition-all duration-200 cursor-pointer"
>
<div className="flex items-start gap-3 mb-3">
{toolkit.logo ? (
<img src={toolkit.logo} alt={toolkit.name} className="w-10 h-10 rounded-lg object-cover p-2 bg-muted rounded-xl border" />
) : (
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<span className="text-primary text-sm font-medium">{toolkit.name.charAt(0)}</span>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm leading-tight truncate mb-1">{toolkit.name}</h3>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
Connected as "{profile.profile_name}"
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onManageTools(connectedApp)}
disabled={isUpdating}
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
{hasEnabledTools ? `${mcpConfig.enabledTools.length} tools enabled` : 'Connected (no tools)'}
</div>
</div>
</div>
</div>
);
};
const AppCard = ({ app, profiles, onConnect, onConfigure, isConnectedToAgent, currentAgentId, mode }: {
app: ComposioToolkit;
profiles: ComposioProfile[];
onConnect: () => void;
onConfigure: (profile: ComposioProfile) => void;
isConnectedToAgent: boolean;
currentAgentId?: string;
mode?: 'full' | 'profile-only';
}) => {
const connectedProfiles = profiles.filter(p => p.is_connected);
const canConnect = mode === 'profile-only' ? true : (!isConnectedToAgent && currentAgentId);
return (
<div onClick={connectedProfiles.length > 0 ? () => onConfigure(connectedProfiles[0]) : onConnect} className="group border bg-card hover:bg-muted rounded-2xl p-4 transition-all duration-200">
<div
onClick={canConnect ? (connectedProfiles.length > 0 ? () => onConfigure(connectedProfiles[0]) : onConnect) : undefined}
className={cn(
"group border bg-card rounded-2xl p-4 transition-all duration-200",
canConnect ? "hover:bg-muted cursor-pointer" : "opacity-60 cursor-not-allowed"
)}
>
<div className="flex items-start gap-3 mb-3">
{app.logo ? (
<img src={app.logo} alt={app.name} className="w-10 h-10 rounded-lg object-cover p-2 bg-muted rounded-xl border" />
@ -111,11 +242,32 @@ const AppCard = ({ app, profiles, onConnect, onConfigure }: {
)}
<div className="flex justify-between items-center">
{connectedProfiles.length > 0 && (
{mode === 'profile-only' ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50" />
{connectedProfiles.length > 0 ? `${connectedProfiles.length} existing profile${connectedProfiles.length !== 1 ? 's' : ''}` : 'Click to connect'}
</div>
</div>
) : isConnectedToAgent ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500" />
Connected to this agent
</div>
</div>
) : connectedProfiles.length > 0 ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Connected ({connectedProfiles.length})
Profile available ({connectedProfiles.length})
</div>
</div>
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50" />
Not connected
</div>
</div>
)}
@ -137,16 +289,20 @@ export const ComposioRegistry: React.FC<ComposioRegistryProps> = ({
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedApp, setSelectedApp] = useState<ComposioToolkit | null>(null);
const [showConnector, setShowConnector] = useState(false);
const [showConnectedApps, setShowConnectedApps] = useState(true);
const [showToolsManager, setShowToolsManager] = useState(false);
const [selectedConnectedApp, setSelectedConnectedApp] = useState<ConnectedApp | null>(null);
const [internalSelectedAgentId, setInternalSelectedAgentId] = useState<string | undefined>(selectedAgentId);
const queryClient = useQueryClient();
const { data: categoriesData, isLoading: isLoadingCategories } = useComposioCategories();
const { data: toolkits, isLoading } = useComposioToolkits(search, selectedCategory);
const { data: profiles } = useComposioProfiles();
const { data: profiles, isLoading: isLoadingProfiles } = useComposioProfiles();
const currentAgentId = selectedAgentId ?? internalSelectedAgentId;
const { data: agent } = useAgent(currentAgentId || '');
const { data: agent, isLoading: isLoadingAgent } = useAgent(currentAgentId || '');
const { mutate: updateAgent, isPending: isUpdatingAgent } = useUpdateAgent();
const handleAgentSelect = (agentId: string | undefined) => {
if (onAgentChange) {
@ -169,13 +325,20 @@ export const ComposioRegistry: React.FC<ComposioRegistryProps> = ({
return grouped;
}, [profiles]);
const connectedApps = useMemo(() => {
if (!currentAgentId || !agent) return [];
return getAgentConnectedApps(agent, profiles || [], toolkits?.toolkits || []);
}, [agent, profiles, toolkits, currentAgentId]);
const isLoadingConnectedApps = currentAgentId && (isLoadingAgent || isLoadingProfiles || isLoading);
const filteredToolkits = useMemo(() => {
if (!toolkits?.toolkits) return [];
return toolkits.toolkits;
}, [toolkits]);
const handleConnect = (app: ComposioToolkit) => {
if (!currentAgentId && showAgentSelector) {
if (mode !== 'profile-only' && !currentAgentId && showAgentSelector) {
toast.error('Please select an agent first');
return;
}
@ -184,7 +347,7 @@ export const ComposioRegistry: React.FC<ComposioRegistryProps> = ({
};
const handleConfigure = (app: ComposioToolkit, profile: ComposioProfile) => {
if (!currentAgentId) {
if (mode !== 'profile-only' && !currentAgentId) {
toast.error('Please select an agent first');
return;
}
@ -192,6 +355,37 @@ export const ComposioRegistry: React.FC<ComposioRegistryProps> = ({
setShowConnector(true);
};
const handleToggleTools = (profileId: string, enabled: boolean) => {
if (!currentAgentId || !agent) return;
const updatedCustomMcps = agent.custom_mcps?.map((mcpConfig: any) => {
if (mcpConfig.config?.profile_id === profileId) {
return {
...mcpConfig,
enabledTools: enabled ? mcpConfig.enabledTools || [] : []
};
}
return mcpConfig;
}) || [];
updateAgent({
agentId: currentAgentId,
custom_mcps: updatedCustomMcps
}, {
onSuccess: () => {
toast.success(enabled ? 'Tools enabled' : 'Tools disabled');
},
onError: (error: any) => {
toast.error(error.message || 'Failed to update tools');
}
});
};
const handleManageTools = (connectedApp: ConnectedApp) => {
setSelectedConnectedApp(connectedApp);
setShowToolsManager(true);
};
const handleConnectionComplete = (profileId: string, appName: string, appSlug: string) => {
setShowConnector(false);
queryClient.invalidateQueries({ queryKey: ['composio', 'profiles'] });
@ -263,9 +457,14 @@ export const ComposioRegistry: React.FC<ComposioRegistryProps> = ({
<div className="flex-shrink-0 border-b p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold">App Integrations</h2>
<h2 className="text-xl font-semibold">
{mode === 'profile-only' ? 'Connect New App' : 'App Integrations'}
</h2>
<p className="text-sm text-muted-foreground">
Connect your favorite apps powered by Composio
{mode === 'profile-only'
? 'Create a connection profile for your favorite apps'
: `Connect your favorite apps with ${currentAgentId ? 'this agent' : 'your agent'}`
}
</p>
</div>
<div className="flex items-center gap-3">
@ -276,11 +475,6 @@ export const ComposioRegistry: React.FC<ComposioRegistryProps> = ({
isSunaAgent={agent?.metadata?.is_suna_default}
/>
)}
{onClose && (
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
@ -313,42 +507,103 @@ export const ComposioRegistry: React.FC<ComposioRegistryProps> = ({
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="p-6">
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<AppCardSkeleton key={i} />
))}
</div>
) : filteredToolkits.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No apps found</h3>
<p className="text-muted-foreground">
{search ? `No apps match "${search}"` : 'No apps available in this category'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredToolkits.map((app) => (
<AppCard
key={app.slug}
app={app}
profiles={profilesByToolkit[app.slug] || []}
onConnect={() => handleConnect(app)}
onConfigure={(profile) => handleConfigure(app, profile)}
/>
))}
</div>
<div className="p-6 space-y-6">
{currentAgentId && (
<Collapsible open={showConnectedApps} onOpenChange={setShowConnectedApps}>
<CollapsibleTrigger asChild>
<div className="w-full hover:underline flex items-center justify-between p-0 h-auto">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium">Connected to this agent</h3>
{isLoadingConnectedApps ? (
<Skeleton className="w-6 h-5 rounded ml-2" />
) : connectedApps.length > 0 && (
<Badge variant="outline" className="ml-2">
{connectedApps.length}
</Badge>
)}
</div>
{showConnectedApps ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
{isLoadingConnectedApps ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<ConnectedAppSkeleton key={i} />
))}
</div>
) : connectedApps.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4 mx-auto">
<Zap className="h-8 w-8 text-muted-foreground" />
</div>
<h4 className="text-sm font-medium mb-2">No connected apps</h4>
<p className="text-xs">Connect apps below to manage tools for this agent.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{connectedApps.map((connectedApp) => (
<ConnectedAppCard
key={connectedApp.profile.profile_id}
connectedApp={connectedApp}
onToggleTools={handleToggleTools}
onConfigure={handleConfigure}
onManageTools={handleManageTools}
isUpdating={isUpdatingAgent}
/>
))}
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
<div>
<h3 className="text-lg font-medium mb-4">
{currentAgentId ? 'Available Apps' : 'Browse Apps'}
</h3>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<AppCardSkeleton key={i} />
))}
</div>
) : filteredToolkits.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No apps found</h3>
<p className="text-muted-foreground">
{search ? `No apps match "${search}"` : 'No apps available in this category'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredToolkits.map((app) => (
<AppCard
key={app.slug}
app={app}
profiles={profilesByToolkit[app.slug] || []}
onConnect={() => handleConnect(app)}
onConfigure={(profile) => handleConfigure(app, profile)}
isConnectedToAgent={isAppConnectedToAgent(agent, app.slug, profiles || [])}
currentAgentId={currentAgentId}
mode={mode}
/>
))}
</div>
)}
</div>
</div>
</ScrollArea>
</div>
</div>
</div>
{selectedApp && (
<ComposioConnector
app={selectedApp}
@ -356,6 +611,26 @@ export const ComposioRegistry: React.FC<ComposioRegistryProps> = ({
open={showConnector}
onOpenChange={setShowConnector}
onComplete={handleConnectionComplete}
mode={mode}
/>
)}
{selectedConnectedApp && currentAgentId && (
<ComposioToolsManager
agentId={currentAgentId}
open={showToolsManager}
onOpenChange={setShowToolsManager}
profileId={selectedConnectedApp.profile.profile_id}
profileInfo={{
profile_id: selectedConnectedApp.profile.profile_id,
profile_name: selectedConnectedApp.profile.profile_name,
toolkit_name: selectedConnectedApp.toolkit.name,
toolkit_slug: selectedConnectedApp.toolkit.slug,
}}
appLogo={selectedConnectedApp.toolkit.logo}
onToolsUpdate={() => {
queryClient.invalidateQueries({ queryKey: ['agents', currentAgentId] });
}}
/>
)}
</div>

View File

@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Search, Save, AlertCircle, Zap, CheckCircle2, Settings2, Loader2 } from 'lucide-react';
import { useComposioProfiles } from '@/hooks/react-query/composio/use-composio-profiles';
import { useComposioToolkitIcon } from '@/hooks/react-query/composio/use-composio';
import { backendApi } from '@/lib/api-client';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
@ -129,6 +130,9 @@ export const ComposioToolsManager: React.FC<ComposioToolsManagerProps> = ({
const { data: profiles } = useComposioProfiles();
const currentProfile = profileInfo || profiles?.find(p => p.profile_id === profileId);
const { data: iconData } = useComposioToolkitIcon(currentProfile?.toolkit_slug || '', {
enabled: !!currentProfile?.toolkit_slug
});
const filteredTools = useMemo(() => {
if (!searchTerm) return availableTools;
@ -241,11 +245,11 @@ export const ComposioToolsManager: React.FC<ComposioToolsManagerProps> = ({
<DialogContent className="max-w-2xl h-[80vh] p-0 gap-0 flex flex-col">
<DialogHeader className="p-6 pb-4 border-b flex-shrink-0">
<div className="flex items-center gap-3">
{appLogo ? (
{iconData?.icon_url || appLogo ? (
<img
src={appLogo}
src={iconData?.icon_url || appLogo}
alt={currentProfile?.toolkit_name}
className="w-10 h-10 rounded-lg object-contain bg-muted p-1"
className="w-10 h-10 rounded-lg border object-contain bg-muted p-1"
/>
) : (
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-primary font-semibold">

View File

@ -1,11 +1,12 @@
'use client';
import React from 'react';
import React, { useState } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { SearchBar } from './search-bar';
import { MarketplaceSectionHeader } from './marketplace-section-header';
import { AgentCard } from './agent-card';
import { MarketplaceAgentPreviewDialog } from '@/components/agents/marketplace-agent-preview-dialog';
import type { MarketplaceTemplate } from '@/components/agents/installation/types';
interface MarketplaceTabProps {
@ -41,6 +42,25 @@ export const MarketplaceTab = ({
getItemStyling,
currentUserId
}: MarketplaceTabProps) => {
const [previewAgent, setPreviewAgent] = useState<MarketplaceTemplate | null>(null);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const handleAgentClick = (item: MarketplaceTemplate) => {
setPreviewAgent(item);
setPreviewDialogOpen(true);
};
const handlePreviewClose = () => {
setPreviewDialogOpen(false);
setPreviewAgent(null);
};
const handleInstallFromPreview = (agent: MarketplaceTemplate) => {
onInstallClick(agent);
setPreviewDialogOpen(false);
setPreviewAgent(null);
};
return (
<div className="space-y-6 mt-8 flex flex-col min-h-full">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
@ -107,7 +127,7 @@ export const MarketplaceTab = ({
isActioning={installingItemId === item.id}
onPrimaryAction={onInstallClick}
onDeleteAction={onDeleteTemplate}
onClick={() => onInstallClick(item)}
onClick={() => handleAgentClick(item)}
currentUserId={currentUserId}
/>
))}
@ -126,7 +146,7 @@ export const MarketplaceTab = ({
isActioning={installingItemId === item.id}
onPrimaryAction={onInstallClick}
onDeleteAction={onDeleteTemplate}
onClick={() => onInstallClick(item)}
onClick={() => handleAgentClick(item)}
currentUserId={currentUserId}
/>
))}
@ -145,7 +165,7 @@ export const MarketplaceTab = ({
isActioning={installingItemId === item.id}
onPrimaryAction={onInstallClick}
onDeleteAction={onDeleteTemplate}
onClick={() => onInstallClick(item)}
onClick={() => handleAgentClick(item)}
currentUserId={currentUserId}
/>
))}
@ -154,6 +174,14 @@ export const MarketplaceTab = ({
</div>
)}
</div>
<MarketplaceAgentPreviewDialog
agent={previewAgent}
isOpen={previewDialogOpen}
onClose={handlePreviewClose}
onInstall={handleInstallFromPreview}
isInstalling={installingItemId === previewAgent?.id}
/>
</div>
);
};

View File

@ -12,6 +12,7 @@ export interface MarketplaceTemplate {
avatar_color?: string;
template_id: string;
is_kortix_team?: boolean;
agentpress_tools?: Record<string, any>;
mcp_requirements?: Array<{
qualified_name: string;
display_name: string;

View File

@ -0,0 +1,282 @@
'use client';
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Bot, Download, Wrench, Plug, Tag, User, Calendar, Loader2 } from 'lucide-react';
import type { MarketplaceTemplate } from '@/components/agents/installation/types';
import { AGENTPRESS_TOOL_DEFINITIONS } from '@/components/agents/tools';
import { useComposioToolkitIcon } from '@/hooks/react-query/composio/use-composio';
import { usePipedreamAppIcon } from '@/hooks/react-query/pipedream/use-pipedream';
interface MarketplaceAgentPreviewDialogProps {
agent: MarketplaceTemplate | null;
isOpen: boolean;
onClose: () => void;
onInstall: (agent: MarketplaceTemplate) => void;
isInstalling?: boolean;
}
const extractAppInfo = (qualifiedName: string, customType?: string) => {
if (customType === 'pipedream') {
const qualifiedMatch = qualifiedName.match(/^pipedream_([^_]+)_/);
if (qualifiedMatch) {
return { type: 'pipedream', slug: qualifiedMatch[1] };
}
}
if (customType === 'composio') {
if (qualifiedName.startsWith('composio.')) {
const extractedSlug = qualifiedName.substring(9);
if (extractedSlug) {
return { type: 'composio', slug: extractedSlug };
}
}
}
return null;
};
const IntegrationLogo: React.FC<{
qualifiedName: string;
displayName: string;
customType?: string;
}> = ({ qualifiedName, displayName, customType }) => {
const appInfo = extractAppInfo(qualifiedName, customType);
const { data: pipedreamIconData } = usePipedreamAppIcon(
appInfo?.type === 'pipedream' ? appInfo.slug : '',
{ enabled: appInfo?.type === 'pipedream' }
);
const { data: composioIconData } = useComposioToolkitIcon(
appInfo?.type === 'composio' ? appInfo.slug : '',
{ enabled: appInfo?.type === 'composio' }
);
let logoUrl: string | undefined;
if (appInfo?.type === 'pipedream') {
logoUrl = pipedreamIconData?.icon_url;
} else if (appInfo?.type === 'composio') {
logoUrl = composioIconData?.icon_url;
}
const firstLetter = displayName.charAt(0).toUpperCase();
return (
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0 overflow-hidden rounded-sm">
{logoUrl ? (
<img
src={logoUrl}
alt={displayName}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div className={logoUrl ? "hidden" : "flex w-full h-full items-center justify-center bg-muted rounded-sm text-xs font-medium text-muted-foreground"}>
{firstLetter}
</div>
</div>
);
};
export const MarketplaceAgentPreviewDialog: React.FC<MarketplaceAgentPreviewDialogProps> = ({
agent,
isOpen,
onClose,
onInstall,
isInstalling = false
}) => {
if (!agent) return null;
const { avatar, avatar_color } = agent;
const isSunaAgent = agent.is_kortix_team || false;
const tools = agent.mcp_requirements || [];
const integrations = tools.filter(tool => !tool.custom_type || tool.custom_type !== 'sse');
const customTools = tools.filter(tool => tool.custom_type === 'sse');
const agentpressTools = Object.entries(agent.agentpress_tools || {})
.filter(([_, enabled]) => enabled)
.map(([toolName]) => toolName);
const handleInstall = () => {
onInstall(agent);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getAppDisplayName = (qualifiedName: string) => {
if (qualifiedName.includes('_')) {
const parts = qualifiedName.split('_');
return parts[parts.length - 1].replace(/\b\w/g, l => l.toUpperCase());
}
return qualifiedName.replace(/\b\w/g, l => l.toUpperCase());
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] p-0 overflow-hidden">
<DialogHeader className='p-6'>
<DialogTitle className='sr-only'>Agent Preview</DialogTitle>
<div className='relative h-20 w-20 aspect-square bg-muted rounded-2xl flex items-center justify-center' style={{ backgroundColor: avatar_color }}>
<div className="text-4xl drop-shadow-lg">
{avatar || '🤖'}
</div>
<div
className="absolute inset-0 rounded-2xl pointer-events-none opacity-0 dark:opacity-100 transition-opacity"
style={{
boxShadow: `0 16px 48px -8px ${avatar_color}70, 0 8px 24px -4px ${avatar_color}50`
}}
/>
</div>
</DialogHeader>
<div className="-mt-4 flex flex-col max-h-[calc(90vh-8rem)] overflow-hidden">
<div className="p-6 py-0 pb-4">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h2 className="text-2xl font-bold text-foreground mb-2">
{agent.name}
</h2>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
{agent.creator_name || 'Unknown'}
</div>
<div className="flex items-center gap-1">
<Download className="h-4 w-4" />
{agent.download_count} downloads
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(agent.created_at)}
</div>
</div>
</div>
</div>
<p className="text-muted-foreground leading-relaxed mb-4">
{agent.description || 'No description available'}
</p>
{agent.tags && agent.tags.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<Tag className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-wrap gap-1">
{agent.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
)}
</div>
<div className="flex-1 gap-4 overflow-y-auto p-6 pt-4 space-y-4">
{integrations.length > 0 && (
<Card className='p-0 border-none bg-transparent shadow-none'>
<CardContent className="p-0">
<div className="flex items-center gap-2 mb-3">
<Plug className="h-4 w-4 text-primary" />
<h3 className="font-semibold">Integrations</h3>
</div>
<div className="flex flex-wrap gap-2">
{integrations.map((integration, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center px-3 py-1.5 bg-muted/50 hover:bg-muted border"
>
<IntegrationLogo
qualifiedName={integration.qualified_name}
displayName={integration.display_name || getAppDisplayName(integration.qualified_name)}
customType={integration.custom_type}
/>
<span className="text-sm font-medium">
{integration.display_name || getAppDisplayName(integration.qualified_name)}
</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{customTools.length > 0 && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Wrench className="h-4 w-4 text-primary" />
<h3 className="font-semibold">Custom Tools</h3>
</div>
<div className="flex flex-wrap gap-2">
{customTools.map((tool, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1.5 px-3 py-1.5 bg-muted/50 hover:bg-muted border"
>
<IntegrationLogo
qualifiedName={tool.qualified_name}
displayName={tool.display_name || getAppDisplayName(tool.qualified_name)}
customType={tool.custom_type}
/>
<span className="text-sm font-medium">
{tool.display_name || getAppDisplayName(tool.qualified_name)}
</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{agentpressTools.length === 0 && tools.length === 0 && (
<Card>
<CardContent className="p-4 text-center">
<Bot className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-muted-foreground">
This agent uses basic functionality without external integrations or specialized tools.
</p>
</CardContent>
</Card>
)}
</div>
<div className="p-6 pt-4">
<div className="flex gap-3">
<Button
onClick={handleInstall}
disabled={isInstalling}
className="flex-1"
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Install Agent
</>
)}
</Button>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -90,8 +90,6 @@ export const MCPConfigurationNew: React.FC<MCPConfigurationProps> = ({
const handleToolsSelected = (profileId: string, selectedTools: string[], appName: string, appSlug: string) => {
console.log('Tools selected:', { profileId, selectedTools, appName, appSlug });
setShowRegistryDialog(false);
// ComposioRegistry handles all the actual configuration internally
// We need to refresh the agent data to show updated configuration
queryClient.invalidateQueries({ queryKey: ['agents'] });
queryClient.invalidateQueries({ queryKey: ['agent', selectedAgentId] });
queryClient.invalidateQueries({ queryKey: ['composio', 'profiles'] });

View File

@ -152,10 +152,9 @@ export function WorkflowBuilder({
<div className="min-h-full bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-3xl md:px-8 min-w-0 py-8">
{editableSteps.length === 0 ? (
// Empty state
<div className="flex-1 min-h-[60vh] flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-zinc-100 dark:bg-zinc-800 rounded-lg flex items-center justify-center mx-auto mb-4">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<Plus className="h-8 w-8 text-zinc-400" />
</div>
<h3 className="text-lg font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
@ -165,13 +164,12 @@ export function WorkflowBuilder({
Add steps to create a workflow that guides your agent through tasks.
</p>
<Button onClick={() => handleAddStep(-1)}>
<Plus className="h-4 w-4 mr-2" />
<Plus className="h-4 w-4" />
Add first step
</Button>
</div>
</div>
) : (
// Steps list - uses the children array from root node
<div className="space-y-8 min-w-0">
<WorkflowSteps
steps={editableSteps}

View File

@ -87,7 +87,7 @@ export const BASE_STEP_DEFINITIONS: StepDefinition[] = [
},
{
id: 'credentials_profile',
name: 'Browse App Registry',
name: 'Browse Apps',
description: 'Select and configure credential profiles for authentication',
icon: Globe,
category: 'configuration',
@ -125,7 +125,6 @@ export const CATEGORY_DEFINITIONS: CategoryDefinition[] = [
}
];
// Helper function to get tool definition
export function getToolDefinition(toolName: string): StepDefinition | null {
if (!TOOL_ICONS[toolName]) return null;

View File

@ -14,11 +14,14 @@ import { motion, AnimatePresence } from 'framer-motion';
import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
import { getStepIconAndColor } from './workflow-definitions';
import { PipedreamRegistry } from '@/components/agents/pipedream/pipedream-registry';
import { ComposioRegistry } from '@/components/agents/composio/composio-registry';
import { CustomMCPDialog } from '@/components/agents/mcp/custom-mcp-dialog';
import { useAgent, useUpdateAgent } from '@/hooks/react-query/agents/use-agents';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { composioApi } from '@/hooks/react-query/composio/utils';
import { backendApi } from '@/lib/api-client';
interface StepType {
id: string;
@ -72,6 +75,7 @@ export function WorkflowSidePanel({
onToolsUpdate
}: WorkflowSidePanelProps) {
const [showPipedreamRegistry, setShowPipedreamRegistry] = useState(false);
const [showComposioRegistry, setShowComposioRegistry] = useState(false);
const [showCustomMCPDialog, setShowCustomMCPDialog] = useState(false);
const queryClient = useQueryClient();
const { data: agent } = useAgent(agentId || '');
@ -105,7 +109,7 @@ export function WorkflowSidePanel({
const handleStepTypeClick = (stepType: StepType) => {
if (stepType.id === 'credentials_profile') {
setShowPipedreamRegistry(true);
setShowComposioRegistry(true);
} else if (stepType.id === 'mcp_configuration') {
setShowCustomMCPDialog(true);
} else {
@ -113,88 +117,25 @@ export function WorkflowSidePanel({
}
};
const handlePipedreamToolsSelected = async (profileId: string, selectedTools: string[], appName: string, appSlug: string) => {
try {
const pipedreamMCP = {
name: appName,
qualifiedName: `pipedream_${appSlug}_${profileId}`,
config: {
url: 'https://remote.mcp.pipedream.net',
headers: {
'x-pd-app-slug': appSlug,
},
profile_id: profileId
},
enabledTools: selectedTools,
selectedProfileId: profileId
};
// Update agent with new MCP
const existingCustomMCPs = agent?.custom_mcps || [];
const nonPipedreamMCPs = existingCustomMCPs.filter((mcp: any) =>
mcp.type !== 'pipedream' || mcp.config?.profile_id !== profileId
);
await updateAgentMutation.mutateAsync({
agentId: agentId!,
custom_mcps: [
...nonPipedreamMCPs,
{
name: appName,
type: 'pipedream',
config: pipedreamMCP.config,
enabledTools: selectedTools
} as any
]
});
// Create a step for the credentials profile
const credentialsStep: ConditionalStep = {
id: `credentials-${Date.now()}`,
name: `${appName} Credentials`,
description: `Credentials profile for ${appName} integration`,
type: 'instruction',
config: {
step_type: 'credentials_profile',
tool_name: selectedTools[0] || `${appName} Profile`,
profile_id: profileId,
app_name: appName,
app_slug: appSlug
},
order: 0,
enabled: true,
children: []
};
onCreateStep({
id: 'credentials_profile',
name: credentialsStep.name,
description: credentialsStep.description,
icon: 'Key',
category: 'configuration',
config: credentialsStep.config
});
// Invalidate queries to refresh tools
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
queryClient.invalidateQueries({ queryKey: ['agent-tools', agentId] });
// Trigger parent tools update
if (onToolsUpdate) {
onToolsUpdate();
}
setShowPipedreamRegistry(false);
toast.success(`Added ${appName} credentials profile!`);
} catch (error) {
toast.error('Failed to add integration');
const handleComposioToolsSelected = (profileId: string, selectedTools: string[], appName: string, appSlug: string) => {
console.log('Tools selected:', { profileId, selectedTools, appName, appSlug });
setShowComposioRegistry(false);
// ComposioRegistry handles all the actual configuration internally
// We need to refresh the agent data to show updated configuration
queryClient.invalidateQueries({ queryKey: ['agents'] });
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
queryClient.invalidateQueries({ queryKey: ['composio', 'profiles'] });
if (onToolsUpdate) {
onToolsUpdate();
}
toast.success(`Connected ${appName} integration!`);
};
const handleCustomMCPSave = async (customConfig: any) => {
try {
const existingCustomMCPs = agent?.custom_mcps || [];
await updateAgentMutation.mutateAsync({
agentId: agentId!,
custom_mcps: [
@ -208,7 +149,6 @@ export function WorkflowSidePanel({
]
});
// Create a step for the MCP configuration
const mcpStep: ConditionalStep = {
id: `mcp-${Date.now()}`,
name: `${customConfig.name} MCP`,
@ -302,7 +242,7 @@ export function WorkflowSidePanel({
onClick={() => handleStepTypeClick(stepType)}
className="w-full p-3 text-left border border-border rounded-2xl hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-3">
<div className="flex items-center gap-3">
<div className={`relative p-2 rounded-lg bg-gradient-to-br ${color} border`}>
<IconComponent className="w-5 h-5" />
</div>
@ -472,30 +412,18 @@ export function WorkflowSidePanel({
{renderContent()}
</motion.div>
</AnimatePresence>
{/* Pipedream Registry Dialog */}
<Dialog open={showPipedreamRegistry} onOpenChange={setShowPipedreamRegistry}>
<DialogContent className="p-0 max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="hidden">
<DialogTitle></DialogTitle>
<Dialog open={showComposioRegistry} onOpenChange={setShowComposioRegistry}>
<DialogContent className="p-0 max-w-6xl h-[90vh] overflow-hidden">
<DialogHeader className="sr-only">
<DialogTitle>App Integrations</DialogTitle>
</DialogHeader>
<PipedreamRegistry
showAgentSelector={false}
<ComposioRegistry
selectedAgentId={agentId}
onAgentChange={() => { }}
onToolsSelected={handlePipedreamToolsSelected}
versionData={versionData ? {
configured_mcps: versionData.configured_mcps || [],
custom_mcps: versionData.custom_mcps || [],
system_prompt: versionData.system_prompt || '',
agentpress_tools: versionData.agentpress_tools || {}
} : undefined}
versionId={versionData?.version_id || 'current'}
onClose={() => setShowComposioRegistry(false)}
onToolsSelected={handleComposioToolsSelected}
/>
</DialogContent>
</Dialog>
{/* Custom MCP Dialog */}
<CustomMCPDialog
open={showCustomMCPDialog}
onOpenChange={setShowCustomMCPDialog}

View File

@ -37,6 +37,20 @@ export const useComposioToolkits = (search?: string, category?: string) => {
});
};
export const useComposioToolkitIcon = (toolkitSlug: string, options?: { enabled?: boolean }) => {
return useQuery({
queryKey: ['composio', 'toolkit-icon', toolkitSlug],
queryFn: async (): Promise<{ success: boolean; icon_url?: string }> => {
const result = await composioApi.getToolkitIcon(toolkitSlug);
console.log(`🎨 Composio Icon for ${toolkitSlug}:`, result);
return result;
},
enabled: options?.enabled !== undefined ? options.enabled : !!toolkitSlug,
staleTime: 60 * 60 * 1000,
retry: 2,
});
};
export const useCreateComposioProfile = () => {
const queryClient = useQueryClient();

View File

@ -85,19 +85,24 @@ export const apiClient = {
clearTimeout(timeoutId);
if (!response.ok) {
const error: ApiError = new Error(`HTTP ${response.status}: ${response.statusText}`);
error.status = response.status;
error.response = response;
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
let errorData: any = null;
try {
const errorData = await response.json();
error.details = errorData;
errorData = await response.json();
if (errorData.message) {
error.message = errorData.message;
errorMessage = errorData.message;
}
} catch {
}
const error: ApiError = new Error(errorMessage);
error.status = response.status;
error.response = response;
if (errorData) {
error.details = errorData;
}
if (showErrors) {
handleApiError(error, errorContext);
}