Merge pull request #634 from escapade-mckv/feature-flags

feat: feature flags using redis
This commit is contained in:
Bobbie 2025-06-05 16:28:35 +05:30 committed by GitHub
commit ef28b0a74a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1174 additions and 278 deletions

View File

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

View File

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

View File

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

View File

32
backend/flags/api.py Normal file
View File

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

151
backend/flags/flags.py Normal file
View File

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

173
backend/flags/setup.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

7
frontend/src/flags.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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