mirror of https://github.com/kortix-ai/suna.git
improve composio UC
This commit is contained in:
parent
99a2e9af18
commit
50b6ad5189
|
@ -121,8 +121,6 @@ class CredentialProfileTool(AgentBuilderBaseTool):
|
|||
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": {
|
||||
|
|
|
@ -26,42 +26,33 @@ 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(
|
||||
|
@ -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 = []
|
||||
|
|
|
@ -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,16 +4,19 @@ 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': '🔥',
|
||||
|
@ -26,6 +29,16 @@ const CATEGORY_EMOJIS: Record<string, string> = {
|
|||
'scheduling': '📅',
|
||||
};
|
||||
|
||||
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;
|
||||
|
@ -37,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">
|
||||
|
@ -57,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" />
|
||||
|
@ -99,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>
|
||||
)}
|
||||
|
@ -125,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) {
|
||||
|
@ -157,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;
|
||||
}
|
||||
|
@ -172,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;
|
||||
}
|
||||
|
@ -180,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'] });
|
||||
|
@ -251,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">
|
||||
|
@ -264,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>
|
||||
|
||||
|
@ -301,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}
|
||||
|
@ -344,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">
|
||||
|
|
Loading…
Reference in New Issue