diff --git a/backend/README.md b/backend/README.md index ad42b76e..389cf192 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 [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 diff --git a/backend/agent/api.py b/backend/agent/api.py index 4d324f4f..6832a2f8 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -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 diff --git a/backend/api.py b/backend/api.py index 6f07e4d8..65fcf5c7 100644 --- a/backend/api.py +++ b/backend/api.py @@ -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") diff --git a/backend/flags/__init__.py b/backend/flags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/flags/api.py b/backend/flags/api.py new file mode 100644 index 00000000..b890d1c4 --- /dev/null +++ b/backend/flags/api.py @@ -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 + } \ No newline at end of file diff --git a/backend/flags/flags.py b/backend/flags/flags.py new file mode 100644 index 00000000..7277aaf1 --- /dev/null +++ b/backend/flags/flags.py @@ -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() diff --git a/backend/flags/setup.py b/backend/flags/setup.py new file mode 100644 index 00000000..3ce5d057 --- /dev/null +++ b/backend/flags/setup.py @@ -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()) \ No newline at end of file diff --git a/backend/mcp_local/api.py b/backend/mcp_local/api.py index 60312ffa..b6deaaab 100644 --- a/backend/mcp_local/api.py +++ b/backend/mcp_local/api.py @@ -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 diff --git a/frontend/.env.example b/frontend/.env.example index 6500b295..ea1747a6 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,4 +4,4 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY="" NEXT_PUBLIC_BACKEND_URL="" NEXT_PUBLIC_URL="" NEXT_PUBLIC_GOOGLE_CLIENT_ID="" -OPENAI_API_KEY="" \ No newline at end of file +OPENAI_API_KEY="" diff --git a/frontend/src/app/(dashboard)/agents/layout.tsx b/frontend/src/app/(dashboard)/agents/layout.tsx index b13139a9..d0771499 100644 --- a/frontend/src/app/(dashboard)/agents/layout.tsx +++ b/frontend/src/app/(dashboard)/agents/layout.tsx @@ -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}; } diff --git a/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx b/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx index 5c2902d5..72782f20 100644 --- a/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx +++ b/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx @@ -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}; } diff --git a/frontend/src/app/(dashboard)/agents/page.tsx b/frontend/src/app/(dashboard)/agents/page.tsx index db49b818..7d96c6d4 100644 --- a/frontend/src/app/(dashboard)/agents/page.tsx +++ b/frontend/src/app/(dashboard)/agents/page.tsx @@ -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(null); const [viewMode, setViewMode] = useState('grid'); + // Server-side parameters const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(''); diff --git a/frontend/src/app/(dashboard)/dashboard/_components/dashboard-content.tsx b/frontend/src/app/(dashboard)/dashboard/_components/dashboard-content.tsx new file mode 100644 index 00000000..6021f0a2 --- /dev/null +++ b/frontend/src/app/(dashboard)/dashboard/_components/dashboard-content.tsx @@ -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(); + const [initiatedThreadId, setInitiatedThreadId] = useState(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(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 ( + <> + +
+ {isMobile && ( +
+ + + + + Open menu + +
+ )} + +
+
+
+

+ Hey, I am +

+ +
+

+ What would you like to do today? +

+
+ +
+ +
+ + +
+ + +
+ + ); +} diff --git a/frontend/src/app/(dashboard)/dashboard/page.tsx b/frontend/src/app/(dashboard)/dashboard/page.tsx index fff1574d..ef80c942 100644 --- a/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -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(); - 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(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 ( - <> - -
- {isMobile && ( -
- - - - - Open menu - -
- )} - -
-
-
-

- Hey, I am -

- -
-

- What would you like to do today? -

-
- -
- -
- - -
- - -
- - ); -} - -export default function DashboardPage() { +export default async function DashboardPage() { return ( {children}; } diff --git a/frontend/src/components/dashboard/agent-selector.tsx b/frontend/src/components/dashboard/agent-selector.tsx index 6bf58337..1c2cd869 100644 --- a/frontend/src/components/dashboard/agent-selector.tsx +++ b/frontend/src/components/dashboard/agent-selector.tsx @@ -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 ( +
+ + Suna + +
+ ); + } + } + if (isLoading) { if (variant === 'heading') { return ( diff --git a/frontend/src/components/sidebar/search-search.tsx b/frontend/src/components/sidebar/search-search.tsx index b797a713..45700b91 100644 --- a/frontend/src/components/sidebar/search-search.tsx +++ b/frontend/src/components/sidebar/search-search.tsx @@ -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(), }); diff --git a/frontend/src/components/sidebar/sidebar-left.tsx b/frontend/src/components/sidebar/sidebar-left.tsx index d3286878..a984d20f 100644 --- a/frontend/src/components/sidebar/sidebar-left.tsx +++ b/frontend/src/components/sidebar/sidebar-left.tsx @@ -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({ - - - - - - Agent Playground - - New - - - - - - - - - - Marketplace - - New - - - - - + {!flagsLoading && (customAgentsEnabled || marketplaceEnabled) && ( + + {customAgentsEnabled && ( + + + + + Agent Playground + + New + + + + + )} + {marketplaceEnabled && ( + + + + + Marketplace + + New + + + + + )} + + )} {state !== 'collapsed' && ( diff --git a/frontend/src/components/thread/chat-input/chat-input.tsx b/frontend/src/components/thread/chat-input/chat-input.tsx index afe4456b..dbe4ad41 100644 --- a/frontend/src/components/thread/chat-input/chat-input.tsx +++ b/frontend/src/components/thread/chat-input/chat-input.tsx @@ -250,17 +250,7 @@ export const ChatInput = forwardRef( }} >
- - {onAgentSelect && ( -
- -
- )} + ( maxHeight="216px" showPreviews={true} /> - => { 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 => { 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 => { export const createAgent = async (agentData: AgentCreateRequest): Promise => { 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 export const updateAgent = async (agentId: string, agentData: AgentUpdateRequest): Promise => { 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 => { 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 => { export const getThreadAgent = async (threadId: string): Promise => { 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 => { 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 => { 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(); diff --git a/frontend/src/hooks/react-query/marketplace/use-marketplace.ts b/frontend/src/hooks/react-query/marketplace/use-marketplace.ts index d8912dd6..4610d86a 100644 --- a/frontend/src/hooks/react-query/marketplace/use-marketplace.ts +++ b/frontend/src/hooks/react-query/marketplace/use-marketplace.ts @@ -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 => { 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 => { 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 => { 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 => { 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(); diff --git a/frontend/src/hooks/react-query/sidebar/use-sidebar.ts b/frontend/src/hooks/react-query/sidebar/use-sidebar.ts index 19c12739..14bb3b26 100644 --- a/frontend/src/hooks/react-query/sidebar/use-sidebar.ts +++ b/frontend/src/hooks/react-query/sidebar/use-sidebar.ts @@ -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(), }); diff --git a/frontend/src/lib/feature-flags.ts b/frontend/src/lib/feature-flags.ts new file mode 100644 index 00000000..c798d73a --- /dev/null +++ b/frontend/src/lib/feature-flags.ts @@ -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; +} + +const flagCache = new Map(); +const CACHE_DURATION = 5 * 60 * 1000; + +let globalFlagsCache: { flags: Record; 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 { + 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 { + 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> { + 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 { + 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 => { + return featureFlagManager.isEnabled(flagName); +}; + +export const isFlagEnabled = isEnabled; + +export const getFlagDetails = (flagName: string): Promise => { + return featureFlagManager.getFlagDetails(flagName); +}; + +export const getAllFlags = (): Promise> => { + return featureFlagManager.getAllFlags(); +}; + +export const clearFlagCache = (): void => { + featureFlagManager.clearCache(); +}; + +export const preloadFlags = (flagNames: string[]): Promise => { + return featureFlagManager.preloadFlags(flagNames); +}; + +export const useFeatureFlag = (flagName: string) => { + const [enabled, setEnabled] = React.useState(false); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(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>({}); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(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 }; +};