From 0d50a90089cd60a9611879a07fd5bcdd714bcaa8 Mon Sep 17 00:00:00 2001 From: Eunsoo Lee Date: Mon, 25 Aug 2025 16:04:30 -0700 Subject: [PATCH 1/7] mochi kickoff --- .../src/components/common/SettingsButton.tsx | 1277 ++++++++++------- .../src/components/common/TeamSelector.tsx | 278 ++++ .../components/common/TeamSettingsButton.tsx | 136 ++ .../src/components/common/TeamUploadTab.tsx | 204 +++ .../src/components/content/PlanPanelLeft.tsx | 164 ++- .../src/components/content/TaskList.tsx | 4 +- src/frontend_react/package-lock.json | 6 + 7 files changed, 1493 insertions(+), 576 deletions(-) create mode 100644 src/frontend/src/components/common/TeamSelector.tsx create mode 100644 src/frontend/src/components/common/TeamSettingsButton.tsx create mode 100644 src/frontend/src/components/common/TeamUploadTab.tsx create mode 100644 src/frontend_react/package-lock.json diff --git a/src/frontend/src/components/common/SettingsButton.tsx b/src/frontend/src/components/common/SettingsButton.tsx index 3b6eecfb4..8ad5d8974 100644 --- a/src/frontend/src/components/common/SettingsButton.tsx +++ b/src/frontend/src/components/common/SettingsButton.tsx @@ -1,4 +1,9 @@ -import React, { useState } from 'react'; +// DEV NOTE: SettingsButton – shows a Team picker with upload + delete. +// Goal: while backend is offline, surface 2–3 mock teams at the TOP of the list +// so you can do visual polish. When backend succeeds, mocks still appear first; +// when it fails, we fall back to just the mocks. Everything else is untouched. + +import React, { useState } from "react"; import { Button, Dialog, @@ -17,7 +22,17 @@ import { Tooltip, Badge, Input, -} from '@fluentui/react-components'; + Body1Strong, + Tag, + Radio, + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + TabList, + Tab, +} from "@fluentui/react-components"; import { Settings20Regular, CloudAdd20Regular, @@ -37,77 +52,181 @@ import { WindowConsole20Regular, Code20Regular, Wrench20Regular, -} from '@fluentui/react-icons'; -import { TeamConfig } from '../../models/Team'; -import { TeamService } from '../../services/TeamService'; + RadioButton20Regular, + RadioButton20Filled, + MoreHorizontal20Regular, + Agents20Regular, + ArrowUploadRegular, +} from "@fluentui/react-icons"; +import { TeamConfig } from "../../models/Team"; +import { TeamService } from "../../services/TeamService"; +import { MoreHorizontal } from "@/coral/imports/bundleicons"; -// Icon mapping function to convert string icons to FluentUI icons +// DEV NOTE: map string tokens from JSON to Fluent UI icons. +// If a token is missing or unknown, we use a friendly default. 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': , - + Terminal: , + MonitorCog: , + BookMarked: , + Search: , + Robot: , // Fallback (no Robot20Regular) + Code: , + Play: , + Shield: , + Globe: , + Person: , + Database: , + Document: , + // Team logos - 'Wrench': , - 'TestTube': , // Fallback since TestTube20Regular doesn't exist - 'Building': , - 'Desktop': , - - // Common fallbacks - 'default': , + Wrench: , + TestTube: , // Fallback (no TestTube20Regular) + Building: , + Desktop: , + + // Fallback + default: , }; - - return iconMap[iconString] || iconMap['default'] || ; + + return iconMap[iconString] || iconMap["default"] || ; }; +// DEV NOTE: MOCK TEAMS – strictly for visual work. +// They are shaped exactly like TeamConfig so the rest of the UI +// (badges, selection state, cards) behaves identically. +const MOCK_TEAMS: TeamConfig[] = [ + { + id: "mock-01", + team_id: "mock-01", + name: "Invoice QA (Mock)", + description: + "Validates invoice totals, flags anomalies, and drafts vendor replies.", + status: "active", + logo: "Document", + protected: false, + created_by: "mock", + agents: [ + { + name: "Line-Item Checker", + type: "tool", + input_key: "invoice_pdf", + deployment_name: "gpt-mini", + icon: "Search", + }, + { + name: "Policy Guard", + type: "tool", + input_key: "policy_text", + deployment_name: "gpt-mini", + icon: "Shield", + }, + ], + }, + { + id: "mock-02", + team_id: "mock-02", + name: "RAG Research (Mock)", + description: + "Summarizes docs and cites sources with a lightweight RAG pass.", + status: "active", + logo: "Database", + protected: false, + created_by: "mock", + agents: [ + { + name: "Retriever", + type: "rag", + input_key: "query", + deployment_name: "gpt-mini", + index_name: "docs-index", + icon: "Database", + }, + { + name: "Writer", + type: "tool", + input_key: "draft", + deployment_name: "gpt-mini", + icon: "Code", + }, + ], + }, + { + id: "mock-03", + team_id: "mock-03", + name: "Website Auditor (Mock)", + description: "Checks accessibility, meta tags, and perf hints for a URL.", + status: "active", + logo: "Globe", + protected: false, + created_by: "mock", + agents: [ + { + name: "Scanner", + type: "tool", + input_key: "url", + deployment_name: "gpt-mini", + icon: "Globe", + }, + { + name: "A11y Linter", + type: "tool", + input_key: "report", + deployment_name: "gpt-mini", + icon: "Wrench", + }, + ], + }, +]; + interface SettingsButtonProps { onTeamSelect?: (team: TeamConfig | null) => void; onTeamUpload?: () => Promise; selectedTeam?: TeamConfig | null; + trigger?: React.ReactNode; } const SettingsButton: React.FC = ({ onTeamSelect, onTeamUpload, selectedTeam, + trigger, }) => { + // DEV NOTE: local UI state – dialog, lists, loading, upload feedback. 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 [tempSelectedTeam, setTempSelectedTeam] = useState( + null + ); + const [searchQuery, setSearchQuery] = useState(""); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [teamToDelete, setTeamToDelete] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); + // DEV NOTE: Load teams. If backend returns, we prepend mocks so they show first. + // If backend throws (offline), we silently switch to only mocks to keep UI clean. 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); + const withMocksOnTop = [...MOCK_TEAMS.slice(0, 3), ...(teamsData || [])]; + setTeams(withMocksOnTop); } catch (err: any) { - setError(err.message || 'Failed to load teams'); + // Backend offline → visual-only mode + setTeams(MOCK_TEAMS.slice(0, 3)); + setError(null); // No scary error banner while you design } finally { setLoading(false); } }; + // DEV NOTE: Opening the dialog triggers a load; closing resets transient UI. const handleOpenChange = async (open: boolean) => { setIsOpen(open); if (open) { @@ -115,15 +234,16 @@ const SettingsButton: React.FC = ({ setTempSelectedTeam(selectedTeam || null); setError(null); setUploadMessage(null); - setSearchQuery(''); // Clear search when opening + setSearchQuery(""); } else { setTempSelectedTeam(null); setError(null); setUploadMessage(null); - setSearchQuery(''); // Clear search when closing + setSearchQuery(""); } }; + // DEV NOTE: Confirm & cancel handlers – pass selection back up and close. const handleContinue = () => { if (tempSelectedTeam) { onTeamSelect?.(tempSelectedTeam); @@ -136,220 +256,313 @@ const SettingsButton: React.FC = ({ 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()) + // DEV NOTE: Search – filters by name or description, case-insensitive. + 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[] } => { + // DEV NOTE: Schema validation for uploads – keeps UX consistent without backend. + 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'); + 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.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'); + 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'); + 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'); + errors.push("Agents array is required"); } else if (data.agents.length === 0) { - errors.push('Team must have at least one agent'); + 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') { + 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.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.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`); + 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`); + 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`); + 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') { + 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.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') { + 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') { + 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'); + errors.push("Starting tasks must be an array if provided"); } else { data.starting_tasks.forEach((task: any, index: number) => { - if (!task || typeof task !== 'object') { + 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.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.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.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.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.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`); + 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') { + 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'); + 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) => { + // DEV NOTE: File upload – validates locally; if backend is up, we append the + // uploaded team into the current list (mocks stay on top). + 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...'); + 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'); + 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'); + } catch { + throw new Error("Invalid JSON format. Please check your file syntax"); } - // Validate the team configuration - setUploadMessage('Validating team configuration structure...'); + 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')}`; + const errorMessage = `Team configuration validation failed:\n\n${validation.errors + .map((error) => `• ${error}`) + .join("\n")}`; throw new Error(errorMessage); } - setUploadMessage('Uploading team configuration...'); + 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 + setUploadMessage("Team uploaded successfully!"); + if (result.team) { - setTeams(currentTeams => [...currentTeams, result.team!]); + // Keep mocks pinned on top; append uploaded team after mocks + setTeams((current) => { + const mocks = current.filter((t) => t.created_by === "mock"); + const nonMocks = current.filter((t) => t.created_by !== "mock"); + return [...mocks, result.team!, ...nonMocks]; + }); } - + 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.'); + 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.'); + 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.'); + 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'); + setError(result.error || "Failed to upload team configuration"); setUploadMessage(null); } } catch (err: any) { - setError(err.message || 'Failed to upload team configuration'); + setError(err.message || "Failed to upload team configuration"); setUploadMessage(null); } finally { setUploadLoading(false); - // Reset the input - event.target.value = ''; + event.target.value = ""; } }; + // DEV NOTE: Delete – optimistic UI: remove locally, then re-sync from server. + // If team is protected, we block deletion. const handleDeleteTeam = (team: TeamConfig, event: React.MouseEvent) => { event.stopPropagation(); setTeamToDelete(team); @@ -363,64 +576,59 @@ const SettingsButton: React.FC = ({ const confirmDeleteTeam = async () => { if (!teamToDelete || deleteLoading) return; - - // Check if team is protected + if (teamToDelete.protected) { - setError('This team is protected and cannot be deleted.'); + 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 + + setTeams((currentTeams) => + currentTeams.filter((team) => team.id !== teamToDelete.id) + ); + await loadTeams(); - } else { - setError('Failed to delete team configuration. The server rejected the deletion request.'); + 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.'; - + 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.'; + 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.'; + 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.'; + 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); @@ -429,386 +637,439 @@ const SettingsButton: React.FC = ({ } }; + // DEV NOTE: Pure view – one card per team with selection + delete. const renderTeamCard = (team: TeamConfig, isCustom = false) => { const isSelected = tempSelectedTeam?.team_id === team.team_id; - + return ( { + e.stopPropagation(); + handleTeamSelect(team); }} > - {/* Team Icon and Title */} -
-
-
- {getIconFromString(team.logo)} + {/* Header: icon, title, select, delete */} + +
+ {/* Selected checkmark */} + {isSelected && ( + + )} + + {!isSelected && } + +
+ {team.name} + + {/* Description */} +
+ + {team.description} +
- -
- - {team.name} - + + {/* Agents */} + +
+ {team.agents.map((agent) => ( + + {agent.name} + + ))}
- {/* Selection Checkmark */} - {isSelected && ( -
- -
- )} + {/* Actions */} - {/* Action Buttons */} -
- {!isSelected && ( - - - - )} - +
- - {/* Description */} -
- - {team.description} - -
- - {/* Agents Section */} -
- - Agents - -
- {team.agents.map((agent) => ( - - - {getIconFromString(agent.icon || 'default')} - - {agent.name} - - ))} -
-
); }; + // DEV NOTE: Render – dialog with search, upload, list (mocks pinned), and actions. return ( <> - handleOpenChange(data.open)}> - - - + + )} + + + - Settings - - - - - - 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%' }} + Select a Team +
+ +
+ + + + {error && ( +
+ {error} +
+ )} - {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. - + {uploadMessage && ( +
+ + {uploadMessage} +
+ )} + + + Teams + Upload Team + + + {/* DEV NOTE: Lightweight requirements card – keeps UX self-explanatory. */} + {/*
+ + 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 +
• Starting tasks are optional but must have{" "} + name and prompt if included +
• All text fields cannot be empty +
+
*/} + + {/* DEV NOTE: Search – filters the already merged list (mocks + real). */} +
+ setSearchQuery(e.target.value)} + contentBefore={} + style={{ + width: "100%", + borderRadius: "8px", + padding: "12px", + }} + appearance="filled-darker" + />
-
- + + {/* DEV NOTE: List – shows merged teams. Mocks remain visually identical. */} + {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} + + - - - - -
+ + + + {/* DEV NOTE: Delete confirmation – warns that teams are shared across users. */} + 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. + +
+
+
+ + + + +
+
+
); }; diff --git a/src/frontend/src/components/common/TeamSelector.tsx b/src/frontend/src/components/common/TeamSelector.tsx new file mode 100644 index 000000000..75c1374f6 --- /dev/null +++ b/src/frontend/src/components/common/TeamSelector.tsx @@ -0,0 +1,278 @@ +// TeamSelector — header (search + errors) stays fixed; only the list scrolls. + +import React, { useEffect, useMemo, useState } from "react"; +import { + Spinner, Text, Input, Body1, Body1Strong, Card, Tooltip, Button, + Dialog, DialogSurface, DialogContent, DialogBody, DialogActions, DialogTitle, Tag +} from "@fluentui/react-components"; +import { + Delete20Regular, RadioButton20Filled, RadioButton20Regular, Search20Regular, + WindowConsole20Regular, Desktop20Regular, BookmarkMultiple20Regular, Person20Regular, + Building20Regular, Document20Regular, Database20Regular, Play20Regular, Shield20Regular, + Globe20Regular, Clipboard20Regular, Code20Regular, Wrench20Regular +} from "@fluentui/react-icons"; +import { TeamConfig } from "../../models/Team"; +import { TeamService } from "../../services/TeamService"; + +const getIconFromString = (iconString: string): React.ReactNode => { + const iconMap: Record = { + Terminal: , MonitorCog: , + BookMarked: , Search: , + Robot: , Code: , Play: , + Shield: , Globe: , Person: , + Database: , Document: , Wrench: , + TestTube: , Building: , Desktop: , + default: , + }; + return iconMap[iconString] || iconMap.default; +}; + +interface Props { + isOpen: boolean; + refreshKey: number; + selectedTeam: TeamConfig | null; + onTeamSelect: (team: TeamConfig | null) => void; +} + +const TeamSelector: React.FC = ({ isOpen, refreshKey, selectedTeam, onTeamSelect }) => { + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [error, setError] = useState(null); + + // Delete dialog state + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [teamToDelete, setTeamToDelete] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + const loadTeams = async () => { + setLoading(true); + setError(null); + try { + const teamsData = await TeamService.getUserTeams(); + setTeams(Array.isArray(teamsData) ? teamsData : []); + } catch (err: any) { + setTeams([]); + setError(err?.message ?? "Failed to load teams."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isOpen) loadTeams(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, refreshKey]); + + const filtered = useMemo(() => { + const q = searchQuery.toLowerCase(); + return teams.filter( + (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) + ); + }, [teams, searchQuery]); + + const handleDeleteTeam = (team: TeamConfig, e: React.MouseEvent) => { + e.stopPropagation(); + setTeamToDelete(team); + setDeleteConfirmOpen(true); + }; + + const confirmDeleteTeam = async () => { + if (!teamToDelete || deleteLoading) return; + if (teamToDelete.protected) { + setError("This team is protected and cannot be deleted."); + setDeleteConfirmOpen(false); + setTeamToDelete(null); + return; + } + setDeleteLoading(true); + try { + const ok = await TeamService.deleteTeam(teamToDelete.id); + if (ok) setTeams((list) => list.filter((t) => t.id !== teamToDelete.id)); + else setError("Failed to delete team configuration."); + } catch (err: any) { + setError(err?.message ?? "Failed to delete team configuration."); + } finally { + setDeleteLoading(false); + setDeleteConfirmOpen(false); + setTeamToDelete(null); + } + }; + + return ( + // -------- Outer container: no scroll here, we manage it in the list wrapper +
+ {/* Header (non-scrollable): search + error */} +
+
+ setSearchQuery(e.target.value)} + contentBefore={} + style={{ width: "100%", borderRadius: 8, padding: 12 }} + appearance="filled-darker" + /> +
+ + {error && ( +
+ {error} +
+ )} +
+ + {/* Scrollable list area */} +
+ {loading ? ( +
+ +
+ ) : filtered.length > 0 ? ( +
+ {filtered.map((team) => { + const isSelected = selectedTeam?.team_id === team.team_id; + return ( + onTeamSelect(team)} + style={{ + border: isSelected + ? "1px solid var(--colorBrandBackground)" + : "1px solid var(--colorNeutralStroke1)", + borderRadius: 8, + backgroundColor: isSelected + ? "var(--colorBrandBackground2)" + : "var(--colorNeutralBackground1)", + padding: 20, + marginBottom: 8, + boxShadow: "none" + }} + > +
+ {isSelected ? ( + + ) : ( + + )} + +
+ {team.name} +
+ + {team.description} + +
+ +
+ {team.agents.map((a) => ( + + {a.name} + + ))} +
+ + +
+
+ ); + })} +
+ ) : teams.length === 0 ? ( +
+ + No teams available + + + Use the Upload tab to add a JSON team configuration + +
+ ) : ( +
+ + No teams match your search + + + Try a different term + +
+ )} +
+ + {/* Delete confirmation (outside scroll; modal anyway) */} + setDeleteConfirmOpen(d.open)}> + + + + ⚠️ Delete Team Configuration +
+ + Are you sure you want to delete "{teamToDelete?.name}"? + +
+ + Important Notice: + + + This team configuration is shared across users. Deleting it removes it for everyone. + +
+
+
+ + + + +
+
+
+
+ ); +}; + +export default TeamSelector; diff --git a/src/frontend/src/components/common/TeamSettingsButton.tsx b/src/frontend/src/components/common/TeamSettingsButton.tsx new file mode 100644 index 000000000..1bc9121a9 --- /dev/null +++ b/src/frontend/src/components/common/TeamSettingsButton.tsx @@ -0,0 +1,136 @@ +// DEV NOTE: Dialog shell for Team Settings +// - Children = trigger (your "Current team" tile) +// - Tabs: Teams | Upload Team +// - Holds tempSelectedTeam; "Continue" commits to parent + +import React, { useEffect, useState } from "react"; +import { + Button, + Dialog, + DialogTrigger, + DialogSurface, + DialogTitle, + DialogContent, + DialogBody, + DialogActions, + Body1Strong, + TabList, + Tab, +} from "@fluentui/react-components"; +import { Settings20Regular } from "@fluentui/react-icons"; +import { TeamConfig } from "../../models/Team"; +import TeamSelector from "./TeamSelector"; +import TeamUploadTab from "./TeamUploadTab"; +import { Dismiss } from "@/coral/imports/bundleicons"; + +interface TeamSettingsButtonProps { + onTeamSelect?: (team: TeamConfig | null) => void; + selectedTeam?: TeamConfig | null; + children?: React.ReactNode; // trigger +} + +const TeamSettingsButton: React.FC = ({ + onTeamSelect, + selectedTeam, + children, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState<"teams" | "upload">("teams"); + const [tempSelectedTeam, setTempSelectedTeam] = useState( + null + ); + const [refreshKey, setRefreshKey] = useState(0); // bump to refresh TeamSelector + + useEffect(() => { + if (isOpen) { + setTempSelectedTeam(selectedTeam ?? null); + } else { + setActiveTab("teams"); + } + }, [isOpen, selectedTeam]); + + return ( + setIsOpen(d.open)}> + + {children ?? ( + + )} + + + + + Select a Team + + + + + setActiveTab(data.value as "teams" | "upload") + } + style={{width:'calc(100% + 16px)', margin:'8px 0 0 0', alignSelf:'center'}} + > + Teams + Upload team + + + + + {activeTab === "teams" ? ( + + ) : ( + { + setActiveTab("teams"); + setRefreshKey((k) => k + 1); + }} + /> + )} + + + + + {/* */} + + + + + ); +}; + +export default TeamSettingsButton; diff --git a/src/frontend/src/components/common/TeamUploadTab.tsx b/src/frontend/src/components/common/TeamUploadTab.tsx new file mode 100644 index 000000000..e972e4e8b --- /dev/null +++ b/src/frontend/src/components/common/TeamUploadTab.tsx @@ -0,0 +1,204 @@ +// DEV NOTE: Upload tab +// - Drag & drop or click to browse +// - Validates JSON; uploads; shows progress/errors +// - onUploaded() => parent refreshes Teams and switches tab + +import React, { useRef, useState } from "react"; +import { Button, Body1, Body1Strong, Spinner, Text } from "@fluentui/react-components"; +import { AddCircle24Color, AddCircleColor, ArrowUploadRegular, DocumentAdd20Color, DocumentAddColor } from "@fluentui/react-icons"; +import { TeamService } from "../../services/TeamService"; + +interface Props { + onUploaded: () => void; +} + +const TeamUploadTab: React.FC = ({ onUploaded }) => { + const inputRef = useRef(null); + const [uploadLoading, setUploadLoading] = useState(false); + const [uploadMessage, setUploadMessage] = useState(null); + const [error, setError] = useState(null); + + const validateTeamConfig = (data: any): { isValid: boolean; errors: string[] } => { + const errors: string[] = []; + if (!data || typeof data !== "object") errors.push("JSON file cannot be empty and must contain a valid object"); + if (!data?.name || !String(data.name).trim()) errors.push("Team name is required and cannot be empty"); + if (!data?.description || !String(data.description).trim()) errors.push("Team description is required and cannot be empty"); + if (!data?.status || !String(data.status).trim()) errors.push("Team status is required and cannot be empty"); + if (!Array.isArray(data?.agents) || data.agents.length === 0) { + errors.push("Team must have at least one agent"); + } else { + data.agents.forEach((agent: any, i: number) => { + if (!agent || typeof agent !== "object") errors.push(`Agent ${i + 1}: Invalid object`); + if (!agent?.name || !String(agent.name).trim()) errors.push(`Agent ${i + 1}: name required`); + if (!agent?.type || !String(agent.type).trim()) errors.push(`Agent ${i + 1}: type required`); + if (!agent?.input_key || !String(agent.input_key).trim()) errors.push(`Agent ${i + 1}: input_key required`); + if (!agent?.deployment_name || !String(agent.deployment_name).trim()) errors.push(`Agent ${i + 1}: deployment_name required`); + if (String(agent.type).toLowerCase() === "rag" && (!agent?.index_name || !String(agent.index_name).trim())) { + errors.push(`Agent ${i + 1}: index_name required for RAG agents`); + } + }); + } + return { isValid: errors.length === 0, errors }; + }; + + const processFile = async (file: File) => { + setError(null); + setUploadLoading(true); + setUploadMessage("Reading and validating team configuration..."); + + try { + if (!file.name.toLowerCase().endsWith(".json")) { + throw new Error("Please upload a valid JSON file"); + } + + const content = await file.text(); + let teamData: any; + try { + teamData = JSON.parse(content); + } catch { + throw new Error("Invalid JSON format. Please check your file syntax"); + } + + setUploadMessage("Validating team configuration structure..."); + const validation = validateTeamConfig(teamData); + if (!validation.isValid) { + throw new Error( + `Team configuration validation failed:\n\n${validation.errors.map((e) => `• ${e}`).join("\n")}` + ); + } + + setUploadMessage("Uploading team configuration..."); + const result = await TeamService.uploadCustomTeam(file); + + if (result.success) { + setUploadMessage("Team uploaded successfully!"); + setTimeout(() => { + setUploadMessage(null); + onUploaded(); + }, 200); + } else if (result.raiError) { + throw new Error("❌ Content Safety Check Failed\n\nYour team configuration doesn't meet content guidelines."); + } else if (result.modelError) { + throw new Error("🤖 Model Deployment Validation Failed\n\nVerify deployment_name values and access to AI services."); + } else if (result.searchError) { + throw new Error("🔍 RAG Search Configuration Error\n\nVerify search index names and access."); + } else { + throw new Error(result.error || "Failed to upload team configuration"); + } + } catch (err: any) { + setError(err.message || "Failed to upload team configuration"); + setUploadMessage(null); + } finally { + setUploadLoading(false); + if (inputRef.current) inputRef.current.value = ""; + } + }; + + return ( +
+ + + + + + + + + + + {/* Drag & drop zone (also clickable) */} +
{ + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + if (file) processFile(file); + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onClick={() => inputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + inputRef.current?.click(); + } + }} + style={{ + border: "1px dashed var(--colorNeutralStroke1)", + height:'100%', + borderRadius: 8, + padding: 24, + textAlign: "center", + background: "var(--colorNeutralBackground2)", + cursor: "pointer", + userSelect: "none", + display:'flex', + flexDirection:'column', + justifyContent:'center', + alignContent:'center', + alignItems:'center', + flex: 1 + }} + > + + +
+ + Drag & drop your team JSON here + + + or click to browse + + { + const f = e.target.files?.[0]; + if (f) processFile(f); + }} + style={{ display: "none" }} + /> +
+ + {/* Status + errors */} + {uploadMessage && ( +
+ + {uploadMessage} +
+ )} + + {error && ( +
+ {error} +
+ )} + + + + {/* Requirements */} +
+ Upload requirements + + • JSON must include name, description, and status +
• At least one agent with name, type, input_key, and deployment_name +
• RAG agents additionally require index_name +
• Starting tasks are optional, but if provided must include name and prompt +
• Text fields cannot be empty +
+
+ + + +
+ ); +}; + +export default TeamUploadTab; diff --git a/src/frontend/src/components/content/PlanPanelLeft.tsx b/src/frontend/src/components/content/PlanPanelLeft.tsx index 0ab07bb64..9b0a90874 100644 --- a/src/frontend/src/components/content/PlanPanelLeft.tsx +++ b/src/frontend/src/components/content/PlanPanelLeft.tsx @@ -1,8 +1,13 @@ +// DEV NOTE: Left plan panel. Change: the "Current team" tile is now the trigger +// that opens SettingsButton’s modal. Hover → bg to background3, chevron to fg1. + import PanelLeft from "@/coral/components/Panels/PanelLeft"; import PanelLeftToolbar from "@/coral/components/Panels/PanelLeftToolbar"; import { Body1Strong, Button, + Caption1, + Divider, Subtitle1, Subtitle2, Toast, @@ -13,7 +18,9 @@ import { } from "@fluentui/react-components"; import { Add20Regular, + ArrowSwap20Regular, ChatAdd20Regular, + ChevronUpDown20Regular, ErrorCircle20Regular, } from "@fluentui/react-icons"; import TaskList from "./TaskList"; @@ -29,14 +36,15 @@ 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 TeamSettingsButton from "../common/TeamSettingsButton"; import { TeamConfig } from "../../models/Team"; -const PlanPanelLeft: React.FC = ({ - reloadTasks, - restReload, - onTeamSelect, +const PlanPanelLeft: React.FC = ({ + reloadTasks, + restReload, + onTeamSelect, onTeamUpload, - selectedTeam: parentSelectedTeam + selectedTeam: parentSelectedTeam, }) => { const { dispatchToast } = useToastController("toast"); const navigate = useNavigate(); @@ -47,14 +55,13 @@ const PlanPanelLeft: React.FC = ({ const [plans, setPlans] = useState(null); const [plansLoading, setPlansLoading] = useState(false); const [plansError, setPlansError] = useState(null); - const [userInfo, setUserInfo] = useState( - getUserInfoGlobal() - ); - - // Use parent's selected team if provided, otherwise use local state + const [userInfo, setUserInfo] = useState(getUserInfoGlobal()); + + // DEV NOTE: If parent gives a team, use that; otherwise manage local selection. const [localSelectedTeam, setLocalSelectedTeam] = useState(null); const selectedTeam = parentSelectedTeam || localSelectedTeam; + // DEV NOTE: Load and transform plans → task lists. const loadPlansData = useCallback(async (forceRefresh = false) => { try { setPlansLoading(true); @@ -63,9 +70,7 @@ const PlanPanelLeft: React.FC = ({ setPlans(plansData); } catch (error) { console.log("Failed to load plans:", error); - setPlansError( - error instanceof Error ? error : new Error("Failed to load plans") - ); + setPlansError(error instanceof Error ? error : new Error("Failed to load plans")); } finally { setPlansLoading(false); } @@ -77,8 +82,6 @@ const PlanPanelLeft: React.FC = ({ restReload?.(); } }, [reloadTasks, loadPlansData, restReload]); - // Fetch plans - useEffect(() => { loadPlansData(); @@ -86,8 +89,7 @@ const PlanPanelLeft: React.FC = ({ useEffect(() => { if (plans) { - const { inProgress, completed } = - TaskService.transformPlansToTasks(plans); + const { inProgress, completed } = TaskService.transformPlansToTasks(plans); setInProgressTasks(inProgress); setCompletedTasks(completed); } @@ -108,15 +110,13 @@ const PlanPanelLeft: React.FC = ({ } }, [plansError, dispatchToast]); - // Get the session_id that matches the current URL's planId - const selectedTaskId = - plans?.find((plan) => plan.id === planId)?.session_id ?? null; + // DEV NOTE: Pick the session_id of the plan currently in the URL. + const selectedTaskId = plans?.find((plan) => plan.id === planId)?.session_id ?? null; + // DEV NOTE: Navigate when a task is chosen from the list. const handleTaskSelect = useCallback( (taskId: string) => { - const selectedPlan = plans?.find( - (plan: PlanWithSteps) => plan.session_id === taskId - ); + const selectedPlan = plans?.find((plan: PlanWithSteps) => plan.session_id === taskId); if (selectedPlan) { navigate(`/plan/${selectedPlan.id}`); } @@ -124,9 +124,9 @@ const PlanPanelLeft: React.FC = ({ [plans, navigate] ); + // DEV NOTE: Bubble selection up if parent wants it; otherwise update local state + toast. const handleTeamSelect = useCallback( (team: TeamConfig | null) => { - // Use parent's team select handler if provided, otherwise use local state if (onTeamSelect) { onTeamSelect(team); } else { @@ -134,60 +134,93 @@ const PlanPanelLeft: React.FC = ({ setLocalSelectedTeam(team); dispatchToast( - Team Selected - - {team.name} team has been selected with {team.agents.length} agents - - , - { intent: "success" } - ); + 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" } - ); + Team Deselected + No team is currently selected + , + { intent: "info" } + ); } } }, [onTeamSelect, dispatchToast] ); + // DEV NOTE (UI): Hover state for the "Current team" tile to flip bg + chevron color. + const [teamTileHovered, setTeamTileHovered] = useState(false); + + // DEV NOTE: Build the trigger tile that opens the modal. +const teamTrigger = ( +
{ if (e.key === "Enter" || e.key === " ") e.preventDefault(); }} + onMouseEnter={() => setTeamTileHovered(true)} + onMouseLeave={() => setTeamTileHovered(false)} + style={{ + margin: "16px 16px", + backgroundColor: teamTileHovered + ? "var(--colorNeutralBackground3)" + : "var(--colorNeutralBackground2)", + padding: "12px 16px", + textAlign: "left", + borderRadius: 8, + cursor: "pointer", + outline: "none", + userSelect: "none", + }} + > +
+
+ + {selectedTeam ? "Current team" : "Choose a team"} + +
+ {selectedTeam ? selectedTeam.name : "No team selected"} +
+ +
+
+); + return (
- } - > + }> +
- {/* Team Display Section */} - {selectedTeam && ( -
- - {selectedTeam.name} - -
- )} + {/* DEV NOTE: SettingsButton rendered with a custom trigger (the tile above). + Clicking the tile opens the modal. */} + + {teamTrigger} +
navigate("/", { state: { focusInput: true } })} - tabIndex={0} // ✅ allows tab focus - role="button" // ✅ announces as button + tabIndex={0} + role="button" onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -211,17 +244,16 @@ const PlanPanelLeft: React.FC = ({ /> -
- {/* Settings Button on top */} - - {/* User Card below */} +
diff --git a/src/frontend/src/components/content/TaskList.tsx b/src/frontend/src/components/content/TaskList.tsx index 47d2f0d39..d2fda06a0 100644 --- a/src/frontend/src/components/content/TaskList.tsx +++ b/src/frontend/src/components/content/TaskList.tsx @@ -81,7 +81,7 @@ const TaskList: React.FC = ({
- + In progress @@ -93,7 +93,7 @@ const TaskList: React.FC = ({ - Completed + Completed {loading ? Array.from({ length: 5 }, (_, i) => diff --git a/src/frontend_react/package-lock.json b/src/frontend_react/package-lock.json new file mode 100644 index 000000000..fedd0b47d --- /dev/null +++ b/src/frontend_react/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "frontend_react", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 66437241474c0795e0e72871290b9328e995f60c Mon Sep 17 00:00:00 2001 From: Dhruvkumar-Microsoft Date: Tue, 26 Aug 2025 19:37:08 +0530 Subject: [PATCH 2/7] added the local deployment changes --- azure_custom.yaml | 33 + infra/main.bicep | 2 + infra/main_custom.bicep | 1798 +++++++++++++++++ infra/scripts/add_cosmosdb_access.sh | 55 + infra/scripts/assign_azure_ai_user_role.sh | 49 + .../cosmosdb_and_ai_user_role_assignment.sh | 163 ++ infra/scripts/package_frontend.ps1 | 11 + infra/scripts/package_frontend.sh | 14 + src/backend/Dockerfile | 13 +- 9 files changed, 2133 insertions(+), 5 deletions(-) create mode 100644 azure_custom.yaml create mode 100644 infra/main_custom.bicep create mode 100644 infra/scripts/add_cosmosdb_access.sh create mode 100644 infra/scripts/assign_azure_ai_user_role.sh create mode 100644 infra/scripts/cosmosdb_and_ai_user_role_assignment.sh create mode 100644 infra/scripts/package_frontend.ps1 create mode 100644 infra/scripts/package_frontend.sh diff --git a/azure_custom.yaml b/azure_custom.yaml new file mode 100644 index 000000000..b126e05db --- /dev/null +++ b/azure_custom.yaml @@ -0,0 +1,33 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +name: multi-agent-custom-automation-engine-solution-accelerator +metadata: + template: multi-agent-custom-automation-engine-solution-accelerator@1.0 +requiredVersions: + azd: ">=1.15.0 !=1.17.1" + +services: + backend: + project: ./src/backend + language: py + host: containerapp + docker: + image: backend + remoteBuild: true + + frontend: + project: ./src/frontend + language: py + host: appservice + dist: ./dist + hooks: + prepackage: + windows: + shell: pwsh + run: ../../infra/scripts/package_frontend.ps1 + interactive: true + continueOnError: false + posix: + shell: sh + run: bash ../../infra/scripts/package_frontend.sh + interactive: true + continueOnError: false \ No newline at end of file diff --git a/infra/main.bicep b/infra/main.bicep index f6ea978ee..317315053 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1739,3 +1739,5 @@ output AZURE_AI_MODEL_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeploymen output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeployment.name output AZURE_AI_AGENT_ENDPOINT string = aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint output APP_ENV string = 'Prod' +output AI_FOUNDRY_RESOURCE_ID string = aiFoundryAiServices.outputs.resourceId +output COSMOSDB_ACCOUNT_NAME string = cosmosDbResourceName diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep new file mode 100644 index 000000000..102bc73ec --- /dev/null +++ b/infra/main_custom.bicep @@ -0,0 +1,1798 @@ +metadata name = 'Multi-Agent Custom Automation Engine' +metadata description = 'This module contains the resources required to deploy the Multi-Agent Custom Automation Engine solution accelerator for both Sandbox environments and WAF aligned environments.' + +@description('Set to true if you want to deploy WAF-aligned infrastructure.') +param useWafAlignedArchitecture bool + +@description('Use this parameter to use an existing AI project resource ID') +param existingFoundryProjectResourceId string = '' + +@description('Required. Name of the environment to deploy the solution into.') +param environmentName string + +@description('Required. Location for all Resources except AI Foundry.') +param solutionLocation string = resourceGroup().location + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +param existingLogAnalyticsWorkspaceId string = '' + +param azureopenaiVersion string = '2025-01-01-preview' + +// Restricting deployment to only supported Azure OpenAI regions validated with GPT-4o model +@metadata({ + azd : { + type: 'location' + usageName : [ + 'OpenAI.GlobalStandard.gpt-4o, 150' + ] + } +}) +@allowed(['australiaeast', 'eastus2', 'francecentral', 'japaneast', 'norwayeast', 'swedencentral', 'uksouth', 'westus']) +@description('Azure OpenAI Location') +param aiDeploymentsLocation string + +@minLength(1) +@description('Name of the GPT model to deploy:') +param gptModelName string = 'gpt-4o' + +param gptModelVersion string = '2024-08-06' + +@minLength(1) +@description('GPT model deployment type:') +param modelDeploymentType string = 'GlobalStandard' + +@description('Optional. AI model deployment token capacity.') +param gptModelCapacity int = 150 + +@description('Set the image tag for the container images used in the solution. Default is "latest".') +param imageTag string = 'latest' + +param solutionPrefix string = 'macae-${padLeft(take(toLower(uniqueString(subscription().id, environmentName, resourceGroup().location, resourceGroup().name)), 12), 12, '0')}' + +@description('Optional. The tags to apply to all deployed Azure resources.') +param tags object = { + app: solutionPrefix + location: solutionLocation +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Log Analytics Workspace resource.') +param logAnalyticsWorkspaceConfiguration logAnalyticsWorkspaceConfigurationType = { + enabled: true + name: 'log-${solutionPrefix}' + location: solutionLocation + sku: 'PerGB2018' + tags: tags + dataRetentionInDays: useWafAlignedArchitecture ? 365 : 30 + existingWorkspaceResourceId: existingLogAnalyticsWorkspaceId +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Application Insights resource.') +param applicationInsightsConfiguration applicationInsightsConfigurationType = { + enabled: true + name: 'appi-${solutionPrefix}' + location: solutionLocation + tags: tags + retentionInDays: useWafAlignedArchitecture ? 365 : 30 +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Managed Identity resource.') +param userAssignedManagedIdentityConfiguration userAssignedManagedIdentityType = { + enabled: true + name: 'id-${solutionPrefix}' + location: solutionLocation + tags: tags +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Network Security Group resource for the backend subnet.') +param networkSecurityGroupBackendConfiguration networkSecurityGroupConfigurationType = { + enabled: true + name: 'nsg-backend-${solutionPrefix}' + location: solutionLocation + tags: tags + securityRules: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Network Security Group resource for the containers subnet.') +param networkSecurityGroupContainersConfiguration networkSecurityGroupConfigurationType = { + enabled: true + name: 'nsg-containers-${solutionPrefix}' + location: solutionLocation + tags: tags + securityRules: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Network Security Group resource for the Bastion subnet.') +param networkSecurityGroupBastionConfiguration networkSecurityGroupConfigurationType = { + enabled: true + name: 'nsg-bastion-${solutionPrefix}' + location: solutionLocation + tags: tags + securityRules: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine Network Security Group resource for the administration subnet.') +param networkSecurityGroupAdministrationConfiguration networkSecurityGroupConfigurationType = { + enabled: true + name: 'nsg-administration-${solutionPrefix}' + location: solutionLocation + tags: tags + securityRules: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine virtual network resource.') +param virtualNetworkConfiguration virtualNetworkConfigurationType = { + enabled: useWafAlignedArchitecture ? true : false + name: 'vnet-${solutionPrefix}' + location: solutionLocation + tags: tags + addressPrefixes: null //Default value set on module configuration + subnets: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Multi-Agent Custom Automation Engine bastion resource.') +param bastionConfiguration bastionConfigurationType = { + enabled: true + name: 'bas-${solutionPrefix}' + location: solutionLocation + tags: tags + sku: 'Standard' + virtualNetworkResourceId: null //Default value set on module configuration + publicIpResourceName: 'pip-bas${solutionPrefix}' +} + +@description('Optional. Configuration for the Windows virtual machine.') +param virtualMachineConfiguration virtualMachineConfigurationType = { + enabled: true + name: 'vm${solutionPrefix}' + location: solutionLocation + tags: tags + adminUsername: 'adminuser' + adminPassword: useWafAlignedArchitecture? 'P@ssw0rd1234' : guid(solutionPrefix, subscription().subscriptionId) + vmSize: 'Standard_D2s_v3' + subnetResourceId: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the AI Foundry AI Services resource.') +param aiFoundryAiServicesConfiguration aiServicesConfigurationType = { + enabled: true + name: 'aisa-${solutionPrefix}' + location: aiDeploymentsLocation + sku: 'S0' + deployments: null //Default value set on module configuration + subnetResourceId: null //Default value set on module configuration + modelCapacity: gptModelCapacity +} + +@description('Optional. The configuration to apply for the AI Foundry AI Project resource.') +param aiFoundryAiProjectConfiguration aiProjectConfigurationType = { + enabled: true + name: 'aifp-${solutionPrefix}' + location: aiDeploymentsLocation + sku: 'Basic' + tags: tags +} + +@description('Optional. The configuration to apply for the Cosmos DB Account resource.') +param cosmosDbAccountConfiguration cosmosDbAccountConfigurationType = { + enabled: true + name: 'cosmos-${solutionPrefix}' + location: solutionLocation + tags: tags + subnetResourceId: null //Default value set on module configuration + sqlDatabases: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Container App Environment resource.') +param containerAppEnvironmentConfiguration containerAppEnvironmentConfigurationType = { + enabled: true + name: 'cae-${solutionPrefix}' + location: solutionLocation + tags: tags + subnetResourceId: null //Default value set on module configuration +} + +@description('Optional. The configuration to apply for the Container App resource.') +param containerAppConfiguration containerAppConfigurationType = { + enabled: true + name: 'ca-${solutionPrefix}' + location: solutionLocation + tags: union(tags, { 'azd-service-name': 'backend' }) + environmentResourceId: null //Default value set on module configuration + concurrentRequests: '100' + containerCpu: '2.0' + containerMemory: '4.0Gi' + containerImageRegistryDomain: '' + containerImageName: 'macaebackend' + containerImageTag: imageTag + containerName: 'backend' + ingressTargetPort: 8000 + maxReplicas: 1 + minReplicas: 1 +} + +@description('Optional. The configuration to apply for the Web Server Farm resource.') +param webServerFarmConfiguration webServerFarmConfigurationType = { + enabled: true + name: 'asp-${solutionPrefix}' + location: solutionLocation + skuName: useWafAlignedArchitecture? 'P1v3' : 'B2' + skuCapacity: useWafAlignedArchitecture ? 3 : 1 + tags: tags +} + +@description('Optional. The configuration to apply for the Web Server Farm resource.') +param webSiteConfiguration webSiteConfigurationType = { + enabled: true + name: 'app-${solutionPrefix}' + location: solutionLocation + containerImageRegistryDomain: 'biabcontainerreg.azurecr.io' + containerImageName: 'macaefrontend' + containerImageTag: imageTag + containerName: 'backend' + tags: union(tags, { 'azd-service-name': 'frontend' }) + environmentResourceId: null //Default value set on module configuration +} + +// ========== Resource Group Tag ========== // +resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { + name: 'default' + properties: { + tags: { + ...tags + TemplateName: 'Macae' + } + } +} + +// ========== Log Analytics Workspace ========== // +// WAF best practices for Log Analytics: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-log-analytics +// Log Analytics configuration defaults +var logAnalyticsWorkspaceEnabled = logAnalyticsWorkspaceConfiguration.?enabled ?? true +var logAnalyticsWorkspaceResourceName = logAnalyticsWorkspaceConfiguration.?name ?? 'log-${solutionPrefix}' +var existingWorkspaceResourceId = logAnalyticsWorkspaceConfiguration.?existingWorkspaceResourceId ?? '' +var useExistingWorkspace = existingWorkspaceResourceId != '' + +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.2' = if (logAnalyticsWorkspaceEnabled && !useExistingWorkspace) { + name: take('avm.res.operational-insights.workspace.${logAnalyticsWorkspaceResourceName}', 64) + params: { + name: logAnalyticsWorkspaceResourceName + tags: logAnalyticsWorkspaceConfiguration.?tags ?? tags + location: logAnalyticsWorkspaceConfiguration.?location ?? solutionLocation + enableTelemetry: enableTelemetry + skuName: logAnalyticsWorkspaceConfiguration.?sku ?? 'PerGB2018' + dataRetention: logAnalyticsWorkspaceConfiguration.?dataRetentionInDays ?? 365 + diagnosticSettings: [{ useThisWorkspace: true }] + } +} + +var logAnalyticsWorkspaceId = useExistingWorkspace ? existingWorkspaceResourceId : logAnalyticsWorkspace.outputs.resourceId + +// ========== Application Insights ========== // +// WAF best practices for Application Insights: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/application-insights +// Application Insights configuration defaults +var applicationInsightsEnabled = applicationInsightsConfiguration.?enabled ?? true +var applicationInsightsResourceName = applicationInsightsConfiguration.?name ?? 'appi-${solutionPrefix}' +module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (applicationInsightsEnabled) { + name: take('avm.res.insights.component.${applicationInsightsResourceName}', 64) + params: { + name: applicationInsightsResourceName + workspaceResourceId: logAnalyticsWorkspaceId + location: applicationInsightsConfiguration.?location ?? solutionLocation + enableTelemetry: enableTelemetry + tags: applicationInsightsConfiguration.?tags ?? tags + retentionInDays: applicationInsightsConfiguration.?retentionInDays ?? 365 + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + kind: 'web' + disableIpMasking: false + flowType: 'Bluefield' + } +} + +// ========== User assigned identity Web Site ========== // +// WAF best practices for identity and access management: https://learn.microsoft.com/en-us/azure/well-architected/security/identity-access +var userAssignedManagedIdentityEnabled = userAssignedManagedIdentityConfiguration.?enabled ?? true +var userAssignedManagedIdentityResourceName = userAssignedManagedIdentityConfiguration.?name ?? 'id-${solutionPrefix}' +module userAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = if (userAssignedManagedIdentityEnabled) { + name: take('avm.res.managed-identity.user-assigned-identity.${userAssignedManagedIdentityResourceName}', 64) + params: { + name: userAssignedManagedIdentityResourceName + tags: userAssignedManagedIdentityConfiguration.?tags ?? tags + location: userAssignedManagedIdentityConfiguration.?location ?? solutionLocation + enableTelemetry: enableTelemetry + } +} + +// ========== Network Security Groups ========== // +// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network +// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking +var networkSecurityGroupBackendEnabled = networkSecurityGroupBackendConfiguration.?enabled ?? true +var networkSecurityGroupBackendResourceName = networkSecurityGroupBackendConfiguration.?name ?? 'nsg-backend-${solutionPrefix}' +module networkSecurityGroupBackend 'br/public:avm/res/network/network-security-group:0.5.1' = if (virtualNetworkEnabled && networkSecurityGroupBackendEnabled) { + name: take('avm.res.network.network-security-group.${networkSecurityGroupBackendResourceName}', 64) + params: { + name: networkSecurityGroupBackendResourceName + location: networkSecurityGroupBackendConfiguration.?location ?? solutionLocation + tags: networkSecurityGroupBackendConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + securityRules: networkSecurityGroupBackendConfiguration.?securityRules ?? [ + // { + // name: 'DenySshRdpOutbound' //Azure Bastion + // properties: { + // priority: 200 + // access: 'Deny' + // protocol: '*' + // direction: 'Outbound' + // sourceAddressPrefix: 'VirtualNetwork' + // sourcePortRange: '*' + // destinationAddressPrefix: '*' + // destinationPortRanges: [ + // '3389' + // '22' + // ] + // } + // } + ] + } +} + +var networkSecurityGroupContainersEnabled = networkSecurityGroupContainersConfiguration.?enabled ?? true +var networkSecurityGroupContainersResourceName = networkSecurityGroupContainersConfiguration.?name ?? 'nsg-containers-${solutionPrefix}' +module networkSecurityGroupContainers 'br/public:avm/res/network/network-security-group:0.5.1' = if (virtualNetworkEnabled && networkSecurityGroupContainersEnabled) { + name: take('avm.res.network.network-security-group.${networkSecurityGroupContainersResourceName}', 64) + params: { + name: networkSecurityGroupContainersResourceName + location: networkSecurityGroupContainersConfiguration.?location ?? solutionLocation + tags: networkSecurityGroupContainersConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + securityRules: networkSecurityGroupContainersConfiguration.?securityRules ?? [ + // { + // name: 'DenySshRdpOutbound' //Azure Bastion + // properties: { + // priority: 200 + // access: 'Deny' + // protocol: '*' + // direction: 'Outbound' + // sourceAddressPrefix: 'VirtualNetwork' + // sourcePortRange: '*' + // destinationAddressPrefix: '*' + // destinationPortRanges: [ + // '3389' + // '22' + // ] + // } + // } + ] + } +} + +var networkSecurityGroupBastionEnabled = networkSecurityGroupBastionConfiguration.?enabled ?? true +var networkSecurityGroupBastionResourceName = networkSecurityGroupBastionConfiguration.?name ?? 'nsg-bastion-${solutionPrefix}' +module networkSecurityGroupBastion 'br/public:avm/res/network/network-security-group:0.5.1' = if (virtualNetworkEnabled && networkSecurityGroupBastionEnabled) { + name: take('avm.res.network.network-security-group.${networkSecurityGroupBastionResourceName}', 64) + params: { + name: networkSecurityGroupBastionResourceName + location: networkSecurityGroupBastionConfiguration.?location ?? solutionLocation + tags: networkSecurityGroupBastionConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + securityRules: networkSecurityGroupBastionConfiguration.?securityRules ?? [ + { + name: 'AllowHttpsInBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: 'Internet' + destinationPortRange: '443' + destinationAddressPrefix: '*' + access: 'Allow' + priority: 100 + direction: 'Inbound' + } + } + { + name: 'AllowGatewayManagerInBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: 'GatewayManager' + destinationPortRange: '443' + destinationAddressPrefix: '*' + access: 'Allow' + priority: 110 + direction: 'Inbound' + } + } + { + name: 'AllowLoadBalancerInBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: 'AzureLoadBalancer' + destinationPortRange: '443' + destinationAddressPrefix: '*' + access: 'Allow' + priority: 120 + direction: 'Inbound' + } + } + { + name: 'AllowBastionHostCommunicationInBound' + properties: { + protocol: '*' + sourcePortRange: '*' + sourceAddressPrefix: 'VirtualNetwork' + destinationPortRanges: [ + '8080' + '5701' + ] + destinationAddressPrefix: 'VirtualNetwork' + access: 'Allow' + priority: 130 + direction: 'Inbound' + } + } + { + name: 'DenyAllInBound' + properties: { + protocol: '*' + sourcePortRange: '*' + sourceAddressPrefix: '*' + destinationPortRange: '*' + destinationAddressPrefix: '*' + access: 'Deny' + priority: 1000 + direction: 'Inbound' + } + } + { + name: 'AllowSshRdpOutBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: '*' + destinationPortRanges: [ + '22' + '3389' + ] + destinationAddressPrefix: 'VirtualNetwork' + access: 'Allow' + priority: 100 + direction: 'Outbound' + } + } + { + name: 'AllowAzureCloudCommunicationOutBound' + properties: { + protocol: 'Tcp' + sourcePortRange: '*' + sourceAddressPrefix: '*' + destinationPortRange: '443' + destinationAddressPrefix: 'AzureCloud' + access: 'Allow' + priority: 110 + direction: 'Outbound' + } + } + { + name: 'AllowBastionHostCommunicationOutBound' + properties: { + protocol: '*' + sourcePortRange: '*' + sourceAddressPrefix: 'VirtualNetwork' + destinationPortRanges: [ + '8080' + '5701' + ] + destinationAddressPrefix: 'VirtualNetwork' + access: 'Allow' + priority: 120 + direction: 'Outbound' + } + } + { + name: 'AllowGetSessionInformationOutBound' + properties: { + protocol: '*' + sourcePortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'Internet' + destinationPortRanges: [ + '80' + '443' + ] + access: 'Allow' + priority: 130 + direction: 'Outbound' + } + } + { + name: 'DenyAllOutBound' + properties: { + protocol: '*' + sourcePortRange: '*' + destinationPortRange: '*' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + access: 'Deny' + priority: 1000 + direction: 'Outbound' + } + } + ] + } +} + +var networkSecurityGroupAdministrationEnabled = networkSecurityGroupAdministrationConfiguration.?enabled ?? true +var networkSecurityGroupAdministrationResourceName = networkSecurityGroupAdministrationConfiguration.?name ?? 'nsg-administration-${solutionPrefix}' +module networkSecurityGroupAdministration 'br/public:avm/res/network/network-security-group:0.5.1' = if (virtualNetworkEnabled && networkSecurityGroupAdministrationEnabled) { + name: take('avm.res.network.network-security-group.${networkSecurityGroupAdministrationResourceName}', 64) + params: { + name: networkSecurityGroupAdministrationResourceName + location: networkSecurityGroupAdministrationConfiguration.?location ?? solutionLocation + tags: networkSecurityGroupAdministrationConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + securityRules: networkSecurityGroupAdministrationConfiguration.?securityRules ?? [ + // { + // name: 'DenySshRdpOutbound' //Azure Bastion + // properties: { + // priority: 200 + // access: 'Deny' + // protocol: '*' + // direction: 'Outbound' + // sourceAddressPrefix: 'VirtualNetwork' + // sourcePortRange: '*' + // destinationAddressPrefix: '*' + // destinationPortRanges: [ + // '3389' + // '22' + // ] + // } + // } + ] + } +} + +// ========== Virtual Network ========== // +// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network +// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking +var virtualNetworkEnabled = virtualNetworkConfiguration.?enabled ?? true +var virtualNetworkResourceName = virtualNetworkConfiguration.?name ?? 'vnet-${solutionPrefix}' +module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = if (virtualNetworkEnabled) { + name: take('avm.res.network.virtual-network.${virtualNetworkResourceName}', 64) + params: { + name: virtualNetworkResourceName + location: virtualNetworkConfiguration.?location ?? solutionLocation + tags: virtualNetworkConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + addressPrefixes: virtualNetworkConfiguration.?addressPrefixes ?? ['10.0.0.0/8'] + subnets: virtualNetworkConfiguration.?subnets ?? [ + { + name: 'backend' + addressPrefix: '10.0.0.0/27' + //defaultOutboundAccess: false TODO: check this configuration for a more restricted outbound access + networkSecurityGroupResourceId: networkSecurityGroupBackend.outputs.resourceId + } + { + name: 'administration' + addressPrefix: '10.0.0.32/27' + networkSecurityGroupResourceId: networkSecurityGroupAdministration.outputs.resourceId + } + { + // For Azure Bastion resources deployed on or after November 2, 2021, the minimum AzureBastionSubnet size is /26 or larger (/25, /24, etc.). + // https://learn.microsoft.com/en-us/azure/bastion/configuration-settings#subnet + name: 'AzureBastionSubnet' //This exact name is required for Azure Bastion + addressPrefix: '10.0.0.64/26' + networkSecurityGroupResourceId: networkSecurityGroupBastion.outputs.resourceId + } + { + // If you use your own vnw, you need to provide a subnet that is dedicated exclusively to the Container App environment you deploy. This subnet isn't available to other services + // https://learn.microsoft.com/en-us/azure/container-apps/networking?tabs=workload-profiles-env%2Cazure-cli#custom-vnw-configuration + name: 'containers' + addressPrefix: '10.0.2.0/23' //subnet of size /23 is required for container app + delegation: 'Microsoft.App/environments' + networkSecurityGroupResourceId: networkSecurityGroupContainers.outputs.resourceId + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + } + ] + } +} +var bastionEnabled = bastionConfiguration.?enabled ?? true +var bastionResourceName = bastionConfiguration.?name ?? 'bas-${solutionPrefix}' + +// ========== Bastion host ========== // +// WAF best practices for virtual networks: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-network +// WAF recommendations for networking and connectivity: https://learn.microsoft.com/en-us/azure/well-architected/security/networking +module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (virtualNetworkEnabled && bastionEnabled) { + name: take('avm.res.network.bastion-host.${bastionResourceName}', 64) + params: { + name: bastionResourceName + location: bastionConfiguration.?location ?? solutionLocation + skuName: bastionConfiguration.?sku ?? 'Standard' + enableTelemetry: enableTelemetry + tags: bastionConfiguration.?tags ?? tags + virtualNetworkResourceId: bastionConfiguration.?virtualNetworkResourceId ?? virtualNetwork.?outputs.?resourceId + publicIPAddressObject: { + name: bastionConfiguration.?publicIpResourceName ?? 'pip-bas${solutionPrefix}' + zones: [] + } + disableCopyPaste: false + enableFileCopy: false + enableIpConnect: true + enableShareableLink: true + } +} + +// ========== Virtual machine ========== // +// WAF best practices for virtual machines: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/virtual-machines +var virtualMachineEnabled = virtualMachineConfiguration.?enabled ?? true +var virtualMachineResourceName = virtualMachineConfiguration.?name ?? 'vm${solutionPrefix}' +module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.13.0' = if (virtualNetworkEnabled && virtualMachineEnabled) { + name: take('avm.res.compute.virtual-machine.${virtualMachineResourceName}', 64) + params: { + name: virtualMachineResourceName + computerName: take(virtualMachineResourceName, 15) + location: virtualMachineConfiguration.?location ?? solutionLocation + tags: virtualMachineConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + vmSize: virtualMachineConfiguration.?vmSize ?? 'Standard_D2s_v3' + adminUsername: virtualMachineConfiguration.?adminUsername ?? 'adminuser' + adminPassword: virtualMachineConfiguration.?adminPassword ?? guid(solutionPrefix, subscription().subscriptionId) + nicConfigurations: [ + { + name: 'nic-${virtualMachineResourceName}' + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + ipConfigurations: [ + { + name: '${virtualMachineResourceName}-nic01-ipconfig01' + subnetResourceId: virtualMachineConfiguration.?subnetResourceId ?? virtualNetwork.outputs.subnetResourceIds[1] + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + } + ] + } + ] + imageReference: { + publisher: 'microsoft-dsvm' + offer: 'dsvm-win-2022' + sku: 'winserver-2022' + version: 'latest' + } + osDisk: { + name: 'osdisk-${virtualMachineResourceName}' + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Standard_LRS' + } + diskSizeGB: 128 + caching: 'ReadWrite' + } + osType: 'Windows' + encryptionAtHost: false //The property 'securityProfile.encryptionAtHost' is not valid because the 'Microsoft.Compute/EncryptionAtHost' feature is not enabled for this subscription. + zone: 0 + extensionAadJoinConfig: { + enabled: true + typeHandlerVersion: '1.0' + } + } +} + +// ========== AI Foundry: AI Services ========== // +// WAF best practices for Open AI: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-openai +var openAiSubResource = 'account' +var openAiPrivateDnsZones = { + 'privatelink.cognitiveservices.azure.com': openAiSubResource + 'privatelink.openai.azure.com': openAiSubResource + 'privatelink.services.ai.azure.com': openAiSubResource +} + +module privateDnsZonesAiServices 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ + for zone in objectKeys(openAiPrivateDnsZones): if (virtualNetworkEnabled && aiFoundryAIservicesEnabled) { + name: take( + 'avm.res.network.private-dns-zone.ai-services.${uniqueString(aiFoundryAiServicesResourceName,zone)}.${solutionPrefix}', + 64 + ) + params: { + name: zone + tags: tags + enableTelemetry: enableTelemetry + virtualNetworkLinks: [ + { + name: 'vnetlink-${split(zone, '.')[1]}' + virtualNetworkResourceId: virtualNetwork.outputs.resourceId + } + ] + } + } +] + +// NOTE: Required version 'Microsoft.CognitiveServices/accounts@2024-04-01-preview' not available in AVM +var useExistingFoundryProject = !empty(existingFoundryProjectResourceId) +var existingAiFoundryName = useExistingFoundryProject?split( existingFoundryProjectResourceId,'/')[8]:'' +var aiFoundryAiServicesResourceName = useExistingFoundryProject? existingAiFoundryName : aiFoundryAiServicesConfiguration.?name ?? 'aisa-${solutionPrefix}' +var aiFoundryAIservicesEnabled = aiFoundryAiServicesConfiguration.?enabled ?? true +var aiFoundryAiServicesModelDeployment = { + format: 'OpenAI' + name: gptModelName + version: gptModelVersion + sku: { + name: modelDeploymentType + //Curently the capacity is set to 140 for opinanal performance. + capacity: aiFoundryAiServicesConfiguration.?modelCapacity ?? gptModelCapacity + } + raiPolicyName: 'Microsoft.Default' +} + +module aiFoundryAiServices 'modules/account/main.bicep' = if (aiFoundryAIservicesEnabled) { + name: take('avm.res.cognitive-services.account.${aiFoundryAiServicesResourceName}', 64) + params: { + name: aiFoundryAiServicesResourceName + tags: aiFoundryAiServicesConfiguration.?tags ?? tags + location: aiFoundryAiServicesConfiguration.?location ?? aiDeploymentsLocation + enableTelemetry: enableTelemetry + projectName: 'aifp-${solutionPrefix}' + projectDescription: 'aifp-${solutionPrefix}' + existingFoundryProjectResourceId: existingFoundryProjectResourceId + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + sku: aiFoundryAiServicesConfiguration.?sku ?? 'S0' + kind: 'AIServices' + disableLocalAuth: true //Should be set to true for WAF aligned configuration + customSubDomainName: aiFoundryAiServicesResourceName + apiProperties: { + //staticsEnabled: false + } + allowProjectManagement: true + managedIdentities: { + systemAssigned: true + } + publicNetworkAccess: virtualNetworkEnabled ? 'Disabled' : 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: (virtualNetworkEnabled) ? 'Deny' : 'Allow' + } + privateEndpoints: virtualNetworkEnabled && !useExistingFoundryProject + ? ([ + { + name: 'pep-${aiFoundryAiServicesResourceName}' + customNetworkInterfaceName: 'nic-${aiFoundryAiServicesResourceName}' + subnetResourceId: aiFoundryAiServicesConfiguration.?subnetResourceId ?? virtualNetwork.outputs.subnetResourceIds[0] + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: map(objectKeys(openAiPrivateDnsZones), zone => { + name: replace(zone, '.', '-') + privateDnsZoneResourceId: resourceId('Microsoft.Network/privateDnsZones', zone) + }) + } + } + ]) + : [] + deployments: aiFoundryAiServicesConfiguration.?deployments ?? [ + { + name: aiFoundryAiServicesModelDeployment.name + model: { + format: aiFoundryAiServicesModelDeployment.format + name: aiFoundryAiServicesModelDeployment.name + version: aiFoundryAiServicesModelDeployment.version + } + raiPolicyName: aiFoundryAiServicesModelDeployment.raiPolicyName + sku: { + name: aiFoundryAiServicesModelDeployment.sku.name + capacity: aiFoundryAiServicesModelDeployment.sku.capacity + } + } + ] + } +} + +// AI Foundry: AI Project +// WAF best practices for Open AI: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-openai +var existingAiFounryProjectName = useExistingFoundryProject ? last(split( existingFoundryProjectResourceId,'/')) : '' +var aiFoundryAiProjectName = useExistingFoundryProject ? existingAiFounryProjectName : aiFoundryAiProjectConfiguration.?name ?? 'aifp-${solutionPrefix}' + +var useExistingResourceId = !empty(existingFoundryProjectResourceId) + +module cogServiceRoleAssignmentsNew './modules/role.bicep' = if(!useExistingResourceId) { + params: { + name: 'new-${guid(containerApp.name, aiFoundryAiServices.outputs.resourceId)}' + principalId: containerApp.outputs.?systemAssignedMIPrincipalId! + aiServiceName: aiFoundryAiServices.outputs.name + } + scope: resourceGroup(subscription().subscriptionId, resourceGroup().name) +} + +module cogServiceRoleAssignmentsExisting './modules/role.bicep' = if(useExistingResourceId) { + params: { + name: 'reuse-${guid(containerApp.name, aiFoundryAiServices.outputs.aiProjectInfo.resourceId)}' + principalId: containerApp.outputs.?systemAssignedMIPrincipalId! + aiServiceName: aiFoundryAiServices.outputs.name + } + scope: resourceGroup( split(existingFoundryProjectResourceId, '/')[2], split(existingFoundryProjectResourceId, '/')[4]) +} + +// ========== Cosmos DB ========== // +// WAF best practices for Cosmos DB: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/cosmos-db +module privateDnsZonesCosmosDb 'br/public:avm/res/network/private-dns-zone:0.7.0' = if (virtualNetworkEnabled) { + name: take('avm.res.network.private-dns-zone.cosmos-db.${solutionPrefix}', 64) + params: { + name: 'privatelink.documents.azure.com' + enableTelemetry: enableTelemetry + virtualNetworkLinks: [ + { + name: 'vnetlink-cosmosdb' + virtualNetworkResourceId: virtualNetwork.outputs.resourceId + } + ] + tags: tags + } +} + +var cosmosDbAccountEnabled = cosmosDbAccountConfiguration.?enabled ?? true +var cosmosDbResourceName = cosmosDbAccountConfiguration.?name ?? 'cosmos-${solutionPrefix}' +var cosmosDbDatabaseName = 'macae' +var cosmosDbDatabaseMemoryContainerName = 'memory' +module cosmosDb 'br/public:avm/res/document-db/database-account:0.12.0' = if (cosmosDbAccountEnabled) { + name: take('avm.res.document-db.database-account.${cosmosDbResourceName}', 64) + params: { + // Required parameters + name: cosmosDbAccountConfiguration.?name ?? 'cosmos-${solutionPrefix}' + location: cosmosDbAccountConfiguration.?location ?? solutionLocation + tags: cosmosDbAccountConfiguration.?tags ?? tags + enableTelemetry: enableTelemetry + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + databaseAccountOfferType: 'Standard' + enableFreeTier: false + networkRestrictions: { + networkAclBypass: 'None' + publicNetworkAccess: virtualNetworkEnabled ? 'Disabled' : 'Enabled' + } + privateEndpoints: virtualNetworkEnabled + ? [ + { + name: 'pep-${cosmosDbResourceName}' + customNetworkInterfaceName: 'nic-${cosmosDbResourceName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [{ privateDnsZoneResourceId: privateDnsZonesCosmosDb.outputs.resourceId }] + } + service: 'Sql' + subnetResourceId: cosmosDbAccountConfiguration.?subnetResourceId ?? virtualNetwork.outputs.subnetResourceIds[0] + } + ] + : [] + sqlDatabases: concat(cosmosDbAccountConfiguration.?sqlDatabases ?? [], [ + { + name: cosmosDbDatabaseName + containers: [ + { + name: cosmosDbDatabaseMemoryContainerName + paths: [ + '/session_id' + ] + kind: 'Hash' + version: 2 + } + ] + } + ]) + locations: [ + { + locationName: cosmosDbAccountConfiguration.?location ?? solutionLocation + failoverPriority: 0 + isZoneRedundant: false + } + ] + capabilitiesToAdd: [ + 'EnableServerless' + ] + sqlRoleAssignmentsPrincipalIds: [ + containerApp.outputs.?systemAssignedMIPrincipalId + ] + sqlRoleDefinitions: [ + { + // Replace this with built-in role definition Cosmos DB Built-in Data Contributor: https://docs.azure.cn/en-us/cosmos-db/nosql/security/reference-data-plane-roles#cosmos-db-built-in-data-contributor + roleType: 'CustomRole' + roleName: 'Cosmos DB SQL Data Contributor' + name: 'cosmos-db-sql-data-contributor' + dataAction: [ + 'Microsoft.DocumentDB/databaseAccounts/readMetadata' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' + ] + } + ] + } +} + +// ========== Container Registry ========== // +module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.1' = { + name: 'registryDeployment' + params: { + name: 'cr${replace(solutionPrefix,'-','')}' + acrAdminUserEnabled: false + acrSku: 'Basic' + azureADAuthenticationAsArmPolicyStatus: 'enabled' + exportPolicyStatus: 'enabled' + location: solutionLocation + softDeletePolicyDays: 7 + softDeletePolicyStatus: 'disabled' + tags: tags + networkRuleBypassOptions: 'AzureServices' + roleAssignments: [ + { + roleDefinitionIdOrName: acrPullRole + principalType: 'ServicePrincipal' + principalId: userAssignedIdentity.outputs.principalId + } + ] + } +} + +var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + +// ========== Backend Container App Environment ========== // +// WAF best practices for container apps: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-container-apps +var containerAppEnvironmentEnabled = containerAppEnvironmentConfiguration.?enabled ?? true +var containerAppEnvironmentResourceName = containerAppEnvironmentConfiguration.?name ?? 'cae-${solutionPrefix}' +module containerAppEnvironment 'modules/container-app-environment.bicep' = if (containerAppEnvironmentEnabled) { + name: take('module.container-app-environment.${containerAppEnvironmentResourceName}', 64) + params: { + name: containerAppEnvironmentResourceName + tags: containerAppEnvironmentConfiguration.?tags ?? tags + location: containerAppEnvironmentConfiguration.?location ?? solutionLocation + logAnalyticsResourceId: logAnalyticsWorkspaceId + publicNetworkAccess: 'Enabled' + zoneRedundant: false + applicationInsightsConnectionString: applicationInsights.outputs.connectionString + enableTelemetry: enableTelemetry + subnetResourceId: virtualNetworkEnabled + ? containerAppEnvironmentConfiguration.?subnetResourceId ?? virtualNetwork.?outputs.?subnetResourceIds[3] ?? '' + : '' + } +} + +// ========== Backend Container App Service ========== // +// WAF best practices for container apps: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-container-apps +var containerAppEnabled = containerAppConfiguration.?enabled ?? true +var containerAppResourceName = containerAppConfiguration.?name ?? 'ca-${solutionPrefix}' +module containerApp 'br/public:avm/res/app/container-app:0.14.2' = if (containerAppEnabled) { + name: take('avm.res.app.container-app.${containerAppResourceName}', 64) + params: { + name: containerAppResourceName + tags: containerAppConfiguration.?tags ?? tags + location: containerAppConfiguration.?location ?? solutionLocation + enableTelemetry: enableTelemetry + environmentResourceId: containerAppConfiguration.?environmentResourceId ?? containerAppEnvironment.outputs.resourceId + managedIdentities: { + systemAssigned: true //Replace with user assigned identity + userAssignedResourceIds: [userAssignedIdentity.outputs.resourceId] + } + ingressTargetPort: containerAppConfiguration.?ingressTargetPort ?? 8000 + ingressExternal: true + activeRevisionsMode: 'Single' + corsPolicy: { + allowedOrigins: [ + 'https://${webSiteName}.azurewebsites.net' + 'http://${webSiteName}.azurewebsites.net' + ] + } + scaleSettings: { + //TODO: Make maxReplicas and minReplicas parameterized + maxReplicas: containerAppConfiguration.?maxReplicas ?? 1 + minReplicas: containerAppConfiguration.?minReplicas ?? 1 + rules: [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: containerAppConfiguration.?concurrentRequests ?? '100' + } + } + } + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: userAssignedIdentity.outputs.resourceId + } + ] + containers: [ + { + name: containerAppConfiguration.?containerName ?? 'backend' + image: 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + //image: '${containerAppConfiguration.?containerImageRegistryDomain ?? 'biabcontainerreg.azurecr.io'}/${containerAppConfiguration.?containerImageName ?? 'macaebackend'}:${containerAppConfiguration.?containerImageTag ?? 'latest'}' + resources: { + //TODO: Make cpu and memory parameterized + cpu: containerAppConfiguration.?containerCpu ?? '2.0' + memory: containerAppConfiguration.?containerMemory ?? '4.0Gi' + } + env: [ + { + name: 'COSMOSDB_ENDPOINT' + value: 'https://${cosmosDbResourceName}.documents.azure.com:443/' + } + { + name: 'COSMOSDB_DATABASE' + value: cosmosDbDatabaseName + } + { + name: 'COSMOSDB_CONTAINER' + value: cosmosDbDatabaseMemoryContainerName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: 'https://${aiFoundryAiServicesResourceName}.openai.azure.com/' + } + { + name: 'AZURE_OPENAI_MODEL_NAME' + value: aiFoundryAiServicesModelDeployment.name + } + { + name: 'AZURE_OPENAI_DEPLOYMENT_NAME' + value: aiFoundryAiServicesModelDeployment.name + } + { + name: 'AZURE_OPENAI_API_VERSION' + value: azureopenaiVersion + } + { + name: 'APPLICATIONINSIGHTS_INSTRUMENTATION_KEY' + value: applicationInsights.outputs.instrumentationKey + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.outputs.connectionString + } + { + name: 'AZURE_AI_SUBSCRIPTION_ID' + value: subscription().subscriptionId + } + { + name: 'AZURE_AI_RESOURCE_GROUP' + value: resourceGroup().name + } + { + name: 'AZURE_AI_PROJECT_NAME' + value: aiFoundryAiProjectName + } + { + name: 'FRONTEND_SITE_NAME' + value: 'https://${webSiteName}.azurewebsites.net' + } + { + name: 'AZURE_AI_AGENT_ENDPOINT' + value: aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint + } + { + name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' + value: aiFoundryAiServicesModelDeployment.name + } + { + name: 'APP_ENV' + value: 'Prod' + } + ] + } + ] + } +} + +var webServerFarmEnabled = webServerFarmConfiguration.?enabled ?? true +var webServerFarmResourceName = webServerFarmConfiguration.?name ?? 'asp-${solutionPrefix}' + +// ========== Frontend server farm ========== // +// WAF best practices for web app service: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps +module webServerFarm 'br/public:avm/res/web/serverfarm:0.4.1' = if (webServerFarmEnabled) { + name: take('avm.res.web.serverfarm.${webServerFarmResourceName}', 64) + params: { + name: webServerFarmResourceName + tags: tags + location: webServerFarmConfiguration.?location ?? solutionLocation + skuName: webServerFarmConfiguration.?skuName ?? 'P1v3' + skuCapacity: webServerFarmConfiguration.?skuCapacity ?? 3 + reserved: true + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + kind: 'linux' + zoneRedundant: false //TODO: make it zone redundant for waf aligned + } +} + +// ========== Frontend web site ========== // +// WAF best practices for web app service: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps +var webSiteEnabled = webSiteConfiguration.?enabled ?? true + +var webSiteName = 'app-${solutionPrefix}' +module webSite 'br/public:avm/res/web/site:0.15.1' = if (webSiteEnabled) { + name: take('avm.res.web.site.${webSiteName}', 64) + params: { + name: webSiteName + tags: webSiteConfiguration.?tags ?? tags + location: webSiteConfiguration.?location ?? solutionLocation + kind: 'app,linux' + //kind: 'app,linux,container' + enableTelemetry: enableTelemetry + serverFarmResourceId: webSiteConfiguration.?environmentResourceId ?? webServerFarm.?outputs.resourceId + appInsightResourceId: applicationInsights.outputs.resourceId + diagnosticSettings: [{ workspaceResourceId: logAnalyticsWorkspaceId }] + publicNetworkAccess: 'Enabled' //TODO: use Azure Front Door WAF or Application Gateway WAF instead + siteConfig: { + //linuxFxVersion: 'DOCKER|${webSiteConfiguration.?containerImageRegistryDomain ?? 'biabcontainerreg.azurecr.io'}/${webSiteConfiguration.?containerImageName ?? 'macaefrontend'}:${webSiteConfiguration.?containerImageTag ?? 'latest'}', + linuxFxVersion: 'python|3.11' + appCommandLine: 'python3 -m uvicorn frontend_server:app --host 0.0.0.0 --port 8000' + } + appSettingsKeyValuePairs: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + //DOCKER_REGISTRY_SERVER_URL: 'https://${webSiteConfiguration.?containerImageRegistryDomain ?? 'biabcontainerreg.azurecr.io'}' + WEBSITES_PORT: '8000' + // WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed + BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}' + AUTH_ENABLED: 'false' + APP_ENV: 'Prod' + ENABLE_ORYX_BUILD: 'True' + } + } +} + +// ============ // +// Outputs // +// ============ // + +// Add your outputs here + +@description('The default url of the website to connect to the Multi-Agent Custom Automation Engine solution.') +output webSiteDefaultHostname string = webSite.outputs.defaultHostname + + + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer + +// @description('The name of the resource.') +// output name string = .name + +// @description('The location the resource was deployed into.') +// output location string = .location + +// ================ // +// Definitions // +// ================ // +// +// Add your User-defined-types here, if any +// + + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Log Analytics Workspace resource configuration.') +type logAnalyticsWorkspaceConfigurationType = { + @description('Optional. If the Log Analytics Workspace resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Log Analytics Workspace resource.') + @maxLength(63) + name: string? + + @description('Optional. Location for the Log Analytics Workspace resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to for the Log Analytics Workspace resource.') + tags: object? + + @description('Optional. The SKU for the Log Analytics Workspace resource.') + sku: ('CapacityReservation' | 'Free' | 'LACluster' | 'PerGB2018' | 'PerNode' | 'Premium' | 'Standalone' | 'Standard')? + + @description('Optional. The number of days to retain the data in the Log Analytics Workspace. If empty, it will be set to 365 days.') + @maxValue(730) + dataRetentionInDays: int? + + @description('Optional: Existing Log Analytics Workspace Resource ID') + existingWorkspaceResourceId: string? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Application Insights resource configuration.') +type applicationInsightsConfigurationType = { + @description('Optional. If the Application Insights resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Application Insights resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Application Insights resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Application Insights resource.') + tags: object? + + @description('Optional. The retention of Application Insights data in days. If empty, Standard will be used.') + retentionInDays: (120 | 180 | 270 | 30 | 365 | 550 | 60 | 730 | 90)? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Application User Assigned Managed Identity resource configuration.') +type userAssignedManagedIdentityType = { + @description('Optional. If the User Assigned Managed Identity resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the User Assigned Managed Identity resource.') + @maxLength(128) + name: string? + + @description('Optional. Location for the User Assigned Managed Identity resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the User Assigned Managed Identity resource.') + tags: object? +} + +@export() +import { securityRuleType } from 'br/public:avm/res/network/network-security-group:0.5.1' +@description('The type for the Multi-Agent Custom Automation Engine Network Security Group resource configuration.') +type networkSecurityGroupConfigurationType = { + @description('Optional. If the Network Security Group resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Network Security Group resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Network Security Group resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Network Security Group resource.') + tags: object? + + @description('Optional. The security rules to set for the Network Security Group resource.') + securityRules: securityRuleType[]? +} + +@export() +@description('The type for the Multi-Agent Custom Automation virtual network resource configuration.') +type virtualNetworkConfigurationType = { + @description('Optional. If the Virtual Network resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Virtual Network resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Virtual Network resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Virtual Network resource.') + tags: object? + + @description('Optional. An array of 1 or more IP Addresses prefixes for the Virtual Network resource.') + addressPrefixes: string[]? + + @description('Optional. An array of 1 or more subnets for the Virtual Network resource.') + subnets: subnetType[]? +} + +import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.5.1' +type subnetType = { + @description('Optional. The Name of the subnet resource.') + name: string + + @description('Conditional. The address prefix for the subnet. Required if `addressPrefixes` is empty.') + addressPrefix: string? + + @description('Conditional. List of address prefixes for the subnet. Required if `addressPrefix` is empty.') + addressPrefixes: string[]? + + @description('Optional. Application gateway IP configurations of virtual network resource.') + applicationGatewayIPConfigurations: object[]? + + @description('Optional. The delegation to enable on the subnet.') + delegation: string? + + @description('Optional. The resource ID of the NAT Gateway to use for the subnet.') + natGatewayResourceId: string? + + @description('Optional. The resource ID of the network security group to assign to the subnet.') + networkSecurityGroupResourceId: string? + + @description('Optional. enable or disable apply network policies on private endpoint in the subnet.') + privateEndpointNetworkPolicies: ('Disabled' | 'Enabled' | 'NetworkSecurityGroupEnabled' | 'RouteTableEnabled')? + + @description('Optional. enable or disable apply network policies on private link service in the subnet.') + privateLinkServiceNetworkPolicies: ('Disabled' | 'Enabled')? + + @description('Optional. Array of role assignments to create.') + roleAssignments: roleAssignmentType[]? + + @description('Optional. The resource ID of the route table to assign to the subnet.') + routeTableResourceId: string? + + @description('Optional. An array of service endpoint policies.') + serviceEndpointPolicies: object[]? + + @description('Optional. The service endpoints to enable on the subnet.') + serviceEndpoints: string[]? + + @description('Optional. Set this property to false to disable default outbound connectivity for all VMs in the subnet. This property can only be set at the time of subnet creation and cannot be updated for an existing subnet.') + defaultOutboundAccess: bool? + + @description('Optional. Set this property to Tenant to allow sharing subnet with other subscriptions in your AAD tenant. This property can only be set if defaultOutboundAccess is set to false, both properties can only be set if subnet is empty.') + sharingScope: ('DelegatedServices' | 'Tenant')? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Bastion resource configuration.') +type bastionConfigurationType = { + @description('Optional. If the Bastion resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Bastion resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Bastion resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Bastion resource.') + tags: object? + + @description('Optional. The SKU for the Bastion resource.') + sku: ('Basic' | 'Developer' | 'Premium' | 'Standard')? + + @description('Optional. The Virtual Network resource id where the Bastion resource should be deployed.') + virtualNetworkResourceId: string? + + @description('Optional. The name of the Public Ip resource created to connect to Bastion.') + publicIpResourceName: string? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine virtual machine resource configuration.') +type virtualMachineConfigurationType = { + @description('Optional. If the Virtual Machine resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Virtual Machine resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the Virtual Machine resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Virtual Machine resource.') + tags: object? + + @description('Optional. Specifies the size for the Virtual Machine resource.') + vmSize: ( + | 'Basic_A0' + | 'Basic_A1' + | 'Basic_A2' + | 'Basic_A3' + | 'Basic_A4' + | 'Standard_A0' + | 'Standard_A1' + | 'Standard_A2' + | 'Standard_A3' + | 'Standard_A4' + | 'Standard_A5' + | 'Standard_A6' + | 'Standard_A7' + | 'Standard_A8' + | 'Standard_A9' + | 'Standard_A10' + | 'Standard_A11' + | 'Standard_A1_v2' + | 'Standard_A2_v2' + | 'Standard_A4_v2' + | 'Standard_A8_v2' + | 'Standard_A2m_v2' + | 'Standard_A4m_v2' + | 'Standard_A8m_v2' + | 'Standard_B1s' + | 'Standard_B1ms' + | 'Standard_B2s' + | 'Standard_B2ms' + | 'Standard_B4ms' + | 'Standard_B8ms' + | 'Standard_D1' + | 'Standard_D2' + | 'Standard_D3' + | 'Standard_D4' + | 'Standard_D11' + | 'Standard_D12' + | 'Standard_D13' + | 'Standard_D14' + | 'Standard_D1_v2' + | 'Standard_D2_v2' + | 'Standard_D3_v2' + | 'Standard_D4_v2' + | 'Standard_D5_v2' + | 'Standard_D2_v3' + | 'Standard_D4_v3' + | 'Standard_D8_v3' + | 'Standard_D16_v3' + | 'Standard_D32_v3' + | 'Standard_D64_v3' + | 'Standard_D2s_v3' + | 'Standard_D4s_v3' + | 'Standard_D8s_v3' + | 'Standard_D16s_v3' + | 'Standard_D32s_v3' + | 'Standard_D64s_v3' + | 'Standard_D11_v2' + | 'Standard_D12_v2' + | 'Standard_D13_v2' + | 'Standard_D14_v2' + | 'Standard_D15_v2' + | 'Standard_DS1' + | 'Standard_DS2' + | 'Standard_DS3' + | 'Standard_DS4' + | 'Standard_DS11' + | 'Standard_DS12' + | 'Standard_DS13' + | 'Standard_DS14' + | 'Standard_DS1_v2' + | 'Standard_DS2_v2' + | 'Standard_DS3_v2' + | 'Standard_DS4_v2' + | 'Standard_DS5_v2' + | 'Standard_DS11_v2' + | 'Standard_DS12_v2' + | 'Standard_DS13_v2' + | 'Standard_DS14_v2' + | 'Standard_DS15_v2' + | 'Standard_DS13-4_v2' + | 'Standard_DS13-2_v2' + | 'Standard_DS14-8_v2' + | 'Standard_DS14-4_v2' + | 'Standard_E2_v3' + | 'Standard_E4_v3' + | 'Standard_E8_v3' + | 'Standard_E16_v3' + | 'Standard_E32_v3' + | 'Standard_E64_v3' + | 'Standard_E2s_v3' + | 'Standard_E4s_v3' + | 'Standard_E8s_v3' + | 'Standard_E16s_v3' + | 'Standard_E32s_v3' + | 'Standard_E64s_v3' + | 'Standard_E32-16_v3' + | 'Standard_E32-8s_v3' + | 'Standard_E64-32s_v3' + | 'Standard_E64-16s_v3' + | 'Standard_F1' + | 'Standard_F2' + | 'Standard_F4' + | 'Standard_F8' + | 'Standard_F16' + | 'Standard_F1s' + | 'Standard_F2s' + | 'Standard_F4s' + | 'Standard_F8s' + | 'Standard_F16s' + | 'Standard_F2s_v2' + | 'Standard_F4s_v2' + | 'Standard_F8s_v2' + | 'Standard_F16s_v2' + | 'Standard_F32s_v2' + | 'Standard_F64s_v2' + | 'Standard_F72s_v2' + | 'Standard_G1' + | 'Standard_G2' + | 'Standard_G3' + | 'Standard_G4' + | 'Standard_G5' + | 'Standard_GS1' + | 'Standard_GS2' + | 'Standard_GS3' + | 'Standard_GS4' + | 'Standard_GS5' + | 'Standard_GS4-8' + | 'Standard_GS4-4' + | 'Standard_GS5-16' + | 'Standard_GS5-8' + | 'Standard_H8' + | 'Standard_H16' + | 'Standard_H8m' + | 'Standard_H16m' + | 'Standard_H16r' + | 'Standard_H16mr' + | 'Standard_L4s' + | 'Standard_L8s' + | 'Standard_L16s' + | 'Standard_L32s' + | 'Standard_M64s' + | 'Standard_M64ms' + | 'Standard_M128s' + | 'Standard_M128ms' + | 'Standard_M64-32ms' + | 'Standard_M64-16ms' + | 'Standard_M128-64ms' + | 'Standard_M128-32ms' + | 'Standard_NC6' + | 'Standard_NC12' + | 'Standard_NC24' + | 'Standard_NC24r' + | 'Standard_NC6s_v2' + | 'Standard_NC12s_v2' + | 'Standard_NC24s_v2' + | 'Standard_NC24rs_v2' + | 'Standard_NC6s_v3' + | 'Standard_NC12s_v3' + | 'Standard_NC24s_v3' + | 'Standard_NC24rs_v3' + | 'Standard_ND6s' + | 'Standard_ND12s' + | 'Standard_ND24s' + | 'Standard_ND24rs' + | 'Standard_NV6' + | 'Standard_NV12' + | 'Standard_NV24')? + + @description('Optional. The username for the administrator account on the virtual machine. Required if a virtual machine is created as part of the module.') + adminUsername: string? + + @description('Optional. The password for the administrator account on the virtual machine. Required if a virtual machine is created as part of the module.') + @secure() + adminPassword: string? + + @description('Optional. The resource ID of the subnet where the Virtual Machine resource should be deployed.') + subnetResourceId: string? +} + +@export() +import { deploymentType } from 'br/public:avm/res/cognitive-services/account:0.10.2' +@description('The type for the Multi-Agent Custom Automation Engine AI Services resource configuration.') +type aiServicesConfigurationType = { + @description('Optional. If the AI Services resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the AI Services resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the AI Services resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the AI Services resource.') + tags: object? + + @description('Optional. The SKU of the AI Services resource. Use \'Get-AzCognitiveServicesAccountSku\' to determine a valid combinations of \'kind\' and \'SKU\' for your Azure region.') + sku: ( + | 'C2' + | 'C3' + | 'C4' + | 'F0' + | 'F1' + | 'S' + | 'S0' + | 'S1' + | 'S10' + | 'S2' + | 'S3' + | 'S4' + | 'S5' + | 'S6' + | 'S7' + | 'S8' + | 'S9')? + + @description('Optional. The resource Id of the subnet where the AI Services private endpoint should be created.') + subnetResourceId: string? + + @description('Optional. The model deployments to set for the AI Services resource.') + deployments: deploymentType[]? + + @description('Optional. The capacity to set for AI Services GTP model.') + modelCapacity: int? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine AI Foundry AI Project resource configuration.') +type aiProjectConfigurationType = { + @description('Optional. If the AI Project resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the AI Project resource.') + @maxLength(90) + name: string? + + @description('Optional. Location for the AI Project resource deployment.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The SKU of the AI Project resource.') + sku: ('Basic' | 'Free' | 'Standard' | 'Premium')? + + @description('Optional. The tags to set for the AI Project resource.') + tags: object? +} + +import { sqlDatabaseType } from 'br/public:avm/res/document-db/database-account:0.13.0' +@export() +@description('The type for the Multi-Agent Custom Automation Engine Cosmos DB Account resource configuration.') +type cosmosDbAccountConfigurationType = { + @description('Optional. If the Cosmos DB Account resource should be deployed or not.') + enabled: bool? + @description('Optional. The name of the Cosmos DB Account resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Cosmos DB Account resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Cosmos DB Account resource.') + tags: object? + + @description('Optional. The resource Id of the subnet where the Cosmos DB Account private endpoint should be created.') + subnetResourceId: string? + + @description('Optional. The SQL databases configuration for the Cosmos DB Account resource.') + sqlDatabases: sqlDatabaseType[]? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Container App Environment resource configuration.') +type containerAppEnvironmentConfigurationType = { + @description('Optional. If the Container App Environment resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Container App Environment resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Container App Environment resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Container App Environment resource.') + tags: object? + + @description('Optional. The resource Id of the subnet where the Container App Environment private endpoint should be created.') + subnetResourceId: string? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Container App resource configuration.') +type containerAppConfigurationType = { + @description('Optional. If the Container App resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Container App resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Container App resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Container App resource.') + tags: object? + + @description('Optional. The resource Id of the Container App Environment where the Container App should be created.') + environmentResourceId: string? + + @description('Optional. The maximum number of replicas of the Container App.') + maxReplicas: int? + + @description('Optional. The minimum number of replicas of the Container App.') + minReplicas: int? + + @description('Optional. The ingress target port of the Container App.') + ingressTargetPort: int? + + @description('Optional. The concurrent requests allowed for the Container App.') + concurrentRequests: string? + + @description('Optional. The name given to the Container App.') + containerName: string? + + @description('Optional. The container registry domain of the container image to be used by the Container App. Default to `biabcontainerreg.azurecr.io`') + containerImageRegistryDomain: string? + + @description('Optional. The name of the container image to be used by the Container App.') + containerImageName: string? + + @description('Optional. The tag of the container image to be used by the Container App.') + containerImageTag: string? + + @description('Optional. The CPU reserved for the Container App. Defaults to 2.0') + containerCpu: string? + + @description('Optional. The Memory reserved for the Container App. Defaults to 4.0Gi') + containerMemory: string? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Entra ID Application resource configuration.') +type entraIdApplicationConfigurationType = { + @description('Optional. If the Entra ID Application for website authentication should be deployed or not.') + enabled: bool? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Web Server Farm resource configuration.') +type webServerFarmConfigurationType = { + @description('Optional. If the Web Server Farm resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Web Server Farm resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Web Server Farm resource.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Web Server Farm resource.') + tags: object? + + @description('Optional. The name of th SKU that will determine the tier, size and family for the Web Server Farm resource. This defaults to P1v3 to leverage availability zones.') + skuName: string? + + @description('Optional. Number of workers associated with the App Service Plan. This defaults to 3, to leverage availability zones.') + skuCapacity: int? +} + +@export() +@description('The type for the Multi-Agent Custom Automation Engine Web Site resource configuration.') +type webSiteConfigurationType = { + @description('Optional. If the Web Site resource should be deployed or not.') + enabled: bool? + + @description('Optional. The name of the Web Site resource.') + @maxLength(60) + name: string? + + @description('Optional. Location for the Web Site resource deployment.') + @metadata({ azd: { type: 'location' } }) + location: string? + + @description('Optional. The tags to set for the Web Site resource.') + tags: object? + + @description('Optional. The resource Id of the Web Site Environment where the Web Site should be created.') + environmentResourceId: string? + + @description('Optional. The name given to the Container App.') + containerName: string? + + @description('Optional. The container registry domain of the container image to be used by the Web Site. Default to `biabcontainerreg.azurecr.io`') + containerImageRegistryDomain: string? + + @description('Optional. The name of the container image to be used by the Web Site.') + containerImageName: string? + + @description('Optional. The tag of the container image to be used by the Web Site.') + containerImageTag: string? +} + + +output COSMOSDB_ENDPOINT string = 'https://${cosmosDbResourceName}.documents.azure.com:443/' +output COSMOSDB_DATABASE string = cosmosDbDatabaseName +output COSMOSDB_CONTAINER string = cosmosDbDatabaseMemoryContainerName +output AZURE_OPENAI_ENDPOINT string = 'https://${aiFoundryAiServicesResourceName}.openai.azure.com/' +output AZURE_OPENAI_MODEL_NAME string = aiFoundryAiServicesModelDeployment.name +output AZURE_OPENAI_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeployment.name +output AZURE_OPENAI_API_VERSION string = azureopenaiVersion +// output APPLICATIONINSIGHTS_INSTRUMENTATION_KEY string = applicationInsights.outputs.instrumentationKey +// output AZURE_AI_PROJECT_ENDPOINT string = aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint +output AZURE_AI_SUBSCRIPTION_ID string = subscription().subscriptionId +output AZURE_AI_RESOURCE_GROUP string = resourceGroup().name +output AZURE_AI_PROJECT_NAME string = aiFoundryAiProjectName +output AZURE_AI_MODEL_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeployment.name +// output APPLICATIONINSIGHTS_CONNECTION_STRING string = applicationInsights.outputs.connectionString +output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = aiFoundryAiServicesModelDeployment.name +output AZURE_AI_AGENT_ENDPOINT string = aiFoundryAiServices.outputs.aiProjectInfo.apiEndpoint +output APP_ENV string = 'Prod' +output AI_FOUNDRY_RESOURCE_ID string = aiFoundryAiServices.outputs.resourceId +output COSMOSDB_ACCOUNT_NAME string = cosmosDbResourceName diff --git a/infra/scripts/add_cosmosdb_access.sh b/infra/scripts/add_cosmosdb_access.sh new file mode 100644 index 000000000..86a848b36 --- /dev/null +++ b/infra/scripts/add_cosmosdb_access.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Variables +resource_group="$1" +account_name="$2" +principal_ids="$3" + +# Authenticate with Azure +if az account show &> /dev/null; then + echo "Already authenticated with Azure." +else + if [ -n "$managedIdentityClientId" ]; then + # Use managed identity if running in Azure + echo "Authenticating with Managed Identity..." + az login --identity --client-id ${managedIdentityClientId} + else + # Use Azure CLI login if running locally + echo "Authenticating with Azure CLI..." + az login + fi + echo "Not authenticated with Azure. Attempting to authenticate..." +fi + + +IFS=',' read -r -a principal_ids_array <<< $principal_ids + +echo "Assigning Cosmos DB Built-in Data Contributor role to users" +for principal_id in "${principal_ids_array[@]}"; do + + # Check if the user has the Cosmos DB Built-in Data Contributor role + echo "Checking if user - ${principal_id} has the Cosmos DB Built-in Data Contributor role" + roleExists=$(az cosmosdb sql role assignment list \ + --resource-group $resource_group \ + --account-name $account_name \ + --query "[?roleDefinitionId.ends_with(@, '00000000-0000-0000-0000-000000000002') && principalId == '$principal_id']" -o tsv) + + # Check if the role exists + if [ -n "$roleExists" ]; then + echo "User - ${principal_id} already has the Cosmos DB Built-in Data Contributer role." + else + echo "User - ${principal_id} does not have the Cosmos DB Built-in Data Contributer role. Assigning the role." + MSYS_NO_PATHCONV=1 az cosmosdb sql role assignment create \ + --resource-group $resource_group \ + --account-name $account_name \ + --role-definition-id 00000000-0000-0000-0000-000000000002 \ + --principal-id $principal_id \ + --scope "/" \ + --output none + if [ $? -eq 0 ]; then + echo "Cosmos DB Built-in Data Contributer role assigned successfully." + else + echo "Failed to assign Cosmos DB Built-in Data Contributer role." + fi + fi +done \ No newline at end of file diff --git a/infra/scripts/assign_azure_ai_user_role.sh b/infra/scripts/assign_azure_ai_user_role.sh new file mode 100644 index 000000000..e44dad6cb --- /dev/null +++ b/infra/scripts/assign_azure_ai_user_role.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Variables +resource_group="$1" +aif_resource_id="$2" +principal_ids="$3" + + +# Authenticate with Azure +if az account show &> /dev/null; then + echo "Already authenticated with Azure." +else + if [ -n "$managedIdentityClientId" ]; then + # Use managed identity if running in Azure + echo "Authenticating with Managed Identity..." + az login --identity --client-id ${managedIdentityClientId} + else + # Use Azure CLI login if running locally + echo "Authenticating with Azure CLI..." + az login + fi + echo "Not authenticated with Azure. Attempting to authenticate..." +fi + + +IFS=',' read -r -a principal_ids_array <<< $principal_ids + +echo "Assigning Azure AI User role role to users" + +echo "Using provided Azure AI resource id: $aif_resource_id" + +for principal_id in "${principal_ids_array[@]}"; do + + # Check if the user has the Azure AI User role + echo "Checking if user - ${principal_id} has the Azure AI User role" + role_assignment=$(MSYS_NO_PATHCONV=1 az role assignment list --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --assignee $principal_id --query "[].roleDefinitionId" -o tsv) + if [ -z "$role_assignment" ]; then + echo "User - ${principal_id} does not have the Azure AI User role. Assigning the role." + MSYS_NO_PATHCONV=1 az role assignment create --assignee $principal_id --role 53ca6127-db72-4b80-b1b0-d745d6d5456d --scope $aif_resource_id --output none + if [ $? -eq 0 ]; then + echo "Azure AI User role assigned successfully." + else + echo "Failed to assign Azure AI User role." + exit 1 + fi + else + echo "User - ${principal_id} already has the Azure AI User role." + fi +done \ No newline at end of file diff --git a/infra/scripts/cosmosdb_and_ai_user_role_assignment.sh b/infra/scripts/cosmosdb_and_ai_user_role_assignment.sh new file mode 100644 index 000000000..f8c14522a --- /dev/null +++ b/infra/scripts/cosmosdb_and_ai_user_role_assignment.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +# Variables + +principal_ids="$1" +cosmosDbAccountName="$2" +resourceGroupName="$3" +managedIdentityClientId="$4" +aif_resource_id="${5}" + +# Function to merge and deduplicate principal IDs +merge_principal_ids() { + local param_ids="$1" + local env_ids="$2" + local all_ids="" + + # Add parameter IDs if provided + if [ -n "$param_ids" ]; then + all_ids="$param_ids" + fi + + signed_user_id=$(az ad signed-in-user show --query id -o tsv) + + # Add environment variable IDs if provided + if [ -n "$env_ids" ]; then + if [ -n "$all_ids" ]; then + all_ids="$all_ids,$env_ids" + else + all_ids="$env_ids" + fi + fi + + all_ids="$all_ids,$signed_user_id" + # Remove duplicates and return + if [ -n "$all_ids" ]; then + # Convert to array, remove duplicates, and join back + IFS=',' read -r -a ids_array <<< "$all_ids" + declare -A unique_ids + for id in "${ids_array[@]}"; do + # Trim whitespace + id=$(echo "$id" | xargs) + if [ -n "$id" ]; then + unique_ids["$id"]=1 + fi + done + + # Join unique IDs back with commas + local result="" + for id in "${!unique_ids[@]}"; do + if [ -n "$result" ]; then + result="$result,$id" + else + result="$id" + fi + done + echo "$result" + fi +} + + +# get parameters from azd env, if not provided +if [ -z "$resourceGroupName" ]; then + resourceGroupName=$(azd env get-value AZURE_RESOURCE_GROUP) +fi + +if [ -z "$cosmosDbAccountName" ]; then + cosmosDbAccountName=$(azd env get-value COSMOSDB_ACCOUNT_NAME) +fi + +if [ -z "$aif_resource_id" ]; then + aif_resource_id=$(azd env get-value AI_FOUNDRY_RESOURCE_ID) +fi + +azSubscriptionId=$(azd env get-value AZURE_SUBSCRIPTION_ID) +env_principal_ids=$(azd env get-value PRINCIPAL_IDS) + +# Merge principal IDs from parameter and environment variable +principal_ids=$(merge_principal_ids "$principal_ids_param" "$env_principal_ids") + +# Check if all required arguments are provided +if [ -z "$principal_ids" ] || [ -z "$cosmosDbAccountName" ] || [ -z "$resourceGroupName" ] || [ -z "$aif_resource_id" ] ; then + echo "Usage: $0 " + exit 1 +fi + +echo "Using principal IDs: $principal_ids" + +# Authenticate with Azure +if az account show &> /dev/null; then + echo "Already authenticated with Azure." +else + if [ -n "$managedIdentityClientId" ]; then + # Use managed identity if running in Azure + echo "Authenticating with Managed Identity..." + az login --identity --client-id ${managedIdentityClientId} + else + # Use Azure CLI login if running locally + echo "Authenticating with Azure CLI..." + az login + fi + echo "Not authenticated with Azure. Attempting to authenticate..." +fi + +#check if user has selected the correct subscription +currentSubscriptionId=$(az account show --query id -o tsv) +currentSubscriptionName=$(az account show --query name -o tsv) +if [ "$currentSubscriptionId" != "$azSubscriptionId" ]; then + echo "Current selected subscription is $currentSubscriptionName ( $currentSubscriptionId )." + read -rp "Do you want to continue with this subscription?(y/n): " confirmation + if [[ "$confirmation" != "y" && "$confirmation" != "Y" ]]; then + echo "Fetching available subscriptions..." + availableSubscriptions=$(az account list --query "[?state=='Enabled'].[name,id]" --output tsv) + while true; do + echo "" + echo "Available Subscriptions:" + echo "========================" + echo "$availableSubscriptions" | awk '{printf "%d. %s ( %s )\n", NR, $1, $2}' + echo "========================" + echo "" + read -rp "Enter the number of the subscription (1-$(echo "$availableSubscriptions" | wc -l)) to use: " subscriptionIndex + if [[ "$subscriptionIndex" =~ ^[0-9]+$ ]] && [ "$subscriptionIndex" -ge 1 ] && [ "$subscriptionIndex" -le $(echo "$availableSubscriptions" | wc -l) ]; then + selectedSubscription=$(echo "$availableSubscriptions" | sed -n "${subscriptionIndex}p") + selectedSubscriptionName=$(echo "$selectedSubscription" | cut -f1) + selectedSubscriptionId=$(echo "$selectedSubscription" | cut -f2) + + # Set the selected subscription + if az account set --subscription "$selectedSubscriptionId"; then + echo "Switched to subscription: $selectedSubscriptionName ( $selectedSubscriptionId )" + break + else + echo "Failed to switch to subscription: $selectedSubscriptionName ( $selectedSubscriptionId )." + fi + else + echo "Invalid selection. Please try again." + fi + done + else + echo "Proceeding with the current subscription: $currentSubscriptionName ( $currentSubscriptionId )" + az account set --subscription "$currentSubscriptionId" + fi +else + echo "Proceeding with the subscription: $currentSubscriptionName ( $currentSubscriptionId )" + az account set --subscription "$currentSubscriptionId" +fi + +# Call add_cosmosdb_access.sh +echo "Running add_cosmosdb_access.sh" +bash infra/scripts/add_cosmosdb_access.sh "$resourceGroupName" "$cosmosDbAccountName" "$principal_ids" "$managedIdentityClientId" +if [ $? -ne 0 ]; then + echo "Error: add_cosmosdb_access.sh failed." + exit 1 +fi +echo "add_cosmosdb_access.sh completed successfully." + + +# Call add_cosmosdb_access.sh +echo "Running assign_azure_ai_user_role.sh" +bash infra/scripts/assign_azure_ai_user_role.sh "$resourceGroupName" "$aif_resource_id" "$principal_ids" "$managedIdentityClientId" +if [ $? -ne 0 ]; then + echo "Error: assign_azure_ai_user_role.sh failed." + exit 1 +fi +echo "assign_azure_ai_user_role.sh completed successfully." \ No newline at end of file diff --git a/infra/scripts/package_frontend.ps1 b/infra/scripts/package_frontend.ps1 new file mode 100644 index 000000000..71364c296 --- /dev/null +++ b/infra/scripts/package_frontend.ps1 @@ -0,0 +1,11 @@ +mkdir dist -Force +rm dist/* -r -Force + +# Python +cp requirements.txt dist -Force +cp *.py dist -Force + +# Node +npm install +npm run build +cp -r build dist -Force \ No newline at end of file diff --git a/infra/scripts/package_frontend.sh b/infra/scripts/package_frontend.sh new file mode 100644 index 000000000..e334d6b2a --- /dev/null +++ b/infra/scripts/package_frontend.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -eou pipefail + +mkdir -p dist +rm -rf dist/* + +#python +cp -f requirements.txt dist +cp -f *.py dist + +#node +npm install +npm run build +cp -rf build dist \ No newline at end of file diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index 23ecf1ba7..35d969796 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -10,14 +10,17 @@ WORKDIR /app COPY uv.lock pyproject.toml /app/ # Install the project's dependencies using the lockfile and settings -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project --no-dev +# RUN --mount=type=cache,target=/root/.cache/uv \ +# --mount=type=bind,source=uv.lock,target=uv.lock \ +# --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ +# uv sync --frozen --no-install-project --no-dev +RUN uv sync --frozen --no-install-project --no-dev # Backend app setup COPY . /app -RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev +#RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev +RUN uv sync --frozen --no-dev + FROM base From acf075bb8841fda9ea06a95c73362ce9f12095a0 Mon Sep 17 00:00:00 2001 From: UtkarshMishra-Microsoft Date: Wed, 27 Aug 2025 11:55:36 +0530 Subject: [PATCH 3/7] cleanup: drop unintended frontend changes, keep only infra + backend --- .../src/components/common/SettingsButton.tsx | 1277 +++++++---------- .../components/common/TeamSelector.module.css | 462 ++++++ .../src/components/common/TeamSelector.tsx | 822 ++++++++--- .../components/common/TeamSettingsButton.tsx | 136 -- .../src/components/common/TeamUploadTab.tsx | 204 --- .../src/components/content/PlanPanelLeft.tsx | 159 +- .../src/components/content/TaskList.tsx | 4 +- src/frontend/src/hooks/useTeamSelection.tsx | 94 ++ src/frontend/src/pages/PlanPage.tsx | 2 +- src/frontend/src/services/TeamService.tsx | 34 + src/frontend_react/package-lock.json | 6 - 11 files changed, 1783 insertions(+), 1417 deletions(-) create mode 100644 src/frontend/src/components/common/TeamSelector.module.css delete mode 100644 src/frontend/src/components/common/TeamSettingsButton.tsx delete mode 100644 src/frontend/src/components/common/TeamUploadTab.tsx create mode 100644 src/frontend/src/hooks/useTeamSelection.tsx delete mode 100644 src/frontend_react/package-lock.json diff --git a/src/frontend/src/components/common/SettingsButton.tsx b/src/frontend/src/components/common/SettingsButton.tsx index 8ad5d8974..3b6eecfb4 100644 --- a/src/frontend/src/components/common/SettingsButton.tsx +++ b/src/frontend/src/components/common/SettingsButton.tsx @@ -1,9 +1,4 @@ -// DEV NOTE: SettingsButton – shows a Team picker with upload + delete. -// Goal: while backend is offline, surface 2–3 mock teams at the TOP of the list -// so you can do visual polish. When backend succeeds, mocks still appear first; -// when it fails, we fall back to just the mocks. Everything else is untouched. - -import React, { useState } from "react"; +import React, { useState } from 'react'; import { Button, Dialog, @@ -22,17 +17,7 @@ import { Tooltip, Badge, Input, - Body1Strong, - Tag, - Radio, - Menu, - MenuTrigger, - MenuPopover, - MenuList, - MenuItem, - TabList, - Tab, -} from "@fluentui/react-components"; +} from '@fluentui/react-components'; import { Settings20Regular, CloudAdd20Regular, @@ -52,181 +37,77 @@ import { WindowConsole20Regular, Code20Regular, Wrench20Regular, - RadioButton20Regular, - RadioButton20Filled, - MoreHorizontal20Regular, - Agents20Regular, - ArrowUploadRegular, -} from "@fluentui/react-icons"; -import { TeamConfig } from "../../models/Team"; -import { TeamService } from "../../services/TeamService"; -import { MoreHorizontal } from "@/coral/imports/bundleicons"; +} from '@fluentui/react-icons'; +import { TeamConfig } from '../../models/Team'; +import { TeamService } from '../../services/TeamService'; -// DEV NOTE: map string tokens from JSON to Fluent UI icons. -// If a token is missing or unknown, we use a friendly default. +// 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 (no Robot20Regular) - Code: , - Play: , - Shield: , - Globe: , - Person: , - Database: , - Document: , - + 'Terminal': , + 'MonitorCog': , + 'BookMarked': , + 'Search': , + 'Robot': , // Fallback since Robot20Regular doesn't exist + 'Code': , + 'Play': , + 'Shield': , + 'Globe': , + 'Person': , + 'Database': , + 'Document': , + // Team logos - Wrench: , - TestTube: , // Fallback (no TestTube20Regular) - Building: , - Desktop: , - - // Fallback - default: , + 'Wrench': , + 'TestTube': , // Fallback since TestTube20Regular doesn't exist + 'Building': , + 'Desktop': , + + // Common fallbacks + 'default': , }; - - return iconMap[iconString] || iconMap["default"] || ; + + return iconMap[iconString] || iconMap['default'] || ; }; -// DEV NOTE: MOCK TEAMS – strictly for visual work. -// They are shaped exactly like TeamConfig so the rest of the UI -// (badges, selection state, cards) behaves identically. -const MOCK_TEAMS: TeamConfig[] = [ - { - id: "mock-01", - team_id: "mock-01", - name: "Invoice QA (Mock)", - description: - "Validates invoice totals, flags anomalies, and drafts vendor replies.", - status: "active", - logo: "Document", - protected: false, - created_by: "mock", - agents: [ - { - name: "Line-Item Checker", - type: "tool", - input_key: "invoice_pdf", - deployment_name: "gpt-mini", - icon: "Search", - }, - { - name: "Policy Guard", - type: "tool", - input_key: "policy_text", - deployment_name: "gpt-mini", - icon: "Shield", - }, - ], - }, - { - id: "mock-02", - team_id: "mock-02", - name: "RAG Research (Mock)", - description: - "Summarizes docs and cites sources with a lightweight RAG pass.", - status: "active", - logo: "Database", - protected: false, - created_by: "mock", - agents: [ - { - name: "Retriever", - type: "rag", - input_key: "query", - deployment_name: "gpt-mini", - index_name: "docs-index", - icon: "Database", - }, - { - name: "Writer", - type: "tool", - input_key: "draft", - deployment_name: "gpt-mini", - icon: "Code", - }, - ], - }, - { - id: "mock-03", - team_id: "mock-03", - name: "Website Auditor (Mock)", - description: "Checks accessibility, meta tags, and perf hints for a URL.", - status: "active", - logo: "Globe", - protected: false, - created_by: "mock", - agents: [ - { - name: "Scanner", - type: "tool", - input_key: "url", - deployment_name: "gpt-mini", - icon: "Globe", - }, - { - name: "A11y Linter", - type: "tool", - input_key: "report", - deployment_name: "gpt-mini", - icon: "Wrench", - }, - ], - }, -]; - interface SettingsButtonProps { onTeamSelect?: (team: TeamConfig | null) => void; onTeamUpload?: () => Promise; selectedTeam?: TeamConfig | null; - trigger?: React.ReactNode; } const SettingsButton: React.FC = ({ onTeamSelect, onTeamUpload, selectedTeam, - trigger, }) => { - // DEV NOTE: local UI state – dialog, lists, loading, upload feedback. 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 [tempSelectedTeam, setTempSelectedTeam] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [teamToDelete, setTeamToDelete] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); - // DEV NOTE: Load teams. If backend returns, we prepend mocks so they show first. - // If backend throws (offline), we silently switch to only mocks to keep UI clean. 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(); - const withMocksOnTop = [...MOCK_TEAMS.slice(0, 3), ...(teamsData || [])]; - setTeams(withMocksOnTop); + setTeams(teamsData); } catch (err: any) { - // Backend offline → visual-only mode - setTeams(MOCK_TEAMS.slice(0, 3)); - setError(null); // No scary error banner while you design + setError(err.message || 'Failed to load teams'); } finally { setLoading(false); } }; - // DEV NOTE: Opening the dialog triggers a load; closing resets transient UI. const handleOpenChange = async (open: boolean) => { setIsOpen(open); if (open) { @@ -234,16 +115,15 @@ const SettingsButton: React.FC = ({ setTempSelectedTeam(selectedTeam || null); setError(null); setUploadMessage(null); - setSearchQuery(""); + setSearchQuery(''); // Clear search when opening } else { setTempSelectedTeam(null); setError(null); setUploadMessage(null); - setSearchQuery(""); + setSearchQuery(''); // Clear search when closing } }; - // DEV NOTE: Confirm & cancel handlers – pass selection back up and close. const handleContinue = () => { if (tempSelectedTeam) { onTeamSelect?.(tempSelectedTeam); @@ -256,313 +136,220 @@ const SettingsButton: React.FC = ({ setIsOpen(false); }; - // DEV NOTE: Search – filters by name or description, case-insensitive. - const filteredTeams = teams.filter( - (team) => - team.name.toLowerCase().includes(searchQuery.toLowerCase()) || - team.description.toLowerCase().includes(searchQuery.toLowerCase()) + // Filter teams based on search query + const filteredTeams = teams.filter(team => + team.name.toLowerCase().includes(searchQuery.toLowerCase()) || + team.description.toLowerCase().includes(searchQuery.toLowerCase()) ); - // DEV NOTE: Schema validation for uploads – keeps UX consistent without backend. - const validateTeamConfig = ( - data: any - ): { isValid: boolean; errors: string[] } => { + // Validation function for team configuration JSON + const validateTeamConfig = (data: any): { isValid: boolean; errors: string[] } => { const errors: string[] = []; - if (!data || typeof data !== "object") { - errors.push("JSON file cannot be empty and must contain a valid object"); + // 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 }; } - if ( - !data.name || - typeof data.name !== "string" || - data.name.trim() === "" - ) { - errors.push("Team name is required and cannot be empty"); + // 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"); + if (!data.description || typeof data.description !== 'string' || data.description.trim() === '') { + errors.push('Team description is required and cannot be empty'); } - if ( - !data.status || - typeof data.status !== "string" || - data.status.trim() === "" - ) { - errors.push("Team status 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"); + errors.push('Agents array is required'); } else if (data.agents.length === 0) { - errors.push("Team must have at least one agent"); + 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") { + 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.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.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` - ); + + 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`); } - 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` - ); + + // 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`); } - 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` - ); + + // 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`); } } - if ( - agent.description !== undefined && - typeof agent.description !== "string" - ) { + + // 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.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") { + + if (agent.icon !== undefined && typeof agent.icon !== 'string') { errors.push(`Agent ${index + 1}: Agent icon must be a string`); } - if ( - agent.type && - agent.type.toLowerCase() !== "rag" && - agent.index_name !== undefined && - typeof agent.index_name !== "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"); + errors.push('Starting tasks must be an array if provided'); } else { data.starting_tasks.forEach((task: any, index: number) => { - if (!task || typeof task !== "object") { + 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.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.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.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.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.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` - ); + + if (task.logo !== undefined && typeof task.logo !== 'string') { + errors.push(`Starting task ${index + 1}: Task logo must be a string if provided`); } }); } } - const stringFields = ["status", "logo", "plan"]; - stringFields.forEach((field) => { - if (data[field] !== undefined && typeof data[field] !== "string") { + // 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"); + if (data.protected !== undefined && typeof data.protected !== 'boolean') { + errors.push('Protected field must be a boolean if provided'); } return { isValid: errors.length === 0, errors }; }; - // DEV NOTE: File upload – validates locally; if backend is up, we append the - // uploaded team into the current list (mocks stay on top). - const handleFileUpload = async ( - event: React.ChangeEvent - ) => { + 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..."); + setUploadMessage('Reading and validating team configuration...'); try { - if (!file.name.toLowerCase().endsWith(".json")) { - throw new Error("Please upload a valid JSON file"); + // 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 { - throw new Error("Invalid JSON format. Please check your file syntax"); + } catch (parseError) { + throw new Error('Invalid JSON format. Please check your file syntax'); } - setUploadMessage("Validating team configuration structure..."); + // 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")}`; + const errorMessage = `Team configuration validation failed:\n\n${validation.errors.map(error => `• ${error}`).join('\n')}`; throw new Error(errorMessage); } - setUploadMessage("Uploading team configuration..."); + setUploadMessage('Uploading team configuration...'); const result = await TeamService.uploadCustomTeam(file); - + if (result.success) { - setUploadMessage("Team uploaded successfully!"); - + setUploadMessage('Team uploaded successfully!'); + + // Add the new team to the existing list instead of full refresh if (result.team) { - // Keep mocks pinned on top; append uploaded team after mocks - setTeams((current) => { - const mocks = current.filter((t) => t.created_by === "mock"); - const nonMocks = current.filter((t) => t.created_by !== "mock"); - return [...mocks, result.team!, ...nonMocks]; - }); + 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." - ); + 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." - ); + 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." - ); + 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"); + setError(result.error || 'Failed to upload team configuration'); setUploadMessage(null); } } catch (err: any) { - setError(err.message || "Failed to upload team configuration"); + setError(err.message || 'Failed to upload team configuration'); setUploadMessage(null); } finally { setUploadLoading(false); - event.target.value = ""; + // Reset the input + event.target.value = ''; } }; - // DEV NOTE: Delete – optimistic UI: remove locally, then re-sync from server. - // If team is protected, we block deletion. const handleDeleteTeam = (team: TeamConfig, event: React.MouseEvent) => { event.stopPropagation(); setTeamToDelete(team); @@ -576,59 +363,64 @@ const SettingsButton: React.FC = ({ const confirmDeleteTeam = async () => { if (!teamToDelete || deleteLoading) return; - + + // Check if team is protected if (teamToDelete.protected) { - setError("This team is protected and cannot be deleted."); + 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); } } - - setTeams((currentTeams) => - currentTeams.filter((team) => team.id !== teamToDelete.id) - ); - + + // 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." - ); + setError('Failed to delete team configuration. The server rejected the deletion request.'); setDeleteConfirmOpen(false); setTeamToDelete(null); } } catch (err: any) { - let errorMessage = - "Failed to delete team configuration. Please try again."; - + + // 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."; + 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."; + 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."; + 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); @@ -637,439 +429,386 @@ const SettingsButton: React.FC = ({ } }; - // DEV NOTE: Pure view – one card per team with selection + delete. const renderTeamCard = (team: TeamConfig, isCustom = false) => { const isSelected = tempSelectedTeam?.team_id === team.team_id; - + return ( { - e.stopPropagation(); - handleTeamSelect(team); + border: isSelected ? '2px solid var(--colorBrandBackground)' : '1px solid var(--colorNeutralStroke1)', + borderRadius: '8px', + position: 'relative', + transition: 'all 0.2s ease', + backgroundColor: isSelected ? 'var(--colorBrandBackground2)' : 'var(--colorNeutralBackground1)', + padding: '12px', + width: '100%', + boxSizing: 'border-box', + display: 'block', }} > - {/* Header: icon, title, select, delete */} - -
- {/* Selected checkmark */} - {isSelected && ( - - )} - - {!isSelected && } - -
- {team.name} - - {/* Description */} -
- - {team.description} - + {/* Team Icon and Title */} +
+
+
+ {getIconFromString(team.logo)}
- - {/* Agents */} - -
- {team.agents.map((agent) => ( - - {agent.name} - - ))} + +
+ + {team.name} +
- {/* Actions */} + {/* Selection Checkmark */} + {isSelected && ( +
+ +
+ )} -
+ {/* Action Buttons */} +
+ {!isSelected && ( + + + + )} +
+ + {/* Description */} +
+ + {team.description} + +
+ + {/* Agents Section */} +
+ + Agents + +
+ {team.agents.map((agent) => ( + + + {getIconFromString(agent.icon || 'default')} + + {agent.name} + + ))} +
+
); }; - // DEV NOTE: Render – dialog with search, upload, list (mocks pinned), and actions. return ( <> - handleOpenChange(data.open)} - > - - {/** If a custom trigger is passed, use it; otherwise render the default button */} - {trigger ?? ( - - - - )} - - - 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 +
+
- {/* DEV NOTE: Lightweight requirements card – keeps UX self-explanatory. */} - {/*
- - 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 -
• 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%' }} + /> +
- {/* DEV NOTE: Search – filters the already merged list (mocks + real). */} -
- setSearchQuery(e.target.value)} - contentBefore={} - style={{ - width: "100%", - borderRadius: "8px", - padding: "12px", - }} - appearance="filled-darker" - /> + {loading ? ( +
+
- - {/* DEV NOTE: List – shows merged teams. Mocks remain visually identical. */} - {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} - - + ) : 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. + +
+
+
- - -
-
- - {/* DEV NOTE: Delete confirmation – warns that teams are shared across users. */} - 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. - -
-
-
- - - - -
-
-
+ + + ); }; diff --git a/src/frontend/src/components/common/TeamSelector.module.css b/src/frontend/src/components/common/TeamSelector.module.css new file mode 100644 index 000000000..08a15d7a7 --- /dev/null +++ b/src/frontend/src/components/common/TeamSelector.module.css @@ -0,0 +1,462 @@ +/* Team Selector Dialog Styles */ +.dialogSurface { + background-color: var(--colorNeutralBackground1) !important; + border-radius: 12px !important; + padding: 0 !important; + border: 1px solid var(--colorNeutralStroke1) !important; + box-sizing: border-box !important; + overflow: hidden !important; + max-width: 800px; + width: 90vw; + min-width: 500px; +} + +.dialogContent { + padding: 0; + width: 100%; + margin: 0; + overflow: hidden; +} + +.dialogTitle { + color: var(--colorNeutralForeground1) !important; + padding: 24px 24px 16px 24px !important; + font-size: 20px !important; + font-weight: 600 !important; + margin: 0 !important; + width: 100% !important; + box-sizing: border-box !important; +} + +.dialogBody { + color: var(--colorNeutralForeground1) !important; + padding: 24px !important; + background-color: var(--colorNeutralBackground1) !important; + width: 100% !important; + margin: 0 !important; + display: block !important; + overflow: hidden !important; + gap: unset !important; + max-height: unset !important; + grid-template-rows: unset !important; + grid-template-columns: unset !important; +} + +.dialogActions { + padding: 16px 24px; + background-color: var(--colorNeutralBackground1); + border-top: 1px solid var(--colorNeutralStroke2); +} + +/* Tab Container */ +.tabContainer { + margin-bottom: 20px; + width: 100%; +} + +.tabContentContainer { + overflow: hidden; + width: 100%; +} + +.teamsTabContent { + width: 100%; +} + +.uploadTabContent { + width: 100%; +} + +/* Tab Styles */ +.tabList { + margin-bottom: 20px !important; + width: 100% !important; + background-color: var(--colorNeutralBackground1) !important; +} + +.tab { + color: var(--colorNeutralForeground2) !important; + font-weight: 400 !important; + padding: 12px 16px !important; + margin-right: 24px !important; +} + +.tabSelected { + color: var(--colorBrandForeground1) !important; + font-weight: 600 !important; +} + +/* Search Input */ +.searchContainer { + margin-bottom: 16px; + width: 100%; +} + +.searchInput { + width: 100%; + margin-bottom: 16px; + background-color: var(--colorNeutralBackground1) !important; + border: 1px solid var(--colorNeutralStroke1) !important; + color: var(--colorNeutralForeground1) !important; +} + +/* Loading Container */ +.loadingContainer { + display: flex; + justify-content: center; + padding: 32px; + width: 100%; +} + +/* Teams Container */ +.teamsContainer { + width: 100%; +} + +.radioGroup { + width: 100%; +} + +.teamsList { + max-height: 400px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 8px; + margin-right: -8px; + width: calc(100% + 8px); + box-sizing: border-box; +} + +/* Error Message */ +.errorMessage { + color: var(--colorPaletteRedForeground1); + background-color: var(--colorPaletteRedBackground1); + border: 1px solid var(--colorPaletteRedBorder1); + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; +} + +.errorText { + color: var(--colorPaletteRedForeground1); + white-space: pre-line; +} + +/* Team List */ +.teamList { + max-height: 400px; + overflow-y: auto; +} + +/* No Teams Container */ +.noTeamsContainer { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px 16px; + text-align: center; +} + +.noTeamsText { + color: var(--colorNeutralForeground3); + margin-bottom: 8px; +} + +.noTeamsSubtext { + color: var(--colorNeutralForeground3); +} + +/* Team Item */ +.teamItem { + display: flex; + align-items: center; + padding: 16px; + border: 1px solid var(--colorNeutralStroke1); + border-radius: 8px; + margin-bottom: 12px; + justify-content: space-between; + background-color: var(--colorNeutralBackground1); + cursor: pointer; + transition: all 0.2s ease; + box-sizing: border-box; + width: 100%; + overflow: hidden; +} + +.teamItem:hover { + border-color: var(--colorBrandStroke1); + background-color: var(--colorBrandBackground2); +} + +.teamItemSelected { + background-color: var(--colorBrandBackground2); + border-color: var(--colorBrandStroke1); +} + +.teamInfo { + flex: 2; + margin-left: 16px; +} + +.teamName { + font-weight: 600; + color: var(--colorNeutralForeground1); +} + +.teamDescription { + font-size: 13px; + color: var(--colorNeutralForeground2); + margin-top: 4px; +} + +.teamBadges { + flex: 1; + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + align-items: center; + margin-left: 16px; + margin-right: 12px; +} + +.agentBadge { + background-color: var(--colorBrandBackground2); + color: var(--colorBrandForeground2); + border: 1px solid var(--colorBrandStroke2); + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + min-height: 24px; +} + +/* Agent Badge Icon */ +.badgeIcon { + display: flex; + align-items: center; + font-size: 12px; +} + +/* Delete Button */ +.deleteButton { + color: var(--colorPaletteRedForeground1); + margin-left: 12px; + min-width: 32px; +} + +/* Team Selector Button */ +.teamSelectorButton { + width: 100%; + height: auto; + min-height: 44px; + padding: 12px 16px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--colorNeutralForeground1); + text-align: left; + font-size: 14px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.teamSelectorContent { + display: flex; + flex-direction: column; + align-items: flex-start; + flex: 1; +} + +.currentTeamLabel { + color: var(--colorNeutralForeground2); + font-size: 11px; + margin-bottom: 2px; +} + +.currentTeamName { + color: var(--colorNeutralForeground1); + font-weight: 500; + font-size: 14px; +} + +.chevronIcon { + color: var(--colorNeutralForeground2); +} + +/* Upload Tab */ +.uploadContainer { + width: 100%; +} + +.uploadMessage { + color: var(--colorPaletteGreenForeground1); + margin-bottom: 16px; + padding: 12px; + background-color: var(--colorPaletteGreenBackground1); + border: 1px solid var(--colorPaletteGreenBorder1); + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.dropZone { + border: 2px dashed var(--colorNeutralStroke1); + border-radius: 8px; + padding: 40px 20px; + text-align: center; + background-color: var(--colorNeutralBackground1); + margin-bottom: 20px; + cursor: pointer; + transition: all 0.2s ease; +} + +.dropZone:hover, +.dropZoneHover { + border-color: var(--colorBrandStroke1); + background-color: var(--colorBrandBackground2); +} + +.dropZoneContent { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.uploadIcon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--colorNeutralForeground3); +} + +.uploadTitle { + font-size: 16px; + font-weight: 500; + color: var(--colorNeutralForeground1); + margin-bottom: 4px; +} + +.uploadSubtitle { + font-size: 14px; + color: var(--colorNeutralForeground2); +} + +.browseLink { + color: var(--colorBrandForeground1); + text-decoration: underline; + cursor: pointer; +} + +.hiddenInput { + display: none; +} + +.requirementsBox { + background-color: var(--colorNeutralBackground2); + padding: 16px; + border-radius: 8px; +} + +.requirementsTitle { + font-size: 14px; + font-weight: 600; + color: var(--colorNeutralForeground1); + display: block; + margin-bottom: 12px; +} + +.requirementsList { + margin: 0; + padding-left: 20px; + list-style: disc; +} + +.requirementsItem { + margin-bottom: 8px; +} + +.requirementsText { + font-size: 13px; + color: var(--colorNeutralForeground2); +} + +.requirementsStrong { + color: var(--colorNeutralForeground1); +} + +/* Button Styles */ +.continueButton { + padding: 12px 24px; +} + +.cancelButton { + padding: 8px 16px; + margin-right: 12px; + min-width: 80px; +} + +/* Delete Confirmation Dialog */ +.deleteDialogSurface { + background-color: var(--colorNeutralBackground1); + max-width: 500px; + width: 90vw; +} + +.deleteDialogContent { + display: flex; + flex-direction: column; + overflow: visible; + gap: 16px; + max-height: none; +} + +.deleteDialogBody { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; +} + +.deleteDialogTitle { + margin: 0; + padding: 0; + color: var(--colorNeutralForeground1); +} + +.deleteConfirmText { + display: block; + margin-bottom: 16px; + color: var(--colorNeutralForeground1); +} + +.deleteDialogActions { + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 12px; + padding: 0 24px 24px 24px; +} + +.warningBox { + padding: 16px; + background-color: var(--colorPaletteYellowBackground1); + border: 1px solid var(--colorPaletteYellowBorder1); + border-radius: 6px; +} + +.warningText { + color: var(--colorPaletteRedForeground1); +} + +.deleteConfirmButton { + background-color: var(--colorPaletteRedBackground1); + color: var(--colorNeutralForegroundOnBrand); + padding: 8px 16px; + min-width: 100px; +} diff --git a/src/frontend/src/components/common/TeamSelector.tsx b/src/frontend/src/components/common/TeamSelector.tsx index 75c1374f6..3082724bc 100644 --- a/src/frontend/src/components/common/TeamSelector.tsx +++ b/src/frontend/src/components/common/TeamSelector.tsx @@ -1,277 +1,703 @@ -// TeamSelector — header (search + errors) stays fixed; only the list scrolls. - -import React, { useEffect, useMemo, useState } from "react"; +import React, { useState, useCallback } from 'react'; import { - Spinner, Text, Input, Body1, Body1Strong, Card, Tooltip, Button, - Dialog, DialogSurface, DialogContent, DialogBody, DialogActions, DialogTitle, Tag -} from "@fluentui/react-components"; + Button, + Dialog, + DialogTrigger, + DialogSurface, + DialogTitle, + DialogContent, + DialogBody, + DialogActions, + Text, + Spinner, + Card, + Body1, + Body2, + Caption1, + Badge, + Input, + Radio, + RadioGroup, + Tab, + TabList +} from '@fluentui/react-components'; import { - Delete20Regular, RadioButton20Filled, RadioButton20Regular, Search20Regular, - WindowConsole20Regular, Desktop20Regular, BookmarkMultiple20Regular, Person20Regular, - Building20Regular, Document20Regular, Database20Regular, Play20Regular, Shield20Regular, - Globe20Regular, Clipboard20Regular, Code20Regular, Wrench20Regular -} from "@fluentui/react-icons"; -import { TeamConfig } from "../../models/Team"; -import { TeamService } from "../../services/TeamService"; + ChevronDown16Regular, + ChevronUpDown16Regular, + CloudAdd20Regular, + Delete20Regular, + 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'; +import styles from './TeamSelector.module.css'; +// Icon mapping function to convert string icons to FluentUI icons const getIconFromString = (iconString: string): React.ReactNode => { const iconMap: Record = { - Terminal: , MonitorCog: , - BookMarked: , Search: , - Robot: , Code: , Play: , - Shield: , Globe: , Person: , - Database: , Document: , Wrench: , - TestTube: , Building: , Desktop: , - default: , + 'Terminal': , + 'MonitorCog': , + 'BookMarked': , + 'Search': , + 'Robot': , + 'Code': , + 'Play': , + 'Shield': , + 'Globe': , + 'Person': , + 'Database': , + 'Document': , + 'Wrench': , + 'TestTube': , + 'Building': , + 'Desktop': , + 'default': , }; - return iconMap[iconString] || iconMap.default; + + return iconMap[iconString] || iconMap['default'] || ; }; -interface Props { - isOpen: boolean; - refreshKey: number; - selectedTeam: TeamConfig | null; - onTeamSelect: (team: TeamConfig | null) => void; +interface TeamSelectorProps { + onTeamSelect?: (team: TeamConfig | null) => void; + onTeamUpload?: () => Promise; + selectedTeam?: TeamConfig | null; + sessionId?: string; // Optional session ID for team selection } -const TeamSelector: React.FC = ({ isOpen, refreshKey, selectedTeam, onTeamSelect }) => { +const TeamSelector: React.FC = ({ + onTeamSelect, + onTeamUpload, + selectedTeam, + sessionId, +}) => { + const [isOpen, setIsOpen] = useState(false); const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); + const [uploadLoading, setUploadLoading] = useState(false); const [error, setError] = useState(null); - - // Delete dialog state + 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 [activeTab, setActiveTab] = useState('teams'); + const [selectionLoading, setSelectionLoading] = useState(false); const loadTeams = async () => { setLoading(true); setError(null); try { const teamsData = await TeamService.getUserTeams(); - setTeams(Array.isArray(teamsData) ? teamsData : []); + setTeams(teamsData); } catch (err: any) { - setTeams([]); - setError(err?.message ?? "Failed to load teams."); + setError(err.message || 'Failed to load teams'); } finally { setLoading(false); } }; - useEffect(() => { - if (isOpen) loadTeams(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, refreshKey]); + const handleOpenChange = async (open: boolean) => { + setIsOpen(open); + if (open) { + await loadTeams(); + setTempSelectedTeam(selectedTeam || null); + setError(null); + setUploadMessage(null); + setSearchQuery(''); + setActiveTab('teams'); + } else { + setTempSelectedTeam(null); + setError(null); + setUploadMessage(null); + setSearchQuery(''); + } + }; + + const handleContinue = async () => { + if (!tempSelectedTeam) return; - const filtered = useMemo(() => { - const q = searchQuery.toLowerCase(); - return teams.filter( - (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) - ); - }, [teams, searchQuery]); + setSelectionLoading(true); + setError(null); + + try { + // Call the backend API to select the team + const result = await TeamService.selectTeam(tempSelectedTeam.team_id, sessionId); + + if (result.success) { + // Successfully selected team on backend + console.log('Team selected:', result.data); + onTeamSelect?.(tempSelectedTeam); + setIsOpen(false); + } else { + // Handle selection error + setError(result.error || 'Failed to select team'); + } + } catch (err: any) { + console.error('Error selecting team:', err); + setError('Failed to select team. Please try again.'); + } finally { + setSelectionLoading(false); + } + }; + + const handleCancel = () => { + setTempSelectedTeam(null); + setIsOpen(false); + }; - const handleDeleteTeam = (team: TeamConfig, e: React.MouseEvent) => { - e.stopPropagation(); + // Filter teams based on search query + const filteredTeams = teams.filter(team => + team.name.toLowerCase().includes(searchQuery.toLowerCase()) || + team.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleDeleteTeam = (team: TeamConfig, event: React.MouseEvent) => { + event.stopPropagation(); setTeamToDelete(team); setDeleteConfirmOpen(true); }; const confirmDeleteTeam = async () => { if (!teamToDelete || deleteLoading) return; + if (teamToDelete.protected) { - setError("This team is protected and cannot be deleted."); + setError('This team is protected and cannot be deleted.'); setDeleteConfirmOpen(false); setTeamToDelete(null); return; } + setDeleteLoading(true); + try { - const ok = await TeamService.deleteTeam(teamToDelete.id); - if (ok) setTeams((list) => list.filter((t) => t.id !== teamToDelete.id)); - else setError("Failed to delete team configuration."); + const success = await TeamService.deleteTeam(teamToDelete.id); + + if (success) { + setDeleteConfirmOpen(false); + setTeamToDelete(null); + setDeleteLoading(false); + + if (tempSelectedTeam?.team_id === teamToDelete.team_id) { + setTempSelectedTeam(null); + if (selectedTeam?.team_id === teamToDelete.team_id) { + onTeamSelect?.(null); + } + } + + setTeams(currentTeams => currentTeams.filter(team => team.id !== teamToDelete.id)); + await loadTeams(); + } else { + setError('Failed to delete team configuration.'); + setDeleteConfirmOpen(false); + setTeamToDelete(null); + } } catch (err: any) { - setError(err?.message ?? "Failed to delete team configuration."); - } finally { - setDeleteLoading(false); + 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); } }; - return ( - // -------- Outer container: no scroll here, we manage it in the list wrapper -
- {/* Header (non-scrollable): search + error */} -
-
- setSearchQuery(e.target.value)} - contentBefore={} - style={{ width: "100%", borderRadius: 8, padding: 12 }} - appearance="filled-darker" - /> -
+ const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; - {error && ( -
- {error} -
- )} -
+ setUploadLoading(true); + setError(null); + setUploadMessage('Reading and validating team configuration...'); + + try { + if (!file.name.toLowerCase().endsWith('.json')) { + throw new Error('Please upload a valid JSON file'); + } + + // Read and parse the JSON file to check agent count + const fileText = await file.text(); + let teamData; + try { + teamData = JSON.parse(fileText); + } catch (parseError) { + throw new Error('Invalid JSON file format'); + } + + // Check if the team has more than 6 agents + if (teamData.agents && Array.isArray(teamData.agents) && teamData.agents.length > 6) { + throw new Error(`Team configuration cannot have more than 6 agents. Your team has ${teamData.agents.length} agents.`); + } + + setUploadMessage('Uploading team configuration...'); + const result = await TeamService.uploadCustomTeam(file); + + if (result.success) { + setUploadMessage('Team uploaded successfully!'); + + // Immediately add the team to local state for instant visibility + if (result.team) { + setTeams(currentTeams => [...currentTeams, result.team!]); + } + + // Also reload teams from server in the background to ensure consistency + setTimeout(() => { + loadTeams().catch(console.error); + }, 1000); + + setUploadMessage(null); + 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.'); + setUploadMessage(null); + } else if (result.modelError) { + setError('🤖 Model Deployment Validation Failed\n\nYour team configuration references models that are not properly deployed.'); + setUploadMessage(null); + } else if (result.searchError) { + setError('🔍 RAG Search Configuration Error\n\nYour team configuration includes RAG/search agents but has search index issues.'); + 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); + event.target.value = ''; + } + }; - {/* Scrollable list area */} -
) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.add(styles.dropZoneHover); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove(styles.dropZoneHover); + }; + + const handleDrop = async (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + + // Reset visual state + event.currentTarget.classList.remove(styles.dropZoneHover); + + const files = event.dataTransfer.files; + if (files.length === 0) return; + + const file = files[0]; + if (!file.name.toLowerCase().endsWith('.json')) { + setError('Please upload a valid JSON file'); + return; + } + + setUploadLoading(true); + setError(null); + setUploadMessage('Reading and validating team configuration...'); + + try { + // Read and parse the JSON file to check agent count + const fileText = await file.text(); + let teamData; + try { + teamData = JSON.parse(fileText); + } catch (parseError) { + throw new Error('Invalid JSON file format'); + } + + // Check if the team has more than 6 agents + if (teamData.agents && Array.isArray(teamData.agents) && teamData.agents.length > 6) { + throw new Error(`Team configuration cannot have more than 6 agents. Your team has ${teamData.agents.length} agents.`); + } + + setUploadMessage('Uploading team configuration...'); + const result = await TeamService.uploadCustomTeam(file); + + if (result.success) { + setUploadMessage('Team uploaded successfully!'); + + // Immediately add the team to local state for instant visibility + if (result.team) { + setTeams(currentTeams => [...currentTeams, result.team!]); + } + + // Also reload teams from server in the background to ensure consistency + setTimeout(() => { + loadTeams().catch(console.error); + }, 1000); + + setUploadMessage(null); + 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.'); + setUploadMessage(null); + } else if (result.modelError) { + setError('🤖 Model Deployment Validation Failed\n\nYour team configuration references models that are not properly deployed.'); + setUploadMessage(null); + } else if (result.searchError) { + setError('🔍 RAG Search Configuration Error\n\nYour team configuration includes RAG/search agents but has search index issues.'); + 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); + } + }; + + const renderTeamCard = (team: TeamConfig) => { + const isSelected = tempSelectedTeam?.team_id === team.team_id; + + return ( +
setTempSelectedTeam(team)} > - {loading ? ( -
- + {/* Radio Button */} + + + {/* Team Info */} +
+
+ {team.name} +
+
+ {team.description}
- ) : filtered.length > 0 ? ( -
- {filtered.map((team) => { - const isSelected = selectedTeam?.team_id === team.team_id; - return ( - onTeamSelect(team)} - style={{ - border: isSelected - ? "1px solid var(--colorBrandBackground)" - : "1px solid var(--colorNeutralStroke1)", - borderRadius: 8, - backgroundColor: isSelected - ? "var(--colorBrandBackground2)" - : "var(--colorNeutralBackground1)", - padding: 20, - marginBottom: 8, - boxShadow: "none" - }} +
+ + {/* Tags */} +
+ {team.agents.slice(0, 3).map((agent) => ( + + {agent.icon && ( + + {getIconFromString(agent.icon)} + + )} + {agent.type} + + ))} + {team.agents.length > 3 && ( + + +{team.agents.length - 3} + + )} +
+ + {/* Delete Button */} +
+ ); + }; + + return ( + <> + handleOpenChange(data.open)}> + + + + + + Select a Team + + + +
+ setActiveTab(data.value as string)} > -
- {isSelected ? ( - - ) : ( - - )} - -
- {team.name} -
- - {team.description} - + + Teams + + + Upload Team + + +
+ + {/* Tab Content - Directly below tabs without separation */} +
+ {activeTab === 'teams' && ( +
+ {error && ( +
+ {error}
+ )} -
- {team.agents.map((a) => ( - - {a.name} - - ))} + {/* Search Input */} +
+ setSearchQuery(e.target.value)} + contentBefore={} + /> +
+ + {/* Teams List */} + {loading ? ( +
+ +
+ ) : filteredTeams.length > 0 ? ( +
+ +
+ {filteredTeams.map((team) => renderTeamCard(team))} +
+
+ ) : searchQuery ? ( +
+ + No teams found matching "{searchQuery}" + +
+ ) : ( +
+ + No teams available + + + Upload a JSON team configuration to get started + +
+ )} +
+ )} + + {activeTab === 'upload' && ( +
+ {uploadMessage && ( +
+ + {uploadMessage} +
+ )} + + {error && ( +
+ {error} +
+ )} - -
- ) : teams.length === 0 ? ( -
- - No teams available - - - Use the Upload tab to add a JSON team configuration - -
- ) : ( -
- - No teams match your search - - - Try a different term - -
- )} -
- {/* Delete confirmation (outside scroll; modal anyway) */} - setDeleteConfirmOpen(d.open)}> - - - - ⚠️ Delete Team Configuration -
- + {/* Upload Requirements */} +
+ + Upload Requirements + +
    +
  • + + JSON must include name, description, and status + +
  • +
  • + + At least one agent with name, type, input_key, and deployment_name + +
  • +
  • + + Maximum of 6 agents per team configuration + +
  • +
  • + + RAG agents additionally require index_name + +
  • +
  • + + Starting tasks are optional, but if provided must include name and prompt + +
  • +
+
+
+ )} +
+ + + + + + + +
+ + {/* Delete Confirmation Dialog */} + setDeleteConfirmOpen(data.open)}> + + + + ⚠️ Delete Team Configuration +
+ Are you sure you want to delete "{teamToDelete?.name}"? -
- - Important Notice: - - - This team configuration is shared across users. Deleting it removes it for everyone. +
+ + This action cannot be undone and will remove the team for all users.
- - -
-
+ ); }; diff --git a/src/frontend/src/components/common/TeamSettingsButton.tsx b/src/frontend/src/components/common/TeamSettingsButton.tsx deleted file mode 100644 index 1bc9121a9..000000000 --- a/src/frontend/src/components/common/TeamSettingsButton.tsx +++ /dev/null @@ -1,136 +0,0 @@ -// DEV NOTE: Dialog shell for Team Settings -// - Children = trigger (your "Current team" tile) -// - Tabs: Teams | Upload Team -// - Holds tempSelectedTeam; "Continue" commits to parent - -import React, { useEffect, useState } from "react"; -import { - Button, - Dialog, - DialogTrigger, - DialogSurface, - DialogTitle, - DialogContent, - DialogBody, - DialogActions, - Body1Strong, - TabList, - Tab, -} from "@fluentui/react-components"; -import { Settings20Regular } from "@fluentui/react-icons"; -import { TeamConfig } from "../../models/Team"; -import TeamSelector from "./TeamSelector"; -import TeamUploadTab from "./TeamUploadTab"; -import { Dismiss } from "@/coral/imports/bundleicons"; - -interface TeamSettingsButtonProps { - onTeamSelect?: (team: TeamConfig | null) => void; - selectedTeam?: TeamConfig | null; - children?: React.ReactNode; // trigger -} - -const TeamSettingsButton: React.FC = ({ - onTeamSelect, - selectedTeam, - children, -}) => { - const [isOpen, setIsOpen] = useState(false); - const [activeTab, setActiveTab] = useState<"teams" | "upload">("teams"); - const [tempSelectedTeam, setTempSelectedTeam] = useState( - null - ); - const [refreshKey, setRefreshKey] = useState(0); // bump to refresh TeamSelector - - useEffect(() => { - if (isOpen) { - setTempSelectedTeam(selectedTeam ?? null); - } else { - setActiveTab("teams"); - } - }, [isOpen, selectedTeam]); - - return ( - setIsOpen(d.open)}> - - {children ?? ( - - )} - - - - - Select a Team - - - - - setActiveTab(data.value as "teams" | "upload") - } - style={{width:'calc(100% + 16px)', margin:'8px 0 0 0', alignSelf:'center'}} - > - Teams - Upload team - - - - - {activeTab === "teams" ? ( - - ) : ( - { - setActiveTab("teams"); - setRefreshKey((k) => k + 1); - }} - /> - )} - - - - - {/* */} - - - - - ); -}; - -export default TeamSettingsButton; diff --git a/src/frontend/src/components/common/TeamUploadTab.tsx b/src/frontend/src/components/common/TeamUploadTab.tsx deleted file mode 100644 index e972e4e8b..000000000 --- a/src/frontend/src/components/common/TeamUploadTab.tsx +++ /dev/null @@ -1,204 +0,0 @@ -// DEV NOTE: Upload tab -// - Drag & drop or click to browse -// - Validates JSON; uploads; shows progress/errors -// - onUploaded() => parent refreshes Teams and switches tab - -import React, { useRef, useState } from "react"; -import { Button, Body1, Body1Strong, Spinner, Text } from "@fluentui/react-components"; -import { AddCircle24Color, AddCircleColor, ArrowUploadRegular, DocumentAdd20Color, DocumentAddColor } from "@fluentui/react-icons"; -import { TeamService } from "../../services/TeamService"; - -interface Props { - onUploaded: () => void; -} - -const TeamUploadTab: React.FC = ({ onUploaded }) => { - const inputRef = useRef(null); - const [uploadLoading, setUploadLoading] = useState(false); - const [uploadMessage, setUploadMessage] = useState(null); - const [error, setError] = useState(null); - - const validateTeamConfig = (data: any): { isValid: boolean; errors: string[] } => { - const errors: string[] = []; - if (!data || typeof data !== "object") errors.push("JSON file cannot be empty and must contain a valid object"); - if (!data?.name || !String(data.name).trim()) errors.push("Team name is required and cannot be empty"); - if (!data?.description || !String(data.description).trim()) errors.push("Team description is required and cannot be empty"); - if (!data?.status || !String(data.status).trim()) errors.push("Team status is required and cannot be empty"); - if (!Array.isArray(data?.agents) || data.agents.length === 0) { - errors.push("Team must have at least one agent"); - } else { - data.agents.forEach((agent: any, i: number) => { - if (!agent || typeof agent !== "object") errors.push(`Agent ${i + 1}: Invalid object`); - if (!agent?.name || !String(agent.name).trim()) errors.push(`Agent ${i + 1}: name required`); - if (!agent?.type || !String(agent.type).trim()) errors.push(`Agent ${i + 1}: type required`); - if (!agent?.input_key || !String(agent.input_key).trim()) errors.push(`Agent ${i + 1}: input_key required`); - if (!agent?.deployment_name || !String(agent.deployment_name).trim()) errors.push(`Agent ${i + 1}: deployment_name required`); - if (String(agent.type).toLowerCase() === "rag" && (!agent?.index_name || !String(agent.index_name).trim())) { - errors.push(`Agent ${i + 1}: index_name required for RAG agents`); - } - }); - } - return { isValid: errors.length === 0, errors }; - }; - - const processFile = async (file: File) => { - setError(null); - setUploadLoading(true); - setUploadMessage("Reading and validating team configuration..."); - - try { - if (!file.name.toLowerCase().endsWith(".json")) { - throw new Error("Please upload a valid JSON file"); - } - - const content = await file.text(); - let teamData: any; - try { - teamData = JSON.parse(content); - } catch { - throw new Error("Invalid JSON format. Please check your file syntax"); - } - - setUploadMessage("Validating team configuration structure..."); - const validation = validateTeamConfig(teamData); - if (!validation.isValid) { - throw new Error( - `Team configuration validation failed:\n\n${validation.errors.map((e) => `• ${e}`).join("\n")}` - ); - } - - setUploadMessage("Uploading team configuration..."); - const result = await TeamService.uploadCustomTeam(file); - - if (result.success) { - setUploadMessage("Team uploaded successfully!"); - setTimeout(() => { - setUploadMessage(null); - onUploaded(); - }, 200); - } else if (result.raiError) { - throw new Error("❌ Content Safety Check Failed\n\nYour team configuration doesn't meet content guidelines."); - } else if (result.modelError) { - throw new Error("🤖 Model Deployment Validation Failed\n\nVerify deployment_name values and access to AI services."); - } else if (result.searchError) { - throw new Error("🔍 RAG Search Configuration Error\n\nVerify search index names and access."); - } else { - throw new Error(result.error || "Failed to upload team configuration"); - } - } catch (err: any) { - setError(err.message || "Failed to upload team configuration"); - setUploadMessage(null); - } finally { - setUploadLoading(false); - if (inputRef.current) inputRef.current.value = ""; - } - }; - - return ( -
- - - - - - - - - - - {/* Drag & drop zone (also clickable) */} -
{ - e.preventDefault(); - const file = e.dataTransfer.files?.[0]; - if (file) processFile(file); - }} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }} - onClick={() => inputRef.current?.click()} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - inputRef.current?.click(); - } - }} - style={{ - border: "1px dashed var(--colorNeutralStroke1)", - height:'100%', - borderRadius: 8, - padding: 24, - textAlign: "center", - background: "var(--colorNeutralBackground2)", - cursor: "pointer", - userSelect: "none", - display:'flex', - flexDirection:'column', - justifyContent:'center', - alignContent:'center', - alignItems:'center', - flex: 1 - }} - > - - -
- - Drag & drop your team JSON here - - - or click to browse - - { - const f = e.target.files?.[0]; - if (f) processFile(f); - }} - style={{ display: "none" }} - /> -
- - {/* Status + errors */} - {uploadMessage && ( -
- - {uploadMessage} -
- )} - - {error && ( -
- {error} -
- )} - - - - {/* Requirements */} -
- Upload requirements - - • JSON must include name, description, and status -
• At least one agent with name, type, input_key, and deployment_name -
• RAG agents additionally require index_name -
• Starting tasks are optional, but if provided must include name and prompt -
• Text fields cannot be empty -
-
- - - -
- ); -}; - -export default TeamUploadTab; diff --git a/src/frontend/src/components/content/PlanPanelLeft.tsx b/src/frontend/src/components/content/PlanPanelLeft.tsx index 9b0a90874..fbb67a127 100644 --- a/src/frontend/src/components/content/PlanPanelLeft.tsx +++ b/src/frontend/src/components/content/PlanPanelLeft.tsx @@ -1,13 +1,8 @@ -// DEV NOTE: Left plan panel. Change: the "Current team" tile is now the trigger -// that opens SettingsButton’s modal. Hover → bg to background3, chevron to fg1. - import PanelLeft from "@/coral/components/Panels/PanelLeft"; import PanelLeftToolbar from "@/coral/components/Panels/PanelLeftToolbar"; import { Body1Strong, Button, - Caption1, - Divider, Subtitle1, Subtitle2, Toast, @@ -18,9 +13,7 @@ import { } from "@fluentui/react-components"; import { Add20Regular, - ArrowSwap20Regular, ChatAdd20Regular, - ChevronUpDown20Regular, ErrorCircle20Regular, } from "@fluentui/react-icons"; import TaskList from "./TaskList"; @@ -35,16 +28,15 @@ 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 TeamSettingsButton from "../common/TeamSettingsButton"; +import TeamSelector from "../common/TeamSelector"; import { TeamConfig } from "../../models/Team"; -const PlanPanelLeft: React.FC = ({ - reloadTasks, - restReload, - onTeamSelect, +const PlanPanelLeft: React.FC = ({ + reloadTasks, + restReload, + onTeamSelect, onTeamUpload, - selectedTeam: parentSelectedTeam, + selectedTeam: parentSelectedTeam }) => { const { dispatchToast } = useToastController("toast"); const navigate = useNavigate(); @@ -55,13 +47,14 @@ const PlanPanelLeft: React.FC = ({ const [plans, setPlans] = useState(null); const [plansLoading, setPlansLoading] = useState(false); const [plansError, setPlansError] = useState(null); - const [userInfo, setUserInfo] = useState(getUserInfoGlobal()); - - // DEV NOTE: If parent gives a team, use that; otherwise manage local selection. + 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; - // DEV NOTE: Load and transform plans → task lists. const loadPlansData = useCallback(async (forceRefresh = false) => { try { setPlansLoading(true); @@ -70,7 +63,9 @@ const PlanPanelLeft: React.FC = ({ setPlans(plansData); } catch (error) { console.log("Failed to load plans:", error); - setPlansError(error instanceof Error ? error : new Error("Failed to load plans")); + setPlansError( + error instanceof Error ? error : new Error("Failed to load plans") + ); } finally { setPlansLoading(false); } @@ -82,6 +77,8 @@ const PlanPanelLeft: React.FC = ({ restReload?.(); } }, [reloadTasks, loadPlansData, restReload]); + // Fetch plans + useEffect(() => { loadPlansData(); @@ -89,7 +86,8 @@ const PlanPanelLeft: React.FC = ({ useEffect(() => { if (plans) { - const { inProgress, completed } = TaskService.transformPlansToTasks(plans); + const { inProgress, completed } = + TaskService.transformPlansToTasks(plans); setInProgressTasks(inProgress); setCompletedTasks(completed); } @@ -110,13 +108,15 @@ const PlanPanelLeft: React.FC = ({ } }, [plansError, dispatchToast]); - // DEV NOTE: Pick the session_id of the plan currently in the URL. - const selectedTaskId = plans?.find((plan) => plan.id === planId)?.session_id ?? null; + // Get the session_id that matches the current URL's planId + const selectedTaskId = + plans?.find((plan) => plan.id === planId)?.session_id ?? null; - // DEV NOTE: Navigate when a task is chosen from the list. const handleTaskSelect = useCallback( (taskId: string) => { - const selectedPlan = plans?.find((plan: PlanWithSteps) => plan.session_id === taskId); + const selectedPlan = plans?.find( + (plan: PlanWithSteps) => plan.session_id === taskId + ); if (selectedPlan) { navigate(`/plan/${selectedPlan.id}`); } @@ -124,9 +124,9 @@ const PlanPanelLeft: React.FC = ({ [plans, navigate] ); - // DEV NOTE: Bubble selection up if parent wants it; otherwise update local state + toast. const handleTeamSelect = useCallback( (team: TeamConfig | null) => { + // Use parent's team select handler if provided, otherwise use local state if (onTeamSelect) { onTeamSelect(team); } else { @@ -134,93 +134,55 @@ const PlanPanelLeft: React.FC = ({ setLocalSelectedTeam(team); dispatchToast( - Team Selected - - {team.name} team has been selected with {team.agents.length} agents - - , - { intent: "success" } - ); + 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" } - ); + Team Deselected + + No team is currently selected + + , + { intent: "info" } + ); } } }, [onTeamSelect, dispatchToast] ); - // DEV NOTE (UI): Hover state for the "Current team" tile to flip bg + chevron color. - const [teamTileHovered, setTeamTileHovered] = useState(false); - - // DEV NOTE: Build the trigger tile that opens the modal. -const teamTrigger = ( -
{ if (e.key === "Enter" || e.key === " ") e.preventDefault(); }} - onMouseEnter={() => setTeamTileHovered(true)} - onMouseLeave={() => setTeamTileHovered(false)} - style={{ - margin: "16px 16px", - backgroundColor: teamTileHovered - ? "var(--colorNeutralBackground3)" - : "var(--colorNeutralBackground2)", - padding: "12px 16px", - textAlign: "left", - borderRadius: 8, - cursor: "pointer", - outline: "none", - userSelect: "none", - }} - > -
-
- - {selectedTeam ? "Current team" : "Choose a team"} - -
- {selectedTeam ? selectedTeam.name : "No team selected"} -
- -
-
-); - return (
- }> -
+ } + > - {/* DEV NOTE: SettingsButton rendered with a custom trigger (the tile above). - Clicking the tile opens the modal. */} - - {teamTrigger} - - -
+ {/* Team Selector right under the toolbar */} +
+ +
navigate("/", { state: { focusInput: true } })} - tabIndex={0} - role="button" + tabIndex={0} // ✅ allows tab focus + role="button" // ✅ announces as button onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -244,16 +206,11 @@ const teamTrigger = ( /> -
+
+ {/* User Card */}
diff --git a/src/frontend/src/components/content/TaskList.tsx b/src/frontend/src/components/content/TaskList.tsx index d2fda06a0..47d2f0d39 100644 --- a/src/frontend/src/components/content/TaskList.tsx +++ b/src/frontend/src/components/content/TaskList.tsx @@ -81,7 +81,7 @@ const TaskList: React.FC = ({
- + In progress @@ -93,7 +93,7 @@ const TaskList: React.FC = ({ - Completed + Completed {loading ? Array.from({ length: 5 }, (_, i) => diff --git a/src/frontend/src/hooks/useTeamSelection.tsx b/src/frontend/src/hooks/useTeamSelection.tsx new file mode 100644 index 000000000..bbfc1a5db --- /dev/null +++ b/src/frontend/src/hooks/useTeamSelection.tsx @@ -0,0 +1,94 @@ +import { useState, useCallback } from 'react'; +import { TeamConfig } from '../models/Team'; +import { TeamService } from '../services/TeamService'; + +interface UseTeamSelectionProps { + sessionId?: string; + onTeamSelected?: (team: TeamConfig, result: any) => void; + onError?: (error: string) => void; +} + +interface UseTeamSelectionReturn { + selectedTeam: TeamConfig | null; + isLoading: boolean; + error: string | null; + selectTeam: (team: TeamConfig) => Promise; + clearSelection: () => void; + clearError: () => void; +} + +/** + * React hook for managing team selection with backend integration + */ +export const useTeamSelection = ({ + sessionId, + onTeamSelected, + onError, +}: UseTeamSelectionProps = {}): UseTeamSelectionReturn => { + const [selectedTeam, setSelectedTeam] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const selectTeam = useCallback(async (team: TeamConfig): Promise => { + if (isLoading) return false; + + setIsLoading(true); + setError(null); + + try { + console.log('Selecting team:', team.name, 'with session ID:', sessionId); + + const result = await TeamService.selectTeam(team.team_id, sessionId); + + if (result.success) { + setSelectedTeam(team); + console.log('Team selection successful:', result.data); + + // Call success callback + onTeamSelected?.(team, result.data); + + return true; + } else { + const errorMessage = result.error || 'Failed to select team'; + setError(errorMessage); + + // Call error callback + onError?.(errorMessage); + + return false; + } + } catch (err: any) { + const errorMessage = err.message || 'Failed to select team'; + setError(errorMessage); + + console.error('Team selection error:', err); + + // Call error callback + onError?.(errorMessage); + + return false; + } finally { + setIsLoading(false); + } + }, [isLoading, sessionId, onTeamSelected, onError]); + + const clearSelection = useCallback(() => { + setSelectedTeam(null); + setError(null); + }, []); + + const clearError = useCallback(() => { + setError(null); + }, []); + + return { + selectedTeam, + isLoading, + error, + selectTeam, + clearSelection, + clearError, + }; +}; + +export default useTeamSelection; diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index 2b81a827f..d4d0ea577 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -450,4 +450,4 @@ const PlanPage: React.FC = () => { ); }; -export default PlanPage; +export default PlanPage; \ No newline at end of file diff --git a/src/frontend/src/services/TeamService.tsx b/src/frontend/src/services/TeamService.tsx index 839055afc..065f6ec35 100644 --- a/src/frontend/src/services/TeamService.tsx +++ b/src/frontend/src/services/TeamService.tsx @@ -127,6 +127,40 @@ export class TeamService { } } + /** + * Select a team for a plan/session + */ + static async selectTeam(teamId: string, sessionId?: string): Promise<{ + success: boolean; + data?: any; + error?: string; + }> { + try { + const response = await apiClient.post('/v3/select_team', { + team_id: teamId, + session_id: sessionId + }); + + return { + success: true, + data: response + }; + } catch (error: any) { + let errorMessage = 'Failed to select team'; + + if (error.response?.data?.detail) { + errorMessage = error.response.data.detail; + } else if (error.message) { + errorMessage = error.message; + } + + return { + success: false, + error: errorMessage + }; + } + } + /** * Validate a team configuration JSON structure */ diff --git a/src/frontend_react/package-lock.json b/src/frontend_react/package-lock.json deleted file mode 100644 index fedd0b47d..000000000 --- a/src/frontend_react/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "frontend_react", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From 19a1080802b13e8fbafd3cdd34869124511f92dc Mon Sep 17 00:00:00 2001 From: UtkarshMishra-Microsoft Date: Wed, 27 Aug 2025 12:02:57 +0530 Subject: [PATCH 4/7] cleanup: remove unintended frontend files and reset edited ones to base --- .../components/common/TeamSelector.module.css | 462 ------------------ src/frontend/src/hooks/useTeamSelection.tsx | 94 ---- 2 files changed, 556 deletions(-) delete mode 100644 src/frontend/src/components/common/TeamSelector.module.css delete mode 100644 src/frontend/src/hooks/useTeamSelection.tsx diff --git a/src/frontend/src/components/common/TeamSelector.module.css b/src/frontend/src/components/common/TeamSelector.module.css deleted file mode 100644 index 08a15d7a7..000000000 --- a/src/frontend/src/components/common/TeamSelector.module.css +++ /dev/null @@ -1,462 +0,0 @@ -/* Team Selector Dialog Styles */ -.dialogSurface { - background-color: var(--colorNeutralBackground1) !important; - border-radius: 12px !important; - padding: 0 !important; - border: 1px solid var(--colorNeutralStroke1) !important; - box-sizing: border-box !important; - overflow: hidden !important; - max-width: 800px; - width: 90vw; - min-width: 500px; -} - -.dialogContent { - padding: 0; - width: 100%; - margin: 0; - overflow: hidden; -} - -.dialogTitle { - color: var(--colorNeutralForeground1) !important; - padding: 24px 24px 16px 24px !important; - font-size: 20px !important; - font-weight: 600 !important; - margin: 0 !important; - width: 100% !important; - box-sizing: border-box !important; -} - -.dialogBody { - color: var(--colorNeutralForeground1) !important; - padding: 24px !important; - background-color: var(--colorNeutralBackground1) !important; - width: 100% !important; - margin: 0 !important; - display: block !important; - overflow: hidden !important; - gap: unset !important; - max-height: unset !important; - grid-template-rows: unset !important; - grid-template-columns: unset !important; -} - -.dialogActions { - padding: 16px 24px; - background-color: var(--colorNeutralBackground1); - border-top: 1px solid var(--colorNeutralStroke2); -} - -/* Tab Container */ -.tabContainer { - margin-bottom: 20px; - width: 100%; -} - -.tabContentContainer { - overflow: hidden; - width: 100%; -} - -.teamsTabContent { - width: 100%; -} - -.uploadTabContent { - width: 100%; -} - -/* Tab Styles */ -.tabList { - margin-bottom: 20px !important; - width: 100% !important; - background-color: var(--colorNeutralBackground1) !important; -} - -.tab { - color: var(--colorNeutralForeground2) !important; - font-weight: 400 !important; - padding: 12px 16px !important; - margin-right: 24px !important; -} - -.tabSelected { - color: var(--colorBrandForeground1) !important; - font-weight: 600 !important; -} - -/* Search Input */ -.searchContainer { - margin-bottom: 16px; - width: 100%; -} - -.searchInput { - width: 100%; - margin-bottom: 16px; - background-color: var(--colorNeutralBackground1) !important; - border: 1px solid var(--colorNeutralStroke1) !important; - color: var(--colorNeutralForeground1) !important; -} - -/* Loading Container */ -.loadingContainer { - display: flex; - justify-content: center; - padding: 32px; - width: 100%; -} - -/* Teams Container */ -.teamsContainer { - width: 100%; -} - -.radioGroup { - width: 100%; -} - -.teamsList { - max-height: 400px; - overflow-y: auto; - overflow-x: hidden; - padding-right: 8px; - margin-right: -8px; - width: calc(100% + 8px); - box-sizing: border-box; -} - -/* Error Message */ -.errorMessage { - color: var(--colorPaletteRedForeground1); - background-color: var(--colorPaletteRedBackground1); - border: 1px solid var(--colorPaletteRedBorder1); - padding: 12px; - border-radius: 6px; - margin-bottom: 16px; -} - -.errorText { - color: var(--colorPaletteRedForeground1); - white-space: pre-line; -} - -/* Team List */ -.teamList { - max-height: 400px; - overflow-y: auto; -} - -/* No Teams Container */ -.noTeamsContainer { - display: flex; - flex-direction: column; - align-items: center; - padding: 32px 16px; - text-align: center; -} - -.noTeamsText { - color: var(--colorNeutralForeground3); - margin-bottom: 8px; -} - -.noTeamsSubtext { - color: var(--colorNeutralForeground3); -} - -/* Team Item */ -.teamItem { - display: flex; - align-items: center; - padding: 16px; - border: 1px solid var(--colorNeutralStroke1); - border-radius: 8px; - margin-bottom: 12px; - justify-content: space-between; - background-color: var(--colorNeutralBackground1); - cursor: pointer; - transition: all 0.2s ease; - box-sizing: border-box; - width: 100%; - overflow: hidden; -} - -.teamItem:hover { - border-color: var(--colorBrandStroke1); - background-color: var(--colorBrandBackground2); -} - -.teamItemSelected { - background-color: var(--colorBrandBackground2); - border-color: var(--colorBrandStroke1); -} - -.teamInfo { - flex: 2; - margin-left: 16px; -} - -.teamName { - font-weight: 600; - color: var(--colorNeutralForeground1); -} - -.teamDescription { - font-size: 13px; - color: var(--colorNeutralForeground2); - margin-top: 4px; -} - -.teamBadges { - flex: 1; - display: flex; - flex-wrap: wrap; - gap: 6px; - justify-content: flex-end; - align-items: center; - margin-left: 16px; - margin-right: 12px; -} - -.agentBadge { - background-color: var(--colorBrandBackground2); - color: var(--colorBrandForeground2); - border: 1px solid var(--colorBrandStroke2); - display: flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - min-height: 24px; -} - -/* Agent Badge Icon */ -.badgeIcon { - display: flex; - align-items: center; - font-size: 12px; -} - -/* Delete Button */ -.deleteButton { - color: var(--colorPaletteRedForeground1); - margin-left: 12px; - min-width: 32px; -} - -/* Team Selector Button */ -.teamSelectorButton { - width: 100%; - height: auto; - min-height: 44px; - padding: 12px 16px; - border-radius: 6px; - border: none; - background: transparent; - color: var(--colorNeutralForeground1); - text-align: left; - font-size: 14px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.teamSelectorContent { - display: flex; - flex-direction: column; - align-items: flex-start; - flex: 1; -} - -.currentTeamLabel { - color: var(--colorNeutralForeground2); - font-size: 11px; - margin-bottom: 2px; -} - -.currentTeamName { - color: var(--colorNeutralForeground1); - font-weight: 500; - font-size: 14px; -} - -.chevronIcon { - color: var(--colorNeutralForeground2); -} - -/* Upload Tab */ -.uploadContainer { - width: 100%; -} - -.uploadMessage { - color: var(--colorPaletteGreenForeground1); - margin-bottom: 16px; - padding: 12px; - background-color: var(--colorPaletteGreenBackground1); - border: 1px solid var(--colorPaletteGreenBorder1); - border-radius: 4px; - display: flex; - align-items: center; - gap: 8px; -} - -.dropZone { - border: 2px dashed var(--colorNeutralStroke1); - border-radius: 8px; - padding: 40px 20px; - text-align: center; - background-color: var(--colorNeutralBackground1); - margin-bottom: 20px; - cursor: pointer; - transition: all 0.2s ease; -} - -.dropZone:hover, -.dropZoneHover { - border-color: var(--colorBrandStroke1); - background-color: var(--colorBrandBackground2); -} - -.dropZoneContent { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -.uploadIcon { - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - color: var(--colorNeutralForeground3); -} - -.uploadTitle { - font-size: 16px; - font-weight: 500; - color: var(--colorNeutralForeground1); - margin-bottom: 4px; -} - -.uploadSubtitle { - font-size: 14px; - color: var(--colorNeutralForeground2); -} - -.browseLink { - color: var(--colorBrandForeground1); - text-decoration: underline; - cursor: pointer; -} - -.hiddenInput { - display: none; -} - -.requirementsBox { - background-color: var(--colorNeutralBackground2); - padding: 16px; - border-radius: 8px; -} - -.requirementsTitle { - font-size: 14px; - font-weight: 600; - color: var(--colorNeutralForeground1); - display: block; - margin-bottom: 12px; -} - -.requirementsList { - margin: 0; - padding-left: 20px; - list-style: disc; -} - -.requirementsItem { - margin-bottom: 8px; -} - -.requirementsText { - font-size: 13px; - color: var(--colorNeutralForeground2); -} - -.requirementsStrong { - color: var(--colorNeutralForeground1); -} - -/* Button Styles */ -.continueButton { - padding: 12px 24px; -} - -.cancelButton { - padding: 8px 16px; - margin-right: 12px; - min-width: 80px; -} - -/* Delete Confirmation Dialog */ -.deleteDialogSurface { - background-color: var(--colorNeutralBackground1); - max-width: 500px; - width: 90vw; -} - -.deleteDialogContent { - display: flex; - flex-direction: column; - overflow: visible; - gap: 16px; - max-height: none; -} - -.deleteDialogBody { - display: flex; - flex-direction: column; - gap: 16px; - padding: 24px; -} - -.deleteDialogTitle { - margin: 0; - padding: 0; - color: var(--colorNeutralForeground1); -} - -.deleteConfirmText { - display: block; - margin-bottom: 16px; - color: var(--colorNeutralForeground1); -} - -.deleteDialogActions { - display: flex; - flex-direction: row; - justify-content: flex-end; - gap: 12px; - padding: 0 24px 24px 24px; -} - -.warningBox { - padding: 16px; - background-color: var(--colorPaletteYellowBackground1); - border: 1px solid var(--colorPaletteYellowBorder1); - border-radius: 6px; -} - -.warningText { - color: var(--colorPaletteRedForeground1); -} - -.deleteConfirmButton { - background-color: var(--colorPaletteRedBackground1); - color: var(--colorNeutralForegroundOnBrand); - padding: 8px 16px; - min-width: 100px; -} diff --git a/src/frontend/src/hooks/useTeamSelection.tsx b/src/frontend/src/hooks/useTeamSelection.tsx deleted file mode 100644 index bbfc1a5db..000000000 --- a/src/frontend/src/hooks/useTeamSelection.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useState, useCallback } from 'react'; -import { TeamConfig } from '../models/Team'; -import { TeamService } from '../services/TeamService'; - -interface UseTeamSelectionProps { - sessionId?: string; - onTeamSelected?: (team: TeamConfig, result: any) => void; - onError?: (error: string) => void; -} - -interface UseTeamSelectionReturn { - selectedTeam: TeamConfig | null; - isLoading: boolean; - error: string | null; - selectTeam: (team: TeamConfig) => Promise; - clearSelection: () => void; - clearError: () => void; -} - -/** - * React hook for managing team selection with backend integration - */ -export const useTeamSelection = ({ - sessionId, - onTeamSelected, - onError, -}: UseTeamSelectionProps = {}): UseTeamSelectionReturn => { - const [selectedTeam, setSelectedTeam] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const selectTeam = useCallback(async (team: TeamConfig): Promise => { - if (isLoading) return false; - - setIsLoading(true); - setError(null); - - try { - console.log('Selecting team:', team.name, 'with session ID:', sessionId); - - const result = await TeamService.selectTeam(team.team_id, sessionId); - - if (result.success) { - setSelectedTeam(team); - console.log('Team selection successful:', result.data); - - // Call success callback - onTeamSelected?.(team, result.data); - - return true; - } else { - const errorMessage = result.error || 'Failed to select team'; - setError(errorMessage); - - // Call error callback - onError?.(errorMessage); - - return false; - } - } catch (err: any) { - const errorMessage = err.message || 'Failed to select team'; - setError(errorMessage); - - console.error('Team selection error:', err); - - // Call error callback - onError?.(errorMessage); - - return false; - } finally { - setIsLoading(false); - } - }, [isLoading, sessionId, onTeamSelected, onError]); - - const clearSelection = useCallback(() => { - setSelectedTeam(null); - setError(null); - }, []); - - const clearError = useCallback(() => { - setError(null); - }, []); - - return { - selectedTeam, - isLoading, - error, - selectTeam, - clearSelection, - clearError, - }; -}; - -export default useTeamSelection; From 4d7e9cc3c98d42dab9aad82ce1d7826519f79b3d Mon Sep 17 00:00:00 2001 From: UtkarshMishra-Microsoft Date: Wed, 27 Aug 2025 12:04:46 +0530 Subject: [PATCH 5/7] Delete src/frontend/src/components/common/TeamSelector.tsx --- .../src/components/common/TeamSelector.tsx | 704 ------------------ 1 file changed, 704 deletions(-) delete mode 100644 src/frontend/src/components/common/TeamSelector.tsx diff --git a/src/frontend/src/components/common/TeamSelector.tsx b/src/frontend/src/components/common/TeamSelector.tsx deleted file mode 100644 index 3082724bc..000000000 --- a/src/frontend/src/components/common/TeamSelector.tsx +++ /dev/null @@ -1,704 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { - Button, - Dialog, - DialogTrigger, - DialogSurface, - DialogTitle, - DialogContent, - DialogBody, - DialogActions, - Text, - Spinner, - Card, - Body1, - Body2, - Caption1, - Badge, - Input, - Radio, - RadioGroup, - Tab, - TabList -} from '@fluentui/react-components'; -import { - ChevronDown16Regular, - ChevronUpDown16Regular, - CloudAdd20Regular, - Delete20Regular, - 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'; -import styles from './TeamSelector.module.css'; - -// Icon mapping function to convert string icons to FluentUI icons -const getIconFromString = (iconString: string): React.ReactNode => { - const iconMap: Record = { - 'Terminal': , - 'MonitorCog': , - 'BookMarked': , - 'Search': , - 'Robot': , - 'Code': , - 'Play': , - 'Shield': , - 'Globe': , - 'Person': , - 'Database': , - 'Document': , - 'Wrench': , - 'TestTube': , - 'Building': , - 'Desktop': , - 'default': , - }; - - return iconMap[iconString] || iconMap['default'] || ; -}; - -interface TeamSelectorProps { - onTeamSelect?: (team: TeamConfig | null) => void; - onTeamUpload?: () => Promise; - selectedTeam?: TeamConfig | null; - sessionId?: string; // Optional session ID for team selection -} - -const TeamSelector: React.FC = ({ - onTeamSelect, - onTeamUpload, - selectedTeam, - sessionId, -}) => { - 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 [activeTab, setActiveTab] = useState('teams'); - const [selectionLoading, setSelectionLoading] = useState(false); - - const loadTeams = async () => { - setLoading(true); - setError(null); - try { - 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(''); - setActiveTab('teams'); - } else { - setTempSelectedTeam(null); - setError(null); - setUploadMessage(null); - setSearchQuery(''); - } - }; - - const handleContinue = async () => { - if (!tempSelectedTeam) return; - - setSelectionLoading(true); - setError(null); - - try { - // Call the backend API to select the team - const result = await TeamService.selectTeam(tempSelectedTeam.team_id, sessionId); - - if (result.success) { - // Successfully selected team on backend - console.log('Team selected:', result.data); - onTeamSelect?.(tempSelectedTeam); - setIsOpen(false); - } else { - // Handle selection error - setError(result.error || 'Failed to select team'); - } - } catch (err: any) { - console.error('Error selecting team:', err); - setError('Failed to select team. Please try again.'); - } finally { - setSelectionLoading(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()) - ); - - const handleDeleteTeam = (team: TeamConfig, event: React.MouseEvent) => { - event.stopPropagation(); - setTeamToDelete(team); - setDeleteConfirmOpen(true); - }; - - const confirmDeleteTeam = async () => { - if (!teamToDelete || deleteLoading) return; - - if (teamToDelete.protected) { - setError('This team is protected and cannot be deleted.'); - setDeleteConfirmOpen(false); - setTeamToDelete(null); - return; - } - - setDeleteLoading(true); - - try { - const success = await TeamService.deleteTeam(teamToDelete.id); - - if (success) { - setDeleteConfirmOpen(false); - setTeamToDelete(null); - setDeleteLoading(false); - - if (tempSelectedTeam?.team_id === teamToDelete.team_id) { - setTempSelectedTeam(null); - if (selectedTeam?.team_id === teamToDelete.team_id) { - onTeamSelect?.(null); - } - } - - setTeams(currentTeams => currentTeams.filter(team => team.id !== teamToDelete.id)); - await loadTeams(); - } else { - setError('Failed to delete team configuration.'); - setDeleteConfirmOpen(false); - setTeamToDelete(null); - } - } catch (err: any) { - 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 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 { - if (!file.name.toLowerCase().endsWith('.json')) { - throw new Error('Please upload a valid JSON file'); - } - - // Read and parse the JSON file to check agent count - const fileText = await file.text(); - let teamData; - try { - teamData = JSON.parse(fileText); - } catch (parseError) { - throw new Error('Invalid JSON file format'); - } - - // Check if the team has more than 6 agents - if (teamData.agents && Array.isArray(teamData.agents) && teamData.agents.length > 6) { - throw new Error(`Team configuration cannot have more than 6 agents. Your team has ${teamData.agents.length} agents.`); - } - - setUploadMessage('Uploading team configuration...'); - const result = await TeamService.uploadCustomTeam(file); - - if (result.success) { - setUploadMessage('Team uploaded successfully!'); - - // Immediately add the team to local state for instant visibility - if (result.team) { - setTeams(currentTeams => [...currentTeams, result.team!]); - } - - // Also reload teams from server in the background to ensure consistency - setTimeout(() => { - loadTeams().catch(console.error); - }, 1000); - - setUploadMessage(null); - 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.'); - setUploadMessage(null); - } else if (result.modelError) { - setError('🤖 Model Deployment Validation Failed\n\nYour team configuration references models that are not properly deployed.'); - setUploadMessage(null); - } else if (result.searchError) { - setError('🔍 RAG Search Configuration Error\n\nYour team configuration includes RAG/search agents but has search index issues.'); - 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); - event.target.value = ''; - } - }; - - const handleDragOver = (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.classList.add(styles.dropZoneHover); - }; - - const handleDragLeave = (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.classList.remove(styles.dropZoneHover); - }; - - const handleDrop = async (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - - // Reset visual state - event.currentTarget.classList.remove(styles.dropZoneHover); - - const files = event.dataTransfer.files; - if (files.length === 0) return; - - const file = files[0]; - if (!file.name.toLowerCase().endsWith('.json')) { - setError('Please upload a valid JSON file'); - return; - } - - setUploadLoading(true); - setError(null); - setUploadMessage('Reading and validating team configuration...'); - - try { - // Read and parse the JSON file to check agent count - const fileText = await file.text(); - let teamData; - try { - teamData = JSON.parse(fileText); - } catch (parseError) { - throw new Error('Invalid JSON file format'); - } - - // Check if the team has more than 6 agents - if (teamData.agents && Array.isArray(teamData.agents) && teamData.agents.length > 6) { - throw new Error(`Team configuration cannot have more than 6 agents. Your team has ${teamData.agents.length} agents.`); - } - - setUploadMessage('Uploading team configuration...'); - const result = await TeamService.uploadCustomTeam(file); - - if (result.success) { - setUploadMessage('Team uploaded successfully!'); - - // Immediately add the team to local state for instant visibility - if (result.team) { - setTeams(currentTeams => [...currentTeams, result.team!]); - } - - // Also reload teams from server in the background to ensure consistency - setTimeout(() => { - loadTeams().catch(console.error); - }, 1000); - - setUploadMessage(null); - 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.'); - setUploadMessage(null); - } else if (result.modelError) { - setError('🤖 Model Deployment Validation Failed\n\nYour team configuration references models that are not properly deployed.'); - setUploadMessage(null); - } else if (result.searchError) { - setError('🔍 RAG Search Configuration Error\n\nYour team configuration includes RAG/search agents but has search index issues.'); - 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); - } - }; - - const renderTeamCard = (team: TeamConfig) => { - const isSelected = tempSelectedTeam?.team_id === team.team_id; - - return ( -
setTempSelectedTeam(team)} - > - {/* Radio Button */} - - - {/* Team Info */} -
-
- {team.name} -
-
- {team.description} -
-
- - {/* Tags */} -
- {team.agents.slice(0, 3).map((agent) => ( - - {agent.icon && ( - - {getIconFromString(agent.icon)} - - )} - {agent.type} - - ))} - {team.agents.length > 3 && ( - - +{team.agents.length - 3} - - )} -
- - {/* Delete Button */} -
- ); - }; - - return ( - <> - handleOpenChange(data.open)}> - - - - - - Select a Team - - - -
- setActiveTab(data.value as string)} - > - - Teams - - - Upload Team - - -
- - {/* Tab Content - Directly below tabs without separation */} -
- {activeTab === 'teams' && ( -
- {error && ( -
- {error} -
- )} - - {/* Search Input */} -
- setSearchQuery(e.target.value)} - contentBefore={} - /> -
- - {/* Teams List */} - {loading ? ( -
- -
- ) : filteredTeams.length > 0 ? ( -
- -
- {filteredTeams.map((team) => renderTeamCard(team))} -
-
-
- ) : searchQuery ? ( -
- - No teams found matching "{searchQuery}" - -
- ) : ( -
- - No teams available - - - Upload a JSON team configuration to get started - -
- )} -
- )} - - {activeTab === 'upload' && ( -
- {uploadMessage && ( -
- - {uploadMessage} -
- )} - - {error && ( -
- {error} -
- )} - - {/* Drag and Drop Zone */} -
document.getElementById('team-upload-input')?.click()} - > -
- {/*
- ↑ -
*/} - - Drag & drop your team JSON here - - - or click to browse - -
- - -
- - {/* Upload Requirements */} -
- - Upload Requirements - -
    -
  • - - JSON must include name, description, and status - -
  • -
  • - - At least one agent with name, type, input_key, and deployment_name - -
  • -
  • - - Maximum of 6 agents per team configuration - -
  • -
  • - - RAG agents additionally require index_name - -
  • -
  • - - Starting tasks are optional, but if provided must include name and prompt - -
  • -
-
-
- )} -
-
-
- - - - -
-
- - {/* Delete Confirmation Dialog */} - setDeleteConfirmOpen(data.open)}> - - - - ⚠️ Delete Team Configuration -
- - Are you sure you want to delete "{teamToDelete?.name}"? - -
- - This action cannot be undone and will remove the team for all users. - -
-
-
- - - - -
-
-
- - ); -}; - -export default TeamSelector; From 3f903641a64defb9f985ff749a3534d0a7819738 Mon Sep 17 00:00:00 2001 From: UtkarshMishra-Microsoft Date: Wed, 27 Aug 2025 12:05:19 +0530 Subject: [PATCH 6/7] Delete src/frontend/src/pages/PlanPage.tsx --- src/frontend/src/pages/PlanPage.tsx | 453 ---------------------------- 1 file changed, 453 deletions(-) delete mode 100644 src/frontend/src/pages/PlanPage.tsx diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx deleted file mode 100644 index d4d0ea577..000000000 --- a/src/frontend/src/pages/PlanPage.tsx +++ /dev/null @@ -1,453 +0,0 @@ -import React, { useCallback, useEffect, useState, useRef } from "react"; -import { useParams, useNavigate } from "react-router-dom"; -import { - Text, - ToggleButton, - Toast, - ToastTitle, - ToastBody, - useToastController, -} from "@fluentui/react-components"; -import "../styles/PlanPage.css"; -import CoralShellColumn from "../coral/components/Layout/CoralShellColumn"; -import CoralShellRow from "../coral/components/Layout/CoralShellRow"; -import Content from "../coral/components/Content/Content"; -import { NewTaskService } from "../services/NewTaskService"; -import { PlanDataService } from "../services/PlanDataService"; -import { Step, ProcessedPlanData } from "@/models"; -import PlanPanelLeft from "@/components/content/PlanPanelLeft"; -import ContentToolbar from "@/coral/components/Content/ContentToolbar"; -import PlanChat from "@/components/content/PlanChat"; -import PlanPanelRight from "@/components/content/PlanPanelRight"; -import InlineToaster, { - useInlineToaster, -} from "../components/toast/InlineToaster"; -import Octo from "../coral/imports/Octopus.png"; // 🐙 Animated PNG loader -import PanelRightToggles from "@/coral/components/Header/PanelRightToggles"; -import { TaskListSquareLtr } from "@/coral/imports/bundleicons"; -import LoadingMessage, { loadingMessages } from "@/coral/components/LoadingMessage"; -import { RAIErrorCard, RAIErrorData } from "../components/errors"; -import { TeamConfig } from "../models/Team"; -import { TeamService } from "../services/TeamService"; -import { webSocketService, StreamMessage, StreamingPlanUpdate } from "../services/WebSocketService"; - -/** - * Page component for displaying a specific plan - * Accessible via the route /plan/{plan_id} - */ -const PlanPage: React.FC = () => { - const { planId } = useParams<{ planId: string }>(); - const navigate = useNavigate(); - const { showToast, dismissToast } = useInlineToaster(); - const { dispatchToast } = useToastController("toast"); - - const [input, setInput] = useState(""); - const [planData, setPlanData] = useState(null); - const [allPlans, setAllPlans] = useState([]); - const [loading, setLoading] = useState(true); - const [submittingChatDisableInput, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [processingSubtaskId, setProcessingSubtaskId] = useState( - null - ); - const [reloadLeftList, setReloadLeftList] = useState(true); - const [raiError, setRAIError] = useState(null); - const [selectedTeam, setSelectedTeam] = useState(null); - const [streamingMessages, setStreamingMessages] = useState([]); - const [wsConnected, setWsConnected] = useState(false); - - const loadPlanDataRef = useRef<((navigate?: boolean) => Promise) | null>(null); - - const [loadingMessage, setLoadingMessage] = useState(loadingMessages[0]); - - // WebSocket connection and streaming setup - useEffect(() => { - const initializeWebSocket = async () => { - try { - await webSocketService.connect(); - setWsConnected(true); - } catch (error) { - console.error('Failed to connect to WebSocket:', error); - setWsConnected(false); - } - }; - - initializeWebSocket(); - - // Set up WebSocket event listeners - const unsubscribeConnectionStatus = webSocketService.on('connection_status', (message: StreamMessage) => { - setWsConnected(message.data?.connected || false); - }); - - const unsubscribePlanUpdate = webSocketService.on('plan_update', (message: StreamMessage) => { - if (message.data && message.data.plan_id === planId) { - console.log('Plan update received:', message.data); - setStreamingMessages(prev => [...prev, message.data as StreamingPlanUpdate]); - - // Refresh plan data for major updates - if (message.data.status === 'completed' && loadPlanDataRef.current) { - loadPlanDataRef.current(false); - } - } - }); - - const unsubscribeStepUpdate = webSocketService.on('step_update', (message: StreamMessage) => { - if (message.data && message.data.plan_id === planId) { - console.log('Step update received:', message.data); - setStreamingMessages(prev => [...prev, message.data as StreamingPlanUpdate]); - } - }); - - const unsubscribeAgentMessage = webSocketService.on('agent_message', (message: StreamMessage) => { - if (message.data && message.data.plan_id === planId) { - console.log('Agent message received:', message.data); - setStreamingMessages(prev => [...prev, message.data as StreamingPlanUpdate]); - } - }); - - const unsubscribeError = webSocketService.on('error', (message: StreamMessage) => { - console.error('WebSocket error:', message.data); - showToast('Connection error: ' + (message.data?.error || 'Unknown error'), 'error'); - }); - - // Cleanup function - return () => { - unsubscribeConnectionStatus(); - unsubscribePlanUpdate(); - unsubscribeStepUpdate(); - unsubscribeAgentMessage(); - unsubscribeError(); - webSocketService.disconnect(); - }; - }, [planId, showToast]); - - // Subscribe to plan updates when planId changes - useEffect(() => { - if (planId && wsConnected) { - console.log('Subscribing to plan updates for:', planId); - webSocketService.subscribeToPlan(planId); - - return () => { - webSocketService.unsubscribeFromPlan(planId); - }; - } - }, [planId, wsConnected]); - - // 🌀 Cycle loading messages while loading - useEffect(() => { - if (!loading) return; - let index = 0; - const interval = setInterval(() => { - index = (index + 1) % loadingMessages.length; - setLoadingMessage(loadingMessages[index]); - }, 2000); - return () => clearInterval(interval); - }, [loading]); - - // Load default team on component mount - useEffect(() => { - const loadDefaultTeam = async () => { - let defaultTeam = TeamService.getStoredTeam(); - if (defaultTeam) { - setSelectedTeam(defaultTeam); - console.log('Default team loaded from storage:', defaultTeam.name); - return; - } - - 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"); - defaultTeam = businessOpsTeam || teams[0]; - TeamService.storageTeam(defaultTeam); - setSelectedTeam(defaultTeam); - console.log('Default team loaded:', defaultTeam.name); - } - } catch (error) { - console.error('Error loading default team:', error); - } - }; - - loadDefaultTeam(); - }, []); - - - useEffect(() => { - const currentPlan = allPlans.find( - (plan) => plan.plan.id === planId - ); - setPlanData(currentPlan || null); - }, [allPlans,planId]); - - const loadPlanData = useCallback( - async (navigate: boolean = true) => { - if (!planId) return; - - try { - setInput(""); // Clear input on new load - if (navigate) { - setPlanData(null); - setLoading(true); - setError(null); - setProcessingSubtaskId(null); - } - - setError(null); - const data = await PlanDataService.fetchPlanData(planId,navigate); - let plans = [...allPlans]; - const existingIndex = plans.findIndex(p => p.plan.id === data.plan.id); - if (existingIndex !== -1) { - plans[existingIndex] = data; - } else { - plans.push(data); - } - setAllPlans(plans); - //setPlanData(data); - } catch (err) { - console.log("Failed to load plan data:", err); - setError( - err instanceof Error ? err : new Error("Failed to load plan data") - ); - } finally { - setLoading(false); - } - }, - [planId] - ); - - // Update the ref whenever loadPlanData changes - useEffect(() => { - loadPlanDataRef.current = loadPlanData; - }, [loadPlanData]); - - const handleOnchatSubmit = useCallback( - async (chatInput: string) => { - - if (!chatInput.trim()) { - showToast("Please enter a clarification", "error"); - return; - } - setInput(""); - setRAIError(null); // Clear any previous RAI errors - if (!planData?.plan) return; - setSubmitting(true); - let id = showToast("Submitting clarification", "progress"); - try { - await PlanDataService.submitClarification( - planData.plan.id, - planData.plan.session_id, - chatInput - ); - setInput(""); - dismissToast(id); - showToast("Clarification submitted successfully", "success"); - await loadPlanData(false); - } catch (error: any) { - dismissToast(id); - - // Check if this is an RAI validation error - let errorDetail = null; - try { - // Try to parse the error detail if it's a string - if (typeof error?.response?.data?.detail === 'string') { - errorDetail = JSON.parse(error.response.data.detail); - } else { - errorDetail = error?.response?.data?.detail; - } - } catch (parseError) { - // If parsing fails, use the original error - errorDetail = error?.response?.data?.detail; - } - - // Handle RAI validation errors with better UX - if (errorDetail?.error_type === 'RAI_VALIDATION_FAILED') { - setRAIError(errorDetail); - } else { - // Handle other errors with toast messages - showToast("Failed to submit clarification", "error"); - } - } finally { - setInput(""); - setSubmitting(false); - } - }, - [planData, loadPlanData] - ); - - const handleApproveStep = useCallback( - async (step: Step, total: number, completed: number, approve: boolean) => { - setProcessingSubtaskId(step.id); - const toastMessage = approve ? "Approving step" : "Rejecting step"; - let id = showToast(toastMessage, "progress"); - setSubmitting(true); - try { - let approveRejectDetails = await PlanDataService.stepStatus(step, approve); - dismissToast(id); - showToast(`Step ${approve ? "approved" : "rejected"} successfully`, "success"); - if (approveRejectDetails && Object.keys(approveRejectDetails).length > 0) { - await loadPlanData(false); - } - setReloadLeftList(true); - } catch (error) { - dismissToast(id); - showToast(`Failed to ${approve ? "approve" : "reject"} step`, "error"); - } finally { - setProcessingSubtaskId(null); - setSubmitting(false); - } - }, - [loadPlanData] - ); - - - useEffect(() => { - loadPlanData(true); - }, [loadPlanData]); - - const handleNewTaskButton = () => { - NewTaskService.handleNewTaskFromPlan(navigate); - }; - - /** - * Handle team selection from the TeamSelector - */ - 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 - */ - 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); - - 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]); - - if (!planId) { - return ( -
- Error: No plan ID provided -
- ); - } - - return ( - - - setReloadLeftList(false)} - onTeamSelect={handleTeamSelect} - onTeamUpload={handleTeamUpload} - selectedTeam={selectedTeam} - /> - - - {/* 🐙 Only replaces content body, not page shell */} - {loading ? ( - <> - - - ) : ( - <> - } - > - - } - /> - - - - {/* Show RAI error if present */} - {raiError && ( -
- { - setRAIError(null); - }} - onDismiss={() => setRAIError(null)} - /> -
- )} - - - - )} -
- - -
-
- ); -}; - -export default PlanPage; \ No newline at end of file From 939a6227f443efea4b305601f6fbeaa5ad6cf9ae Mon Sep 17 00:00:00 2001 From: UtkarshMishra-Microsoft Date: Wed, 27 Aug 2025 12:05:48 +0530 Subject: [PATCH 7/7] Delete src/frontend/src/services/TeamService.tsx --- src/frontend/src/services/TeamService.tsx | 236 ---------------------- 1 file changed, 236 deletions(-) delete mode 100644 src/frontend/src/services/TeamService.tsx diff --git a/src/frontend/src/services/TeamService.tsx b/src/frontend/src/services/TeamService.tsx deleted file mode 100644 index 065f6ec35..000000000 --- a/src/frontend/src/services/TeamService.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { TeamConfig } from '../models/Team'; -import { apiClient } from '../api/apiClient'; - -export class TeamService { - /** - * Upload a custom team configuration - */ - private static readonly STORAGE_KEY = 'macae.v3.customTeam'; - - static storageTeam(team: TeamConfig): boolean { - // Persist a TeamConfig to localStorage (browser-only). - if (typeof window === 'undefined' || !window.localStorage) return false; - try { - const serialized = JSON.stringify(team); - window.localStorage.setItem(TeamService.STORAGE_KEY, serialized); - return true; - } catch { - return false; - } - } - - static getStoredTeam(): TeamConfig | null { - if (typeof window === 'undefined' || !window.localStorage) return null; - try { - const raw = window.localStorage.getItem(TeamService.STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - return parsed as TeamConfig; - } catch { - return null; - } - } - - static async uploadCustomTeam(teamFile: File): Promise<{ - modelError?: any; success: boolean; team?: TeamConfig; error?: string; raiError?: any; searchError?: any - }> { - try { - const formData = new FormData(); - formData.append('file', teamFile); - console.log(formData); - const response = await apiClient.upload('/v3/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('/v3/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(`/v3/team_configs/${teamId}`); - return true; - } catch (error: any) { - return false; - } - } - - /** - * Select a team for a plan/session - */ - static async selectTeam(teamId: string, sessionId?: string): Promise<{ - success: boolean; - data?: any; - error?: string; - }> { - try { - const response = await apiClient.post('/v3/select_team', { - team_id: teamId, - session_id: sessionId - }); - - return { - success: true, - data: response - }; - } catch (error: any) { - let errorMessage = 'Failed to select team'; - - if (error.response?.data?.detail) { - errorMessage = error.response.data.detail; - } else if (error.message) { - errorMessage = error.message; - } - - return { - success: false, - error: errorMessage - }; - } - } - - /** - * Validate a team configuration JSON structure - */ - static validateTeamConfig(config: any): { isValid: boolean; errors: string[]; warnings: string[] } { - const errors: string[] = []; - const warnings: string[] = []; - - // Required fields validation - const requiredFields = ['id', 'team_id', 'name', 'description', 'status', 'created', 'created_by', 'agents']; - for (const field of requiredFields) { - if (!config[field]) { - errors.push(`Missing required field: ${field}`); - } - } - - // Status validation - if (config.status && !['visible', 'hidden'].includes(config.status)) { - errors.push('Status must be either "visible" or "hidden"'); - } - - // Agents validation - if (config.agents && Array.isArray(config.agents)) { - config.agents.forEach((agent: any, index: number) => { - const agentRequiredFields = ['input_key', 'type', 'name']; - for (const field of agentRequiredFields) { - if (!agent[field]) { - errors.push(`Agent ${index + 1}: Missing required field: ${field}`); - } - } - - // RAG agent validation - if (agent.use_rag === true && !agent.index_name) { - errors.push(`Agent ${index + 1} (${agent.name}): RAG agents must have an index_name`); - } - - // New field warnings for completeness - if (agent.type === 'RAG' && !agent.use_rag) { - warnings.push(`Agent ${index + 1} (${agent.name}): RAG type agent should have use_rag: true`); - } - - if (agent.use_rag && !agent.index_endpoint) { - warnings.push(`Agent ${index + 1} (${agent.name}): RAG agent missing index_endpoint (will use default)`); - } - }); - } else if (config.agents) { - errors.push('Agents must be an array'); - } - - // Starting tasks validation - if (config.starting_tasks && Array.isArray(config.starting_tasks)) { - config.starting_tasks.forEach((task: any, index: number) => { - const taskRequiredFields = ['id', 'name', 'prompt']; - for (const field of taskRequiredFields) { - if (!task[field]) { - warnings.push(`Starting task ${index + 1}: Missing recommended field: ${field}`); - } - } - }); - } - - // Optional field checks - const optionalFields = ['logo', 'plan', 'protected']; - for (const field of optionalFields) { - if (!config[field]) { - warnings.push(`Optional field missing: ${field} (recommended for better user experience)`); - } - } - - return { isValid: errors.length === 0, errors, warnings }; - } -} - -export default TeamService;