mirror of https://github.com/kortix-ai/suna.git
Merge pull request #1214 from escapade-mckv/composio-1a
Composio integration
This commit is contained in:
commit
351f6500c7
|
@ -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,
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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'] });
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue