Merge pull request #1267 from escapade-mckv/main

UI fixes
This commit is contained in:
Bobbie 2025-08-10 02:44:09 +05:30 committed by GitHub
commit 3a9d8e291e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1184 additions and 133 deletions

View File

@ -1790,7 +1790,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"):
@ -1799,6 +1798,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)
@ -1840,6 +1854,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()
@ -1897,6 +1925,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'],
@ -2298,7 +2330,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"}

View File

@ -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(

View File

@ -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'
}

View File

@ -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:

View File

@ -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
)

View File

@ -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)

View File

@ -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

View File

@ -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}")

View File

@ -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}")

View File

@ -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:

View File

@ -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<string | null>(null);
const [showNewAgentDialog, setShowNewAgentDialog] = useState(false);
const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false);
const [agentLimitError, setAgentLimitError] = useState<AgentCountLimitError | null>(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 && (
<AgentCountLimitDialog
open={showAgentLimitDialog}
onOpenChange={setShowAgentLimitDialog}
currentCount={agentLimitError.detail.current_count}
limit={agentLimitError.detail.limit}
tierName={agentLimitError.detail.tier_name}
/>
)}
</div>
</div>
);

View File

@ -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<AgentCountLimitDialogProps> = ({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl h-auto overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="border border-amber-300 dark:border-amber-900 flex h-8 w-8 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<DialogTitle className="text-lg font-semibold">
Agent Limit Reached
</DialogTitle>
</div>
<DialogDescription className="text-sm text-muted-foreground">
You've reached the maximum number of agents allowed on your current plan.
</DialogDescription>
</DialogHeader>
<div className="[&_.grid]:!grid-cols-4 [&_.grid]:gap-3 mt-8">
<PricingSection
returnUrl={returnUrl}
showTitleAndTabs={false}
insideDialog={true}
showInfo={false}
noPadding={true}
/>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -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<DeleteConfirmationDialogProps> = ({
open,
onOpenChange,
selectedProfiles,
onConfirm,
isDeleting,
}) => {
const isSingle = selectedProfiles.length === 1;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isSingle ? 'Delete Profile' : `Delete ${selectedProfiles.length} Profiles`}
</AlertDialogTitle>
<AlertDialogDescription>
{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.`
}
<div className="mt-2 text-amber-600 dark:text-amber-400">
<strong>Warning:</strong> This may affect existing integrations.
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isDeleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
const McpUrlDialog: React.FC<McpUrlDialogProps> = ({
open,
onOpenChange,
@ -212,11 +273,37 @@ const ToolkitTable: React.FC<ToolkitTableProps> = ({ toolkit }) => {
profileName: string;
toolkitName: string;
} | null>(null);
const [selectedProfiles, setSelectedProfiles] = useState<ComposioProfileSummary[]>([]);
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<ComposioProfileSummary>[] = [
{
id: 'name',
@ -238,7 +325,6 @@ const ToolkitTable: React.FC<ToolkitTableProps> = ({ toolkit }) => {
header: 'MCP URL',
width: 'w-1/3',
cell: (profile) => (
console.log('[DEBUG]: profile', profile),
<div className="flex items-center gap-2">
{profile.has_mcp_url ? (
<div className="flex items-center gap-2">
@ -301,17 +387,27 @@ const ToolkitTable: React.FC<ToolkitTableProps> = ({ toolkit }) => {
<DropdownMenuContent align="end">
{profile.has_mcp_url && (
<>
<DropdownMenuItem onClick={() => handleViewUrl(profile.profile_id, profile.profile_name, toolkit.toolkit_name)}>
<DropdownMenuItem className="rounded-lg" onClick={() => handleViewUrl(profile.profile_id, profile.profile_name, toolkit.toolkit_name)}>
<Eye className="h-4 w-4" />
View MCP URL
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem>
<DropdownMenuItem className="rounded-lg" onClick={() => handleSetDefault(profile.profile_id)} disabled={setDefaultProfile.isPending}>
<CheckCircle2 className="h-4 w-4" />
{profile.is_default ? 'Remove Default' : 'Set as Default'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedProfiles([profile]);
setShowDeleteDialog(true);
}}
className="text-destructive rounded-lg focus:text-destructive focus:bg-destructive/10"
disabled={isDeleting}
>
<Trash2 className="h-4 w-4 text-destructive" />
Delete Profile
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -350,7 +446,25 @@ const ToolkitTable: React.FC<ToolkitTableProps> = ({ 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 ? (
<Button
variant="destructive"
size="sm"
onClick={handleDeleteSelected}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4" />
Delete {selectedProfiles.length > 1 ? `${selectedProfiles.length} Profiles` : 'Profile'}
</Button>
) : null
}
/>
{selectedProfile && (
<McpUrlDialog
open={!!selectedProfile}
@ -360,6 +474,14 @@ const ToolkitTable: React.FC<ToolkitTableProps> = ({ toolkit }) => {
toolkitName={selectedProfile.toolkitName}
/>
)}
<DeleteConfirmationDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
selectedProfiles={selectedProfiles}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
/>
</div>
);
};

View File

@ -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: <User className="h-4 w-4" />,
showInProgress: true
title: 'Connect & Preview',
description: 'Choose profile and explore available tools',
icon: <User className="w-4 h-4" />,
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) ?
<mark key={index} className="bg-yellow-200 dark:bg-yellow-900 px-0.5">{part}</mark> :
part
);
};
return (
<div className="border rounded-xl p-3 space-y-2 hover:bg-muted/50 transition-colors">
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Wrench className="w-4 h-4" />
<h3 className="font-medium text-sm leading-tight">{highlightText(tool.name, searchTerm)}</h3>
</div>
</div>
<p className="text-xs text-muted-foreground line-clamp-1">
{highlightText(tool.description, searchTerm)}
</p>
</div>
</div>
);
};
export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
app,
open,
@ -319,7 +354,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
mode = 'full',
agentId
}) => {
const [step, setStep] = useState<Step>(Step.ProfileSelect);
const [currentStep, setCurrentStep] = useState<Step>(Step.ProfileSelect);
const [profileName, setProfileName] = useState(`${app.name} Profile`);
const [selectedProfileId, setSelectedProfileId] = useState<string>('');
const [createdProfileId, setCreatedProfileId] = useState<string | null>(null);
@ -343,7 +378,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
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<ComposioConnectorProps> = ({
);
}, [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<ComposioConnectorProps> = ({
}, [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<ComposioConnectorProps> = ({
};
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<ComposioConnectorProps> = ({
};
const handleBack = () => {
switch (step) {
switch (currentStep) {
case Step.ProfileCreate:
if (mode === 'profile-only') {
onOpenChange(false);
@ -641,31 +687,14 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
}
};
if (showToolsManager && agentId && selectedProfile) {
return (
<ComposioToolsManager
agentId={agentId}
open={showToolsManager}
onOpenChange={(open) => {
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<ComposioConnectorProps> = ({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn(
"overflow-hidden gap-0",
step === Step.ToolsSelection ? "max-w-2xl h-[85vh] p-0 flex flex-col" : "max-w-lg p-0"
currentStep === Step.ToolsSelection ? "max-w-2xl h-[85vh] p-0 flex flex-col" :
currentStep === Step.ProfileSelect ? "max-w-2xl p-0" : "max-w-lg p-0"
)}>
<StepIndicator currentStep={step} mode={mode} />
<StepIndicator currentStep={currentStep} mode={mode} />
{step !== Step.ToolsSelection ? (
{currentStep !== Step.ToolsSelection ? (
<>
<DialogHeader className="px-8 pt-8 pb-2">
<DialogHeader className="px-8 pb-2">
<div className="flex items-center gap-4">
{app.logo ? (
<img src={app.logo} alt={app.name} className="w-14 h-14 rounded-xl object-contain bg-muted p-2 border" />
@ -707,22 +737,20 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
)}
<div className="flex-1">
<DialogTitle className="text-xl font-semibold">
{step === Step.Success ? 'Connection Complete' : `Connect ${app.name}`}
{stepConfigs.find(config => config.id === currentStep)?.title}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{stepConfigs.find(config => config.id === step)?.description}
{stepConfigs.find(config => config.id === currentStep)?.description}
</p>
{app.description && (step === Step.ProfileSelect || step === Step.ProfileCreate) && (
<p className="text-xs text-muted-foreground/70 line-clamp-2">
{app.description}
</p>
)}
</div>
</div>
</DialogHeader>
<div className="px-8 pb-8 pt-6">
<div className={cn(
"flex-1 overflow-hidden",
currentStep === Step.ProfileSelect ? "px-0 pb-0 pt-0" : "px-8 pb-8 pt-6"
)}>
<AnimatePresence mode="wait" custom={direction}>
{step === Step.ProfileSelect && (
{currentStep === Step.ProfileSelect && (
<motion.div
key="profile-select"
custom={direction}
@ -731,11 +759,18 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
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 && (
<div className="space-y-3">
<Label className="text-sm font-medium">Use Existing Profile</Label>
<div className="col-span-2 flex flex-col space-y-6 p-8 pr-6 border-border/50 overflow-y-auto">
<div>
<h3 className="text-lg font-semibold">Connect to {app.name}</h3>
<p className="text-xs text-muted-foreground">
Choose how you'd like to authenticate with {app.name}
</p>
</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...">
@ -800,32 +835,75 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
{app.name.charAt(0)}
</div>
)}
Connect New {app.name} Account
Connect New Account
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
{existingProfiles.length > 0 && (
</div>
<div className="flex gap-3 pt-4 mt-auto">
<Button
onClick={handleProfileSelect}
disabled={!selectedProfileId}
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1"
>
{mode === 'full' && agentId ? 'Configure Tools' : 'Use Profile'}
<ChevronRight className="h-4 w-4" />
Cancel
</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>
</div>
<div className="col-span-2 flex flex-col pl-2 pr-4 overflow-hidden">
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-muted-foreground/10 pr-2 space-y-2">
{isLoadingToolsPreview ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="border rounded-lg p-3 animate-pulse">
<div className="h-3 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-2 bg-muted rounded w-full mb-2"></div>
<div className="flex gap-2">
<div className="h-4 bg-muted rounded w-12"></div>
<div className="h-4 bg-muted rounded w-16"></div>
</div>
</div>
))}
</div>
) : (
<>
{filteredToolsPreview.length > 0 ? (
<div className="space-y-2">
{filteredToolsPreview.slice(0, 6).map((tool) => (
<ToolPreviewCard
key={tool.slug}
tool={tool}
searchTerm={toolsPreviewSearchTerm}
/>
))}
{filteredToolsPreview.length > 6 && (
<div className="text-center py-2 mb-4 text-sm text-muted-foreground">
+{filteredToolsPreview.length - 6} more tools available
</div>
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
{toolsPreviewSearchTerm ? 'No tools match your search' : 'No tools available'}
</div>
)}
</>
)}
</div>
</div>
</motion.div>
)}
{step === Step.ProfileCreate && (
{currentStep === Step.ProfileCreate && (
<motion.div
key="profile-create"
custom={direction}
@ -923,7 +1001,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
</div>
</motion.div>
)}
{step === Step.Connecting && (
{currentStep === Step.Connecting && (
<motion.div
key="connecting"
custom={direction}
@ -969,7 +1047,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
</Button>
</motion.div>
)}
{step === Step.Success && (
{currentStep === Step.Success && (
<motion.div
key="success"
custom={direction}
@ -998,7 +1076,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
</>
) : (
<>
<DialogHeader className="px-8 py-6 border-b border-border/50 flex-shrink-0 bg-muted/10">
<DialogHeader className="px-8 border-border/50 flex-shrink-0 bg-muted/10">
<div className="flex items-center gap-4">
{app.logo ? (
<img
@ -1022,7 +1100,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
</div>
</DialogHeader>
<AnimatePresence mode="wait" custom={direction}>
{step === Step.ToolsSelection && (
{currentStep === Step.ToolsSelection && (
<motion.div
key="tools-selection"
custom={direction}
@ -1033,7 +1111,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
transition={{ duration: 0.3, ease: "easeInOut" }}
className="flex-1 flex flex-col min-h-0"
>
<div className="px-8 py-4 border-b border-border/50 bg-muted/10 flex-shrink-0">
<div className="px-8 py-4 border-border/50 bg-muted/10 flex-shrink-0">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@ -1082,7 +1160,7 @@ export const ComposioConnector: React.FC<ComposioConnectorProps> = ({
))}
</div>
) : filteredTools.length > 0 ? (
<div className="space-y-3">
<div className="space-y-3 -mt-6">
{filteredTools.map((tool) => (
<ToolCard
key={tool.name}

View File

@ -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<JsonImportDialogProps> = ({
const [currentStep, setCurrentStep] = useState(0);
const [profileMappings, setProfileMappings] = useState<Record<string, string>>({});
const [customMcpConfigs, setCustomMcpConfigs] = useState<Record<string, Record<string, any>>>({});
const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false);
const [agentLimitError, setAgentLimitError] = useState<AgentCountLimitError | null>(null);
const analyzeJsonMutation = useAnalyzeJsonForImport();
const importJsonMutation = useImportAgentFromJson();
@ -47,6 +51,8 @@ export const JsonImportDialog: React.FC<JsonImportDialogProps> = ({
setCurrentStep(0);
setProfileMappings({});
setCustomMcpConfigs({});
setShowAgentLimitDialog(false);
setAgentLimitError(null);
}, []);
useEffect(() => {
@ -215,6 +221,13 @@ export const JsonImportDialog: React.FC<JsonImportDialogProps> = ({
}
onOpenChange(false);
}
},
onError: (error) => {
if (error instanceof AgentCountLimitError) {
setAgentLimitError(error);
setShowAgentLimitDialog(true);
onOpenChange(false);
}
}
}
);
@ -380,7 +393,6 @@ export const JsonImportDialog: React.FC<JsonImportDialogProps> = ({
Import Agent from JSON
</DialogTitle>
</DialogHeader>
{analysis && !analysis.requires_setup && step === 'paste' && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
@ -389,7 +401,6 @@ export const JsonImportDialog: React.FC<JsonImportDialogProps> = ({
</AlertDescription>
</Alert>
)}
{analysis && analysis.requires_setup && step === 'paste' && (
<Alert>
<AlertCircle className="h-4 w-4" />
@ -400,7 +411,6 @@ export const JsonImportDialog: React.FC<JsonImportDialogProps> = ({
</AlertDescription>
</Alert>
)}
{analyzeJsonMutation.isError && step === 'paste' && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
@ -409,7 +419,6 @@ export const JsonImportDialog: React.FC<JsonImportDialogProps> = ({
</AlertDescription>
</Alert>
)}
{importJsonMutation.isError && (step === 'setup' || step === 'importing') && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
@ -418,11 +427,19 @@ export const JsonImportDialog: React.FC<JsonImportDialogProps> = ({
</AlertDescription>
</Alert>
)}
{step === 'paste' && renderPasteStep()}
{step === 'setup' && renderSetupStep()}
{step === 'importing' && renderImportingStep()}
</DialogContent>
{agentLimitError && (
<AgentCountLimitDialog
open={showAgentLimitDialog}
onOpenChange={setShowAgentLimitDialog}
currentCount={agentLimitError.detail.current_count}
limit={agentLimitError.detail.limit}
tierName={agentLimitError.detail.tier_name}
/>
)}
</Dialog>
);
};

View File

@ -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<AgentCountLimitError | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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 && (
<AgentCountLimitDialog
open={showAgentLimitDialog}
onOpenChange={setShowAgentLimitDialog}
currentCount={agentLimitError.detail.current_count}
limit={agentLimitError.detail.limit}
tierName={agentLimitError.detail.tier_name}
/>
)}
</AlertDialog>
);
}

View File

@ -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 (
<section
id="pricing"
className={cn("flex flex-col items-center justify-center gap-10 w-full relative pb-12")}
className={cn("flex flex-col items-center justify-center gap-10 w-full relative", noPadding ? "pb-0" : "pb-12")}
>
{showTitleAndTabs && (
<>
@ -747,14 +747,15 @@ export function PricingSection({
))}
</div>
)}
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg max-w-2xl mx-auto">
<p className="text-sm text-blue-800 dark:text-blue-200 text-center">
<strong>What are AI tokens?</strong> 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.
</p>
</div>
{showInfo && (
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg max-w-2xl mx-auto">
<p className="text-sm text-blue-800 dark:text-blue-200 text-center">
<strong>What are AI tokens?</strong> 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.
</p>
</div>
)}
</section>
);

View File

@ -9,7 +9,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
export interface DataTableColumn<T> {
@ -28,6 +28,11 @@ export interface DataTableProps<T> {
className?: string;
emptyMessage?: string;
onRowClick?: (item: T) => void;
selectable?: boolean;
selectedItems?: T[];
onSelectionChange?: (selectedItems: T[]) => void;
getItemId?: (item: T) => string;
headerActions?: React.ReactNode;
}
export function DataTable<T>({
@ -36,12 +41,68 @@ export function DataTable<T>({
className,
emptyMessage = 'No data available',
onRowClick,
selectable = false,
selectedItems = [],
onSelectionChange,
getItemId,
headerActions,
}: DataTableProps<T>) {
const isAllSelected = selectable && data.length > 0 && selectedItems.length === data.length;
const isSomeSelected = selectable && selectedItems.length > 0 && selectedItems.length < data.length;
const handleSelectAll = () => {
if (!selectable || !onSelectionChange) return;
if (isAllSelected) {
onSelectionChange([]);
} else {
onSelectionChange(data);
}
};
const handleSelectItem = (item: T) => {
if (!selectable || !onSelectionChange || !getItemId) return;
const itemId = getItemId(item);
const isSelected = selectedItems.some(selectedItem => getItemId(selectedItem) === itemId);
if (isSelected) {
onSelectionChange(selectedItems.filter(selectedItem => getItemId(selectedItem) !== itemId));
} else {
onSelectionChange([...selectedItems, item]);
}
};
const isItemSelected = (item: T): boolean => {
if (!selectable || !getItemId) return false;
const itemId = getItemId(item);
return selectedItems.some(selectedItem => getItemId(selectedItem) === itemId);
};
return (
<div className={cn('rounded-md border', className)}>
{selectable && selectedItems.length > 0 && headerActions && (
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="text-sm text-muted-foreground">
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected
</span>
<div className="flex items-center gap-2">
{headerActions}
</div>
</div>
)}
<Table>
<TableHeader>
<TableRow>
{selectable && (
<TableHead className="w-12">
<Checkbox
checked={isAllSelected || isSomeSelected}
onCheckedChange={handleSelectAll}
aria-label="Select all"
/>
</TableHead>
)}
{columns.map((column) => (
<TableHead
key={column.id}
@ -55,19 +116,34 @@ export function DataTable<T>({
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={columns.length + (selectable ? 1 : 0)} className="text-center py-8 text-muted-foreground">
{emptyMessage}
</TableCell>
</TableRow>
) : (
data.map((item, index) => (
<TableRow
key={index}
key={getItemId ? getItemId(item) : index}
className={cn(
onRowClick && 'cursor-pointer hover:bg-muted/50',
selectable && isItemSelected(item) && 'bg-muted/50'
)}
onClick={() => onRowClick?.(item)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('[role="checkbox"]')) {
return;
}
onRowClick?.(item);
}}
>
{selectable && (
<TableCell>
<Checkbox
checked={isItemSelected(item)}
onCheckedChange={() => handleSelectItem(item)}
aria-label={`Select item ${index + 1}`}
/>
</TableCell>
)}
{columns.map((column) => (
<TableCell
key={column.id}

View File

@ -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');
},
}
)();
};

View File

@ -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);
}

View File

@ -256,6 +256,25 @@ export const createAgent = async (agentData: AgentCreateRequest): Promise<Agent>
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}`);
}

View File

@ -0,0 +1,72 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { composioApi } from './utils';
import { composioKeys } from './keys';
import { toast } from 'sonner';
export const useDeleteProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (profileId: string) => composioApi.deleteProfile(profileId),
onSuccess: async (data, profileId) => {
await queryClient.invalidateQueries({
queryKey: ['composio', 'profiles']
});
await queryClient.refetchQueries({
queryKey: ['composio', 'profiles']
});
queryClient.removeQueries({ queryKey: composioKeys.profiles.detail(profileId) });
toast.success('Profile deleted successfully');
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to delete profile');
},
});
};
export const useBulkDeleteProfiles = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (profileIds: string[]) => composioApi.bulkDeleteProfiles(profileIds),
onSuccess: async (data, profileIds) => {
await queryClient.invalidateQueries({
queryKey: ['composio', 'profiles']
});
await queryClient.refetchQueries({
queryKey: ['composio', 'profiles']
});
profileIds.forEach(profileId => {
queryClient.removeQueries({ queryKey: composioKeys.profiles.detail(profileId) });
});
if (data.failed_profiles.length > 0) {
toast.warning(`${data.deleted_count} profiles deleted successfully. ${data.failed_profiles.length} failed to delete.`);
} else {
toast.success(`${data.deleted_count} profiles deleted successfully`);
}
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to delete profiles');
},
});
};
export const useSetDefaultProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (profileId: string) => composioApi.setDefaultProfile(profileId),
onSuccess: async (data, profileId) => {
await queryClient.invalidateQueries({
queryKey: ['composio', 'profiles']
});
await queryClient.refetchQueries({
queryKey: ['composio', 'profiles']
});
toast.success('Default profile updated successfully');
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to set default profile');
},
});
};

View File

@ -8,6 +8,7 @@ import {
type CreateComposioProfileRequest,
type CreateComposioProfileResponse,
type DetailedComposioToolkitResponse,
type ComposioToolsResponse,
} from './utils';
import { composioKeys } from './keys';
import { toast } from 'sonner';
@ -83,6 +84,19 @@ export const useComposioToolkitDetails = (toolkitSlug: string, options?: { enabl
});
};
export const useComposioTools = (toolkitSlug: string, options?: { enabled?: boolean; limit?: number }) => {
return useQuery({
queryKey: ['composio', 'tools', toolkitSlug, options?.limit],
queryFn: async (): Promise<ComposioToolsResponse> => {
const result = await composioApi.getTools(toolkitSlug, options?.limit);
return result;
},
enabled: (options?.enabled ?? true) && !!toolkitSlug,
staleTime: 10 * 60 * 1000,
retry: 2,
});
};
export const useCreateComposioProfile = () => {
const queryClient = useQueryClient();

View File

@ -63,6 +63,33 @@ export interface DetailedComposioToolkitResponse {
error?: string;
}
export interface ComposioTool {
slug: string;
name: string;
description: string;
version: string;
input_parameters: {
properties: Record<string, any>;
required?: string[];
};
output_parameters: {
properties: Record<string, any>;
};
scopes?: string[];
tags?: string[];
no_auth: boolean;
}
export interface ComposioToolsResponse {
success: boolean;
tools: ComposioTool[];
total_items: number;
current_page: number;
total_pages: number;
next_cursor?: string;
error?: string;
}
export interface ComposioToolkitsResponse {
success: boolean;
toolkits: ComposioToolkit[];
@ -159,6 +186,21 @@ export interface ComposioMcpUrlResponse {
warning: string;
}
export interface DeleteProfileResponse {
message: string;
}
export interface BulkDeleteProfilesRequest {
profile_ids: string[];
}
export interface BulkDeleteProfilesResponse {
success: boolean;
deleted_count: number;
failed_profiles: string[];
message: string;
}
export const composioApi = {
async getCategories(): Promise<CompositoCategoriesResponse> {
const result = await backendApi.get<CompositoCategoriesResponse>(
@ -308,4 +350,70 @@ export const composioApi = {
return result.data!;
},
async getTools(toolkitSlug: string, limit: number = 50): Promise<ComposioToolsResponse> {
const result = await backendApi.post<ComposioToolsResponse>(
`/composio/tools/list`,
{
toolkit_slug: toolkitSlug,
limit
},
{
errorContext: { operation: 'get tools', resource: 'Composio tools' },
}
);
if (!result.success) {
throw new Error(result.error?.message || 'Failed to get tools');
}
return result.data!;
},
async deleteProfile(profileId: string): Promise<DeleteProfileResponse> {
const result = await backendApi.delete<DeleteProfileResponse>(
`/secure-mcp/credential-profiles/${profileId}`,
{
errorContext: { operation: 'delete profile', resource: 'Composio profile' },
}
);
if (!result.success) {
throw new Error(result.error?.message || 'Failed to delete profile');
}
return result.data!;
},
async bulkDeleteProfiles(profileIds: string[]): Promise<BulkDeleteProfilesResponse> {
const result = await backendApi.post<BulkDeleteProfilesResponse>(
'/secure-mcp/credential-profiles/bulk-delete',
{ profile_ids: profileIds },
{
errorContext: { operation: 'bulk delete profiles', resource: 'Composio profiles' },
}
);
if (!result.success) {
throw new Error(result.error?.message || 'Failed to bulk delete profiles');
}
return result.data!;
},
async setDefaultProfile(profileId: string): Promise<{ message: string }> {
const result = await backendApi.put<{ message: string }>(
`/secure-mcp/credential-profiles/${profileId}/set-default`,
{},
{
errorContext: { operation: 'set default profile', resource: 'Composio profile' },
}
);
if (!result.success) {
throw new Error(result.error?.message || 'Failed to set default profile');
}
return result.data!;
},
};

View File

@ -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}`);
}

View File

@ -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);

View File

@ -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',