diff --git a/SCENARIO_UPLOAD_DOCUMENTATION.md b/SCENARIO_UPLOAD_DOCUMENTATION.md new file mode 100644 index 000000000..bc5817441 --- /dev/null +++ b/SCENARIO_UPLOAD_DOCUMENTATION.md @@ -0,0 +1,263 @@ +# Scenario Upload and Agent Creation Feature + +## Overview + +The `/api/upload_scenarios` endpoint allows users to upload JSON files containing scenario definitions and automatically create agents in Azure AI Foundry. This feature includes comprehensive RAI (Responsible AI) validation to ensure all content meets safety guidelines. + +## API Endpoint + +### POST `/api/upload_scenarios` + +Upload scenario data and create agents in Azure AI Foundry. + +**Headers:** +- `user_principal_id`: User ID extracted from authentication header (required) + +**Parameters:** +- `file`: JSON file containing scenario data (required) + +**Response Codes:** +- `200`: Scenarios processed and agents created successfully +- `400`: Invalid request, file format, or RAI validation failure +- `401`: Missing or invalid user information +- `500`: Internal server error + +## JSON Structure + +The uploaded JSON file must follow this structure: + +```json +{ + "scenarios": [ + { + "name": "Scenario Name", + "description": "Scenario description", + "agents": [ + { + "name": "Agent Name", + "description": "Agent description", + "instructions": "Detailed agent instructions", + "deployment_name": "model-deployment-name", + "type": "agent_type", + "tools": [ + { + "type": "function", + "function": { + "name": "tool_name", + "description": "Tool description" + } + } + ] + } + ] + } + ] +} +``` + +### Required Fields + +**Scenario Level:** +- `name`: Scenario name +- `description`: Scenario description +- `agents`: Array of agent definitions + +**Agent Level:** +- `name`: Agent name (must be unique) +- `description`: Agent description +- `instructions`: Agent system instructions +- `deployment_name`: Model deployment name in Azure AI Foundry +- `type`: Agent type identifier + +**Optional Fields:** +- `tools`: Array of tool definitions for the agent + +## Validation Process + +The endpoint performs the following validation steps: + +### 1. File Validation +- Verifies file is provided +- Checks file has `.json` extension +- Validates JSON syntax + +### 2. Structure Validation +- Ensures JSON contains valid object +- Validates presence of `scenarios` array +- Checks that at least one scenario is provided + +### 3. RAI (Responsible AI) Validation +- Validates all description and instruction fields +- Rejects content containing: + - Harmful or dangerous instructions + - Biased or discriminatory content + - Inappropriate or offensive material + - Content violating ethical AI principles + +### 4. Agent Creation +- Extracts agent configurations from scenarios +- Creates agents in Azure AI Foundry using the AI Project client +- Tracks success and failure counts + +## Response Format + +### Success Response +```json +{ + "status": "success", + "message": "Successfully created all 5 agents in Azure AI Foundry", + "results": { + "total_agents": 5, + "created_count": 5, + "failed_count": 0, + "created_agents": [ + { + "name": "Agent Name", + "agent_id": "agent-id-in-foundry", + "scenario": "Scenario Name", + "deployment_name": "gpt-4o" + } + ], + "failed_agents": [] + } +} +``` + +### Partial Success Response +```json +{ + "status": "partial_success", + "message": "Created 3 agents successfully, but 2 failed", + "results": { + "total_agents": 5, + "created_count": 3, + "failed_count": 2, + "created_agents": [...], + "failed_agents": [ + { + "name": "Failed Agent", + "scenario": "Scenario Name", + "error": "Error message" + } + ] + } +} +``` + +### Error Response +```json +{ + "detail": "RAI validation failed: Content contains inappropriate material" +} +``` + +## Implementation Details + +### Services + +**FoundryAgentService** (`src/backend/services/foundry_agent_service.py`) +- Handles scenario processing and agent creation +- Integrates with Azure AI Foundry API +- Manages RAI validation workflow + +### Key Methods + +1. **`validate_scenario_descriptions()`** + - Uses existing RAI validation infrastructure + - Validates all text content in scenarios + +2. **`extract_scenarios_and_agents()`** + - Parses JSON structure + - Extracts agent configurations + - Validates required fields + +3. **`create_foundry_agent()`** + - Creates individual agents in Azure AI Foundry + - Handles API errors gracefully + +4. **`create_agents_from_scenarios()`** + - Orchestrates the complete workflow + - Returns comprehensive results + +### Error Handling + +- **RAI Failures**: Complete rejection of file processing +- **Individual Agent Failures**: Partial success with detailed error reporting +- **API Errors**: Graceful handling with retry logic where appropriate +- **Validation Errors**: Clear error messages for structure issues + +## Usage Examples + +### Example 1: Customer Support Scenario +```json +{ + "scenarios": [ + { + "name": "Customer Support Automation", + "description": "Multi-agent customer support system", + "agents": [ + { + "name": "Triage Agent", + "description": "Routes customer inquiries to appropriate specialists", + "instructions": "Categorize and route customer inquiries professionally and efficiently", + "deployment_name": "gpt-4o", + "type": "support_triage" + } + ] + } + ] +} +``` + +### Example 2: E-commerce Processing +```json +{ + "scenarios": [ + { + "name": "Order Processing", + "description": "Automated order validation and fulfillment", + "agents": [ + { + "name": "Order Validator", + "description": "Validates incoming orders", + "instructions": "Review orders for completeness and accuracy", + "deployment_name": "gpt-35-turbo", + "type": "order_validation", + "tools": [ + { + "type": "function", + "function": { + "name": "validate_payment", + "description": "Validate payment information" + } + } + ] + } + ] + } + ] +} +``` + +## Testing + +Use the provided test files: +- `example_scenarios.json`: Valid scenario data for testing success path +- `test_scenarios_rai_fail.json`: Invalid content for testing RAI validation +- `test_scenario_processing.py`: Test script for local validation + +## Security Considerations + +1. **Authentication**: Requires valid user authentication +2. **RAI Validation**: Mandatory content safety checks +3. **Input Validation**: Comprehensive structure and format validation +4. **Error Handling**: No sensitive information exposed in error messages +5. **Audit Trail**: All operations are logged and tracked + +## Future Enhancements + +1. **Batch Processing**: Support for larger scenario uploads +2. **Template Library**: Pre-built scenario templates +3. **Agent Versioning**: Version management for created agents +4. **Advanced Tools**: Support for more complex tool definitions +5. **Integration Testing**: Automated testing of created agents diff --git a/TEAM_CONFIG_VALIDATION.md b/TEAM_CONFIG_VALIDATION.md new file mode 100644 index 000000000..f3ab31771 --- /dev/null +++ b/TEAM_CONFIG_VALIDATION.md @@ -0,0 +1,170 @@ +# Team Configuration Upload Validation + +This document describes the validation system for team configuration JSON uploads in the Multi-Agent Custom Automation Engine. + +## Validation Pipeline + +When a team configuration JSON file is uploaded, it goes through the following validation steps: + +### 1. JSON Format Validation +- Validates that the file contains valid JSON +- Checks for proper syntax and structure + +### 2. RAI (Responsible AI) Content Validation +- Validates content safety and appropriateness +- Checks agent `system_message`, `name`, and `description` fields +- Checks starting task `name` and `prompt` fields +- Rejects configurations with harmful, inappropriate, or unsafe content + +### 3. Model Deployment Validation +- Validates that required AI models are deployed in Azure AI Foundry +- Extracts model references from agent configurations +- Checks against available model deployments +- Provides detailed error messages for missing models + +### 4. RAG Search Index Validation (Conditional) +- **Only runs when the team configuration contains RAG agents** +- Validates that referenced search indexes exist in Azure AI Search +- Checks agents with `type: "RAG"` or non-empty `index_name` fields +- Provides specific error messages for missing or inaccessible indexes + +### 5. Schema Validation +- Validates required fields based on the team configuration schema +- Ensures proper data types and structure +- Generates timestamps and IDs automatically + +## Team Configuration Schema + +```json +{ + "name": "string (required)", + "description": "string (optional)", + "status": "string (required)", + "agents": [ + { + "input_key": "string (required)", + "type": "string (required)", // "MagenticOne", "RAG", "Custom" + "name": "string (required)", + "system_message": "string (optional)", + "description": "string (optional)", + "icon": "string (required)", + "index_name": "string (optional)", // Required for RAG agents + "model_name": "string (optional)" + } + ], + "starting_tasks": [ + { + "id": "string (required)", + "name": "string (required)", + "prompt": "string (required)", + "created": "string (required)", + "creator": "string (required)", + "logo": "string (required)" + } + ] +} +``` + +## RAG Agent Detection + +The system identifies RAG agents by: +1. **Agent type**: `"type": "RAG"` +2. **Index name**: Non-empty `index_name` field + +If RAG agents are detected, the system validates that: +- Azure Search endpoint is configured (`AZURE_SEARCH_ENDPOINT`) +- All referenced search indexes exist and are accessible +- Proper authentication is available + +## Error Messages + +### RAI Validation Error +``` +āŒ Content Safety Check Failed + +Your team configuration contains content that doesn't meet our safety guidelines. Please review and modify: + +• Agent instructions and descriptions +• Task prompts and content +• Team descriptions + +Ensure all content is appropriate, helpful, and follows ethical AI principles. +``` + +### Model Validation Error +``` +The following required models are not deployed in your Azure AI project: gpt-4o, text-embedding-ada-002. +Please deploy these models in Azure AI Foundry before uploading this team configuration. +``` + +### RAG Search Index Error +``` +šŸ” RAG Search Configuration Error + +Your team configuration includes RAG/search agents but has search index issues: + +• Verify search index names are correct +• Ensure indexes exist in Azure AI Search +• Check access permissions to search service +• Confirm RAG agent configurations + +RAG agents require properly configured search indexes to function correctly. +``` + +## Test Files + +The following test files are provided for validation testing: + +### `test_team_no_rag.json` +- Basic team configuration without RAG functionality +- Should pass all validation steps +- Uses only `MagenticOne` and `Custom` agent types + +### `test_team_with_rag.json` +- Team configuration with RAG agents +- Includes agents with `type: "RAG"` and `index_name` fields +- Will trigger search index validation +- Should fail if referenced indexes don't exist + +### `test_team_rai_validation.json` +- Team configuration with inappropriate content +- Designed to test RAI validation +- Should fail RAI validation with helpful error message + +## Environment Variables + +Required environment variables for validation: + +```bash +# For RAI validation +AZURE_OPENAI_ENDPOINT=your_openai_endpoint +AZURE_OPENAI_API_KEY=your_openai_key + +# For model validation +AZURE_AI_PROJECT_NAME=your_project_name +AZURE_AI_HUB_NAME=your_hub_name +AZURE_SUBSCRIPTION_ID=your_subscription_id +AZURE_RESOURCE_GROUP_NAME=your_resource_group + +# For RAG search validation (optional, only needed if using RAG agents) +AZURE_SEARCH_ENDPOINT=your_search_endpoint +AZURE_SEARCH_KEY=your_search_key +``` + +## API Endpoints + +### Upload Team Configuration +- **POST** `/upload_team_config` +- Accepts multipart/form-data with JSON file +- Returns success or detailed validation errors + +### Debug Endpoints +- **GET** `/api/model_deployments` - List available model deployments +- **GET** `/api/search_indexes` - List available search indexes (if configured) + +## Performance Considerations + +- **RAG validation is conditional** - only runs when RAG agents are present +- **Validation is parallel** - multiple checks run simultaneously when possible +- **Early termination** - validation stops at first failure to provide quick feedback +- **Caching** - Model and search index information is cached for performance diff --git a/example_scenarios.json b/example_scenarios.json new file mode 100644 index 000000000..0a0ccb395 --- /dev/null +++ b/example_scenarios.json @@ -0,0 +1,94 @@ +{ + "scenarios": [ + { + "name": "Customer Support Automation", + "description": "Automated customer support scenario with multiple specialized agents", + "agents": [ + { + "name": "Support Triage Agent", + "description": "First-line support agent that categorizes and routes customer inquiries", + "instructions": "You are a customer support triage agent. Your role is to analyze incoming customer inquiries, categorize them by urgency and type, and route them to the appropriate specialist. Always be polite, helpful, and ensure customers feel heard.", + "deployment_name": "gpt-4o", + "type": "support_triage", + "tools": [ + { + "type": "function", + "function": { + "name": "categorize_inquiry", + "description": "Categorize customer inquiry by type and urgency" + } + } + ] + }, + { + "name": "Technical Support Agent", + "description": "Specialized agent for handling technical issues and troubleshooting", + "instructions": "You are a technical support specialist. Help customers resolve technical issues with detailed, step-by-step guidance. Ask clarifying questions when needed and provide clear explanations.", + "deployment_name": "gpt-4o", + "type": "technical_support", + "tools": [ + { + "type": "function", + "function": { + "name": "check_system_status", + "description": "Check system status and known issues" + } + } + ] + }, + { + "name": "Billing Support Agent", + "description": "Agent specialized in handling billing inquiries and account management", + "instructions": "You are a billing support specialist. Help customers with billing questions, payment issues, and account management. Always verify customer identity before discussing account details.", + "deployment_name": "gpt-35-turbo", + "type": "billing_support" + } + ] + }, + { + "name": "E-commerce Order Processing", + "description": "Automated order processing and fulfillment scenario", + "agents": [ + { + "name": "Order Validation Agent", + "description": "Validates incoming orders for completeness and accuracy", + "instructions": "You are an order validation agent. Review incoming orders to ensure all required information is present, validate payment details, and check inventory availability.", + "deployment_name": "gpt-4o", + "type": "order_validation", + "tools": [ + { + "type": "function", + "function": { + "name": "validate_payment", + "description": "Validate payment information and process authorization" + } + }, + { + "type": "function", + "function": { + "name": "check_inventory", + "description": "Check product availability and inventory levels" + } + } + ] + }, + { + "name": "Fulfillment Coordinator", + "description": "Coordinates order fulfillment and shipping arrangements", + "instructions": "You are a fulfillment coordinator. Once orders are validated, coordinate with warehouses, arrange shipping, and provide tracking information to customers.", + "deployment_name": "gpt-35-turbo", + "type": "fulfillment", + "tools": [ + { + "type": "function", + "function": { + "name": "arrange_shipping", + "description": "Arrange shipping and generate tracking numbers" + } + } + ] + } + ] + } + ] +} diff --git a/src/backend/app.py b/src/backend/app.py index d7356cd75..abf1170dd 100644 --- a/src/backend/app.py +++ b/src/backend/app.py @@ -16,7 +16,7 @@ from config_kernel import Config from dateutil import parser from event_utils import track_event_if_configured -from v3.api.router import v3 as api_v3 +from v3.api.router import api_v3 # FastAPI imports from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File diff --git a/src/backend/app_kernel.py b/src/backend/app_kernel.py index afd6015ff..95dd94c5d 100644 --- a/src/backend/app_kernel.py +++ b/src/backend/app_kernel.py @@ -39,11 +39,20 @@ TeamConfiguration, ) from services.json_service import JsonService +from services.model_validation_service import ModelValidationService +from services.search_validation_service import SearchValidationService + # Updated import for KernelArguments -from utils_kernel import initialize_runtime_and_context, rai_success +from utils_kernel import ( + initialize_runtime_and_context, + rai_success, + rai_validate_team_config, +) + from v3.orchestration.manager import OnboardingOrchestrationManager from v3.scenarios.onboarding_cases import MagenticScenarios +from v3.api.router import api_v3 # Check if the Application Insights Instrumentation Key is set in the environment variables connection_string = os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") @@ -91,6 +100,8 @@ # Configure health check app.add_middleware(HealthCheckMiddleware, password="", checks={}) +# v3 endpoints +app.include_router(api_v3) logging.info("Added health check middleware") @@ -1597,6 +1608,101 @@ async def upload_team_config_endpoint(request: Request, file: UploadFile = File( status_code=400, detail=f"Invalid JSON format: {str(e)}" ) + # Validate content with RAI before processing + rai_valid, rai_error = await rai_validate_team_config(json_data) + if not rai_valid: + # Track RAI validation failure + track_event_if_configured( + "Team configuration RAI validation failed", + { + "status": "failed", + "user_id": user_id, + "filename": file.filename, + "reason": rai_error, + }, + ) + raise HTTPException(status_code=400, detail=rai_error) + + # Track successful RAI validation + track_event_if_configured( + "Team configuration RAI validation passed", + { + "status": "passed", + "user_id": user_id, + "filename": file.filename, + }, + ) + + # Validate model deployments + model_validator = ModelValidationService() + models_valid, missing_models = await model_validator.validate_team_models( + json_data + ) + + if not models_valid: + error_message = ( + f"The following required models are not deployed in your Azure AI project: {', '.join(missing_models)}. " + f"Please deploy these models in Azure AI Foundry before uploading this team configuration." + ) + + # Track model validation failure + track_event_if_configured( + "Team configuration model validation failed", + { + "status": "failed", + "user_id": user_id, + "filename": file.filename, + "missing_models": missing_models, + }, + ) + + raise HTTPException(status_code=400, detail=error_message) + + # Track successful model validation + track_event_if_configured( + "Team configuration model validation passed", + { + "status": "passed", + "user_id": user_id, + "filename": file.filename, + }, + ) + + # Validate search indexes + search_validator = SearchValidationService() + search_valid, search_errors = ( + await search_validator.validate_team_search_indexes(json_data) + ) + + if not search_valid: + error_message = ( + f"Search index validation failed:\n\n{chr(10).join([f'• {error}' for error in search_errors])}\n\n" + f"Please ensure all referenced search indexes exist in your Azure AI Search service." + ) + + # Track search validation failure + track_event_if_configured( + "Team configuration search validation failed", + { + "status": "failed", + "user_id": user_id, + "filename": file.filename, + "search_errors": search_errors, + }, + ) + + raise HTTPException(status_code=400, detail=error_message) + + # Track successful search validation + track_event_if_configured( + "Team configuration search validation passed", + { + "status": "passed", + "user_id": user_id, + "filename": file.filename, + }, + ) + # Initialize memory store and service kernel, memory_store = await initialize_runtime_and_context("", user_id) json_service = JsonService(memory_store) @@ -1877,6 +1983,73 @@ async def delete_team_config_endpoint(config_id: str, request: Request): raise HTTPException(status_code=500, detail="Internal server error occurred") +@app.get("/api/model_deployments") +async def get_model_deployments_endpoint(request: Request): + """ + Get information about available model deployments for debugging/validation. + + --- + tags: + - Model Validation + responses: + 200: + description: List of available model deployments + 401: + description: Missing or invalid user information + """ + # Validate user authentication + authenticated_user = get_authenticated_user_details(request_headers=request.headers) + user_id = authenticated_user["user_principal_id"] + if not user_id: + raise HTTPException( + status_code=401, detail="Missing or invalid user information" + ) + + try: + model_validator = ModelValidationService() + deployments = await model_validator.list_model_deployments() + summary = await model_validator.get_deployment_status_summary() + + return {"deployments": deployments, "summary": summary} + + except Exception as e: + logging.error(f"Error retrieving model deployments: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error occurred") + + +@app.get("/api/search_indexes") +async def get_search_indexes_endpoint(request: Request): + """ + Get information about available search indexes for debugging/validation. + + --- + tags: + - Search Validation + responses: + 200: + description: List of available search indexes + 401: + description: Missing or invalid user information + """ + # Validate user authentication + authenticated_user = get_authenticated_user_details(request_headers=request.headers) + user_id = authenticated_user["user_principal_id"] + if not user_id: + raise HTTPException( + status_code=401, detail="Missing or invalid user information" + ) + + try: + search_validator = SearchValidationService() + summary = await search_validator.get_search_index_summary() + + return {"search_summary": summary} + + except Exception as e: + logging.error(f"Error retrieving search indexes: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error occurred") + + # Run the app if __name__ == "__main__": import uvicorn diff --git a/src/backend/context/cosmos_memory_kernel.py b/src/backend/context/cosmos_memory_kernel.py index 9ff19cc74..e618e7102 100644 --- a/src/backend/context/cosmos_memory_kernel.py +++ b/src/backend/context/cosmos_memory_kernel.py @@ -92,15 +92,15 @@ async def initialize(self): id=self._cosmos_container, partition_key=PartitionKey(path="/session_id"), ) + # Only set initialized flag if we successfully got a container + self._initialized.set() except Exception as e: logging.error( f"Failed to initialize CosmosDB container: {e}. Continuing without CosmosDB for testing." ) - # Do not raise to prevent test failures + # Do not set initialized flag if initialization failed self._container = None - self._initialized.set() - # Helper method for awaiting initialization async def ensure_initialized(self): """Ensure that the container is initialized.""" @@ -110,8 +110,10 @@ async def ensure_initialized(self): # If after initialization the container is still None, that means initialization failed if self._container is None: - # Re-attempt initialization once in case the previous attempt failed + # Re-attempt initialization with a small delay for credential refresh try: + import asyncio + await asyncio.sleep(0.5) # Small delay for credential refresh await self.initialize() except Exception as e: logging.error(f"Re-initialization attempt failed: {e}") @@ -130,6 +132,10 @@ async def add_item(self, item: BaseDataModel) -> None: # Convert the model to a dict document = item.model_dump() + # Ensure all documents have session_id for consistent partitioning + if "session_id" not in document: + document["session_id"] = self.session_id + # Handle datetime objects by converting them to ISO format strings for key, value in list(document.items()): if isinstance(value, datetime.datetime): @@ -461,14 +467,46 @@ async def delete_team_by_id(self, id: str) -> bool: # First find the team to get its partition key team = await self.get_team_by_id(id) if team: - # Use the session_id as partition key, or fall back to user_id if no session_id - partition_key = ( - team.session_id - if hasattr(team, "session_id") and team.session_id - else team.user_id - ) - await self._container.delete_item(item=id, partition_key=partition_key) - return True + # Debug logging + logging.info(f"Attempting to delete team {id}") + logging.info(f"Team user_id: {team.user_id}") + logging.info(f"Team session_id: {getattr(team, 'session_id', 'NOT_SET')}") + + # Try different partition key strategies + partition_keys_to_try = [] + + # Strategy 1: Use session_id if it exists + if hasattr(team, "session_id") and team.session_id: + partition_keys_to_try.append(team.session_id) + logging.info(f"Will try session_id: {team.session_id}") + + # Strategy 2: Use user_id as fallback + if team.user_id: + partition_keys_to_try.append(team.user_id) + logging.info(f"Will try user_id: {team.user_id}") + + # Strategy 3: Use current context session_id + if self.session_id: + partition_keys_to_try.append(self.session_id) + logging.info(f"Will try context session_id: {self.session_id}") + + # Strategy 4: Try null/empty partition key (for documents created without session_id) + partition_keys_to_try.extend([None, ""]) + logging.info("Will try null and empty partition keys") + + # Try each partition key until one works + for partition_key in partition_keys_to_try: + try: + logging.info(f"Attempting delete with partition key: {partition_key}") + await self._container.delete_item(item=id, partition_key=partition_key) + logging.info(f"Successfully deleted team {id} with partition key: {partition_key}") + return True + except Exception as e: + logging.warning(f"Delete failed with partition key {partition_key}: {str(e)}") + continue + + logging.error(f"All partition key strategies failed for team {id}") + return False return False except Exception as e: logging.exception(f"Failed to delete team from Cosmos DB: {e}") diff --git a/src/backend/services/json_service.py b/src/backend/services/json_service.py index 5ffab0310..0805f7a6a 100644 --- a/src/backend/services/json_service.py +++ b/src/backend/services/json_service.py @@ -238,15 +238,7 @@ async def delete_team_configuration(self, config_id: str, user_id: str) -> bool: ) return False - # Verify the configuration belongs to the user - if team_config.user_id != user_id: - self.logger.warning( - "Access denied: cannot delete config %s for user %s", - config_id, - user_id, - ) - return False - + # Delete the configuration using the specific delete_team_by_id method success = await self.memory_context.delete_team_by_id(config_id) diff --git a/src/backend/services/model_validation_service.py b/src/backend/services/model_validation_service.py new file mode 100644 index 000000000..9f7e03cfb --- /dev/null +++ b/src/backend/services/model_validation_service.py @@ -0,0 +1,277 @@ +""" +Model Validation Service +Validates that required models are deployed in Azure AI Foundry +""" + +import os +import json +import logging +from typing import List, Dict, Any, Tuple +import aiohttp +from helpers.azure_credential_utils import get_azure_credential + +class ModelValidationService: + """Service for validating model deployments in Azure AI Foundry""" + + def __init__(self): + self.subscription_id = os.getenv("AZURE_AI_SUBSCRIPTION_ID") + self.resource_group = os.getenv("AZURE_AI_RESOURCE_GROUP") + self.project_name = os.getenv("AZURE_AI_PROJECT_NAME") + self.project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT") + + if not all([self.subscription_id, self.resource_group, self.project_name]): + logging.warning("Azure AI project configuration is incomplete") + + async def get_access_token(self) -> str: + """Get Azure access token for API calls""" + try: + credential = get_azure_credential() + # get_token is synchronous for DefaultAzureCredential + token = credential.get_token("https://management.azure.com/.default") + return token.token + except Exception as e: + logging.error(f"Failed to get access token: {e}") + raise + + async def list_model_deployments(self) -> List[Dict[str, Any]]: + """ + List all model deployments in the Azure AI project + Uses the Azure AI Foundry REST API + """ + if not all([self.subscription_id, self.resource_group, self.project_name]): + logging.error("Azure AI project configuration is incomplete") + return [] + + try: + token = await self.get_access_token() + + # Construct the API URL according to the documentation + url = ( + f"https://management.azure.com/subscriptions/{self.subscription_id}/" + f"resourceGroups/{self.resource_group}/providers/Microsoft.MachineLearningServices/" + f"workspaces/{self.project_name}/onlineEndpoints" + ) + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + params = { + "api-version": "2024-10-01" + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, params=params) as response: + if response.status == 200: + data = await response.json() + deployments = data.get("value", []) + + # Extract deployment names and model information + deployment_info = [] + for deployment in deployments: + deployment_info.append({ + "name": deployment.get("name"), + "model": deployment.get("properties", {}).get("model", {}), + "status": deployment.get("properties", {}).get("provisioningState"), + "endpoint_uri": deployment.get("properties", {}).get("scoringUri") + }) + + return deployment_info + else: + error_text = await response.text() + logging.error(f"Failed to list deployments. Status: {response.status}, Error: {error_text}") + return [] + + except Exception as e: + logging.error(f"Error listing model deployments: {e}") + return [] + + def extract_models_from_agent(self, agent: Dict[str, Any]) -> set: + """ + Extract all possible model references from a single agent configuration + + Args: + agent: Single agent configuration dictionary + + Returns: + Set of model names found in the agent + """ + models = set() + + # 1. Direct deployment_name field (primary field for all agents) + if agent.get("deployment_name"): + models.add(agent["deployment_name"].lower()) + + # 2. Legacy model field (for backwards compatibility) + if agent.get("model"): + models.add(agent["model"].lower()) + + # 3. Config section models + config = agent.get("config", {}) + if isinstance(config, dict): + # Common model fields in config + model_fields = ["model", "deployment_name", "engine"] + for field in model_fields: + if config.get(field): + models.add(config[field].lower()) + + # 4. Advanced: Parse instructions for model references (if needed for legacy configs) + instructions = agent.get("instructions", "") or agent.get("system_message", "") + if instructions: + models.update(self.extract_models_from_text(instructions)) + + return models + + def extract_models_from_text(self, text: str) -> set: + """ + Extract model names from text using pattern matching + + Args: + text: Text to search for model references + + Returns: + Set of model names found in text + """ + import re + models = set() + text_lower = text.lower() + + # Common model patterns + model_patterns = [ + r'gpt-4o(?:-\w+)?', + r'gpt-4(?:-\w+)?', + r'gpt-35-turbo(?:-\w+)?', + r'gpt-3\.5-turbo(?:-\w+)?', + r'claude-3(?:-\w+)?', + r'claude-2(?:-\w+)?', + r'gemini-pro(?:-\w+)?', + r'mistral-\w+', + r'llama-?\d+(?:-\w+)?', + r'text-davinci-\d+', + r'text-embedding-\w+', + r'ada-\d+', + r'babbage-\d+', + r'curie-\d+', + r'davinci-\d+', + ] + + for pattern in model_patterns: + matches = re.findall(pattern, text_lower) + models.update(matches) + + return models + + async def validate_team_models(self, team_config: Dict[str, Any]) -> Tuple[bool, List[str]]: + """ + Validate that all models required by agents in the team config are deployed + + Args: + team_config: The team configuration dictionary + + Returns: + Tuple of (is_valid, list_of_missing_models) + """ + try: + # Get list of available deployments + deployments = await self.list_model_deployments() + available_models = [d.get("name", "").lower() for d in deployments if d.get("status") == "Succeeded"] + + # Extract required models from team config + required_models = set() + agents = team_config.get("agents", []) + + for agent in agents: + agent_models = self.extract_models_from_agent(agent) + required_models.update(agent_models) + + # Also check team-level model configurations + team_level_models = self.extract_team_level_models(team_config) + required_models.update(team_level_models) + + # If no specific models found, assume default model is required + if not required_models: + default_model = os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") + required_models.add(default_model.lower()) + + # Check which models are missing + missing_models = [] + for model in required_models: + if model not in available_models: + missing_models.append(model) + + is_valid = len(missing_models) == 0 + + if not is_valid: + logging.warning(f"Missing model deployments: {missing_models}") + logging.info(f"Available deployments: {available_models}") + + return is_valid, missing_models + + except Exception as e: + logging.error(f"Error validating team models: {e}") + # Return True to not block uploads if validation fails + return True, [] + + async def get_deployment_status_summary(self) -> Dict[str, Any]: + """Get a summary of deployment status for debugging/monitoring""" + try: + deployments = await self.list_model_deployments() + + summary = { + "total_deployments": len(deployments), + "successful_deployments": [], + "failed_deployments": [], + "pending_deployments": [] + } + + for deployment in deployments: + name = deployment.get("name", "unknown") + status = deployment.get("status", "unknown") + + if status == "Succeeded": + summary["successful_deployments"].append(name) + elif status in ["Failed", "Canceled"]: + summary["failed_deployments"].append(name) + else: + summary["pending_deployments"].append(name) + + return summary + + except Exception as e: + logging.error(f"Error getting deployment summary: {e}") + return {"error": str(e)} + + def extract_team_level_models(self, team_config: Dict[str, Any]) -> set: + """ + Extract model references from team-level configuration + + Args: + team_config: The team configuration dictionary + + Returns: + Set of model names found at team level + """ + models = set() + + # Team-level model configurations + team_model_fields = ["default_model", "model", "llm_model"] + for field in team_model_fields: + if team_config.get(field): + models.add(team_config[field].lower()) + + # Check team settings + settings = team_config.get("settings", {}) + if isinstance(settings, dict): + for field in ["model", "deployment_name"]: + if settings.get(field): + models.add(settings[field].lower()) + + # Check environment configurations + env_config = team_config.get("environment", {}) + if isinstance(env_config, dict): + for field in ["model", "openai_deployment"]: + if env_config.get(field): + models.add(env_config[field].lower()) + + return models diff --git a/src/backend/services/search_validation_service.py b/src/backend/services/search_validation_service.py new file mode 100644 index 000000000..52a418044 --- /dev/null +++ b/src/backend/services/search_validation_service.py @@ -0,0 +1,207 @@ +""" +Service for validating Azure AI Search index endpoints during team configuration upload. +""" + +import os +import logging +from typing import Dict, Any, Tuple, List +from azure.search.documents import SearchClient +from azure.search.documents.indexes import SearchIndexClient +from azure.core.credentials import AzureKeyCredential +from azure.identity import DefaultAzureCredential +from azure.core.exceptions import ResourceNotFoundError, ClientAuthenticationError, HttpResponseError + +logger = logging.getLogger(__name__) + + +class SearchValidationService: + """Service for validating Azure AI Search indexes.""" + + def __init__(self): + """Initialize the search validation service.""" + self.search_endpoint = os.getenv("AZURE_SEARCH_ENDPOINT") + self.search_key = os.getenv("AZURE_SEARCH_KEY") + + # Use key-based auth if available, otherwise use DefaultAzureCredential + if self.search_key: + self.credential = AzureKeyCredential(self.search_key) + else: + self.credential = DefaultAzureCredential() + + async def validate_team_search_indexes(self, team_config: Dict[str, Any]) -> Tuple[bool, List[str]]: + """ + Validate that all search indexes referenced in the team config exist. + Only validates if there are actually search indexes/RAG agents in the config. + + Args: + team_config: The team configuration dictionary + + Returns: + Tuple of (is_valid, list_of_errors) + """ + try: + # Extract all index names from the team configuration + index_names = self.extract_index_names(team_config) + + # Check if there are any RAG/search agents that need validation + has_rag_agents = self.has_rag_or_search_agents(team_config) + + if not index_names and not has_rag_agents: + # No search indexes or RAG agents specified, validation passes + logger.info("No search indexes or RAG agents found in team config - skipping search validation") + return True, [] + + if not self.search_endpoint: + if index_names or has_rag_agents: + error_msg = "Team configuration references search indexes but no Azure Search endpoint is configured" + logger.warning(error_msg) + return False, [error_msg] + else: + # No search functionality needed and no endpoint configured - that's fine + return True, [] + + if not index_names: + # Has RAG agents but no specific indexes - validation passes (might use default) + logger.info("RAG agents found but no specific search indexes specified") + return True, [] + + # Validate each unique index + validation_errors = [] + unique_indexes = set(index_names) + + logger.info(f"Validating {len(unique_indexes)} search indexes: {list(unique_indexes)}") + + for index_name in unique_indexes: + is_valid, error_message = await self.validate_single_index(index_name) + if not is_valid: + validation_errors.append(error_message) + + return len(validation_errors) == 0, validation_errors + + except Exception as e: + logger.error(f"Error validating search indexes: {str(e)}") + return False, [f"Search index validation error: {str(e)}"] + + def extract_index_names(self, team_config: Dict[str, Any]) -> List[str]: + """ + Extract all index names from RAG agents in the team configuration. + Only RAG agents require index_name for search functionality. + + Args: + team_config: The team configuration dictionary + + Returns: + List of index names found in RAG agents + """ + index_names = [] + + # Check agents for index_name field (only in RAG agents) + agents = team_config.get("agents", []) + for agent in agents: + if isinstance(agent, dict): + # Only check RAG agents for index_name + agent_type = agent.get("type", "").strip().lower() + if agent_type == "rag": + # Extract index_name from RAG agents + index_name = agent.get("index_name") + if index_name and index_name.strip(): + index_names.append(index_name.strip()) + + # Return unique names + return list(set(index_names)) + + def has_rag_or_search_agents(self, team_config: Dict[str, Any]) -> bool: + """ + Check if the team configuration contains RAG agents. + Only RAG agents require search index validation. + + Args: + team_config: The team configuration dictionary + + Returns: + True if RAG agents are found, False otherwise + """ + agents = team_config.get("agents", []) + + for agent in agents: + if isinstance(agent, dict): + # Check agent type for RAG agents (case-insensitive) + agent_type = agent.get("type", "").strip().lower() + if agent_type == "rag": + return True + + return False + + async def validate_single_index(self, index_name: str) -> Tuple[bool, str]: + """ + Validate that a single search index exists and is accessible. + + Args: + index_name: Name of the search index to validate + + Returns: + Tuple of (is_valid, error_message) + """ + try: + # Create SearchIndexClient to check if index exists + index_client = SearchIndexClient( + endpoint=self.search_endpoint, + credential=self.credential + ) + + # Try to get the index + index = index_client.get_index(index_name) + + if index: + logger.info(f"Search index '{index_name}' found and accessible") + return True, "" + else: + error_msg = f"Search index '{index_name}' exists but may not be properly configured" + logger.warning(error_msg) + return False, error_msg + + except ResourceNotFoundError: + error_msg = f"Search index '{index_name}' does not exist" + logger.error(error_msg) + return False, error_msg + + except ClientAuthenticationError as e: + error_msg = f"Authentication failed for search index '{index_name}': {str(e)}" + logger.error(error_msg) + return False, error_msg + + except HttpResponseError as e: + error_msg = f"Error accessing search index '{index_name}': {str(e)}" + logger.error(error_msg) + return False, error_msg + + except Exception as e: + error_msg = f"Unexpected error validating search index '{index_name}': {str(e)}" + logger.error(error_msg) + return False, error_msg + + async def get_search_index_summary(self) -> Dict[str, Any]: + """Get a summary of available search indexes for debugging/monitoring.""" + try: + if not self.search_endpoint: + return {"error": "No Azure Search endpoint configured"} + + index_client = SearchIndexClient( + endpoint=self.search_endpoint, + credential=self.credential + ) + + # List all indexes + indexes = list(index_client.list_indexes()) + + summary = { + "search_endpoint": self.search_endpoint, + "total_indexes": len(indexes), + "available_indexes": [index.name for index in indexes] + } + + return summary + + except Exception as e: + logger.error(f"Error getting search index summary: {e}") + return {"error": str(e)} \ No newline at end of file diff --git a/src/backend/test_utils_date_fixed.py b/src/backend/test_utils_date_fixed.py deleted file mode 100644 index 62eb8fc67..000000000 --- a/src/backend/test_utils_date_fixed.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Quick test for the fixed utils_date.py functionality -""" - -import os -from datetime import datetime -from utils_date import format_date_for_user - - -def test_date_formatting(): - """Test the date formatting function with various inputs""" - - # Set up different language environments - test_cases = [ - ('en-US', '2025-07-29', 'US English'), - ('en-IN', '2025-07-29', 'Indian English'), - ('en-GB', '2025-07-29', 'British English'), - ('fr-FR', '2025-07-29', 'French'), - ('de-DE', '2025-07-29', 'German'), - ] - - print("Testing date formatting with different locales:") - print("=" * 50) - - for locale, date_str, description in test_cases: - os.environ['USER_LOCAL_BROWSER_LANGUAGE'] = locale - try: - result = format_date_for_user(date_str) - print(f"{description} ({locale}): {result}") - except Exception as e: - print(f"{description} ({locale}): ERROR - {e}") - - print("\n" + "=" * 50) - print("Testing with datetime object:") - - # Test with datetime object - os.environ['USER_LOCAL_BROWSER_LANGUAGE'] = 'en-US' - dt = datetime(2025, 7, 29, 14, 30, 0) - result = format_date_for_user(dt) - print(f"Datetime object: {result}") - - print("\nTesting error handling:") - print("=" * 30) - - # Test error handling - try: - result = format_date_for_user('invalid-date-string') - print(f"Invalid date: {result}") - except Exception as e: - print(f"Invalid date: ERROR - {e}") - - -if __name__ == "__main__": - test_date_formatting() diff --git a/src/backend/utils_kernel.py b/src/backend/utils_kernel.py index bbc21ccb9..cc0c32618 100644 --- a/src/backend/utils_kernel.py +++ b/src/backend/utils_kernel.py @@ -293,3 +293,69 @@ async def rai_success(description: str, is_task_creation: bool) -> bool: logging.error(f"Error in RAI check: {str(e)}") # Default to blocking the operation if RAI check fails for safety return False + + +async def rai_validate_team_config(team_config_json: dict) -> tuple[bool, str]: + """ + Validates team configuration JSON content for RAI compliance. + + Args: + team_config_json: The team configuration JSON data to validate + + Returns: + Tuple of (is_valid, error_message) + - is_valid: True if content passes RAI checks, False otherwise + - error_message: Simple error message if validation fails + """ + try: + # Extract all text content from the team configuration + text_content = [] + + # Extract team name and description + if "name" in team_config_json: + text_content.append(team_config_json["name"]) + if "description" in team_config_json: + text_content.append(team_config_json["description"]) + + # Extract agent information (based on actual schema) + if "agents" in team_config_json: + for agent in team_config_json["agents"]: + if isinstance(agent, dict): + # Agent name + if "name" in agent: + text_content.append(agent["name"]) + # Agent description + if "description" in agent: + text_content.append(agent["description"]) + # Agent system message (main field for instructions) + if "system_message" in agent: + text_content.append(agent["system_message"]) + + # Extract starting tasks (based on actual schema) + if "starting_tasks" in team_config_json: + for task in team_config_json["starting_tasks"]: + if isinstance(task, dict): + # Task name + if "name" in task: + text_content.append(task["name"]) + # Task prompt (main field for task description) + if "prompt" in task: + text_content.append(task["prompt"]) + + # Combine all text content for validation + combined_content = " ".join(text_content) + + if not combined_content.strip(): + return False, "Team configuration contains no readable text content" + + # Use existing RAI validation function + rai_result = await rai_success(combined_content, False) + + if not rai_result: + return False, "Team configuration contains inappropriate content and cannot be uploaded." + + return True, "" + + except Exception as e: + logging.error(f"Error validating team configuration with RAI: {str(e)}") + return False, "Unable to validate team configuration content. Please try again." diff --git a/src/frontend/src/api/apiService.tsx b/src/frontend/src/api/apiService.tsx index eac932aa5..5db0dee29 100644 --- a/src/frontend/src/api/apiService.tsx +++ b/src/frontend/src/api/apiService.tsx @@ -9,6 +9,7 @@ import { Step, StepStatus, AgentType, + AgentTypeString, PlanMessage } from '../models'; @@ -468,7 +469,7 @@ export class APIService { * @param agentType Agent type to filter by * @returns Array of steps for the specified agent */ - getStepsForAgent(plan: PlanWithSteps, agentType: AgentType): Step[] { + getStepsForAgent(plan: PlanWithSteps, agentType: AgentTypeString): Step[] { return plan.steps.filter(step => step.agent === agentType); } diff --git a/src/frontend/src/api/config.tsx b/src/frontend/src/api/config.tsx index 5c8fa23e6..cd015245b 100644 --- a/src/frontend/src/api/config.tsx +++ b/src/frontend/src/api/config.tsx @@ -120,9 +120,11 @@ export function getUserId(): string { */ export function headerBuilder(headers?: Record): Record { let userId = getUserId(); + console.log('headerBuilder: Using user ID:', userId); let defaultHeaders = { "x-ms-client-principal-id": String(userId) || "", // Custom header }; + console.log('headerBuilder: Created headers:', defaultHeaders); return { ...defaultHeaders, ...(headers ? headers : {}) diff --git a/src/frontend/src/components/common/SettingsButton.tsx b/src/frontend/src/components/common/SettingsButton.tsx new file mode 100644 index 000000000..3b6eecfb4 --- /dev/null +++ b/src/frontend/src/components/common/SettingsButton.tsx @@ -0,0 +1,816 @@ +import React, { useState } from 'react'; +import { + Button, + Dialog, + DialogTrigger, + DialogSurface, + DialogTitle, + DialogContent, + DialogBody, + DialogActions, + Text, + Spinner, + Card, + Body1, + Body2, + Caption1, + Tooltip, + Badge, + Input, +} from '@fluentui/react-components'; +import { + Settings20Regular, + CloudAdd20Regular, + Delete20Regular, + Checkmark20Filled, + Search20Regular, + Desktop20Regular, + BookmarkMultiple20Regular, + Person20Regular, + Building20Regular, + Document20Regular, + Database20Regular, + Play20Regular, + Shield20Regular, + Globe20Regular, + Clipboard20Regular, + WindowConsole20Regular, + Code20Regular, + Wrench20Regular, +} from '@fluentui/react-icons'; +import { TeamConfig } from '../../models/Team'; +import { TeamService } from '../../services/TeamService'; + +// Icon mapping function to convert string icons to FluentUI icons +const getIconFromString = (iconString: string): React.ReactNode => { + const iconMap: Record = { + // Agent icons + 'Terminal': , + 'MonitorCog': , + 'BookMarked': , + 'Search': , + 'Robot': , // Fallback since Robot20Regular doesn't exist + 'Code': , + 'Play': , + 'Shield': , + 'Globe': , + 'Person': , + 'Database': , + 'Document': , + + // Team logos + 'Wrench': , + 'TestTube': , // Fallback since TestTube20Regular doesn't exist + 'Building': , + 'Desktop': , + + // Common fallbacks + 'default': , + }; + + return iconMap[iconString] || iconMap['default'] || ; +}; + +interface SettingsButtonProps { + onTeamSelect?: (team: TeamConfig | null) => void; + onTeamUpload?: () => Promise; + selectedTeam?: TeamConfig | null; +} + +const SettingsButton: React.FC = ({ + onTeamSelect, + onTeamUpload, + selectedTeam, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(false); + const [uploadLoading, setUploadLoading] = useState(false); + const [error, setError] = useState(null); + const [uploadMessage, setUploadMessage] = useState(null); + const [tempSelectedTeam, setTempSelectedTeam] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [teamToDelete, setTeamToDelete] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + const loadTeams = async () => { + setLoading(true); + setError(null); + try { + // Get all teams from the API (no separation between default and user teams) + const teamsData = await TeamService.getUserTeams(); + setTeams(teamsData); + } catch (err: any) { + setError(err.message || 'Failed to load teams'); + } finally { + setLoading(false); + } + }; + + const handleOpenChange = async (open: boolean) => { + setIsOpen(open); + if (open) { + await loadTeams(); + setTempSelectedTeam(selectedTeam || null); + setError(null); + setUploadMessage(null); + setSearchQuery(''); // Clear search when opening + } else { + setTempSelectedTeam(null); + setError(null); + setUploadMessage(null); + setSearchQuery(''); // Clear search when closing + } + }; + + const handleContinue = () => { + if (tempSelectedTeam) { + onTeamSelect?.(tempSelectedTeam); + } + setIsOpen(false); + }; + + const handleCancel = () => { + setTempSelectedTeam(null); + setIsOpen(false); + }; + + // Filter teams based on search query + const filteredTeams = teams.filter(team => + team.name.toLowerCase().includes(searchQuery.toLowerCase()) || + team.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Validation function for team configuration JSON + const validateTeamConfig = (data: any): { isValid: boolean; errors: string[] } => { + const errors: string[] = []; + + // Check if data is empty or null + if (!data || typeof data !== 'object') { + errors.push('JSON file cannot be empty and must contain a valid object'); + return { isValid: false, errors }; + } + + // Required root level fields + if (!data.name || typeof data.name !== 'string' || data.name.trim() === '') { + errors.push('Team name is required and cannot be empty'); + } + + if (!data.description || typeof data.description !== 'string' || data.description.trim() === '') { + errors.push('Team description is required and cannot be empty'); + } + + // Additional required fields with defaults + if (!data.status || typeof data.status !== 'string' || data.status.trim() === '') { + errors.push('Team status is required and cannot be empty'); + } + + // Note: created and created_by are generated by the backend, so don't validate them here + + // Agents validation + if (!data.agents || !Array.isArray(data.agents)) { + errors.push('Agents array is required'); + } else if (data.agents.length === 0) { + errors.push('Team must have at least one agent'); + } else { + // Validate each agent + data.agents.forEach((agent: any, index: number) => { + if (!agent || typeof agent !== 'object') { + errors.push(`Agent ${index + 1}: Invalid agent object`); + return; + } + + if (!agent.name || typeof agent.name !== 'string' || agent.name.trim() === '') { + errors.push(`Agent ${index + 1}: Agent name is required and cannot be empty`); + } + + if (!agent.type || typeof agent.type !== 'string' || agent.type.trim() === '') { + errors.push(`Agent ${index + 1}: Agent type is required and cannot be empty`); + } + + if (!agent.input_key || typeof agent.input_key !== 'string' || agent.input_key.trim() === '') { + errors.push(`Agent ${index + 1}: Agent input_key is required and cannot be empty`); + } + + // deployment_name is required for all agents (for model validation) + if (!agent.deployment_name || typeof agent.deployment_name !== 'string' || agent.deployment_name.trim() === '') { + errors.push(`Agent ${index + 1}: Agent deployment_name is required and cannot be empty`); + } + + // index_name is required only for RAG agents (for search validation) + if (agent.type && agent.type.toLowerCase() === 'rag') { + if (!agent.index_name || typeof agent.index_name !== 'string' || agent.index_name.trim() === '') { + errors.push(`Agent ${index + 1}: Agent index_name is required for RAG agents and cannot be empty`); + } + } + + // Optional fields validation (can be empty but must be strings if present) + if (agent.description !== undefined && typeof agent.description !== 'string') { + errors.push(`Agent ${index + 1}: Agent description must be a string`); + } + + if (agent.system_message !== undefined && typeof agent.system_message !== 'string') { + errors.push(`Agent ${index + 1}: Agent system_message must be a string`); + } + + if (agent.icon !== undefined && typeof agent.icon !== 'string') { + errors.push(`Agent ${index + 1}: Agent icon must be a string`); + } + + // index_name is only validated for non-RAG agents here (RAG agents are validated above) + if (agent.type && agent.type.toLowerCase() !== 'rag' && agent.index_name !== undefined && typeof agent.index_name !== 'string') { + errors.push(`Agent ${index + 1}: Agent index_name must be a string`); + } + }); + } + + // Starting tasks validation (optional but must be valid if present) + if (data.starting_tasks !== undefined) { + if (!Array.isArray(data.starting_tasks)) { + errors.push('Starting tasks must be an array if provided'); + } else { + data.starting_tasks.forEach((task: any, index: number) => { + if (!task || typeof task !== 'object') { + errors.push(`Starting task ${index + 1}: Invalid task object`); + return; + } + + if (!task.name || typeof task.name !== 'string' || task.name.trim() === '') { + errors.push(`Starting task ${index + 1}: Task name is required and cannot be empty`); + } + + if (!task.prompt || typeof task.prompt !== 'string' || task.prompt.trim() === '') { + errors.push(`Starting task ${index + 1}: Task prompt is required and cannot be empty`); + } + + if (!task.id || typeof task.id !== 'string' || task.id.trim() === '') { + errors.push(`Starting task ${index + 1}: Task id is required and cannot be empty`); + } + + if (!task.created || typeof task.created !== 'string' || task.created.trim() === '') { + errors.push(`Starting task ${index + 1}: Task created date is required and cannot be empty`); + } + + if (!task.creator || typeof task.creator !== 'string' || task.creator.trim() === '') { + errors.push(`Starting task ${index + 1}: Task creator is required and cannot be empty`); + } + + if (task.logo !== undefined && typeof task.logo !== 'string') { + errors.push(`Starting task ${index + 1}: Task logo must be a string if provided`); + } + }); + } + } + + // Optional root level fields validation + const stringFields = ['status', 'logo', 'plan']; + stringFields.forEach(field => { + if (data[field] !== undefined && typeof data[field] !== 'string') { + errors.push(`${field} must be a string if provided`); + } + }); + + if (data.protected !== undefined && typeof data.protected !== 'boolean') { + errors.push('Protected field must be a boolean if provided'); + } + + return { isValid: errors.length === 0, errors }; + }; + + const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setUploadLoading(true); + setError(null); + setUploadMessage('Reading and validating team configuration...'); + + try { + // First, validate the file type + if (!file.name.toLowerCase().endsWith('.json')) { + throw new Error('Please upload a valid JSON file'); + } + + // Read and parse the JSON file + const fileContent = await file.text(); + let teamData; + + try { + teamData = JSON.parse(fileContent); + } catch (parseError) { + throw new Error('Invalid JSON format. Please check your file syntax'); + } + + // Validate the team configuration + setUploadMessage('Validating team configuration structure...'); + const validation = validateTeamConfig(teamData); + + if (!validation.isValid) { + const errorMessage = `Team configuration validation failed:\n\n${validation.errors.map(error => `• ${error}`).join('\n')}`; + throw new Error(errorMessage); + } + + setUploadMessage('Uploading team configuration...'); + const result = await TeamService.uploadCustomTeam(file); + + if (result.success) { + setUploadMessage('Team uploaded successfully!'); + + // Add the new team to the existing list instead of full refresh + if (result.team) { + setTeams(currentTeams => [...currentTeams, result.team!]); + } + + setUploadMessage(null); + // Notify parent component about the upload + if (onTeamUpload) { + await onTeamUpload(); + } + } else if (result.raiError) { + setError('āŒ Content Safety Check Failed\n\nYour team configuration contains content that doesn\'t meet our safety guidelines. Please review and modify:\n\n• Agent instructions and descriptions\n• Task prompts and content\n• Team descriptions\n\nEnsure all content is appropriate, helpful, and follows ethical AI principles.'); + setUploadMessage(null); + } else if (result.modelError) { + setError('šŸ¤– Model Deployment Validation Failed\n\nYour team configuration references models that are not properly deployed:\n\n• Verify deployment_name values are correct\n• Ensure all models are deployed in Azure AI Foundry\n• Check model deployment names match exactly\n• Confirm access permissions to AI services\n\nAll agents require valid deployment_name for model access.'); + setUploadMessage(null); + } else if (result.searchError) { + setError('šŸ” RAG Search Configuration Error\n\nYour team configuration includes RAG/search agents but has search index issues:\n\n• Verify search index names are correct\n• Ensure indexes exist in Azure AI Search\n• Check access permissions to search service\n• Confirm RAG agent configurations\n\nRAG agents require properly configured search indexes to function correctly.'); + setUploadMessage(null); + } else { + setError(result.error || 'Failed to upload team configuration'); + setUploadMessage(null); + } + } catch (err: any) { + setError(err.message || 'Failed to upload team configuration'); + setUploadMessage(null); + } finally { + setUploadLoading(false); + // Reset the input + event.target.value = ''; + } + }; + + const handleDeleteTeam = (team: TeamConfig, event: React.MouseEvent) => { + event.stopPropagation(); + setTeamToDelete(team); + setDeleteConfirmOpen(true); + }; + + const handleTeamSelect = (team: TeamConfig) => { + setTempSelectedTeam(team); + onTeamSelect?.(team); + }; + + const confirmDeleteTeam = async () => { + if (!teamToDelete || deleteLoading) return; + + // Check if team is protected + if (teamToDelete.protected) { + setError('This team is protected and cannot be deleted.'); + setDeleteConfirmOpen(false); + setTeamToDelete(null); + return; + } + + setDeleteLoading(true); + + try { + // Attempt to delete the team + const success = await TeamService.deleteTeam(teamToDelete.id); + + if (success) { + // Close dialog and clear states immediately + setDeleteConfirmOpen(false); + setTeamToDelete(null); + setDeleteLoading(false); + + // If the deleted team was currently selected, clear the selection + if (tempSelectedTeam?.team_id === teamToDelete.team_id) { + setTempSelectedTeam(null); + // Also clear it from the parent component if it was the active selection + if (selectedTeam?.team_id === teamToDelete.team_id) { + onTeamSelect?.(null); + } + } + + // Update the teams list immediately by filtering out the deleted team + setTeams(currentTeams => currentTeams.filter(team => team.id !== teamToDelete.id)); + + // Then reload from server to ensure consistency + await loadTeams(); + + } else { + setError('Failed to delete team configuration. The server rejected the deletion request.'); + setDeleteConfirmOpen(false); + setTeamToDelete(null); + } + } catch (err: any) { + + // Provide more specific error messages based on the error type + let errorMessage = 'Failed to delete team configuration. Please try again.'; + + if (err.response?.status === 404) { + errorMessage = 'Team not found. It may have already been deleted.'; + } else if (err.response?.status === 403) { + errorMessage = 'You do not have permission to delete this team.'; + } else if (err.response?.status === 409) { + errorMessage = 'Cannot delete team because it is currently in use.'; + } else if (err.response?.data?.detail) { + errorMessage = err.response.data.detail; + } else if (err.message) { + errorMessage = `Delete failed: ${err.message}`; + } + + setError(errorMessage); + setDeleteConfirmOpen(false); + setTeamToDelete(null); + } finally { + setDeleteLoading(false); + } + }; + + const renderTeamCard = (team: TeamConfig, isCustom = false) => { + const isSelected = tempSelectedTeam?.team_id === team.team_id; + + return ( + + {/* Team Icon and Title */} +
+
+
+ {getIconFromString(team.logo)} +
+
+ +
+ + {team.name} + +
+ + {/* Selection Checkmark */} + {isSelected && ( +
+ +
+ )} + + {/* Action Buttons */} +
+ {!isSelected && ( + + + + )} + + +
+
+ + {/* Description */} +
+ + {team.description} + +
+ + {/* Agents Section */} +
+ + Agents + +
+ {team.agents.map((agent) => ( + + + {getIconFromString(agent.icon || 'default')} + + {agent.name} + + ))} +
+
+
+ ); + }; + + return ( + <> + handleOpenChange(data.open)}> + + + + + + + + Select a Team +
+ + +
+
+ + + {error && ( +
+ {error} +
+ )} + + {uploadMessage && ( +
+ + {uploadMessage} +
+ )} + + {/* Upload requirements info */} +
+ + Upload Requirements: + + + • JSON file must contain: name and description
+ • At least one agent with name, type, input_key, and deployment_name
+ • RAG agents additionally require index_name for search functionality
+ • Starting tasks are optional but must have name and prompt if included
+ • All text fields cannot be empty +
+
+ + {/* Search input */} +
+ setSearchQuery(e.target.value)} + contentBefore={} + style={{ width: '100%' }} + /> +
+ + {loading ? ( +
+ +
+ ) : filteredTeams.length > 0 ? ( +
+ {filteredTeams.map((team) => ( +
+ {renderTeamCard(team, team.created_by !== 'system')} +
+ ))} +
+ ) : searchQuery ? ( +
+ + No teams found matching "{searchQuery}" + + + Try a different search term + +
+ ) : teams.length === 0 ? ( +
+ + No teams available + + + Upload a JSON team configuration to get started + +
+ ) : null} +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + setDeleteConfirmOpen(data.open)}> + + + + āš ļø Delete Team Configuration +
+ + Are you sure you want to delete "{teamToDelete?.name}"? + +
+ + Important Notice: + + + This team configuration and its agents are shared across all users in the system. + Deleting this team will permanently remove it for everyone, and this action cannot be undone. + +
+
+
+ + + + +
+
+
+ + ); +}; + +export default SettingsButton; diff --git a/src/frontend/src/components/content/HomeInput.tsx b/src/frontend/src/components/content/HomeInput.tsx index c9ba0ece8..313e405b0 100644 --- a/src/frontend/src/components/content/HomeInput.tsx +++ b/src/frontend/src/components/content/HomeInput.tsx @@ -4,6 +4,22 @@ import { Caption1, Title2, } from "@fluentui/react-components"; +import { + Desktop20Regular, + BookmarkMultiple20Regular, + Search20Regular, + Wrench20Regular, + Person20Regular, + Building20Regular, + Document20Regular, + Database20Regular, + Code20Regular, + Play20Regular, + Shield20Regular, + Globe20Regular, + Clipboard20Regular, + WindowConsole20Regular, +} from '@fluentui/react-icons'; import React, { useRef, useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; @@ -11,7 +27,8 @@ import "./../../styles/Chat.css"; import "../../styles/prism-material-oceanic.css"; import "./../../styles/HomeInput.css"; -import { HomeInputProps, quickTasks, QuickTask } from "../../models/homeInput"; +import { HomeInputProps, QuickTask } from "../../models/homeInput"; +import { TeamConfig } from "../../models/Team"; import { TaskService } from "../../services/TaskService"; import { NewTaskService } from "../../services/NewTaskService"; import { RAIErrorCard, RAIErrorData } from "../errors"; @@ -21,9 +38,44 @@ import InlineToaster, { useInlineToaster } from "../toast/InlineToaster"; import PromptCard from "@/coral/components/PromptCard"; import { Send } from "@/coral/imports/bundleicons"; +// Icon mapping function to convert string icons to FluentUI icons +const getIconFromString = (iconString: string | React.ReactNode): React.ReactNode => { + // If it's already a React node, return it + if (typeof iconString !== 'string') { + return iconString; + } + + const iconMap: Record = { + // Task/Logo icons + 'Wrench': , + 'TestTube': , // Fallback since TestTube20Regular doesn't exist + 'Terminal': , + 'MonitorCog': , + 'BookMarked': , + 'Search': , + 'Robot': , // Fallback since Robot20Regular doesn't exist + 'Code': , + 'Play': , + 'Shield': , + 'Globe': , + 'Person': , + 'Database': , + 'Document': , + 'Building': , + 'Desktop': , + + // Default fallback + 'šŸ“‹': , + 'default': , + }; + + return iconMap[iconString] || iconMap['default'] || ; +}; + const HomeInput: React.FC = ({ onInputSubmit, onQuickTaskSelect, + selectedTeam, }) => { const [submitting, setSubmitting] = useState(false); const [input, setInput] = useState(""); @@ -61,7 +113,10 @@ const HomeInput: React.FC = ({ let id = showToast("Creating a plan", "progress"); try { - const response = await TaskService.createPlan(input.trim()); + const response = await TaskService.createPlan( + input.trim(), + selectedTeam?.team_id + ); setInput(""); if (textareaRef.current) { @@ -71,6 +126,9 @@ const HomeInput: React.FC = ({ if (response.plan_id && response.plan_id !== null) { showToast("Plan created!", "success"); dismissToast(id); + + // Navigate to create page (no team ID in URL anymore) + console.log('HomeInput: Navigating to plan creation with team:', selectedTeam?.name); navigate(`/plan/${response.plan_id}/create`); } else { showToast("Failed to create plan", "error"); @@ -129,6 +187,29 @@ const HomeInput: React.FC = ({ } }, [input]); + // Convert team starting_tasks to QuickTask format + const tasksToDisplay: QuickTask[] = selectedTeam && selectedTeam.starting_tasks ? + selectedTeam.starting_tasks.map((task, index) => { + // Handle both string tasks and StartingTask objects + if (typeof task === 'string') { + return { + id: `team-task-${index}`, + title: task, + description: task, + icon: getIconFromString("šŸ“‹") + }; + } else { + // Handle StartingTask objects + const startingTask = task as any; // Type assertion for now + return { + id: startingTask.id || `team-task-${index}`, + title: startingTask.name || startingTask.prompt || 'Task', + description: startingTask.prompt || startingTask.name || 'Task description', + icon: getIconFromString(startingTask.logo || "šŸ“‹") + }; + } + }) : []; + return (
@@ -171,22 +252,44 @@ const HomeInput: React.FC = ({
-
- Quick tasks -
- -
- {quickTasks.map((task) => ( - handleQuickTaskClick(task)} - disabled={submitting} - /> - ))} -
+ {tasksToDisplay.length > 0 && ( + <> +
+ Quick tasks +
+ +
+ {tasksToDisplay.map((task) => ( + handleQuickTaskClick(task)} + disabled={submitting} + /> + ))} +
+ + )} + {tasksToDisplay.length === 0 && selectedTeam && ( +
+ No starting tasks available for this team +
+ )} + {!selectedTeam && ( +
+ Select a team to see available tasks +
+ )}
diff --git a/src/frontend/src/components/content/PlanPanelLeft.tsx b/src/frontend/src/components/content/PlanPanelLeft.tsx index 8f14d823c..0ab07bb64 100644 --- a/src/frontend/src/components/content/PlanPanelLeft.tsx +++ b/src/frontend/src/components/content/PlanPanelLeft.tsx @@ -28,8 +28,16 @@ import "../../styles/PlanPanelLeft.css"; import PanelFooter from "@/coral/components/Panels/PanelFooter"; import PanelUserCard from "../../coral/components/Panels/UserCard"; import { getUserInfoGlobal } from "@/api/config"; +import SettingsButton from "../common/SettingsButton"; +import { TeamConfig } from "../../models/Team"; -const PlanPanelLeft: React.FC = ({ reloadTasks,restReload }) => { +const PlanPanelLeft: React.FC = ({ + reloadTasks, + restReload, + onTeamSelect, + onTeamUpload, + selectedTeam: parentSelectedTeam +}) => { const { dispatchToast } = useToastController("toast"); const navigate = useNavigate(); const { planId } = useParams<{ planId: string }>(); @@ -42,6 +50,10 @@ const PlanPanelLeft: React.FC = ({ reloadTasks,restReload }) const [userInfo, setUserInfo] = useState( getUserInfoGlobal() ); + + // Use parent's selected team if provided, otherwise use local state + const [localSelectedTeam, setLocalSelectedTeam] = useState(null); + const selectedTeam = parentSelectedTeam || localSelectedTeam; const loadPlansData = useCallback(async (forceRefresh = false) => { try { @@ -112,6 +124,41 @@ const PlanPanelLeft: React.FC = ({ reloadTasks,restReload }) [plans, navigate] ); + const handleTeamSelect = useCallback( + (team: TeamConfig | null) => { + // Use parent's team select handler if provided, otherwise use local state + if (onTeamSelect) { + onTeamSelect(team); + } else { + if (team) { + setLocalSelectedTeam(team); + dispatchToast( + + Team Selected + + {team.name} team has been selected with {team.agents.length} agents + + , + { intent: "success" } + ); + } else { + // Handle team deselection (null case) + setLocalSelectedTeam(null); + dispatchToast( + + Team Deselected + + No team is currently selected + + , + { intent: "info" } + ); + } + } + }, + [onTeamSelect, dispatchToast] + ); + return (
@@ -123,6 +170,18 @@ const PlanPanelLeft: React.FC = ({ reloadTasks,restReload }) + {/* Team Display Section */} + {selectedTeam && ( +
+ + {selectedTeam.name} + +
+ )} +
= ({ reloadTasks,restReload }) /> - +
+ {/* Settings Button on top */} + + {/* User Card below */} + +
diff --git a/src/frontend/src/models/Team.tsx b/src/frontend/src/models/Team.tsx new file mode 100644 index 000000000..223fdae52 --- /dev/null +++ b/src/frontend/src/models/Team.tsx @@ -0,0 +1,56 @@ +export interface Agent { + input_key: string; + type: string; + name: string; + system_message?: string; + description?: string; + icon?: string; + index_name?: string; + deployment_name?:string; + id?: string; + capabilities?: string[]; + role?: string; +} + + +export interface StartingTask { + id: string; + name: string; + prompt: string; + created: string; + creator: string; + logo: string; +} + +export interface Team { + id: string; + name: string; + description: string; + agents: Agent[]; + teamType: 'default' | 'custom'; + logoUrl?: string; + category?: string; +} + +// Backend-compatible Team model that matches uploaded JSON structure +export interface TeamConfig { + id: string; + team_id: string; + name: string; + description: string; + status: 'visible' | 'hidden'; + protected?: boolean; + created: string; + created_by: string; + logo: string; + plan: string; + agents: Agent[]; + starting_tasks: StartingTask[]; +} + +export interface TeamUploadResponse { + success: boolean; + teamId?: string; + message?: string; + errors?: string[]; +} diff --git a/src/frontend/src/models/enums.tsx b/src/frontend/src/models/enums.tsx index fc63baadf..f0415ce0e 100644 --- a/src/frontend/src/models/enums.tsx +++ b/src/frontend/src/models/enums.tsx @@ -4,8 +4,10 @@ /** * Enumeration of agent types. + * This includes common/default agent types, but the system supports dynamic agent types from JSON uploads. */ export enum AgentType { + // Legacy/System agent types HUMAN = "Human_Agent", HR = "Hr_Agent", MARKETING = "Marketing_Agent", @@ -14,7 +16,193 @@ export enum AgentType { GENERIC = "Generic_Agent", TECH_SUPPORT = "Tech_Support_Agent", GROUP_CHAT_MANAGER = "Group_Chat_Manager", - PLANNER = "Planner_Agent" + PLANNER = "Planner_Agent", + + // Common uploadable agent types + MAGENTIC_ONE = "MagenticOne", + CUSTOM = "Custom", + RAG = "RAG", + + // Specific agent names (can be any name with any type) + CODER = "Coder", + EXECUTOR = "Executor", + FILE_SURFER = "FileSurfer", + WEB_SURFER = "WebSurfer", + SENSOR_SENTINEL = "SensorSentinel", + MAINTENANCE_KB_AGENT = "MaintanceKBAgent", +} + +/** + * Type representing any agent type - either from the enum or a custom string + */ +export type AgentTypeString = AgentType | string; + +/** + * Utility functions for working with agent types + */ +export class AgentTypeUtils { + /** + * Get display name for an agent type + */ + static getDisplayName(agentType: AgentTypeString): string { + // Convert to string first + const typeStr = String(agentType); + + // Handle specific formatting for known patterns + if (typeStr.includes('_Agent')) { + return typeStr.replace('_Agent', '').replace('_', ' '); + } + + // Handle camelCase and PascalCase names + return typeStr.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim(); + } + + /** + * Check if an agent type is a known/default type + */ + static isKnownType(agentType: AgentTypeString): boolean { + return Object.values(AgentType).includes(agentType as AgentType); + } + + /** + * Get agent type from string, with fallback to the original string + */ + static fromString(type: string): AgentTypeString { + // First check if it's a known enum value + const enumValue = Object.values(AgentType).find(value => value === type); + if (enumValue) { + return enumValue; + } + + // Return the custom type as-is + return type; + } + + /** + * Get agent type category + */ + static getAgentCategory(agentType: AgentTypeString): 'system' | 'magentic-one' | 'custom' | 'rag' | 'unknown' { + const typeStr = String(agentType); + + // System/Legacy agents + if ([ + 'Human_Agent', 'Hr_Agent', 'Marketing_Agent', 'Procurement_Agent', + 'Product_Agent', 'Generic_Agent', 'Tech_Support_Agent', + 'Group_Chat_Manager', 'Planner_Agent' + ].includes(typeStr)) { + return 'system'; + } + + // MagenticOne framework agents + if (typeStr === 'MagenticOne' || [ + 'Coder', 'Executor', 'FileSurfer', 'WebSurfer' + ].includes(typeStr)) { + return 'magentic-one'; + } + + // RAG agents + if (typeStr === 'RAG' || typeStr.toLowerCase().includes('rag') || typeStr.toLowerCase().includes('kb')) { + return 'rag'; + } + + // Custom agents + if (typeStr === 'Custom') { + return 'custom'; + } + + return 'unknown'; + } + + /** + * Get icon for agent type based on category and name + */ + static getAgentIcon(agentType: AgentTypeString, providedIcon?: string): string { + // If icon is explicitly provided, use it + if (providedIcon && providedIcon.trim()) { + return providedIcon; + } + + const category = this.getAgentCategory(agentType); + const typeStr = String(agentType); + + // Specific agent name mappings + const iconMap: Record = { + 'Coder': 'Terminal', + 'Executor': 'MonitorCog', + 'FileSurfer': 'File', + 'WebSurfer': 'Globe', + 'SensorSentinel': 'BookMarked', + 'MaintanceKBAgent': 'Search', + }; + + if (iconMap[typeStr]) { + return iconMap[typeStr]; + } + + // Category-based defaults + switch (category) { + case 'system': + return 'Person'; + case 'magentic-one': + return 'BrainCircuit'; + case 'rag': + return 'Search'; + case 'custom': + return 'Wrench'; + default: + return 'Robot'; + } + } + + /** + * Validate agent configuration + */ + static validateAgent(agent: any): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!agent || typeof agent !== 'object') { + errors.push('Agent must be a valid object'); + return { isValid: false, errors }; + } + + // Required fields + if (!agent.input_key || typeof agent.input_key !== 'string' || agent.input_key.trim() === '') { + errors.push('Agent input_key is required and cannot be empty'); + } + + if (!agent.type || typeof agent.type !== 'string' || agent.type.trim() === '') { + errors.push('Agent type is required and cannot be empty'); + } + + if (!agent.name || typeof agent.name !== 'string' || agent.name.trim() === '') { + errors.push('Agent name is required and cannot be empty'); + } + + // Optional fields validation + const optionalStringFields = ['system_message', 'description', 'icon', 'index_name']; + optionalStringFields.forEach(field => { + if (agent[field] !== undefined && typeof agent[field] !== 'string') { + errors.push(`Agent ${field} must be a string if provided`); + } + }); + + // Special validation for RAG agents + if (agent.type === 'RAG' && (!agent.index_name || agent.index_name.trim() === '')) { + errors.push('RAG agents must have a valid index_name specified'); + } + + return { isValid: errors.length === 0, errors }; + } + + /** + * Get all available agent types (both enum and common custom types) + */ + static getAllAvailableTypes(): AgentTypeString[] { + return [ + ...Object.values(AgentType), + // Add other common types that might come from JSON uploads + ]; + } } export enum role { diff --git a/src/frontend/src/models/homeInput.tsx b/src/frontend/src/models/homeInput.tsx index a5458f161..d6f15bb1e 100644 --- a/src/frontend/src/models/homeInput.tsx +++ b/src/frontend/src/models/homeInput.tsx @@ -1,40 +1,14 @@ -import { DocumentEdit20Regular, Person20Regular, Phone20Regular, ShoppingBag20Regular } from "@fluentui/react-icons"; - export interface QuickTask { id: string; title: string; description: string; - icon: React.ReactNode; + icon: React.ReactNode | string; } -export const quickTasks: QuickTask[] = [ - { - id: "onboard", - title: "Onboard employee", - description: "Onboard a new employee, Jessica Smith.", - icon: , - }, - { - id: "mobile", - title: "Mobile plan query", - description: "Ask about roaming plans prior to heading overseas.", - icon: , - }, - { - id: "addon", - title: "Buy add-on", - description: "Enable roaming on mobile plan, starting next week.", - icon: , - }, - { - id: "press", - title: "Draft a press release", - description: "Write a press release about our current products.", - icon: , - }, -]; - export interface HomeInputProps { onInputSubmit: (input: string) => void; onQuickTaskSelect: (taskDescription: string) => void; + selectedTeam?: TeamConfig | null; } + +import { TeamConfig } from './Team'; diff --git a/src/frontend/src/models/index.tsx b/src/frontend/src/models/index.tsx index da61c4e8e..104e9f676 100644 --- a/src/frontend/src/models/index.tsx +++ b/src/frontend/src/models/index.tsx @@ -10,10 +10,16 @@ export * from './plan'; export * from './messages'; export * from './inputTask'; export * from './agentMessage'; -export * from './taskDetails'; export * from './taskList'; export * from './planPanelLeft'; export * from './homeInput'; -export * from './auth' +export * from './auth'; + +// Export taskDetails with explicit naming to avoid Agent conflict +export type { SubTask, Human, TaskDetailsProps } from './taskDetails'; +export type { Agent as TaskAgent } from './taskDetails'; + +// Export Team models (Agent interface takes precedence) +export * from './Team'; // Add other model exports as needed diff --git a/src/frontend/src/models/inputTask.tsx b/src/frontend/src/models/inputTask.tsx index ba1f654ca..8a24d43c4 100644 --- a/src/frontend/src/models/inputTask.tsx +++ b/src/frontend/src/models/inputTask.tsx @@ -6,6 +6,8 @@ export interface InputTask { session_id?: string; /** The task description or goal */ description: string; + /** Optional team identifier to use for this plan */ + team_id?: string; } /** diff --git a/src/frontend/src/models/messages.tsx b/src/frontend/src/models/messages.tsx index 2d086c192..f14fe2423 100644 --- a/src/frontend/src/models/messages.tsx +++ b/src/frontend/src/models/messages.tsx @@ -112,4 +112,4 @@ export interface PlanStateUpdate { session_id: string; /** Overall status of the plan */ overall_status: PlanStatus; -} +} \ No newline at end of file diff --git a/src/frontend/src/models/plan.tsx b/src/frontend/src/models/plan.tsx index fc19aa716..6f72ed0ca 100644 --- a/src/frontend/src/models/plan.tsx +++ b/src/frontend/src/models/plan.tsx @@ -1,4 +1,4 @@ -import { AgentType, PlanStatus, StepStatus, HumanFeedbackStatus } from './enums'; +import { AgentType, AgentTypeString, PlanStatus, StepStatus, HumanFeedbackStatus } from './enums'; /** * Base interface with common fields @@ -49,7 +49,7 @@ export interface Step extends BaseModel { /** Action to be performed */ action: string; /** Agent assigned to this step */ - agent: AgentType; + agent: AgentTypeString; /** Current status of the step */ status: StepStatus; /** Optional reply from the agent */ @@ -107,7 +107,7 @@ export interface PlanWithSteps extends Plan { */ export interface ProcessedPlanData { plan: PlanWithSteps; - agents: AgentType[]; + agents: AgentTypeString[]; steps: Step[]; hasClarificationRequest: boolean; hasClarificationResponse: boolean; diff --git a/src/frontend/src/models/planPanelLeft.tsx b/src/frontend/src/models/planPanelLeft.tsx index 894027a3d..5b6cb9037 100644 --- a/src/frontend/src/models/planPanelLeft.tsx +++ b/src/frontend/src/models/planPanelLeft.tsx @@ -1,5 +1,10 @@ +import { TeamConfig } from './Team'; + export interface PlanPanelLefProps { reloadTasks?: boolean; onNewTaskButton: () => void; restReload?: () => void; + onTeamSelect?: (team: TeamConfig | null) => void; + onTeamUpload?: () => Promise; + selectedTeam?: TeamConfig | null; } \ No newline at end of file diff --git a/src/frontend/src/pages/HomePage.tsx b/src/frontend/src/pages/HomePage.tsx index fe3487430..3068d1260 100644 --- a/src/frontend/src/pages/HomePage.tsx +++ b/src/frontend/src/pages/HomePage.tsx @@ -22,12 +22,52 @@ import HomeInput from '@/components/content/HomeInput'; import { NewTaskService } from '../services/NewTaskService'; import PlanPanelLeft from '@/components/content/PlanPanelLeft'; import ContentToolbar from '@/coral/components/Content/ContentToolbar'; +import { TaskService } from '../services/TaskService'; +import { TeamConfig } from '../models/Team'; +import { TeamService } from '../services/TeamService'; /** * HomePage component - displays task lists and provides navigation * Accessible via the route "/" */ const HomePage: React.FC = () => { + const navigate = useNavigate(); + const { dispatchToast } = useToastController("toast"); + const [selectedTeam, setSelectedTeam] = useState(null); + const [isLoadingTeam, setIsLoadingTeam] = useState(true); + + /** + * Load teams and set default team on component mount + */ + useEffect(() => { + const loadDefaultTeam = async () => { + setIsLoadingTeam(true); + try { + const teams = await TeamService.getUserTeams(); + console.log('All teams loaded:', teams); + if (teams.length > 0) { + // Always prioritize "Business Operations Team" as default + const businessOpsTeam = teams.find(team => team.name === "Business Operations Team"); + const defaultTeam = businessOpsTeam || teams[0]; + setSelectedTeam(defaultTeam); + console.log('Default team loaded:', defaultTeam.name, 'with', defaultTeam.starting_tasks?.length || 0, 'starting tasks'); + console.log('Team logo:', defaultTeam.logo); + console.log('Team description:', defaultTeam.description); + console.log('Is Business Operations Team:', defaultTeam.name === "Business Operations Team"); + } else { + console.log('No teams found - user needs to upload a team configuration'); + // Even if no teams are found, we clear the loading state to show the "no team" message + } + } catch (error) { + console.error('Error loading default team:', error); + } finally { + setIsLoadingTeam(false); + } + }; + + loadDefaultTeam(); + }, []); + /** * Handle new task creation from the "New task" button * Resets textarea to empty state on HomePage @@ -37,11 +77,119 @@ const HomePage: React.FC = () => { }, []); /** - * Handle new task creation from input submission - placeholder for future implementation + * Handle team selection from the Settings button */ - const handleNewTask = useCallback((taskName: string) => { - console.log('Creating new task:', taskName); - }, []); + const handleTeamSelect = useCallback((team: TeamConfig | null) => { + setSelectedTeam(team); + if (team) { + dispatchToast( + + Team Selected + + {team.name} team has been selected with {team.agents.length} agents + + , + { intent: "success" } + ); + } else { + dispatchToast( + + Team Deselected + + No team is currently selected + + , + { intent: "info" } + ); + } + }, [dispatchToast]); + + /** + * Handle team upload completion - refresh team list and keep Business Operations Team as default + */ + const handleTeamUpload = useCallback(async () => { + try { + const teams = await TeamService.getUserTeams(); + console.log('Teams refreshed after upload:', teams.length); + + if (teams.length > 0) { + // Always keep "Business Operations Team" as default, even after new uploads + const businessOpsTeam = teams.find(team => team.name === "Business Operations Team"); + const defaultTeam = businessOpsTeam || teams[0]; + setSelectedTeam(defaultTeam); + console.log('Default team after upload:', defaultTeam.name); + console.log('Business Operations Team remains default'); + + // Show a toast notification about the upload success + dispatchToast( + + Team Uploaded Successfully! + + Team uploaded. {defaultTeam.name} remains your default team. + + , + { intent: "success" } + ); + } + } catch (error) { + console.error('Error refreshing teams after upload:', error); + } + }, [dispatchToast]); + + /** + * Handle new task creation from input submission + * Creates a plan and navigates to the create plan page + */ + const handleNewTask = useCallback(async (taskName: string) => { + if (taskName.trim()) { + try { + const response = await TaskService.createPlan( + taskName.trim(), + selectedTeam?.team_id + ); + + if (response.plan_id && response.plan_id !== null) { + dispatchToast( + + Plan Created! + + Successfully created plan for: {taskName} + {selectedTeam && ` using ${selectedTeam.name} team`} + + , + { intent: "success" } + ); + + // Navigate to create page (no team ID in URL anymore) + console.log('Navigating to plan creation with team:', selectedTeam?.name); + navigate(`/plan/${response.plan_id}/create`); + } else { + dispatchToast( + + + + Failed to create plan + + Unable to create plan. Please try again. + , + { intent: "error" } + ); + } + } catch (error: any) { + console.error('Error creating plan:', error); + dispatchToast( + + + + Error creating plan + + {error.message || 'Something went wrong'} + , + { intent: "error" } + ); + } + } + }, [navigate, dispatchToast, selectedTeam]); return ( <> @@ -50,15 +198,30 @@ const HomePage: React.FC = () => { - + {!isLoadingTeam ? ( + + ) : ( +
+ +
+ )}
diff --git a/src/frontend/src/pages/PlanCreatePage.tsx b/src/frontend/src/pages/PlanCreatePage.tsx index d95e09656..e00225209 100644 --- a/src/frontend/src/pages/PlanCreatePage.tsx +++ b/src/frontend/src/pages/PlanCreatePage.tsx @@ -24,13 +24,15 @@ import { TaskListSquareLtr } from "@/coral/imports/bundleicons"; import LoadingMessage, { loadingMessages } from "@/coral/components/LoadingMessage"; import { RAIErrorCard, RAIErrorData } from "../components/errors"; import { apiClient } from "../api/apiClient"; +import { TeamConfig } from "../models/Team"; +import { TeamService } from "../services/TeamService"; /** * Page component for creating and viewing a plan being generated - * Accessible via the route /plan/{plan_id}/create + * Accessible via the route /plan/{plan_id}/create/{team_id?} */ const PlanCreatePage: React.FC = () => { - const { planId } = useParams<{ planId: string }>(); + const { planId, teamId } = useParams<{ planId: string; teamId?: string }>(); const navigate = useNavigate(); const { showToast, dismissToast } = useInlineToaster(); @@ -46,6 +48,7 @@ const PlanCreatePage: React.FC = () => { const [reloadLeftList, setReloadLeftList] = useState(true); const [raiError, setRAIError] = useState(null); const [planGenerated, setPlanGenerated] = useState(false); + const [selectedTeam, setSelectedTeam] = useState(null); const [loadingMessage, setLoadingMessage] = useState(loadingMessages[0]); @@ -60,6 +63,28 @@ const PlanCreatePage: React.FC = () => { return () => clearInterval(interval); }, [loading]); + // Load team data if teamId is provided + useEffect(() => { + const loadTeamData = async () => { + if (teamId) { + console.log('Loading team data for ID:', teamId); + try { + const team = await TeamService.getTeamById(teamId); + if (team) { + setSelectedTeam(team); + console.log('Team loaded for plan creation:', team.name); + } else { + console.warn('Team not found for ID:', teamId); + } + } catch (error) { + console.error('Error loading team data:', error); + } + } + }; + + loadTeamData(); + }, [teamId]); + useEffect(() => { const currentPlan = allPlans.find( (plan) => plan.plan.id === planId @@ -247,7 +272,12 @@ const PlanCreatePage: React.FC = () => { return ( - setReloadLeftList(false)}/> + setReloadLeftList(false)} + selectedTeam={selectedTeam} + /> {/* šŸ™ Only replaces content body, not page shell */} diff --git a/src/frontend/src/services/PlanDataService.tsx b/src/frontend/src/services/PlanDataService.tsx index 9196459e7..ae3f3fdb7 100644 --- a/src/frontend/src/services/PlanDataService.tsx +++ b/src/frontend/src/services/PlanDataService.tsx @@ -2,6 +2,7 @@ import { PlanWithSteps, Step, AgentType, + AgentTypeString, ProcessedPlanData, PlanMessage, } from "@/models"; @@ -43,7 +44,7 @@ export class PlanDataService { messages: PlanMessage[] ): ProcessedPlanData { // Extract unique agents from steps - const uniqueAgents = new Set(); + const uniqueAgents = new Set(); plan.steps.forEach((step) => { if (step.agent) { uniqueAgents.add(step.agent); @@ -85,7 +86,7 @@ export class PlanDataService { * @param agentType Agent type to filter by * @returns Array of steps for the specified agent */ - static getStepsForAgent(plan: PlanWithSteps, agentType: AgentType): Step[] { + static getStepsForAgent(plan: PlanWithSteps, agentType: AgentTypeString): Step[] { return apiService.getStepsForAgent(plan, agentType); } diff --git a/src/frontend/src/services/TaskService.tsx b/src/frontend/src/services/TaskService.tsx index 9c418342f..5cdb04249 100644 --- a/src/frontend/src/services/TaskService.tsx +++ b/src/frontend/src/services/TaskService.tsx @@ -200,16 +200,19 @@ export class TaskService { /** * Create a new plan with RAI validation * @param description Task description + * @param teamId Optional team ID to use for this plan * @returns Promise with the response containing plan ID and status */ static async createPlan( - description: string + description: string, + teamId?: string ): Promise<{ plan_id: string; status: string; session_id: string }> { const sessionId = this.generateSessionId(); const inputTask: InputTask = { session_id: sessionId, description: description, + team_id: teamId, }; try { diff --git a/src/frontend/src/services/TeamService.ts b/src/frontend/src/services/TeamService.ts new file mode 100644 index 000000000..0cce86839 --- /dev/null +++ b/src/frontend/src/services/TeamService.ts @@ -0,0 +1,103 @@ +import { TeamConfig } from '../models/Team'; +import { apiClient } from '../api/apiClient'; + +export class TeamService { + /** + * Upload a custom team configuration + */ + static async uploadCustomTeam(teamFile: File): Promise<{ success: boolean; team?: TeamConfig; error?: string; raiError?: any; searchError?: any }> { + try { + const formData = new FormData(); + formData.append('file', teamFile); + + const response = await apiClient.upload('/upload_team_config', formData); + + return { + success: true, + team: response.data + }; + } catch (error: any) { + + // Check if this is an RAI validation error + const errorDetail = error.response?.data?.detail || error.response?.data; + + // If the error message contains "inappropriate content", treat it as RAI error + if (typeof errorDetail === 'string' && errorDetail.includes('inappropriate content')) { + return { + success: false, + raiError: { + error_type: 'RAI_VALIDATION_FAILED', + message: errorDetail, + description: errorDetail + } + }; + } + + // If the error message contains "Search index validation failed", treat it as search error + if (typeof errorDetail === 'string' && errorDetail.includes('Search index validation failed')) { + return { + success: false, + searchError: { + error_type: 'SEARCH_VALIDATION_FAILED', + message: errorDetail, + description: errorDetail + } + }; + } + + // Get error message from the response + let errorMessage = error.message || 'Failed to upload team configuration'; + if (error.response?.data?.detail) { + errorMessage = error.response.data.detail; + } + + return { + success: false, + error: errorMessage + }; + } + } + + /** + * Get user's custom teams + */ + static async getUserTeams(): Promise { + try { + const response = await apiClient.get('/team_configs'); + + // The apiClient returns the response data directly, not wrapped in a data property + const teams = Array.isArray(response) ? response : []; + + return teams; + } catch (error: any) { + return []; + } + } + + /** + * Get a specific team by ID + */ + static async getTeamById(teamId: string): Promise { + try { + const teams = await this.getUserTeams(); + const team = teams.find(t => t.team_id === teamId); + return team || null; + } catch (error: any) { + return null; + } + } + + /** + * Delete a custom team + */ + static async deleteTeam(teamId: string): Promise { + try { + const response = await apiClient.delete(`/team_configs/${teamId}`); + return true; + } catch (error: any) { + return false; + } + } +} + +export default TeamService; diff --git a/test_team_config.json b/test-rai-failure.json similarity index 100% rename from test_team_config.json rename to test-rai-failure.json