From c786e190b682c02ef00092b04f6f993377d2f63d Mon Sep 17 00:00:00 2001 From: Saumya Date: Sun, 10 Aug 2025 01:15:29 +0530 Subject: [PATCH 1/2] limit agent creation based on tiers --- backend/agent/api.py | 49 ++++++++++- backend/agent/json_import_service.py | 3 + backend/agent/utils.py | 60 +++++++++++++ backend/services/billing.py | 26 +++++- backend/templates/api.py | 24 ++++-- backend/templates/installation_service.py | 3 + backend/utils/config.py | 24 ++++++ frontend/src/app/(dashboard)/agents/page.tsx | 24 +++++- .../agents/agent-count-limit-dialog.tsx | 86 +++++++++++++++++++ .../components/agents/json-import-dialog.tsx | 27 ++++-- .../components/agents/new-agent-dialog.tsx | 44 +++++++++- .../home/sections/pricing-section.tsx | 31 +++---- .../hooks/react-query/agents/use-agents.ts | 8 ++ .../react-query/agents/use-json-import.ts | 13 ++- .../src/hooks/react-query/agents/utils.ts | 19 ++++ .../react-query/secure-mcp/use-secure-mcp.ts | 16 ++++ frontend/src/lib/api.ts | 30 +++++++ frontend/src/lib/home.tsx | 5 ++ 18 files changed, 450 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/agents/agent-count-limit-dialog.tsx diff --git a/backend/agent/api.py b/backend/agent/api.py index fc7b27b8..965c7e8b 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -1767,7 +1767,6 @@ async def import_agent_from_json( request: JsonImportRequestModel, user_id: str = Depends(get_current_user_id_from_jwt) ): - """Import an agent from JSON with credential mappings""" logger.info(f"Importing agent from JSON - user: {user_id}") if not await is_enabled("custom_agents"): @@ -1776,6 +1775,21 @@ async def import_agent_from_json( detail="Custom agents currently disabled. This feature is not available at the moment." ) + client = await db.client + from .utils import check_agent_count_limit + limit_check = await check_agent_count_limit(client, user_id) + + if not limit_check['can_create']: + error_detail = { + "message": f"Maximum of {limit_check['limit']} agents allowed for your current plan. You have {limit_check['current_count']} agents.", + "current_count": limit_check['current_count'], + "limit": limit_check['limit'], + "tier_name": limit_check['tier_name'], + "error_code": "AGENT_LIMIT_EXCEEDED" + } + logger.warning(f"Agent limit exceeded for account {user_id}: {limit_check['current_count']}/{limit_check['limit']} agents") + raise HTTPException(status_code=402, detail=error_detail) + try: from agent.json_import_service import JsonImportService, JsonImportRequest import_service = JsonImportService(db) @@ -1817,6 +1831,20 @@ async def create_agent( ) client = await db.client + from .utils import check_agent_count_limit + limit_check = await check_agent_count_limit(client, user_id) + + if not limit_check['can_create']: + error_detail = { + "message": f"Maximum of {limit_check['limit']} agents allowed for your current plan. You have {limit_check['current_count']} agents.", + "current_count": limit_check['current_count'], + "limit": limit_check['limit'], + "tier_name": limit_check['tier_name'], + "error_code": "AGENT_LIMIT_EXCEEDED" + } + logger.warning(f"Agent limit exceeded for account {user_id}: {limit_check['current_count']}/{limit_check['limit']} agents") + raise HTTPException(status_code=402, detail=error_detail) + try: if agent_data.is_default: await client.table('agents').update({"is_default": False}).eq("account_id", user_id).eq("is_default", True).execute() @@ -1874,6 +1902,10 @@ async def create_agent( await client.table('agents').delete().eq('agent_id', agent['agent_id']).execute() raise HTTPException(status_code=500, detail="Failed to create initial version") + # Invalidate agent count cache after successful creation + from utils.cache import Cache + await Cache.invalidate(f"agent_count_limit:{user_id}") + logger.info(f"Created agent {agent['agent_id']} with v1 for user: {user_id}") return AgentResponse( agent_id=agent['agent_id'], @@ -2275,7 +2307,20 @@ async def delete_agent(agent_id: str, user_id: str = Depends(get_current_user_id if agent['is_default']: raise HTTPException(status_code=400, detail="Cannot delete default agent") - await client.table('agents').delete().eq('agent_id', agent_id).execute() + if agent.get('metadata', {}).get('is_suna_default', False): + raise HTTPException(status_code=400, detail="Cannot delete Suna default agent") + + delete_result = await client.table('agents').delete().eq('agent_id', agent_id).execute() + + if not delete_result.data: + logger.warning(f"No agent was deleted for agent_id: {agent_id}, user_id: {user_id}") + raise HTTPException(status_code=403, detail="Unable to delete agent - permission denied or agent not found") + + try: + from utils.cache import Cache + await Cache.invalidate(f"agent_count_limit:{user_id}") + except Exception as cache_error: + logger.warning(f"Cache invalidation failed for user {user_id}: {str(cache_error)}") logger.info(f"Successfully deleted agent: {agent_id}") return {"message": "Agent deleted successfully"} diff --git a/backend/agent/json_import_service.py b/backend/agent/json_import_service.py index 40cc5211..8e0fb767 100644 --- a/backend/agent/json_import_service.py +++ b/backend/agent/json_import_service.py @@ -114,6 +114,9 @@ class JsonImportService: request.custom_system_prompt or json_data.get('system_prompt', '') ) + from utils.cache import Cache + await Cache.invalidate(f"agent_count_limit:{request.account_id}") + logger.info(f"Successfully imported agent {agent_id} from JSON") return JsonImportResult( diff --git a/backend/agent/utils.py b/backend/agent/utils.py index 48fdd372..9162f138 100644 --- a/backend/agent/utils.py +++ b/backend/agent/utils.py @@ -138,3 +138,63 @@ async def check_agent_run_limit(client, account_id: str) -> Dict[str, Any]: 'running_count': 0, 'running_thread_ids': [] } + + +async def check_agent_count_limit(client, account_id: str) -> Dict[str, Any]: + try: + try: + result = await Cache.get(f"agent_count_limit:{account_id}") + if result: + logger.debug(f"Cache hit for agent count limit: {account_id}") + return result + except Exception as cache_error: + logger.warning(f"Cache read failed for agent count limit {account_id}: {str(cache_error)}") + + agents_result = await client.table('agents').select('agent_id, metadata').eq('account_id', account_id).execute() + + non_suna_agents = [] + for agent in agents_result.data or []: + metadata = agent.get('metadata', {}) or {} + is_suna_default = metadata.get('is_suna_default', False) + if not is_suna_default: + non_suna_agents.append(agent) + + current_count = len(non_suna_agents) + logger.debug(f"Account {account_id} has {current_count} custom agents (excluding Suna defaults)") + + try: + from services.billing import get_subscription_tier + tier_name = await get_subscription_tier(client, account_id) + logger.debug(f"Account {account_id} subscription tier: {tier_name}") + except Exception as billing_error: + logger.warning(f"Could not get subscription tier for {account_id}: {str(billing_error)}, defaulting to free") + tier_name = 'free' + + agent_limit = config.AGENT_LIMITS.get(tier_name, config.AGENT_LIMITS['free']) + + can_create = current_count < agent_limit + + result = { + 'can_create': can_create, + 'current_count': current_count, + 'limit': agent_limit, + 'tier_name': tier_name + } + + try: + await Cache.set(f"agent_count_limit:{account_id}", result, ttl=300) + except Exception as cache_error: + logger.warning(f"Cache write failed for agent count limit {account_id}: {str(cache_error)}") + + logger.info(f"Account {account_id} has {current_count}/{agent_limit} agents (tier: {tier_name}) - can_create: {can_create}") + + return result + + except Exception as e: + logger.error(f"Error checking agent count limit for account {account_id}: {str(e)}", exc_info=True) + return { + 'can_create': True, + 'current_count': 0, + 'limit': config.AGENT_LIMITS['free'], + 'tier_name': 'free' + } diff --git a/backend/services/billing.py b/backend/services/billing.py index 9e6bac26..0205d9bf 100644 --- a/backend/services/billing.py +++ b/backend/services/billing.py @@ -592,6 +592,30 @@ async def can_use_model(client, user_id: str, model_name: str): return False, f"Your current subscription plan does not include access to {model_name}. Please upgrade your subscription or choose from your available models: {', '.join(allowed_models)}", allowed_models +async def get_subscription_tier(client, user_id: str) -> str: + try: + subscription = await get_user_subscription(user_id) + + if not subscription: + return 'free' + + price_id = None + if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: + price_id = subscription['items']['data'][0]['price']['id'] + else: + price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) + + tier_info = SUBSCRIPTION_TIERS.get(price_id) + if tier_info: + return tier_info['name'] + + logger.warning(f"Unknown price_id {price_id} for user {user_id}, defaulting to free tier") + return 'free' + + except Exception as e: + logger.error(f"Error getting subscription tier for user {user_id}: {str(e)}") + return 'free' + async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optional[Dict]]: """ Check if a user can run agents based on their subscription and usage. @@ -634,8 +658,6 @@ async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optiona # Calculate current month's usage current_usage = await calculate_monthly_usage(client, user_id) - # TODO: also do user's AAL check - # Check if within limits if current_usage >= tier_info['cost']: return False, f"Monthly limit of {tier_info['cost']} dollars reached. Please upgrade your plan or wait until next month.", subscription diff --git a/backend/templates/api.py b/backend/templates/api.py index 5a6678c2..9b45c60d 100644 --- a/backend/templates/api.py +++ b/backend/templates/api.py @@ -325,15 +325,22 @@ async def install_template( request: InstallTemplateRequest, user_id: str = Depends(get_current_user_id_from_jwt) ): - """ - Install a template as a new agent instance. - - Requires: - - User must have access to the template (own it or it's public) - """ try: - # Validate template access first - template = await validate_template_access_and_get(request.template_id, user_id) + await validate_template_access_and_get(request.template_id, user_id) + client = await db.client + from agent.utils import check_agent_count_limit + limit_check = await check_agent_count_limit(client, user_id) + + if not limit_check['can_create']: + error_detail = { + "message": f"Maximum of {limit_check['limit']} agents allowed for your current plan. You have {limit_check['current_count']} agents.", + "current_count": limit_check['current_count'], + "limit": limit_check['limit'], + "tier_name": limit_check['tier_name'], + "error_code": "AGENT_LIMIT_EXCEEDED" + } + logger.warning(f"Agent limit exceeded for account {user_id}: {limit_check['current_count']}/{limit_check['limit']} agents") + raise HTTPException(status_code=402, detail=error_detail) logger.info(f"User {user_id} installing template {request.template_id}") @@ -362,7 +369,6 @@ async def install_template( ) except HTTPException: - # Re-raise HTTP exceptions from our validation functions raise except TemplateInstallationError as e: logger.warning(f"Template installation failed: {e}") diff --git a/backend/templates/installation_service.py b/backend/templates/installation_service.py index 553643a9..bcf095d6 100644 --- a/backend/templates/installation_service.py +++ b/backend/templates/installation_service.py @@ -107,6 +107,9 @@ class InstallationService: await self._increment_download_count(template.template_id) + from utils.cache import Cache + await Cache.invalidate(f"agent_count_limit:{request.account_id}") + agent_name = request.instance_name or f"{template.name} (from marketplace)" logger.info(f"Successfully installed template {template.template_id} as agent {agent_id}") diff --git a/backend/utils/config.py b/backend/utils/config.py index 5d6d5d81..9344f9ef 100644 --- a/backend/utils/config.py +++ b/backend/utils/config.py @@ -270,6 +270,30 @@ class Configuration: # Agent execution limits (can be overridden via environment variable) _MAX_PARALLEL_AGENT_RUNS_ENV: Optional[str] = None + + # Agent limits per billing tier + AGENT_LIMITS = { + 'free': 2, + 'tier_2_20': 5, + 'tier_6_50': 20, + 'tier_12_100': 20, + 'tier_25_200': 100, + 'tier_50_400': 100, + 'tier_125_800': 100, + 'tier_200_1000': 100, + # Yearly plans have same limits as monthly + 'tier_2_20_yearly': 5, + 'tier_6_50_yearly': 20, + 'tier_12_100_yearly': 20, + 'tier_25_200_yearly': 100, + 'tier_50_400_yearly': 100, + 'tier_125_800_yearly': 100, + 'tier_200_1000_yearly': 100, + # Yearly commitment plans + 'tier_2_17_yearly_commitment': 5, + 'tier_6_42_yearly_commitment': 20, + 'tier_25_170_yearly_commitment': 100, + } @property def MAX_PARALLEL_AGENT_RUNS(self) -> int: diff --git a/frontend/src/app/(dashboard)/agents/page.tsx b/frontend/src/app/(dashboard)/agents/page.tsx index 18670aec..efe7232a 100644 --- a/frontend/src/app/(dashboard)/agents/page.tsx +++ b/frontend/src/app/(dashboard)/agents/page.tsx @@ -23,6 +23,8 @@ import { PublishDialog } from '@/components/agents/custom-agents-page/publish-di import { LoadingSkeleton } from '@/components/agents/custom-agents-page/loading-skeleton'; import { NewAgentDialog } from '@/components/agents/new-agent-dialog'; import { MarketplaceAgentPreviewDialog } from '@/components/agents/marketplace-agent-preview-dialog'; +import { AgentCountLimitDialog } from '@/components/agents/agent-count-limit-dialog'; +import { AgentCountLimitError } from '@/lib/api'; type ViewMode = 'grid' | 'list'; type AgentSortOption = 'name' | 'created_at' | 'updated_at' | 'tools_count'; @@ -86,6 +88,8 @@ export default function AgentsPage() { const [publishingAgentId, setPublishingAgentId] = useState(null); const [showNewAgentDialog, setShowNewAgentDialog] = useState(false); + const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); + const [agentLimitError, setAgentLimitError] = useState(null); const activeTab = useMemo(() => { return searchParams.get('tab') || 'my-agents'; @@ -146,7 +150,6 @@ export default function AgentsPage() { if (marketplaceTemplates) { marketplaceTemplates.forEach(template => { - const item: MarketplaceTemplate = { id: template.template_id, creator_id: template.creator_id, @@ -174,14 +177,12 @@ export default function AgentsPage() { item.creator_name?.toLowerCase().includes(searchLower); })(); - if (!matchesSearch) return; // Skip items that don't match search + if (!matchesSearch) return; - // Always add user's own templates to mineItems for the "mine" filter if (user?.id === template.creator_id) { mineItems.push(item); } - // Categorize all templates (including user's own) for the "all" view if (template.is_kortix_team) { kortixItems.push(item); } else { @@ -392,6 +393,12 @@ export default function AgentsPage() { } catch (error: any) { console.error('Installation error:', error); + if (error instanceof AgentCountLimitError) { + setAgentLimitError(error); + setShowAgentLimitDialog(true); + return; + } + if (error.message?.includes('already in your library')) { toast.error('This agent is already in your library'); } else if (error.message?.includes('Credential profile not found')) { @@ -628,6 +635,15 @@ export default function AgentsPage() { onInstall={handlePreviewInstall} isInstalling={installingItemId === selectedItem?.id} /> + {agentLimitError && ( + + )} ); diff --git a/frontend/src/components/agents/agent-count-limit-dialog.tsx b/frontend/src/components/agents/agent-count-limit-dialog.tsx new file mode 100644 index 00000000..5d822949 --- /dev/null +++ b/frontend/src/components/agents/agent-count-limit-dialog.tsx @@ -0,0 +1,86 @@ +'use client'; + +import React from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { PricingSection } from '@/components/home/sections/pricing-section'; +import { Separator } from '@/components/ui/separator'; + +interface AgentCountLimitDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentCount: number; + limit: number; + tierName: string; +} + +export const AgentCountLimitDialog: React.FC = ({ + open, + onOpenChange, + currentCount, + limit, + tierName, +}) => { + const returnUrl = typeof window !== 'undefined' ? window.location.href : '/'; + + const getNextTierRecommendation = () => { + if (tierName === 'free') { + return { + name: 'Plus', + price: '$20/month', + agentLimit: 5, + }; + } else if (tierName.includes('tier_2_20')) { + return { + name: 'Pro', + price: '$50/month', + agentLimit: 20, + }; + } else if (tierName.includes('tier_6_50')) { + return { + name: 'Business', + price: '$200/month', + agentLimit: 100, + }; + } + return null; + }; + + const nextTier = getNextTierRecommendation(); + + return ( + + + +
+
+ +
+ + Agent Limit Reached + +
+ + You've reached the maximum number of agents allowed on your current plan. + +
+
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/agents/json-import-dialog.tsx b/frontend/src/components/agents/json-import-dialog.tsx index ad416f10..22b28468 100644 --- a/frontend/src/components/agents/json-import-dialog.tsx +++ b/frontend/src/components/agents/json-import-dialog.tsx @@ -11,6 +11,8 @@ import { ProfileConnector } from './installation/streamlined-profile-connector'; import { CustomServerStep } from './installation/custom-server-step'; import type { SetupStep } from './installation/types'; import { useAnalyzeJsonForImport, useImportAgentFromJson, type JsonAnalysisResult, type JsonImportResult } from '@/hooks/react-query/agents/use-json-import'; +import { AgentCountLimitDialog } from './agent-count-limit-dialog'; +import { AgentCountLimitError } from '@/lib/api'; import { cn } from '@/lib/utils'; interface JsonImportDialogProps { @@ -34,6 +36,8 @@ export const JsonImportDialog: React.FC = ({ const [currentStep, setCurrentStep] = useState(0); const [profileMappings, setProfileMappings] = useState>({}); const [customMcpConfigs, setCustomMcpConfigs] = useState>>({}); + const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); + const [agentLimitError, setAgentLimitError] = useState(null); const analyzeJsonMutation = useAnalyzeJsonForImport(); const importJsonMutation = useImportAgentFromJson(); @@ -47,6 +51,8 @@ export const JsonImportDialog: React.FC = ({ setCurrentStep(0); setProfileMappings({}); setCustomMcpConfigs({}); + setShowAgentLimitDialog(false); + setAgentLimitError(null); }, []); useEffect(() => { @@ -215,6 +221,13 @@ export const JsonImportDialog: React.FC = ({ } onOpenChange(false); } + }, + onError: (error) => { + if (error instanceof AgentCountLimitError) { + setAgentLimitError(error); + setShowAgentLimitDialog(true); + onOpenChange(false); + } } } ); @@ -380,7 +393,6 @@ export const JsonImportDialog: React.FC = ({ Import Agent from JSON - {analysis && !analysis.requires_setup && step === 'paste' && ( @@ -389,7 +401,6 @@ export const JsonImportDialog: React.FC = ({ )} - {analysis && analysis.requires_setup && step === 'paste' && ( @@ -400,7 +411,6 @@ export const JsonImportDialog: React.FC = ({ )} - {analyzeJsonMutation.isError && step === 'paste' && ( @@ -409,7 +419,6 @@ export const JsonImportDialog: React.FC = ({ )} - {importJsonMutation.isError && (step === 'setup' || step === 'importing') && ( @@ -418,11 +427,19 @@ export const JsonImportDialog: React.FC = ({ )} - {step === 'paste' && renderPasteStep()} {step === 'setup' && renderSetupStep()} {step === 'importing' && renderImportingStep()} + {agentLimitError && ( + + )} ); }; \ No newline at end of file diff --git a/frontend/src/components/agents/new-agent-dialog.tsx b/frontend/src/components/agents/new-agent-dialog.tsx index 39dff436..c8938b2b 100644 --- a/frontend/src/components/agents/new-agent-dialog.tsx +++ b/frontend/src/components/agents/new-agent-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { Loader2, Plus, FileJson, Code } from 'lucide-react'; import { AlertDialog, @@ -14,6 +14,8 @@ import { } from '@/components/ui/alert-dialog'; import { useCreateNewAgent } from '@/hooks/react-query/agents/use-agents'; import { JsonImportDialog } from './json-import-dialog'; +import { AgentCountLimitDialog } from './agent-count-limit-dialog'; +import { AgentCountLimitError } from '@/lib/api'; import { toast } from 'sonner'; interface NewAgentDialogProps { @@ -25,19 +27,43 @@ interface NewAgentDialogProps { export function NewAgentDialog({ open, onOpenChange, onSuccess }: NewAgentDialogProps) { const [showJsonImport, setShowJsonImport] = useState(false); const [jsonImportText, setJsonImportText] = useState(''); + const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); + const [agentLimitError, setAgentLimitError] = useState(null); const fileInputRef = useRef(null); const createNewAgentMutation = useCreateNewAgent(); + useEffect(() => { + console.log('[DEBUG] NewAgentDialog state:', { + agentLimitError: !!agentLimitError, + showAgentLimitDialog, + errorDetail: agentLimitError?.detail + }); + }, [agentLimitError, showAgentLimitDialog]); + const handleCreateNewAgent = () => { + console.log('[DEBUG] Creating new agent...'); createNewAgentMutation.mutate(undefined, { onSuccess: () => { + console.log('[DEBUG] Agent created successfully'); onOpenChange(false); onSuccess?.(); }, - onError: () => { - // Keep dialog open on error so user can see the error and try again - // The useCreateNewAgent hook already shows error toasts + onError: (error) => { + console.log('[DEBUG] Error creating agent:', error); + console.log('[DEBUG] Error type:', typeof error); + console.log('[DEBUG] Error constructor:', error.constructor.name); + console.log('[DEBUG] Is AgentCountLimitError?', error instanceof AgentCountLimitError); + + if (error instanceof AgentCountLimitError) { + console.log('[DEBUG] Setting agent limit error state'); + setAgentLimitError(error); + setShowAgentLimitDialog(true); + onOpenChange(false); + } else { + console.log('[DEBUG] Not an agent limit error, keeping dialog open'); + toast.error(error instanceof Error ? error.message : 'Failed to create agent'); + } } }); }; @@ -147,6 +173,16 @@ export function NewAgentDialog({ open, onOpenChange, onSuccess }: NewAgentDialog onSuccess?.(); }} /> + + {agentLimitError && ( + + )} ); } \ No newline at end of file diff --git a/frontend/src/components/home/sections/pricing-section.tsx b/frontend/src/components/home/sections/pricing-section.tsx index fe63f487..809dc8d1 100644 --- a/frontend/src/components/home/sections/pricing-section.tsx +++ b/frontend/src/components/home/sections/pricing-section.tsx @@ -566,7 +566,7 @@ function PricingTier({ variant={buttonVariant || 'default'} className={cn( 'w-full font-medium transition-all duration-200', - isCompact || insideDialog ? 'h-8 rounded-md text-xs' : 'h-10 rounded-full text-sm', + isCompact || insideDialog ? 'h-8 text-xs' : 'h-10 rounded-full text-sm', buttonClassName, isPlanLoading && 'animate-pulse', )} @@ -584,13 +584,17 @@ interface PricingSectionProps { showTitleAndTabs?: boolean; hideFree?: boolean; insideDialog?: boolean; + showInfo?: boolean; + noPadding?: boolean; } export function PricingSection({ returnUrl = typeof window !== 'undefined' ? window.location.href : '/', showTitleAndTabs = true, hideFree = false, - insideDialog = false + insideDialog = false, + showInfo = true, + noPadding = false, }: PricingSectionProps) { const [deploymentType, setDeploymentType] = useState<'cloud' | 'self-hosted'>( 'cloud', @@ -598,18 +602,14 @@ export function PricingSection({ const { data: subscriptionData, isLoading: isFetchingPlan, error: subscriptionQueryError, refetch: refetchSubscription } = useSubscription(); const subCommitmentQuery = useSubscriptionCommitment(subscriptionData?.subscription_id); - // Derive authentication and subscription status from the hook data const isAuthenticated = !!subscriptionData && subscriptionQueryError === null; const currentSubscription = subscriptionData || null; - // Determine default billing period based on user's current subscription const getDefaultBillingPeriod = useCallback((): 'monthly' | 'yearly' | 'yearly_commitment' => { if (!isAuthenticated || !currentSubscription) { - // Default to yearly_commitment for non-authenticated users (the new yearly plans) return 'yearly_commitment'; } - // Find current tier to determine if user is on monthly, yearly, or yearly commitment plan const currentTier = siteConfig.cloudPricingItems.find( (p) => p.stripePriceId === currentSubscription.price_id || p.yearlyStripePriceId === currentSubscription.price_id || @@ -685,7 +685,7 @@ export function PricingSection({ return (
{showTitleAndTabs && ( <> @@ -747,14 +747,15 @@ export function PricingSection({ ))} )} -
-

- What are AI tokens? Tokens are units of text that AI models process. - Your plan includes credits to spend on various AI models - the more complex the task, - the more tokens used. -

-
- + {showInfo && ( +
+

+ What are AI tokens? Tokens are units of text that AI models process. + Your plan includes credits to spend on various AI models - the more complex the task, + the more tokens used. +

+
+ )}
); diff --git a/frontend/src/hooks/react-query/agents/use-agents.ts b/frontend/src/hooks/react-query/agents/use-agents.ts index 08703f19..30e74ae6 100644 --- a/frontend/src/hooks/react-query/agents/use-agents.ts +++ b/frontend/src/hooks/react-query/agents/use-agents.ts @@ -42,6 +42,14 @@ export const useCreateAgent = () => { queryClient.setQueryData(agentKeys.detail(data.agent_id), data); toast.success('Agent created successfully'); }, + onError: async (error) => { + const { AgentCountLimitError } = await import('@/lib/api'); + if (error instanceof AgentCountLimitError) { + return; + } + console.error('Error creating agent:', error); + toast.error(error instanceof Error ? error.message : 'Failed to create agent'); + }, } )(); }; diff --git a/frontend/src/hooks/react-query/agents/use-json-import.ts b/frontend/src/hooks/react-query/agents/use-json-import.ts index 59733a5d..cc770498 100644 --- a/frontend/src/hooks/react-query/agents/use-json-import.ts +++ b/frontend/src/hooks/react-query/agents/use-json-import.ts @@ -73,7 +73,18 @@ export const useImportAgentFromJson = () => { const response = await backendApi.post('/agents/json/import', request); return response.data; } catch (error: any) { - // Extract error message from different error formats + const errorData = error.response?.data; + const isAgentLimitError = (error.response?.status === 402) && ( + errorData?.error_code === 'AGENT_LIMIT_EXCEEDED' || + errorData?.detail?.error_code === 'AGENT_LIMIT_EXCEEDED' + ); + + if (isAgentLimitError) { + const { AgentCountLimitError } = await import('@/lib/api'); + const errorDetail = errorData?.detail || errorData; + throw new AgentCountLimitError(error.response.status, errorDetail); + } + const message = error.response?.data?.detail || error.message || 'Failed to import agent'; throw new Error(message); } diff --git a/frontend/src/hooks/react-query/agents/utils.ts b/frontend/src/hooks/react-query/agents/utils.ts index 03ba9ba8..71649dd4 100644 --- a/frontend/src/hooks/react-query/agents/utils.ts +++ b/frontend/src/hooks/react-query/agents/utils.ts @@ -256,6 +256,25 @@ export const createAgent = async (agentData: AgentCreateRequest): Promise if (!response.ok) { const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + console.log('[DEBUG] Error response data:', errorData); + console.log('[DEBUG] Response status:', response.status); + console.log('[DEBUG] Error code check:', errorData.error_code); + + // Check for agent limit error - handle both direct error_code and nested in detail + const isAgentLimitError = (response.status === 402) && ( + errorData.error_code === 'AGENT_LIMIT_EXCEEDED' || + errorData.detail?.error_code === 'AGENT_LIMIT_EXCEEDED' + ); + + if (isAgentLimitError) { + console.log('[DEBUG] Converting to AgentCountLimitError'); + const { AgentCountLimitError } = await import('@/lib/api'); + // Use the nested detail if it exists, otherwise use the errorData directly + const errorDetail = errorData.detail || errorData; + throw new AgentCountLimitError(response.status, errorDetail); + } + + console.log('[DEBUG] Throwing generic error'); throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); } diff --git a/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts b/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts index 1da3e523..9946235e 100644 --- a/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts +++ b/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts @@ -447,6 +447,22 @@ export function useInstallTemplate() { if (!response.ok) { const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + console.log('[DEBUG] Template install error data:', errorData); + + // Check for agent limit error - handle both direct error_code and nested in detail + const isAgentLimitError = (response.status === 402) && ( + errorData.error_code === 'AGENT_LIMIT_EXCEEDED' || + errorData.detail?.error_code === 'AGENT_LIMIT_EXCEEDED' + ); + + if (isAgentLimitError) { + console.log('[DEBUG] Converting template install to AgentCountLimitError'); + const { AgentCountLimitError } = await import('@/lib/api'); + // Use the nested detail if it exists, otherwise use the errorData directly + const errorDetail = errorData.detail || errorData; + throw new AgentCountLimitError(response.status, errorDetail); + } + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ac91d97a..58b8f020 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -59,6 +59,36 @@ export class AgentRunLimitError extends Error { } } +export class AgentCountLimitError extends Error { + status: number; + detail: { + message: string; + current_count: number; + limit: number; + tier_name: string; + error_code: string; + }; + + constructor( + status: number, + detail: { + message: string; + current_count: number; + limit: number; + tier_name: string; + error_code: string; + [key: string]: any; + }, + message?: string, + ) { + super(message || detail.message || `Agent Count Limit Exceeded: ${status}`); + this.name = 'AgentCountLimitError'; + this.status = status; + this.detail = detail; + Object.setPrototypeOf(this, AgentCountLimitError.prototype); + } +} + export class NoAccessTokenAvailableError extends Error { constructor(message?: string, options?: { cause?: Error }) { super(message || 'No access token available', options); diff --git a/frontend/src/lib/home.tsx b/frontend/src/lib/home.tsx index bcc0ebdb..6cbc3a53 100644 --- a/frontend/src/lib/home.tsx +++ b/frontend/src/lib/home.tsx @@ -124,6 +124,7 @@ export const siteConfig = { hours: '60 min', features: [ '$5 free AI tokens included', + '2 custom agents', 'Public projects', 'Basic Models', 'Community support', @@ -145,6 +146,7 @@ export const siteConfig = { hours: '2 hours', features: [ '$20 AI token credits/month', + '5 custom agents', 'Private projects', 'Premium AI Models', 'Community support', @@ -168,6 +170,7 @@ export const siteConfig = { hours: '6 hours', features: [ '$50 AI token credits/month', + '20 custom agents', 'Private projects', 'Premium AI Models', 'Community support', @@ -190,6 +193,7 @@ export const siteConfig = { hours: '12 hours', features: [ '$100 AI token credits/month', + '20 custom agents', 'Private projects', 'Premium AI Models', 'Community support', @@ -212,6 +216,7 @@ export const siteConfig = { hours: '25 hours', features: [ '$200 AI token credits/month', + '100 custom agents', 'Private projects', 'Premium AI Models', 'Priority support', From c56e2a22861dfc38bcd980e26facc2e1216749e1 Mon Sep 17 00:00:00 2001 From: Saumya Date: Sun, 10 Aug 2025 02:42:16 +0530 Subject: [PATCH 2/2] option to delete credential profile --- backend/composio_integration/api.py | 38 ++- .../composio_integration/toolkit_service.py | 103 +++++++- backend/credentials/api.py | 42 ++++ .../composio/composio-connections-section.tsx | 130 +++++++++- .../agents/composio/composio-connector.tsx | 234 ++++++++++++------ frontend/src/components/ui/data-table.tsx | 84 ++++++- .../composio/use-composio-mutations.ts | 72 ++++++ .../react-query/composio/use-composio.ts | 14 ++ .../src/hooks/react-query/composio/utils.ts | 108 ++++++++ 9 files changed, 734 insertions(+), 91 deletions(-) create mode 100644 frontend/src/hooks/react-query/composio/use-composio-mutations.ts diff --git a/backend/composio_integration/api.py b/backend/composio_integration/api.py index 981014f6..807f3566 100644 --- a/backend/composio_integration/api.py +++ b/backend/composio_integration/api.py @@ -10,7 +10,7 @@ from datetime import datetime from .composio_service import ( get_integration_service, ) -from .toolkit_service import ToolkitService +from .toolkit_service import ToolkitService, ToolsListResponse from .composio_profile_service import ComposioProfileService, ComposioProfile router = APIRouter(prefix="/composio", tags=["composio"]) @@ -21,7 +21,6 @@ def initialize(database: DBConnection): global db db = database - class IntegrateToolkitRequest(BaseModel): toolkit_slug: str profile_name: Optional[str] = None @@ -29,7 +28,6 @@ class IntegrateToolkitRequest(BaseModel): mcp_server_name: Optional[str] = None save_as_profile: bool = True - class IntegrationStatusResponse(BaseModel): status: str toolkit: str @@ -40,7 +38,6 @@ class IntegrationStatusResponse(BaseModel): profile_id: Optional[str] = None redirect_url: Optional[str] = None - class CreateProfileRequest(BaseModel): toolkit_slug: str profile_name: str @@ -49,6 +46,10 @@ class CreateProfileRequest(BaseModel): is_default: bool = False initiation_fields: Optional[Dict[str, str]] = None +class ToolsListRequest(BaseModel): + toolkit_slug: str + limit: int = 50 + cursor: Optional[str] = None class ProfileResponse(BaseModel): profile_id: str @@ -406,6 +407,35 @@ async def get_toolkit_icon( raise HTTPException(status_code=500, detail="Internal server error") +@router.post("/tools/list") +async def list_toolkit_tools( + request: ToolsListRequest, + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + try: + logger.info(f"User {current_user_id} requesting tools for toolkit: {request.toolkit_slug}") + + toolkit_service = ToolkitService() + tools_response = await toolkit_service.get_toolkit_tools( + toolkit_slug=request.toolkit_slug, + limit=request.limit, + cursor=request.cursor + ) + + return { + "success": True, + "tools": [tool.dict() for tool in tools_response.items], + "total_items": tools_response.total_items, + "current_page": tools_response.current_page, + "total_pages": tools_response.total_pages, + "next_cursor": tools_response.next_cursor + } + + except Exception as e: + logger.error(f"Failed to list toolkit tools for {request.toolkit_slug}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get toolkit tools: {str(e)}") + + @router.get("/health") async def health_check() -> Dict[str, str]: try: diff --git a/backend/composio_integration/toolkit_service.py b/backend/composio_integration/toolkit_service.py index 5cbb56d9..2f9eec96 100644 --- a/backend/composio_integration/toolkit_service.py +++ b/backend/composio_integration/toolkit_service.py @@ -48,6 +48,31 @@ class DetailedToolkitInfo(BaseModel): base_url: Optional[str] = None +class ParameterSchema(BaseModel): + properties: Dict[str, Any] = {} + required: Optional[List[str]] = None + + +class ToolInfo(BaseModel): + slug: str + name: str + description: str + version: str + input_parameters: ParameterSchema = ParameterSchema() + output_parameters: ParameterSchema = ParameterSchema() + scopes: List[str] = [] + tags: List[str] = [] + no_auth: bool = False + + +class ToolsListResponse(BaseModel): + items: List[ToolInfo] + next_cursor: Optional[str] = None + total_items: int + current_page: int = 1 + total_pages: int = 1 + + class ToolkitService: def __init__(self, api_key: Optional[str] = None): self.client = ComposioClient.get_client(api_key) @@ -388,4 +413,80 @@ class ToolkitService: except Exception as e: logger.error(f"Failed to get detailed toolkit info for {toolkit_slug}: {e}", exc_info=True) - return None + return None + + async def get_toolkit_tools(self, toolkit_slug: str, limit: int = 50, cursor: Optional[str] = None) -> ToolsListResponse: + try: + logger.info(f"Fetching tools for toolkit: {toolkit_slug}") + + params = { + "limit": limit, + "toolkit_slug": toolkit_slug + } + + if cursor: + params["cursor"] = cursor + + tools_response = self.client.tools.list(**params) + + if hasattr(tools_response, '__dict__'): + response_data = tools_response.__dict__ + else: + response_data = tools_response + + items = response_data.get('items', []) + + tools = [] + for item in items: + if hasattr(item, '__dict__'): + tool_data = item.__dict__ + elif hasattr(item, '_asdict'): + tool_data = item._asdict() + else: + tool_data = item + + input_params_raw = tool_data.get("input_parameters", {}) + output_params_raw = tool_data.get("output_parameters", {}) + + input_parameters = ParameterSchema() + if isinstance(input_params_raw, dict): + input_parameters.properties = input_params_raw.get("properties", input_params_raw) + input_parameters.required = input_params_raw.get("required") + + output_parameters = ParameterSchema() + if isinstance(output_params_raw, dict): + output_parameters.properties = output_params_raw.get("properties", output_params_raw) + output_parameters.required = output_params_raw.get("required") + + tool = ToolInfo( + slug=tool_data.get("slug", ""), + name=tool_data.get("name", ""), + description=tool_data.get("description", ""), + version=tool_data.get("version", "1.0.0"), + input_parameters=input_parameters, + output_parameters=output_parameters, + scopes=tool_data.get("scopes", []), + tags=tool_data.get("tags", []), + no_auth=tool_data.get("no_auth", False) + ) + tools.append(tool) + + result = ToolsListResponse( + items=tools, + total_items=response_data.get("total_items", len(tools)), + total_pages=response_data.get("total_pages", 1), + current_page=response_data.get("current_page", 1), + next_cursor=response_data.get("next_cursor") + ) + + logger.info(f"Successfully fetched {len(tools)} tools for toolkit {toolkit_slug}") + return result + + except Exception as e: + logger.error(f"Failed to get tools for toolkit {toolkit_slug}: {e}", exc_info=True) + return ToolsListResponse( + items=[], + total_items=0, + current_page=1, + total_pages=1 + ) diff --git a/backend/credentials/api.py b/backend/credentials/api.py index 024d5083..97363c81 100644 --- a/backend/credentials/api.py +++ b/backend/credentials/api.py @@ -42,6 +42,10 @@ class StoreCredentialProfileRequest(BaseModel): return validate_config_not_empty(v) +class BulkDeleteProfilesRequest(BaseModel): + profile_ids: List[str] + + class CredentialResponse(BaseModel): credential_id: str mcp_qualified_name: str @@ -64,6 +68,13 @@ class CredentialProfileResponse(BaseModel): updated_at: Optional[str] = None +class BulkDeleteProfilesResponse(BaseModel): + success: bool + deleted_count: int + failed_profiles: List[str] = [] + message: str + + class ComposioProfileSummary(BaseModel): profile_id: str profile_name: str @@ -355,6 +366,37 @@ async def delete_credential_profile( raise HTTPException(status_code=500, detail="Internal server error") +@router.post("/credential-profiles/bulk-delete", response_model=BulkDeleteProfilesResponse) +async def bulk_delete_credential_profiles( + request: BulkDeleteProfilesRequest, + user_id: str = Depends(get_current_user_id_from_jwt) +): + try: + profile_service = get_profile_service(db) + deleted_count = 0 + failed_profiles = [] + for profile_id in request.profile_ids: + try: + success = await profile_service.delete_profile(user_id, profile_id) + if success: + deleted_count += 1 + else: + failed_profiles.append(profile_id) + except Exception as e: + logger.error(f"Error deleting profile {profile_id}: {e}") + failed_profiles.append(profile_id) + + return BulkDeleteProfilesResponse( + success=True, + deleted_count=deleted_count, + failed_profiles=failed_profiles, + message="Bulk deletion completed" + ) + except Exception as e: + logger.error(f"Error performing bulk deletion: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.get("/composio-profiles", response_model=ComposioCredentialsResponse) async def get_composio_profiles( user_id: str = Depends(get_current_user_id_from_jwt) diff --git a/frontend/src/components/agents/composio/composio-connections-section.tsx b/frontend/src/components/agents/composio/composio-connections-section.tsx index 0de6ba57..84ec3576 100644 --- a/frontend/src/components/agents/composio/composio-connections-section.tsx +++ b/frontend/src/components/agents/composio/composio-connections-section.tsx @@ -14,6 +14,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { DropdownMenu, DropdownMenuContent, @@ -33,8 +43,10 @@ import { Plus, CheckCircle2, XCircle, + Trash2, } from 'lucide-react'; import { useComposioCredentialsProfiles, useComposioMcpUrl } from '@/hooks/react-query/composio/use-composio-profiles'; +import { useDeleteProfile, useBulkDeleteProfiles, useSetDefaultProfile } from '@/hooks/react-query/composio/use-composio-mutations'; import { ComposioRegistry } from './composio-registry'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; @@ -57,6 +69,55 @@ interface ToolkitTableProps { toolkit: ComposioToolkitGroup; } +interface DeleteConfirmationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedProfiles: ComposioProfileSummary[]; + onConfirm: () => void; + isDeleting: boolean; +} + +const DeleteConfirmationDialog: React.FC = ({ + open, + onOpenChange, + selectedProfiles, + onConfirm, + isDeleting, +}) => { + const isSingle = selectedProfiles.length === 1; + + return ( + + + + + {isSingle ? 'Delete Profile' : `Delete ${selectedProfiles.length} Profiles`} + + + {isSingle + ? `Are you sure you want to delete "${selectedProfiles[0]?.profile_name}"? This action cannot be undone.` + : `Are you sure you want to delete ${selectedProfiles.length} profiles? This action cannot be undone.` + } +
+ Warning: This may affect existing integrations. +
+
+
+ + Cancel + + {isDeleting ? 'Deleting...' : 'Delete'} + + +
+
+ ); +}; + const McpUrlDialog: React.FC = ({ open, onOpenChange, @@ -212,11 +273,37 @@ const ToolkitTable: React.FC = ({ toolkit }) => { profileName: string; toolkitName: string; } | null>(null); + const [selectedProfiles, setSelectedProfiles] = useState([]); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const deleteProfile = useDeleteProfile(); + const bulkDeleteProfiles = useBulkDeleteProfiles(); + const setDefaultProfile = useSetDefaultProfile(); const handleViewUrl = (profileId: string, profileName: string, toolkitName: string) => { setSelectedProfile({ profileId, profileName, toolkitName }); }; + const handleDeleteSelected = () => { + setShowDeleteDialog(true); + }; + + const handleConfirmDelete = async () => { + if (selectedProfiles.length === 1) { + await deleteProfile.mutateAsync(selectedProfiles[0].profile_id); + } else { + await bulkDeleteProfiles.mutateAsync(selectedProfiles.map(p => p.profile_id)); + } + setSelectedProfiles([]); + setShowDeleteDialog(false); + }; + + const handleSetDefault = async (profileId: string) => { + await setDefaultProfile.mutateAsync(profileId); + }; + + const isDeleting = deleteProfile.isPending || bulkDeleteProfiles.isPending; + const columns: DataTableColumn[] = [ { id: 'name', @@ -238,7 +325,6 @@ const ToolkitTable: React.FC = ({ toolkit }) => { header: 'MCP URL', width: 'w-1/3', cell: (profile) => ( - console.log('[DEBUG]: profile', profile),
{profile.has_mcp_url ? (
@@ -301,17 +387,27 @@ const ToolkitTable: React.FC = ({ toolkit }) => { {profile.has_mcp_url && ( <> - handleViewUrl(profile.profile_id, profile.profile_name, toolkit.toolkit_name)}> + handleViewUrl(profile.profile_id, profile.profile_name, toolkit.toolkit_name)}> View MCP URL - )} - + handleSetDefault(profile.profile_id)} disabled={setDefaultProfile.isPending}> {profile.is_default ? 'Remove Default' : 'Set as Default'} + { + setSelectedProfiles([profile]); + setShowDeleteDialog(true); + }} + className="text-destructive rounded-lg focus:text-destructive focus:bg-destructive/10" + disabled={isDeleting} + > + + Delete Profile +
@@ -350,7 +446,25 @@ const ToolkitTable: React.FC = ({ toolkit }) => { data={toolkit.profiles} columns={columns} className="rounded-lg border" + selectable={true} + selectedItems={selectedProfiles} + onSelectionChange={setSelectedProfiles} + getItemId={(profile) => profile.profile_id} + headerActions={ + selectedProfiles.length > 0 ? ( + + ) : null + } /> + {selectedProfile && ( = ({ toolkit }) => { toolkitName={selectedProfile.toolkitName} /> )} + +
); }; diff --git a/frontend/src/components/agents/composio/composio-connector.tsx b/frontend/src/components/agents/composio/composio-connector.tsx index c93a00b9..26809bba 100644 --- a/frontend/src/components/agents/composio/composio-connector.tsx +++ b/frontend/src/components/agents/composio/composio-connector.tsx @@ -5,8 +5,8 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { ArrowLeft, Check, AlertCircle, Plus, ExternalLink, ChevronRight, Search, Save, Loader2, User, Settings, Info } from 'lucide-react'; -import { useCreateComposioProfile } from '@/hooks/react-query/composio/use-composio'; +import { ArrowLeft, Check, AlertCircle, Plus, ExternalLink, ChevronRight, Search, Save, Loader2, User, Settings, Info, Eye, Zap, Wrench } from 'lucide-react'; +import { useCreateComposioProfile, useComposioTools } from '@/hooks/react-query/composio/use-composio'; import { useComposioProfiles } from '@/hooks/react-query/composio/use-composio-profiles'; import { useComposioToolkitDetails } from '@/hooks/react-query/composio/use-composio'; import { ComposioToolsManager } from './composio-tools-manager'; @@ -51,9 +51,10 @@ interface StepConfig { const stepConfigs: StepConfig[] = [ { id: Step.ProfileSelect, - title: 'Select Profile', - icon: , - showInProgress: true + title: 'Connect & Preview', + description: 'Choose profile and explore available tools', + icon: , + showInProgress: true, }, { id: Step.ProfileCreate, @@ -311,6 +312,40 @@ const InitiationFieldInput = ({ field, value, onChange, error }: { ); }; +import type { ComposioTool } from '@/hooks/react-query/composio/utils'; + +const ToolPreviewCard = ({ tool, searchTerm }: { + tool: ComposioTool; + searchTerm: string; +}) => { + const highlightText = (text: string, term: string) => { + if (!term) return text; + const regex = new RegExp(`(${term})`, 'gi'); + const parts = text.split(regex); + return parts.map((part, index) => + regex.test(part) ? + {part} : + part + ); + }; + + return ( +
+
+
+
+ +

{highlightText(tool.name, searchTerm)}

+
+
+

+ {highlightText(tool.description, searchTerm)} +

+
+
+ ); +}; + export const ComposioConnector: React.FC = ({ app, open, @@ -319,7 +354,7 @@ export const ComposioConnector: React.FC = ({ mode = 'full', agentId }) => { - const [step, setStep] = useState(Step.ProfileSelect); + const [currentStep, setCurrentStep] = useState(Step.ProfileSelect); const [profileName, setProfileName] = useState(`${app.name} Profile`); const [selectedProfileId, setSelectedProfileId] = useState(''); const [createdProfileId, setCreatedProfileId] = useState(null); @@ -343,7 +378,7 @@ export const ComposioConnector: React.FC = ({ const { data: toolkitDetails, isLoading: isLoadingToolkitDetails } = useComposioToolkitDetails( app.slug, - { enabled: open && step === Step.ProfileCreate } + { enabled: open && currentStep === Step.ProfileCreate } ); const existingProfiles = profiles?.filter(p => @@ -359,9 +394,20 @@ export const ComposioConnector: React.FC = ({ ); }, [availableTools, searchTerm]); + const [toolsPreviewSearchTerm, setToolsPreviewSearchTerm] = useState(''); + const { data: toolsResponse, isLoading: isLoadingToolsPreview } = useComposioTools( + app.slug, + { + enabled: open && currentStep === Step.ProfileSelect, + limit: 50 + } + ); + + const availableToolsPreview = toolsResponse?.tools || []; + useEffect(() => { if (open) { - setStep(mode === 'profile-only' ? Step.ProfileCreate : Step.ProfileSelect); + setCurrentStep(mode === 'profile-only' ? Step.ProfileCreate : Step.ProfileSelect); setProfileName(`${app.name} Profile`); setSelectedProfileId(''); setSelectedProfile(null); @@ -379,11 +425,11 @@ export const ComposioConnector: React.FC = ({ }, [open, app.name, mode]); useEffect(() => { - if (step === Step.ToolsSelection && selectedProfile) { + if (currentStep === Step.ToolsSelection && selectedProfile) { loadTools(); loadCurrentAgentTools(); } - }, [step, selectedProfile?.profile_id]); + }, [currentStep, selectedProfile?.profile_id]); const loadTools = async () => { if (!selectedProfile) return; @@ -514,10 +560,10 @@ export const ComposioConnector: React.FC = ({ }; const navigateToStep = (newStep: Step) => { - const currentIndex = getStepIndex(step); + const currentIndex = getStepIndex(currentStep); const newIndex = getStepIndex(newStep); setDirection(newIndex > currentIndex ? 'forward' : 'backward'); - setStep(newStep); + setCurrentStep(newStep); }; const handleProfileSelect = () => { @@ -622,7 +668,7 @@ export const ComposioConnector: React.FC = ({ }; const handleBack = () => { - switch (step) { + switch (currentStep) { case Step.ProfileCreate: if (mode === 'profile-only') { onOpenChange(false); @@ -641,31 +687,14 @@ export const ComposioConnector: React.FC = ({ } }; - if (showToolsManager && agentId && selectedProfile) { - return ( - { - if (!open) { - handleToolsSave(); - } - setShowToolsManager(open); - }} - profileId={selectedProfile.profile_id} - profileInfo={{ - profile_id: selectedProfile.profile_id, - profile_name: selectedProfile.profile_name, - toolkit_name: selectedProfile.toolkit_name, - toolkit_slug: selectedProfile.toolkit_slug, - }} - appLogo={app.logo} - onToolsUpdate={() => { - handleToolsSave(); - }} - /> - ); - } + const filteredToolsPreview = availableToolsPreview.filter(tool => + !toolsPreviewSearchTerm || + tool.name.toLowerCase().includes(toolsPreviewSearchTerm.toLowerCase()) || + tool.description.toLowerCase().includes(toolsPreviewSearchTerm.toLowerCase()) || + tool.tags?.some(tag => tag.toLowerCase().includes(toolsPreviewSearchTerm.toLowerCase())) + ); + + const slideVariants = { enter: (direction: 'forward' | 'backward') => ({ @@ -690,13 +719,14 @@ export const ComposioConnector: React.FC = ({ - + - {step !== Step.ToolsSelection ? ( + {currentStep !== Step.ToolsSelection ? ( <> - +
{app.logo ? ( {app.name} @@ -707,22 +737,20 @@ export const ComposioConnector: React.FC = ({ )}
- {step === Step.Success ? 'Connection Complete' : `Connect ${app.name}`} + {stepConfigs.find(config => config.id === currentStep)?.title}

- {stepConfigs.find(config => config.id === step)?.description} + {stepConfigs.find(config => config.id === currentStep)?.description}

- {app.description && (step === Step.ProfileSelect || step === Step.ProfileCreate) && ( -

- {app.description} -

- )}
-
+
- {step === Step.ProfileSelect && ( + {currentStep === Step.ProfileSelect && ( = ({ animate="center" exit="exit" transition={{ duration: 0.3, ease: "easeInOut" }} - className="space-y-6" + className="grid grid-cols-4 gap-0 h-full" > - {existingProfiles.length > 0 && ( -
- +
+
+

Connect to {app.name}

+

+ Choose how you'd like to authenticate with {app.name} +

+
+ {existingProfiles.length > 0 && ( +
+