mirror of https://github.com/kortix-ai/suna.git
commit
3a9d8e291e
|
@ -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"}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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}")
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
}
|
||||
)();
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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!;
|
||||
},
|
||||
};
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue