mirror of https://github.com/kortix-ai/suna.git
Merge pull request #634 from escapade-mckv/feature-flags
feat: feature flags using redis
This commit is contained in:
commit
ef28b0a74a
|
@ -83,6 +83,104 @@ RABBITMQ_PORT=5672
|
|||
|
||||
---
|
||||
|
||||
## Feature Flags
|
||||
|
||||
The backend includes a Redis-backed feature flag system that allows you to control feature availability without code deployments.
|
||||
|
||||
### Setup
|
||||
|
||||
The feature flag system uses the existing Redis service and is automatically available when Redis is running.
|
||||
|
||||
### CLI Management
|
||||
|
||||
Use the CLI tool to manage feature flags:
|
||||
|
||||
```bash
|
||||
cd backend/flags
|
||||
python setup.py <command> [arguments]
|
||||
```
|
||||
|
||||
#### Available Commands
|
||||
|
||||
**Enable a feature flag:**
|
||||
```bash
|
||||
python setup.py enable test_flag "Test decsription"
|
||||
```
|
||||
|
||||
**Disable a feature flag:**
|
||||
```bash
|
||||
python setup.py disable test_flag
|
||||
```
|
||||
|
||||
**List all feature flags:**
|
||||
```bash
|
||||
python setup.py list
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
Feature flags are accessible via REST API:
|
||||
|
||||
**Get all feature flags:**
|
||||
```bash
|
||||
GET /feature-flags
|
||||
```
|
||||
|
||||
**Get specific feature flag:**
|
||||
```bash
|
||||
GET /feature-flags/{flag_name}
|
||||
```
|
||||
|
||||
Example response:
|
||||
```json
|
||||
{
|
||||
"test_flag": {
|
||||
"enabled": true,
|
||||
"description": "Test flag",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Integration
|
||||
|
||||
Use feature flags in your Python code:
|
||||
|
||||
```python
|
||||
from flags.flags import is_enabled
|
||||
|
||||
# Check if a feature is enabled
|
||||
if await is_enabled('test_flag'):
|
||||
# Feature-specific logic
|
||||
pass
|
||||
|
||||
# With fallback value
|
||||
enabled = await is_enabled('new_feature', default=False)
|
||||
```
|
||||
|
||||
### Current Feature Flags
|
||||
|
||||
The system currently supports these feature flags:
|
||||
|
||||
- **`custom_agents`**: Controls custom agent creation and management
|
||||
- **`agent_marketplace`**: Controls agent marketplace functionality
|
||||
|
||||
### Error Handling
|
||||
|
||||
The feature flag system includes robust error handling:
|
||||
|
||||
- If Redis is unavailable, flags default to `False`
|
||||
- API endpoints return empty objects on Redis errors
|
||||
- CLI operations show clear error messages
|
||||
|
||||
### Caching
|
||||
|
||||
- Backend operations are direct Redis calls (no caching)
|
||||
- Frontend includes 5-minute caching for performance
|
||||
- Use `clearCache()` in frontend to force refresh
|
||||
|
||||
---
|
||||
|
||||
## Production Setup
|
||||
|
||||
For production deployments, use the following command to set resource limits
|
||||
|
|
|
@ -22,6 +22,7 @@ from sandbox.sandbox import create_sandbox, get_or_start_sandbox
|
|||
from services.llm import make_llm_api_call
|
||||
from run_agent_background import run_agent_background, _cleanup_redis_response_list, update_agent_run_status
|
||||
from utils.constants import MODEL_NAME_ALIASES
|
||||
from flags.flags import is_enabled
|
||||
|
||||
# Initialize shared resources
|
||||
router = APIRouter()
|
||||
|
@ -1046,6 +1047,12 @@ async def initiate_agent_with_files(
|
|||
# TODO: Clean up created project/thread if initiation fails mid-way
|
||||
raise HTTPException(status_code=500, detail=f"Failed to initiate agent session: {str(e)}")
|
||||
|
||||
|
||||
|
||||
# Custom agents
|
||||
|
||||
|
||||
|
||||
@router.get("/agents", response_model=AgentsResponse)
|
||||
async def get_agents(
|
||||
user_id: str = Depends(get_current_user_id_from_jwt),
|
||||
|
@ -1060,6 +1067,11 @@ async def get_agents(
|
|||
tools: Optional[str] = Query(None, description="Comma-separated list of tools to filter by")
|
||||
):
|
||||
"""Get agents for the current user with pagination, search, sort, and filter support."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agents currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
logger.info(f"Fetching agents for user: {user_id} with page={page}, limit={limit}, search='{search}', sort_by={sort_by}, sort_order={sort_order}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1221,6 +1233,12 @@ async def get_agents(
|
|||
@router.get("/agents/{agent_id}", response_model=AgentResponse)
|
||||
async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_from_jwt)):
|
||||
"""Get a specific agent by ID. Only the owner can access non-public agents."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agents currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Fetching agent {agent_id} for user: {user_id}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1270,6 +1288,11 @@ async def create_agent(
|
|||
):
|
||||
"""Create a new agent."""
|
||||
logger.info(f"Creating new agent for user: {user_id}")
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agents currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
client = await db.client
|
||||
|
||||
try:
|
||||
|
@ -1337,6 +1360,11 @@ async def update_agent(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Update an existing agent."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
logger.info(f"Updating agent {agent_id} for user: {user_id}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1428,6 +1456,11 @@ async def update_agent(
|
|||
@router.delete("/agents/{agent_id}")
|
||||
async def delete_agent(agent_id: str, user_id: str = Depends(get_current_user_id_from_jwt)):
|
||||
"""Delete an agent."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
logger.info(f"Deleting agent: {agent_id}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1490,6 +1523,12 @@ async def get_marketplace_agents(
|
|||
creator: Optional[str] = Query(None, description="Filter by creator name")
|
||||
):
|
||||
"""Get public agents from the marketplace with pagination, search, sort, and filter support."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Fetching marketplace agents with page={page}, limit={limit}, search='{search}', tags='{tags}', sort_by={sort_by}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1556,6 +1595,12 @@ async def publish_agent_to_marketplace(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Publish an agent to the marketplace."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Publishing agent {agent_id} to marketplace")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1595,6 +1640,12 @@ async def unpublish_agent_from_marketplace(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Unpublish an agent from the marketplace."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Unpublishing agent {agent_id} from marketplace")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1629,6 +1680,12 @@ async def add_agent_to_library(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Add an agent from the marketplace to user's library."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Adding marketplace agent {agent_id} to user {user_id} library")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1660,6 +1717,12 @@ async def add_agent_to_library(
|
|||
@router.get("/user/agent-library")
|
||||
async def get_user_agent_library(user_id: str = Depends(get_current_user_id_from_jwt)):
|
||||
"""Get user's agent library (agents added from marketplace)."""
|
||||
if not await is_enabled("agent_marketplace"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agent currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Fetching agent library for user {user_id}")
|
||||
client = await db.client
|
||||
|
||||
|
@ -1693,6 +1756,12 @@ async def get_agent_builder_chat_history(
|
|||
user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Get chat history for agent builder sessions for a specific agent."""
|
||||
if not await is_enabled("custom_agents"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Custom agents currently disabled. This feature is not available at the moment."
|
||||
)
|
||||
|
||||
logger.info(f"Fetching agent builder chat history for agent: {agent_id}")
|
||||
client = await db.client
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ from pydantic import BaseModel
|
|||
from agent import api as agent_api
|
||||
from sandbox import api as sandbox_api
|
||||
from services import billing as billing_api
|
||||
from flags import api as feature_flags_api
|
||||
from services import transcription as transcription_api
|
||||
from services.mcp_custom import discover_custom_tools
|
||||
import sys
|
||||
|
@ -132,6 +133,8 @@ app.include_router(sandbox_api.router, prefix="/api")
|
|||
|
||||
app.include_router(billing_api.router, prefix="/api")
|
||||
|
||||
app.include_router(feature_flags_api.router, prefix="/api")
|
||||
|
||||
from mcp_local import api as mcp_api
|
||||
|
||||
app.include_router(mcp_api.router, prefix="/api")
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
from fastapi import APIRouter
|
||||
from utils.logger import logger
|
||||
from flags.flags import list_flags, is_enabled, get_flag_details
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/feature-flags")
|
||||
async def get_feature_flags():
|
||||
try:
|
||||
flags = await list_flags()
|
||||
return {"flags": flags}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching feature flags: {str(e)}")
|
||||
return {"flags": {}}
|
||||
|
||||
@router.get("/feature-flags/{flag_name}")
|
||||
async def get_feature_flag(flag_name: str):
|
||||
try:
|
||||
enabled = await is_enabled(flag_name)
|
||||
details = await get_flag_details(flag_name)
|
||||
return {
|
||||
"flag_name": flag_name,
|
||||
"enabled": enabled,
|
||||
"details": details
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching feature flag {flag_name}: {str(e)}")
|
||||
return {
|
||||
"flag_name": flag_name,
|
||||
"enabled": False,
|
||||
"details": None
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from services import redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureFlagManager:
|
||||
def __init__(self):
|
||||
"""Initialize with existing Redis service"""
|
||||
self.flag_prefix = "feature_flag:"
|
||||
self.flag_list_key = "feature_flags:list"
|
||||
|
||||
async def set_flag(self, key: str, enabled: bool, description: str = "") -> bool:
|
||||
"""Set a feature flag to enabled or disabled"""
|
||||
try:
|
||||
flag_key = f"{self.flag_prefix}{key}"
|
||||
flag_data = {
|
||||
'enabled': str(enabled).lower(),
|
||||
'description': description,
|
||||
'updated_at': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Use the existing Redis service
|
||||
redis_client = await redis.get_client()
|
||||
await redis_client.hset(flag_key, mapping=flag_data)
|
||||
await redis_client.sadd(self.flag_list_key, key)
|
||||
|
||||
logger.info(f"Set feature flag {key} to {enabled}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set feature flag {key}: {e}")
|
||||
return False
|
||||
|
||||
async def is_enabled(self, key: str) -> bool:
|
||||
"""Check if a feature flag is enabled"""
|
||||
try:
|
||||
flag_key = f"{self.flag_prefix}{key}"
|
||||
redis_client = await redis.get_client()
|
||||
enabled = await redis_client.hget(flag_key, 'enabled')
|
||||
return enabled == 'true' if enabled else False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check feature flag {key}: {e}")
|
||||
# Return False by default if Redis is unavailable
|
||||
return False
|
||||
|
||||
async def get_flag(self, key: str) -> Optional[Dict[str, str]]:
|
||||
"""Get feature flag details"""
|
||||
try:
|
||||
flag_key = f"{self.flag_prefix}{key}"
|
||||
redis_client = await redis.get_client()
|
||||
flag_data = await redis_client.hgetall(flag_key)
|
||||
return flag_data if flag_data else None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get feature flag {key}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_flag(self, key: str) -> bool:
|
||||
"""Delete a feature flag"""
|
||||
try:
|
||||
flag_key = f"{self.flag_prefix}{key}"
|
||||
redis_client = await redis.get_client()
|
||||
deleted = await redis_client.delete(flag_key)
|
||||
if deleted:
|
||||
await redis_client.srem(self.flag_list_key, key)
|
||||
logger.info(f"Deleted feature flag: {key}")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete feature flag {key}: {e}")
|
||||
return False
|
||||
|
||||
async def list_flags(self) -> Dict[str, bool]:
|
||||
"""List all feature flags with their status"""
|
||||
try:
|
||||
redis_client = await redis.get_client()
|
||||
flag_keys = await redis_client.smembers(self.flag_list_key)
|
||||
flags = {}
|
||||
|
||||
for key in flag_keys:
|
||||
flags[key] = await self.is_enabled(key)
|
||||
|
||||
return flags
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list feature flags: {e}")
|
||||
return {}
|
||||
|
||||
async def get_all_flags_details(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Get all feature flags with detailed information"""
|
||||
try:
|
||||
redis_client = await redis.get_client()
|
||||
flag_keys = await redis_client.smembers(self.flag_list_key)
|
||||
flags = {}
|
||||
|
||||
for key in flag_keys:
|
||||
flag_data = await self.get_flag(key)
|
||||
if flag_data:
|
||||
flags[key] = flag_data
|
||||
|
||||
return flags
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all flags details: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
_flag_manager: Optional[FeatureFlagManager] = None
|
||||
|
||||
|
||||
def get_flag_manager() -> FeatureFlagManager:
|
||||
"""Get the global feature flag manager instance"""
|
||||
global _flag_manager
|
||||
if _flag_manager is None:
|
||||
_flag_manager = FeatureFlagManager()
|
||||
return _flag_manager
|
||||
|
||||
|
||||
# Async convenience functions
|
||||
async def set_flag(key: str, enabled: bool, description: str = "") -> bool:
|
||||
return await get_flag_manager().set_flag(key, enabled, description)
|
||||
|
||||
|
||||
async def is_enabled(key: str) -> bool:
|
||||
return await get_flag_manager().is_enabled(key)
|
||||
|
||||
|
||||
async def enable_flag(key: str, description: str = "") -> bool:
|
||||
return await set_flag(key, True, description)
|
||||
|
||||
|
||||
async def disable_flag(key: str, description: str = "") -> bool:
|
||||
return await set_flag(key, False, description)
|
||||
|
||||
|
||||
async def delete_flag(key: str) -> bool:
|
||||
return await get_flag_manager().delete_flag(key)
|
||||
|
||||
|
||||
async def list_flags() -> Dict[str, bool]:
|
||||
return await get_flag_manager().list_flags()
|
||||
|
||||
|
||||
async def get_flag_details(key: str) -> Optional[Dict[str, str]]:
|
||||
return await get_flag_manager().get_flag(key)
|
||||
|
||||
|
||||
async def get_all_flags() -> Dict[str, Dict[str, str]]:
|
||||
"""Get all feature flags with detailed information"""
|
||||
return await get_flag_manager().get_all_flags_details()
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import argparse
|
||||
import asyncio
|
||||
from flags import (
|
||||
enable_flag,
|
||||
disable_flag,
|
||||
is_enabled,
|
||||
list_flags,
|
||||
delete_flag,
|
||||
get_flag_details
|
||||
)
|
||||
|
||||
async def enable_command(flag_name: str, description: str = ""):
|
||||
"""Enable a feature flag"""
|
||||
if await enable_flag(flag_name, description):
|
||||
print(f"✓ Enabled flag: {flag_name}")
|
||||
if description:
|
||||
print(f" Description: {description}")
|
||||
else:
|
||||
print(f"✗ Failed to enable flag: {flag_name}")
|
||||
|
||||
|
||||
async def disable_command(flag_name: str, description: str = ""):
|
||||
"""Disable a feature flag"""
|
||||
if await disable_flag(flag_name, description):
|
||||
print(f"✓ Disabled flag: {flag_name}")
|
||||
if description:
|
||||
print(f" Description: {description}")
|
||||
else:
|
||||
print(f"✗ Failed to disable flag: {flag_name}")
|
||||
|
||||
|
||||
async def list_command():
|
||||
"""List all feature flags"""
|
||||
flags = await list_flags()
|
||||
|
||||
if not flags:
|
||||
print("No feature flags found.")
|
||||
return
|
||||
|
||||
print("Feature Flags:")
|
||||
print("-" * 50)
|
||||
|
||||
for flag_name, enabled in flags.items():
|
||||
details = await get_flag_details(flag_name)
|
||||
description = details.get('description', 'No description') if details else 'No description'
|
||||
updated_at = details.get('updated_at', 'Unknown') if details else 'Unknown'
|
||||
|
||||
status_icon = "✓" if enabled else "✗"
|
||||
status_text = "ENABLED" if enabled else "DISABLED"
|
||||
|
||||
print(f"{status_icon} {flag_name}: {status_text}")
|
||||
print(f" Description: {description}")
|
||||
print(f" Updated: {updated_at}")
|
||||
print()
|
||||
|
||||
|
||||
async def status_command(flag_name: str):
|
||||
"""Show status of a specific feature flag"""
|
||||
details = await get_flag_details(flag_name)
|
||||
|
||||
if not details:
|
||||
print(f"✗ Flag '{flag_name}' not found.")
|
||||
return
|
||||
|
||||
enabled = await is_enabled(flag_name)
|
||||
status_icon = "✓" if enabled else "✗"
|
||||
status_text = "ENABLED" if enabled else "DISABLED"
|
||||
|
||||
print(f"Flag: {flag_name}")
|
||||
print(f"Status: {status_icon} {status_text}")
|
||||
print(f"Description: {details.get('description', 'No description')}")
|
||||
print(f"Updated: {details.get('updated_at', 'Unknown')}")
|
||||
|
||||
|
||||
async def delete_command(flag_name: str):
|
||||
"""Delete a feature flag"""
|
||||
if not await get_flag_details(flag_name):
|
||||
print(f"✗ Flag '{flag_name}' not found.")
|
||||
return
|
||||
|
||||
confirm = input(f"Are you sure you want to delete flag '{flag_name}'? (y/N): ")
|
||||
if confirm.lower() in ['y', 'yes']:
|
||||
if await delete_flag(flag_name):
|
||||
print(f"✓ Deleted flag: {flag_name}")
|
||||
else:
|
||||
print(f"✗ Failed to delete flag: {flag_name}")
|
||||
else:
|
||||
print("Cancelled.")
|
||||
|
||||
|
||||
async def toggle_command(flag_name: str, description: str = ""):
|
||||
"""Toggle a feature flag"""
|
||||
current_status = await is_enabled(flag_name)
|
||||
|
||||
if current_status:
|
||||
await disable_command(flag_name, description)
|
||||
else:
|
||||
await enable_command(flag_name, description)
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Feature Flag Management Tool",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python setup.py enable new_ui "Enable new user interface"
|
||||
python setup.py disable beta_features "Disable beta features"
|
||||
python setup.py list
|
||||
python setup.py status new_ui
|
||||
python setup.py toggle maintenance_mode "Toggle maintenance mode"
|
||||
python setup.py delete old_feature
|
||||
"""
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Enable command
|
||||
enable_parser = subparsers.add_parser('enable', help='Enable a feature flag')
|
||||
enable_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
enable_parser.add_argument('description', nargs='?', default='', help='Optional description')
|
||||
|
||||
# Disable command
|
||||
disable_parser = subparsers.add_parser('disable', help='Disable a feature flag')
|
||||
disable_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
disable_parser.add_argument('description', nargs='?', default='', help='Optional description')
|
||||
|
||||
# List command
|
||||
subparsers.add_parser('list', help='List all feature flags')
|
||||
|
||||
# Status command
|
||||
status_parser = subparsers.add_parser('status', help='Show status of a feature flag')
|
||||
status_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
|
||||
# Delete command
|
||||
delete_parser = subparsers.add_parser('delete', help='Delete a feature flag')
|
||||
delete_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
|
||||
# Toggle command
|
||||
toggle_parser = subparsers.add_parser('toggle', help='Toggle a feature flag')
|
||||
toggle_parser.add_argument('flag_name', help='Name of the feature flag')
|
||||
toggle_parser.add_argument('description', nargs='?', default='', help='Optional description')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
if args.command == 'enable':
|
||||
await enable_command(args.flag_name, args.description)
|
||||
elif args.command == 'disable':
|
||||
await disable_command(args.flag_name, args.description)
|
||||
elif args.command == 'list':
|
||||
await list_command()
|
||||
elif args.command == 'status':
|
||||
await status_command(args.flag_name)
|
||||
elif args.command == 'delete':
|
||||
await delete_command(args.flag_name)
|
||||
elif args.command == 'toggle':
|
||||
await toggle_command(args.flag_name, args.description)
|
||||
except KeyboardInterrupt:
|
||||
print("\nOperation cancelled.")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
|
@ -12,14 +12,6 @@ The flow:
|
|||
2. Configure MCP servers with credentials and save to agent's configured_mcps
|
||||
3. When agent runs, it connects to MCP servers using:
|
||||
https://server.smithery.ai/{qualifiedName}/mcp?config={base64_encoded_config}&api_key={smithery_api_key}
|
||||
|
||||
Example MCP configuration stored in agent's configured_mcps:
|
||||
{
|
||||
"name": "Exa Search",
|
||||
"qualifiedName": "exa",
|
||||
"config": {"exaApiKey": "user's-exa-api-key"},
|
||||
"enabledTools": ["search", "find_similar"]
|
||||
}
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
|
|
|
@ -4,4 +4,4 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
|||
NEXT_PUBLIC_BACKEND_URL=""
|
||||
NEXT_PUBLIC_URL=""
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=""
|
||||
OPENAI_API_KEY=""
|
||||
OPENAI_API_KEY=""
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { agentPlaygroundFlagFrontend } from '@/flags';
|
||||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Agent Conversation | Kortix Suna',
|
||||
|
@ -10,10 +13,14 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export default function AgentsLayout({
|
||||
export default async function AgentsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Agent | Kortix Suna',
|
||||
|
@ -10,10 +12,14 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export default function NewAgentLayout({
|
||||
export default async function NewAgentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import { Pagination } from './_components/pagination';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { DEFAULT_AGENTPRESS_TOOLS } from './_data/tools';
|
||||
import { AgentsParams } from '@/hooks/react-query/agents/utils';
|
||||
import { useFeatureFlags } from '@/lib/feature-flags';
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
type SortOption = 'name' | 'created_at' | 'updated_at' | 'tools_count';
|
||||
|
@ -34,6 +35,7 @@ export default function AgentsPage() {
|
|||
const [editingAgentId, setEditingAgentId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
|
||||
|
||||
// Server-side parameters
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
|
|
@ -0,0 +1,238 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, Suspense, useEffect, useRef } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Menu } from 'lucide-react';
|
||||
import {
|
||||
ChatInput,
|
||||
ChatInputHandles,
|
||||
} from '@/components/thread/chat-input/chat-input';
|
||||
import {
|
||||
BillingError,
|
||||
} from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useBillingError } from '@/hooks/useBillingError';
|
||||
import { BillingErrorAlert } from '@/components/billing/usage-limit-alert';
|
||||
import { useAccounts } from '@/hooks/use-accounts';
|
||||
import { config } from '@/lib/config';
|
||||
import { useInitiateAgentWithInvalidation } from '@/hooks/react-query/dashboard/use-initiate-agent';
|
||||
import { ModalProviders } from '@/providers/modal-providers';
|
||||
import { AgentSelector } from '@/components/dashboard/agent-selector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useModal } from '@/hooks/use-modal-store';
|
||||
import { Examples } from './suggestions/examples';
|
||||
import { useThreadQuery } from '@/hooks/react-query/threads/use-threads';
|
||||
|
||||
const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
|
||||
|
||||
export function DashboardContent() {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [autoSubmit, setAutoSubmit] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | undefined>();
|
||||
const [initiatedThreadId, setInitiatedThreadId] = useState<string | null>(null);
|
||||
const { billingError, handleBillingError, clearBillingError } =
|
||||
useBillingError();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isMobile = useIsMobile();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { data: accounts } = useAccounts();
|
||||
const personalAccount = accounts?.find((account) => account.personal_account);
|
||||
const chatInputRef = useRef<ChatInputHandles>(null);
|
||||
const initiateAgentMutation = useInitiateAgentWithInvalidation();
|
||||
const { onOpen } = useModal();
|
||||
|
||||
const threadQuery = useThreadQuery(initiatedThreadId || '');
|
||||
|
||||
useEffect(() => {
|
||||
const agentIdFromUrl = searchParams.get('agent_id');
|
||||
if (agentIdFromUrl && agentIdFromUrl !== selectedAgentId) {
|
||||
setSelectedAgentId(agentIdFromUrl);
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('agent_id');
|
||||
router.replace(newUrl.pathname + newUrl.search, { scroll: false });
|
||||
}
|
||||
}, [searchParams, selectedAgentId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadQuery.data && initiatedThreadId) {
|
||||
const thread = threadQuery.data;
|
||||
console.log('Thread data received:', thread);
|
||||
if (thread.project_id) {
|
||||
router.push(`/projects/${thread.project_id}/thread/${initiatedThreadId}`);
|
||||
} else {
|
||||
router.push(`/agents/${initiatedThreadId}`);
|
||||
}
|
||||
setInitiatedThreadId(null);
|
||||
}
|
||||
}, [threadQuery.data, initiatedThreadId, router]);
|
||||
|
||||
const secondaryGradient =
|
||||
'bg-gradient-to-r from-blue-500 to-blue-500 bg-clip-text text-transparent';
|
||||
|
||||
const handleSubmit = async (
|
||||
message: string,
|
||||
options?: {
|
||||
model_name?: string;
|
||||
enable_thinking?: boolean;
|
||||
reasoning_effort?: string;
|
||||
stream?: boolean;
|
||||
enable_context_manager?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (
|
||||
(!message.trim() && !chatInputRef.current?.getPendingFiles().length) ||
|
||||
isSubmitting
|
||||
)
|
||||
return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const files = chatInputRef.current?.getPendingFiles() || [];
|
||||
localStorage.removeItem(PENDING_PROMPT_KEY);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('prompt', message);
|
||||
|
||||
// Add selected agent if one is chosen
|
||||
if (selectedAgentId) {
|
||||
formData.append('agent_id', selectedAgentId);
|
||||
}
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file, file.name);
|
||||
});
|
||||
|
||||
if (options?.model_name) formData.append('model_name', options.model_name);
|
||||
formData.append('enable_thinking', String(options?.enable_thinking ?? false));
|
||||
formData.append('reasoning_effort', options?.reasoning_effort ?? 'low');
|
||||
formData.append('stream', String(options?.stream ?? true));
|
||||
formData.append('enable_context_manager', String(options?.enable_context_manager ?? false));
|
||||
|
||||
console.log('FormData content:', Array.from(formData.entries()));
|
||||
|
||||
const result = await initiateAgentMutation.mutateAsync(formData);
|
||||
console.log('Agent initiated:', result);
|
||||
|
||||
if (result.thread_id) {
|
||||
setInitiatedThreadId(result.thread_id);
|
||||
} else {
|
||||
throw new Error('Agent initiation did not return a thread_id.');
|
||||
}
|
||||
chatInputRef.current?.clearPendingFiles();
|
||||
} catch (error: any) {
|
||||
console.error('Error during submission process:', error);
|
||||
if (error instanceof BillingError) {
|
||||
console.log('Handling BillingError:', error.detail);
|
||||
onOpen("paymentRequiredDialog");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY);
|
||||
|
||||
if (pendingPrompt) {
|
||||
setInputValue(pendingPrompt);
|
||||
setAutoSubmit(true);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSubmit && inputValue && !isSubmitting) {
|
||||
const timer = setTimeout(() => {
|
||||
handleSubmit(inputValue);
|
||||
setAutoSubmit(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoSubmit, inputValue, isSubmitting]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalProviders />
|
||||
<div className="flex flex-col h-screen w-full">
|
||||
{isMobile && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setOpenMobile(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open menu</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
|
||||
<div className="flex flex-col items-center text-center w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<h1 className="tracking-tight text-4xl text-muted-foreground leading-tight">
|
||||
Hey, I am
|
||||
</h1>
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={setSelectedAgentId}
|
||||
variant="heading"
|
||||
/>
|
||||
</div>
|
||||
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2">
|
||||
What would you like to do today?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"w-full mb-2",
|
||||
"max-w-full",
|
||||
"sm:max-w-3xl"
|
||||
)}>
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
placeholder="Describe what you need help with..."
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
hideAttachments={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Examples onSelectPrompt={setInputValue} />
|
||||
</div>
|
||||
|
||||
<BillingErrorAlert
|
||||
message={billingError?.message}
|
||||
currentUsage={billingError?.currentUsage}
|
||||
limit={billingError?.limit}
|
||||
accountId={personalAccount?.account_id}
|
||||
onDismiss={clearBillingError}
|
||||
isOpen={!!billingError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,226 +1,10 @@
|
|||
'use client';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DashboardContent } from "./_components/dashboard-content";
|
||||
import { Suspense } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { isFlagEnabled } from "@/lib/feature-flags";
|
||||
|
||||
import React, { useState, Suspense, useEffect, useRef } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Menu } from 'lucide-react';
|
||||
import {
|
||||
ChatInput,
|
||||
ChatInputHandles,
|
||||
} from '@/components/thread/chat-input/chat-input';
|
||||
import {
|
||||
BillingError,
|
||||
} from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useBillingError } from '@/hooks/useBillingError';
|
||||
import { BillingErrorAlert } from '@/components/billing/usage-limit-alert';
|
||||
import { useAccounts } from '@/hooks/use-accounts';
|
||||
import { config } from '@/lib/config';
|
||||
import { useInitiateAgentWithInvalidation } from '@/hooks/react-query/dashboard/use-initiate-agent';
|
||||
import { ModalProviders } from '@/providers/modal-providers';
|
||||
import { AgentSelector } from '@/components/dashboard/agent-selector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useModal } from '@/hooks/use-modal-store';
|
||||
import { Examples } from './_components/suggestions/examples';
|
||||
|
||||
const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
|
||||
|
||||
function DashboardContent() {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [autoSubmit, setAutoSubmit] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | undefined>();
|
||||
const { billingError, handleBillingError, clearBillingError } =
|
||||
useBillingError();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isMobile = useIsMobile();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { data: accounts } = useAccounts();
|
||||
const personalAccount = accounts?.find((account) => account.personal_account);
|
||||
const chatInputRef = useRef<ChatInputHandles>(null);
|
||||
const initiateAgentMutation = useInitiateAgentWithInvalidation();
|
||||
const { onOpen } = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
const agentIdFromUrl = searchParams.get('agent_id');
|
||||
if (agentIdFromUrl && agentIdFromUrl !== selectedAgentId) {
|
||||
setSelectedAgentId(agentIdFromUrl);
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('agent_id');
|
||||
router.replace(newUrl.pathname + newUrl.search, { scroll: false });
|
||||
}
|
||||
}, [searchParams, selectedAgentId, router]);
|
||||
|
||||
const secondaryGradient =
|
||||
'bg-gradient-to-r from-blue-500 to-blue-500 bg-clip-text text-transparent';
|
||||
|
||||
const handleSubmit = async (
|
||||
message: string,
|
||||
options?: {
|
||||
model_name?: string;
|
||||
enable_thinking?: boolean;
|
||||
reasoning_effort?: string;
|
||||
stream?: boolean;
|
||||
enable_context_manager?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (
|
||||
(!message.trim() && !chatInputRef.current?.getPendingFiles().length) ||
|
||||
isSubmitting
|
||||
)
|
||||
return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const files = chatInputRef.current?.getPendingFiles() || [];
|
||||
localStorage.removeItem(PENDING_PROMPT_KEY);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('prompt', message);
|
||||
|
||||
// Add selected agent if one is chosen
|
||||
if (selectedAgentId) {
|
||||
formData.append('agent_id', selectedAgentId);
|
||||
}
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append('files', file, file.name);
|
||||
});
|
||||
|
||||
if (options?.model_name) formData.append('model_name', options.model_name);
|
||||
formData.append('enable_thinking', String(options?.enable_thinking ?? false));
|
||||
formData.append('reasoning_effort', options?.reasoning_effort ?? 'low');
|
||||
formData.append('stream', String(options?.stream ?? true));
|
||||
formData.append('enable_context_manager', String(options?.enable_context_manager ?? false));
|
||||
|
||||
console.log('FormData content:', Array.from(formData.entries()));
|
||||
|
||||
const result = await initiateAgentMutation.mutateAsync(formData);
|
||||
console.log('Agent initiated:', result);
|
||||
|
||||
if (result.thread_id) {
|
||||
router.push(`/agents/${result.thread_id}`);
|
||||
} else {
|
||||
throw new Error('Agent initiation did not return a thread_id.');
|
||||
}
|
||||
chatInputRef.current?.clearPendingFiles();
|
||||
} catch (error: any) {
|
||||
console.error('Error during submission process:', error);
|
||||
if (error instanceof BillingError) {
|
||||
console.log('Handling BillingError:', error.detail);
|
||||
onOpen("paymentRequiredDialog");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY);
|
||||
|
||||
if (pendingPrompt) {
|
||||
setInputValue(pendingPrompt);
|
||||
setAutoSubmit(true);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSubmit && inputValue && !isSubmitting) {
|
||||
const timer = setTimeout(() => {
|
||||
handleSubmit(inputValue);
|
||||
setAutoSubmit(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoSubmit, inputValue, isSubmitting]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalProviders />
|
||||
<div className="flex flex-col h-screen w-full">
|
||||
{isMobile && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setOpenMobile(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open menu</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
|
||||
<div className="flex flex-col items-center text-center w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<h1 className="tracking-tight text-4xl text-muted-foreground leading-tight">
|
||||
Hey, I am
|
||||
</h1>
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={setSelectedAgentId}
|
||||
variant="heading"
|
||||
/>
|
||||
</div>
|
||||
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2">
|
||||
What would you like to do today?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"w-full mb-2",
|
||||
"max-w-full",
|
||||
"sm:max-w-3xl"
|
||||
)}>
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
placeholder="Describe what you need help with..."
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
hideAttachments={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Examples onSelectPrompt={setInputValue} />
|
||||
</div>
|
||||
|
||||
<BillingErrorAlert
|
||||
message={billingError?.message}
|
||||
currentUsage={billingError?.currentUsage}
|
||||
limit={billingError?.limit}
|
||||
accountId={personalAccount?.account_id}
|
||||
onDismiss={clearBillingError}
|
||||
isOpen={!!billingError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
export default async function DashboardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Agent Marketplace | Kortix Suna',
|
||||
|
@ -10,10 +12,14 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export default function MarketplaceLayout({
|
||||
export default async function MarketplaceLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import { useAgents } from '@/hooks/react-query/agents/use-agents';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CreateAgentDialog } from '@/app/(dashboard)/agents/_components/create-agent-dialog';
|
||||
import { useFeatureFlags } from '@/lib/feature-flags';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
onAgentSelect?: (agentId: string | undefined) => void;
|
||||
|
@ -27,13 +28,17 @@ export function AgentSelector({
|
|||
onAgentSelect,
|
||||
selectedAgentId,
|
||||
className,
|
||||
variant = 'default'
|
||||
variant = 'default',
|
||||
}: AgentSelectorProps) {
|
||||
const { data: agentsResponse, isLoading, refetch: loadAgents } = useAgents({
|
||||
limit: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc'
|
||||
});
|
||||
|
||||
|
||||
const { flags, loading: flagsLoading } = useFeatureFlags(['custom_agents']);
|
||||
const customAgentsEnabled = flags.custom_agents;
|
||||
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
@ -69,6 +74,18 @@ export function AgentSelector({
|
|||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!customAgentsEnabled) {
|
||||
if (variant === 'heading') {
|
||||
return (
|
||||
<div className={cn("flex items-center", className)}>
|
||||
<span className="tracking-tight text-4xl font-semibold leading-tight text-primary">
|
||||
Suna
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (variant === 'heading') {
|
||||
return (
|
||||
|
|
|
@ -82,7 +82,7 @@ export function SidebarSearch() {
|
|||
threadId: thread.thread_id,
|
||||
projectId: projectId,
|
||||
projectName: project.name || 'Unnamed Project',
|
||||
url: `/agents/${thread.thread_id}`,
|
||||
url: `/projects/${projectId}/thread/${thread.thread_id}`,
|
||||
updatedAt:
|
||||
thread.updated_at || project.updated_at || new Date().toISOString(),
|
||||
});
|
||||
|
|
|
@ -31,6 +31,7 @@ import { useIsMobile } from '@/hooks/use-mobile';
|
|||
import { Badge } from '../ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useFeatureFlags } from '@/lib/feature-flags';
|
||||
|
||||
export function SidebarLeft({
|
||||
...props
|
||||
|
@ -48,6 +49,9 @@ export function SidebarLeft({
|
|||
});
|
||||
|
||||
const pathname = usePathname();
|
||||
const { flags, loading: flagsLoading } = useFeatureFlags(['custom_agents', 'agent_marketplace']);
|
||||
const customAgentsEnabled = flags.custom_agents;
|
||||
const marketplaceEnabled = flags.agent_marketplace;
|
||||
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
|
@ -134,35 +138,40 @@ export function SidebarLeft({
|
|||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
||||
<SidebarGroup>
|
||||
<Link href="/agents">
|
||||
<SidebarMenuButton className={cn({
|
||||
'bg-primary/10 font-medium': pathname === '/agents',
|
||||
})}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-between w-full">
|
||||
Agent Playground
|
||||
<Badge variant="new">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
|
||||
<Link href="/marketplace">
|
||||
<SidebarMenuButton className={cn({
|
||||
'bg-primary/10 font-medium': pathname === '/marketplace',
|
||||
})}>
|
||||
<Store className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-between w-full">
|
||||
Marketplace
|
||||
<Badge variant="new">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarGroup>
|
||||
{!flagsLoading && (customAgentsEnabled || marketplaceEnabled) && (
|
||||
<SidebarGroup>
|
||||
{customAgentsEnabled && (
|
||||
<Link href="/agents">
|
||||
<SidebarMenuButton className={cn({
|
||||
'bg-primary/10 font-medium': pathname === '/agents',
|
||||
})}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-between w-full">
|
||||
Agent Playground
|
||||
<Badge variant="new">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
)}
|
||||
{marketplaceEnabled && (
|
||||
<Link href="/marketplace">
|
||||
<SidebarMenuButton className={cn({
|
||||
'bg-primary/10 font-medium': pathname === '/marketplace',
|
||||
})}>
|
||||
<Store className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-between w-full">
|
||||
Marketplace
|
||||
<Badge variant="new">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)}
|
||||
<NavAgents />
|
||||
</SidebarContent>
|
||||
{state !== 'collapsed' && (
|
||||
|
|
|
@ -250,17 +250,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
}}
|
||||
>
|
||||
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
||||
<CardContent className={`w-full p-1.5 pb-2 ${bgColor} rounded-2xl border`}>
|
||||
{onAgentSelect && (
|
||||
<div className="mb-2 px-2">
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={onAgentSelect}
|
||||
disabled={loading || disabled}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className={`w-full p-1.5 pb-2 ${bgColor} rounded-2xl border`}>
|
||||
<AttachmentGroup
|
||||
files={uploadedFiles || []}
|
||||
sandboxId={sandboxId}
|
||||
|
@ -269,7 +259,6 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
maxHeight="216px"
|
||||
showPreviews={true}
|
||||
/>
|
||||
|
||||
<MessageInput
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { isLocalMode } from './lib/config';
|
||||
|
||||
export const agentPlaygroundFlagFrontend = isLocalMode();
|
||||
export const marketplaceFlagFrontend = isLocalMode();
|
||||
|
||||
export const agentPlaygroundEnabled = isLocalMode();
|
||||
export const marketplaceEnabled = isLocalMode();
|
|
@ -1,4 +1,5 @@
|
|||
import { createClient } from "@/lib/supabase/client";
|
||||
import { isFlagEnabled } from "@/lib/feature-flags";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
||||
|
@ -98,6 +99,10 @@ export type AgentUpdateRequest = {
|
|||
|
||||
export const getAgents = async (params: AgentsParams = {}): Promise<AgentsResponse> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -142,6 +147,10 @@ export const getAgents = async (params: AgentsParams = {}): Promise<AgentsRespon
|
|||
|
||||
export const getAgent = async (agentId: string): Promise<Agent> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -173,6 +182,10 @@ export const getAgent = async (agentId: string): Promise<Agent> => {
|
|||
|
||||
export const createAgent = async (agentData: AgentCreateRequest): Promise<Agent> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -205,6 +218,10 @@ export const createAgent = async (agentData: AgentCreateRequest): Promise<Agent>
|
|||
|
||||
export const updateAgent = async (agentId: string, agentData: AgentUpdateRequest): Promise<Agent> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -237,6 +254,10 @@ export const updateAgent = async (agentId: string, agentData: AgentUpdateRequest
|
|||
|
||||
export const deleteAgent = async (agentId: string): Promise<void> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -266,6 +287,10 @@ export const deleteAgent = async (agentId: string): Promise<void> => {
|
|||
|
||||
export const getThreadAgent = async (threadId: string): Promise<ThreadAgentResponse> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -297,6 +322,10 @@ export const getThreadAgent = async (threadId: string): Promise<ThreadAgentRespo
|
|||
|
||||
export const getAgentBuilderChatHistory = async (agentId: string): Promise<{messages: any[], thread_id: string | null}> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -364,6 +393,10 @@ export const startAgentBuilderChat = async (
|
|||
signal?: AbortSignal
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
|
||||
if (!agentPlaygroundEnabled) {
|
||||
throw new Error('Custom agents is not enabled');
|
||||
}
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { isFlagEnabled } from '@/lib/feature-flags';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
||||
|
@ -45,6 +46,11 @@ export function useMarketplaceAgents(params: MarketplaceAgentsParams = {}) {
|
|||
queryKey: ['marketplace-agents', params],
|
||||
queryFn: async (): Promise<MarketplaceAgentsResponse> => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -97,6 +103,11 @@ export function useAddAgentToLibrary() {
|
|||
return useMutation({
|
||||
mutationFn: async (originalAgentId: string): Promise<string> => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -138,6 +149,11 @@ export function usePublishAgent() {
|
|||
return useMutation({
|
||||
mutationFn: async ({ agentId, tags = [] }: { agentId: string; tags?: string[] }): Promise<void> => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -175,6 +191,11 @@ export function useUnpublishAgent() {
|
|||
return useMutation({
|
||||
mutationFn: async (agentId: string): Promise<void> => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
@ -211,6 +232,11 @@ export function useUserAgentLibrary() {
|
|||
queryKey: ['user-agent-library'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
|
||||
if (!marketplaceEnabled) {
|
||||
throw new Error('Marketplace is not enabled');
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
|
|
|
@ -115,7 +115,7 @@ export const processThreadsWithProjects = (
|
|||
threadId: thread.thread_id,
|
||||
projectId: projectId,
|
||||
projectName: project.name || 'Unnamed Project',
|
||||
url: `/agents/${thread.thread_id}`,
|
||||
url: `/projects/${projectId}/thread/${thread.thread_id}`,
|
||||
updatedAt:
|
||||
thread.updated_at || project.updated_at || new Date().toISOString(),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
import React from 'react';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
||||
export interface FeatureFlag {
|
||||
flag_name: string;
|
||||
enabled: boolean;
|
||||
details?: {
|
||||
description?: string;
|
||||
updated_at?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface FeatureFlagsResponse {
|
||||
flags: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const flagCache = new Map<string, { value: boolean; timestamp: number }>();
|
||||
const CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
let globalFlagsCache: { flags: Record<string, boolean>; timestamp: number } | null = null;
|
||||
|
||||
export class FeatureFlagManager {
|
||||
private static instance: FeatureFlagManager;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): FeatureFlagManager {
|
||||
if (!FeatureFlagManager.instance) {
|
||||
FeatureFlagManager.instance = new FeatureFlagManager();
|
||||
}
|
||||
return FeatureFlagManager.instance;
|
||||
}
|
||||
|
||||
async isEnabled(flagName: string): Promise<boolean> {
|
||||
try {
|
||||
const cached = flagCache.get(flagName);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return cached.value;
|
||||
}
|
||||
const response = await fetch(`${API_URL}/feature-flags/${flagName}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch feature flag ${flagName}: ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data: FeatureFlag = await response.json();
|
||||
|
||||
flagCache.set(flagName, {
|
||||
value: data.enabled,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return data.enabled;
|
||||
} catch (error) {
|
||||
console.error(`Error checking feature flag ${flagName}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getFlagDetails(flagName: string): Promise<FeatureFlag | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/feature-flags/${flagName}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch feature flag details for ${flagName}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: FeatureFlag = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching feature flag details for ${flagName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllFlags(): Promise<Record<string, boolean>> {
|
||||
try {
|
||||
if (globalFlagsCache && Date.now() - globalFlagsCache.timestamp < CACHE_DURATION) {
|
||||
return globalFlagsCache.flags;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/feature-flags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch all feature flags: ${response.status}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const data: FeatureFlagsResponse = await response.json();
|
||||
globalFlagsCache = {
|
||||
flags: data.flags,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
Object.entries(data.flags).forEach(([flagName, enabled]) => {
|
||||
flagCache.set(flagName, {
|
||||
value: enabled,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
return data.flags;
|
||||
} catch (error) {
|
||||
console.error('Error fetching all feature flags:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
flagCache.clear();
|
||||
globalFlagsCache = null;
|
||||
}
|
||||
|
||||
async preloadFlags(flagNames: string[]): Promise<void> {
|
||||
try {
|
||||
const promises = flagNames.map(flagName => this.isEnabled(flagName));
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
console.error('Error preloading feature flags:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const featureFlagManager = FeatureFlagManager.getInstance();
|
||||
|
||||
export const isEnabled = (flagName: string): Promise<boolean> => {
|
||||
return featureFlagManager.isEnabled(flagName);
|
||||
};
|
||||
|
||||
export const isFlagEnabled = isEnabled;
|
||||
|
||||
export const getFlagDetails = (flagName: string): Promise<FeatureFlag | null> => {
|
||||
return featureFlagManager.getFlagDetails(flagName);
|
||||
};
|
||||
|
||||
export const getAllFlags = (): Promise<Record<string, boolean>> => {
|
||||
return featureFlagManager.getAllFlags();
|
||||
};
|
||||
|
||||
export const clearFlagCache = (): void => {
|
||||
featureFlagManager.clearCache();
|
||||
};
|
||||
|
||||
export const preloadFlags = (flagNames: string[]): Promise<void> => {
|
||||
return featureFlagManager.preloadFlags(flagNames);
|
||||
};
|
||||
|
||||
export const useFeatureFlag = (flagName: string) => {
|
||||
const [enabled, setEnabled] = React.useState<boolean>(false);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkFlag = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await isFlagEnabled(flagName);
|
||||
if (mounted) {
|
||||
setEnabled(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setEnabled(false);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkFlag();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [flagName]);
|
||||
|
||||
return { enabled, loading, error };
|
||||
};
|
||||
|
||||
export const useFeatureFlags = (flagNames: string[]) => {
|
||||
const [flags, setFlags] = React.useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkFlags = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const results = await Promise.all(
|
||||
flagNames.map(async (flagName) => {
|
||||
const enabled = await isFlagEnabled(flagName);
|
||||
return [flagName, enabled] as [string, boolean];
|
||||
})
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
const flagsObject = Object.fromEntries(results);
|
||||
setFlags(flagsObject);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
const disabledFlags = Object.fromEntries(
|
||||
flagNames.map(name => [name, false])
|
||||
);
|
||||
setFlags(disabledFlags);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (flagNames.length > 0) {
|
||||
checkFlags();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [flagNames.join(',')]);
|
||||
|
||||
return { flags, loading, error };
|
||||
};
|
Loading…
Reference in New Issue