From 0d50a90089cd60a9611879a07fd5bcdd714bcaa8 Mon Sep 17 00:00:00 2001 From: Eunsoo Lee Date: Mon, 25 Aug 2025 16:04:30 -0700 Subject: [PATCH 01/41] 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 02/41] 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 03/41] 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 04/41] 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 05/41] 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 06/41] 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 07/41] 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; From 1cde0d611190372b8d9bff55e2b7f872c712cabc Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 27 Aug 2025 09:20:17 -0700 Subject: [PATCH 08/41] ws testing --- src/backend/app_kernel.py | 217 +++++++++++------- src/backend/common/utils/utils_date.py | 4 +- src/backend/v3/api/router.py | 100 +++++--- .../v3/common/services/connection_manager.py | 0 src/backend/v3/config/settings.py | 64 ++++++ .../v3/magentic_agents/models/agent_models.py | 2 +- src/frontend/src/App.tsx | 3 + src/frontend/src/hooks/index.tsx | 1 + src/frontend/src/hooks/useWebSocket.tsx | 83 +++++++ .../src/services/WebSocketService.tsx | 6 +- 10 files changed, 369 insertions(+), 111 deletions(-) create mode 100644 src/backend/v3/common/services/connection_manager.py create mode 100644 src/frontend/src/hooks/useWebSocket.tsx diff --git a/src/backend/app_kernel.py b/src/backend/app_kernel.py index 659ef93e4..dae58955f 100644 --- a/src/backend/app_kernel.py +++ b/src/backend/app_kernel.py @@ -23,12 +23,14 @@ from common.utils.websocket_streaming import (websocket_streaming_endpoint, ws_manager) # FastAPI imports -from fastapi import FastAPI, HTTPException, Query, Request, WebSocket +from fastapi import (FastAPI, HTTPException, Query, Request, WebSocket, + WebSocketDisconnect) from fastapi.middleware.cors import CORSMiddleware from kernel_agents.agent_factory import AgentFactory # Local imports from middleware.health_check import HealthCheckMiddleware from v3.api.router import app_v3 +from v3.config.settings import connection_config # Semantic Kernel imports from v3.orchestration.orchestration_manager import OrchestrationManager @@ -69,7 +71,9 @@ app.add_middleware( CORSMiddleware, allow_origins=[ - frontend_url + "http://localhost:3000", # Add this for local development + "https://localhost:3000", # Add this if using HTTPS locally + "http://127.0.0.1:3000", ], # Allow all origins for development; restrict in production allow_credentials=True, allow_methods=["*"], @@ -84,10 +88,59 @@ # WebSocket streaming endpoint -@app.websocket("/ws/streaming") -async def websocket_endpoint(websocket: WebSocket): - """WebSocket endpoint for real-time plan execution streaming""" - await websocket_streaming_endpoint(websocket) +# @app.websocket("/ws/streaming") +# async def websocket_endpoint(websocket: WebSocket): +# """WebSocket endpoint for real-time plan execution streaming""" +# await websocket_streaming_endpoint(websocket) + +# @app.websocket("/socket/{process_id}") +# async def process_outputs(websocket: WebSocket, process_id: str): +# """ Web-Socket endpoint for real-time process status updates. """ + +# # Always accept the WebSocket connection first +# await websocket.accept() +# connection_config.add_connection(process_id=process_id, connection=websocket) + +@app.websocket("/socket/{process_id}") +async def process_outputs(websocket: WebSocket, process_id: str): + """ Web-Socket endpoint for real-time process status updates. """ + + # Always accept the WebSocket connection first + await websocket.accept() + + # user_id = None + # try: + # # WebSocket headers are different, try to get user info + # headers = dict(websocket.headers) + # authenticated_user = get_authenticated_user_details(request_headers=headers) + # user_id = authenticated_user.get("user_principal_id") + # if not user_id: + # user_id = f"anonymous_{process_id}" + # except Exception as e: + # logging.warning(f"Could not extract user from WebSocket headers: {e}") + # user_id = f"anonymous_{user_id}" + + # Add to the connection manager for backend updates + + connection_config.add_connection(process_id, websocket) + track_event_if_configured("WebSocketConnectionAccepted", {"process_id": "user_id"}) + + # Keep the connection open - FastAPI will close the connection if this returns + while True: + # no expectation that we will receive anything from the client but this keeps + # the connection open and does not take cpu cycle + try: + await websocket.receive_text() + except asyncio.TimeoutError: + pass + + except WebSocketDisconnect: + track_event_if_configured("WebSocketDisconnect", {"process_id": user_id}) + logging.info(f"Client disconnected from batch {user_id}") + await connection_config.close_connection(user_id) + except Exception as e: + logging.error("Error in WebSocket connection", error=str(e)) + await connection_config.close_connection(user_id) @app.post("/api/user_browser_language") @@ -670,9 +723,11 @@ async def get_plans( "UserIdNotFound", {"status_code": 400, "detail": "no user"} ) raise HTTPException(status_code=400, detail="no user") + + await connection_config.send_status_update_async("Test message from get_plans", user_id) # Initialize agent team for this user session - await OrchestrationManager.get_current_orchestration(user_id=user_id) + #await OrchestrationManager.get_current_orchestration(user_id=user_id) # Replace the following with code to get plan run history from the database @@ -895,80 +950,80 @@ async def get_agent_tools(): return [] -@app.post("/api/test/streaming/{plan_id}") -async def test_streaming_updates(plan_id: str): - """ - Test endpoint to simulate streaming updates for a plan. - This is for testing the WebSocket streaming functionality. - """ - from common.utils.websocket_streaming import (send_agent_message, - send_plan_update, - send_step_update) - - try: - # Simulate a series of streaming updates - await send_agent_message( - plan_id=plan_id, - agent_name="Data Analyst", - content="Starting analysis of the data...", - message_type="thinking", - ) - - await asyncio.sleep(1) - - await send_plan_update( - plan_id=plan_id, - step_id="step_1", - agent_name="Data Analyst", - content="Analyzing customer data patterns...", - status="in_progress", - message_type="action", - ) - - await asyncio.sleep(2) - - await send_agent_message( - plan_id=plan_id, - agent_name="Data Analyst", - content="Found 3 key insights in the customer data. Processing recommendations...", - message_type="result", - ) - - await asyncio.sleep(1) - - await send_step_update( - plan_id=plan_id, - step_id="step_1", - status="completed", - content="Data analysis completed successfully!", - ) - - await send_agent_message( - plan_id=plan_id, - agent_name="Business Advisor", - content="Reviewing the analysis results and preparing strategic recommendations...", - message_type="thinking", - ) - - await asyncio.sleep(2) - - await send_plan_update( - plan_id=plan_id, - step_id="step_2", - agent_name="Business Advisor", - content="Based on the data analysis, I recommend focusing on customer retention strategies for the identified high-value segments.", - status="completed", - message_type="result", - ) - - return { - "status": "success", - "message": f"Test streaming updates sent for plan {plan_id}", - } - - except Exception as e: - logging.error(f"Error sending test streaming updates: {e}") - raise HTTPException(status_code=500, detail=str(e)) +# @app.post("/api/test/streaming/{plan_id}") +# async def test_streaming_updates(plan_id: str): +# """ +# Test endpoint to simulate streaming updates for a plan. +# This is for testing the WebSocket streaming functionality. +# """ +# from common.utils.websocket_streaming import (send_agent_message, +# send_plan_update, +# send_step_update) + +# try: +# # Simulate a series of streaming updates +# await send_agent_message( +# plan_id=plan_id, +# agent_name="Data Analyst", +# content="Starting analysis of the data...", +# message_type="thinking", +# ) + +# await asyncio.sleep(1) + +# await send_plan_update( +# plan_id=plan_id, +# step_id="step_1", +# agent_name="Data Analyst", +# content="Analyzing customer data patterns...", +# status="in_progress", +# message_type="action", +# ) + +# await asyncio.sleep(2) + +# await send_agent_message( +# plan_id=plan_id, +# agent_name="Data Analyst", +# content="Found 3 key insights in the customer data. Processing recommendations...", +# message_type="result", +# ) + +# await asyncio.sleep(1) + +# await send_step_update( +# plan_id=plan_id, +# step_id="step_1", +# status="completed", +# content="Data analysis completed successfully!", +# ) + +# await send_agent_message( +# plan_id=plan_id, +# agent_name="Business Advisor", +# content="Reviewing the analysis results and preparing strategic recommendations...", +# message_type="thinking", +# ) + +# await asyncio.sleep(2) + +# await send_plan_update( +# plan_id=plan_id, +# step_id="step_2", +# agent_name="Business Advisor", +# content="Based on the data analysis, I recommend focusing on customer retention strategies for the identified high-value segments.", +# status="completed", +# message_type="result", +# ) + +# return { +# "status": "success", +# "message": f"Test streaming updates sent for plan {plan_id}", +# } + +# except Exception as e: +# logging.error(f"Error sending test streaming updates: {e}") +# raise HTTPException(status_code=500, detail=str(e)) # Run the app diff --git a/src/backend/common/utils/utils_date.py b/src/backend/common/utils/utils_date.py index 0e2c0a513..7e3a6f39c 100644 --- a/src/backend/common/utils/utils_date.py +++ b/src/backend/common/utils/utils_date.py @@ -1,8 +1,10 @@ import json import locale -from datetime import datetime import logging +from datetime import datetime from typing import Optional + +import regex as re from dateutil import parser diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 1e69bc3bd..5ff3aaca1 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import uuid @@ -17,8 +18,11 @@ from pydantic import BaseModel from semantic_kernel.agents.runtime import InProcessRuntime from v3.common.services.team_service import TeamService +from v3.config.settings import connection_config from v3.orchestration.orchestration_manager import OrchestrationManager +router = APIRouter() +logger = logging.getLogger(__name__) class TeamSelectionRequest(BaseModel): """Request model for team selection.""" @@ -82,30 +86,32 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa type: string description: Error message """ - if not await rai_success(input_task.description, False): - track_event_if_configured( - "RAI failed", - { - "status": "Plan not created - RAI check failed", - "description": input_task.description, - "session_id": input_task.session_id, - }, - ) - raise HTTPException( - status_code=400, - detail={ - "error_type": "RAI_VALIDATION_FAILED", - "message": "Content Safety Check Failed", - "description": "Your request contains content that doesn't meet our safety guidelines. Please modify your request to ensure it's appropriate and try again.", - "suggestions": [ - "Remove any potentially harmful, inappropriate, or unsafe content", - "Use more professional and constructive language", - "Focus on legitimate business or educational objectives", - "Ensure your request complies with content policies", - ], - "user_action": "Please revise your request and try again", - }, - ) + await connection_config.send_status_update_async(message="sending test from the server", process_id='12345') + + # if not await rai_success(input_task.description, False): + # track_event_if_configured( + # "RAI failed", + # { + # "status": "Plan not created - RAI check failed", + # "description": input_task.description, + # "session_id": input_task.session_id, + # }, + # ) + # raise HTTPException( + # status_code=400, + # detail={ + # "error_type": "RAI_VALIDATION_FAILED", + # "message": "Content Safety Check Failed", + # "description": "Your request contains content that doesn't meet our safety guidelines. Please modify your request to ensure it's appropriate and try again.", + # "suggestions": [ + # "Remove any potentially harmful, inappropriate, or unsafe content", + # "Use more professional and constructive language", + # "Focus on legitimate business or educational objectives", + # "Ensure your request complies with content policies", + # ], + # "user_action": "Please revise your request and try again", + # }, + # ) authenticated_user = get_authenticated_user_details(request_headers=request.headers) user_id = authenticated_user["user_principal_id"] @@ -126,7 +132,7 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa input_task.session_id = str(uuid.uuid4()) try: - background_tasks.add_task(OrchestrationManager.run_orchestration, user_id, input_task) + #background_tasks.add_task(OrchestrationManager.run_orchestration, user_id, input_task) return { "status": "Request started successfully", @@ -569,7 +575,7 @@ async def get_model_deployments_endpoint(request: Request): try: team_service = TeamService() - deployments = await team_service.list_model_deployments() + deployments = [] #await team_service.extract_models_from_agent() summary = await team_service.get_deployment_status_summary() return {"deployments": deployments, "summary": summary} except Exception as e: @@ -726,3 +732,45 @@ async def get_search_indexes_endpoint(request: Request): except Exception as e: logging.error(f"Error retrieving search indexes: {str(e)}") raise HTTPException(status_code=500, detail="Internal server error occurred") + + +# @app_v3.websocket("/socket/{process_id}") +# async def process_outputs(websocket: WebSocket, process_id: str): +# """ Web-Socket endpoint for real-time process status updates. """ + +# # Always accept the WebSocket connection first +# await websocket.accept() + +# user_id = None +# try: +# # WebSocket headers are different, try to get user info +# headers = dict(websocket.headers) +# authenticated_user = get_authenticated_user_details(request_headers=headers) +# user_id = authenticated_user.get("user_principal_id") +# if not user_id: +# user_id = f"anonymous_{process_id}" +# except Exception as e: +# logger.warning(f"Could not extract user from WebSocket headers: {e}") +# # user_id = f"anonymous_{user_id}" + +# # Add to the connection manager for backend updates + +# connection_config.add_connection(user_id, websocket) +# track_event_if_configured("WebSocketConnectionAccepted", {"process_id": user_id}) + +# # Keep the connection open - FastAPI will close the connection if this returns +# while True: +# # no expectation that we will receive anything from the client but this keeps +# # the connection open and does not take cpu cycle +# try: +# await websocket.receive_text() +# except asyncio.TimeoutError: +# pass + +# except WebSocketDisconnect: +# track_event_if_configured("WebSocketDisconnect", {"process_id": user_id}) +# logger.info(f"Client disconnected from batch {user_id}") +# await connection_config.close_connection(user_id) +# except Exception as e: +# logger.error("Error in WebSocket connection", error=str(e)) +# await connection_config.close_connection(user_id) \ No newline at end of file diff --git a/src/backend/v3/common/services/connection_manager.py b/src/backend/v3/common/services/connection_manager.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/v3/config/settings.py b/src/backend/v3/config/settings.py index 475815645..c8d4371bc 100644 --- a/src/backend/v3/config/settings.py +++ b/src/backend/v3/config/settings.py @@ -3,11 +3,18 @@ Handles Azure OpenAI, MCP, and environment setup. """ +import asyncio +import json +import logging +from typing import Dict + from common.config.app_config import config +from fastapi import WebSocket from semantic_kernel.agents.orchestration.magentic import MagenticOrchestration from semantic_kernel.connectors.ai.open_ai import ( AzureChatCompletion, OpenAIChatPromptExecutionSettings) +logger = logging.getLogger(__name__) class AzureConfig: """Azure OpenAI and authentication configuration.""" @@ -67,9 +74,66 @@ def __init__(self): def get_current_orchestration(self, user_id: str) -> MagenticOrchestration: """get existing orchestration instance.""" return self.orchestrations.get(user_id, None) + +class ConnectionConfig: + """Connection manager for WebSocket connections.""" + + def __init__(self): + self.connections: Dict[str, WebSocket] = {} + + def add_connection(self, process_id, connection): + """Add a new connection.""" + self.connections[process_id] = connection + + def remove_connection(self, process_id): + """Remove a connection.""" + if process_id in self.connections: + del self.connections[process_id] + + def get_connection(self, process_id): + """Get a connection.""" + return self.connections.get(process_id) + + async def close_connection(self, process_id): + """Remove a connection.""" + connection = self.get_connection(process_id) + if connection: + asyncio.run_coroutine_threadsafe(connection.close(), asyncio.get_event_loop()) + logger.info("Connection closed for batch ID: %s", process_id) + else: + logger.warning("No connection found for batch ID: %s", process_id) + connection_config.remove_connection(process_id) + logger.info("Connection removed for batch ID: %s", process_id) + + async def send_status_update_async(self, message: str, process_id: str): + """Send a status update to a specific client.""" + connection = self.get_connection(process_id) + if connection: + await connection.send_text(message) + else: + logger.warning("No connection found for batch ID: %s", process_id) + + + def send_status_update(self, message: str, process_id: str): + """Send a status update to a specific client.""" + connection = self.get_connection(str(process_id)) + if connection: + try: + # Directly send the message using this connection object + asyncio.run_coroutine_threadsafe( + connection.send_text( + message + ), + asyncio.get_event_loop(), + ) + except Exception as e: + logger.error("Failed to send message: %s", e) + else: + logger.warning("No connection found for batch ID: %s", process_id) # Global config instances azure_config = AzureConfig() mcp_config = MCPConfig() orchestration_config = OrchestrationConfig() +connection_config = ConnectionConfig() diff --git a/src/backend/v3/magentic_agents/models/agent_models.py b/src/backend/v3/magentic_agents/models/agent_models.py index 4e7769e27..e3c0d9187 100644 --- a/src/backend/v3/magentic_agents/models/agent_models.py +++ b/src/backend/v3/magentic_agents/models/agent_models.py @@ -74,4 +74,4 @@ def from_env(cls) -> "SearchConfig": index_name=index_name, endpoint=endpoint, api_key=api_key, - ) \ No newline at end of file + ) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index e5f5ce4ff..fef933a8f 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -2,8 +2,11 @@ import React from 'react'; import './App.css'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { HomePage, PlanPage, PlanCreatePage } from './pages'; +import { useWebSocket } from './hooks/useWebSocket'; function App() { + const { isConnected, isConnecting, error } = useWebSocket(); + return ( diff --git a/src/frontend/src/hooks/index.tsx b/src/frontend/src/hooks/index.tsx index 497005355..70bfbf9c7 100644 --- a/src/frontend/src/hooks/index.tsx +++ b/src/frontend/src/hooks/index.tsx @@ -1 +1,2 @@ export { default as useRAIErrorHandling } from './useRAIErrorHandling'; +export { useWebSocket } from './useWebSocket'; \ No newline at end of file diff --git a/src/frontend/src/hooks/useWebSocket.tsx b/src/frontend/src/hooks/useWebSocket.tsx new file mode 100644 index 000000000..0d1b9b52d --- /dev/null +++ b/src/frontend/src/hooks/useWebSocket.tsx @@ -0,0 +1,83 @@ +// Warning: Vibe coded as a simple websocket test + +import { useEffect, useRef, useState } from 'react'; +import { webSocketService, StreamMessage } from '../services/WebSocketService'; + +export interface WebSocketState { + isConnected: boolean; + isConnecting: boolean; + error: string | null; +} + +export const useWebSocket = () => { + const [state, setState] = useState({ + isConnected: false, + isConnecting: false, + error: null + }); + + const hasConnected = useRef(false); + + useEffect(() => { + // Prevent multiple connections + if (hasConnected.current) return; + hasConnected.current = true; + + const connectWebSocket = async () => { + setState(prev => ({ ...prev, isConnecting: true, error: null })); + + try { + await webSocketService.connect(); + setState(prev => ({ ...prev, isConnected: true, isConnecting: false })); + } catch (error) { + console.error('Failed to connect to WebSocket:', error); + setState(prev => ({ + ...prev, + isConnected: false, + isConnecting: false, + error: 'Failed to connect to server' + })); + } + }; + + // Set up connection status listener + const unsubscribeStatus = webSocketService.on('connection_status', (message: StreamMessage) => { + if (message.data?.connected !== undefined) { + setState(prev => ({ + ...prev, + isConnected: message.data.connected, + isConnecting: false, + error: message.data.connected ? null : prev.error + })); + } + }); + + // Set up error listener + const unsubscribeError = webSocketService.on('error', (message: StreamMessage) => { + setState(prev => ({ + ...prev, + error: message.data?.error || 'WebSocket error occurred' + })); + }); + + // Connect + connectWebSocket(); + + // Cleanup on unmount + return () => { + unsubscribeStatus(); + unsubscribeError(); + webSocketService.disconnect(); + hasConnected.current = false; + }; + }, []); + + return { + ...state, + webSocketService, + reconnect: () => { + setState(prev => ({ ...prev, isConnecting: true, error: null })); + return webSocketService.connect(); + } + }; +}; \ No newline at end of file diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index 11a63aa04..36f5ee544 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -37,7 +37,8 @@ class WebSocketService { // Get WebSocket URL from environment or default to localhost const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = process.env.REACT_APP_WS_HOST || '127.0.0.1:8000'; - const wsUrl = `${wsProtocol}//${wsHost}/ws/streaming`; + const processId = '12345'; // Replace with actual process ID as needed' + const wsUrl = `${wsProtocol}//${wsHost}/socket/${processId}`; console.log('Connecting to WebSocket:', wsUrl); @@ -52,7 +53,8 @@ class WebSocketService { this.ws.onmessage = (event) => { try { - const message: StreamMessage = JSON.parse(event.data); + //const message: StreamMessage = JSON.parse(event.data); + const message: StreamMessage = event.data; this.handleMessage(message); } catch (error) { console.error('Error parsing WebSocket message:', error); From a802ca4f151a5c9ae5063b3afa124514ac6e509a Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 27 Aug 2025 10:29:34 -0700 Subject: [PATCH 09/41] add mcp dependency --- src/backend/pyproject.toml | 1 + src/backend/uv.lock | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index e9610b73a..72b62beb6 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -31,4 +31,5 @@ dependencies = [ "uvicorn>=0.34.2", "pylint-pydantic>=0.3.5", "pexpect>=4.9.0", + "mcp>=1.13.1", ] diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 4cce9c072..7f47f3b75 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -448,6 +448,7 @@ dependencies = [ { name = "azure-monitor-opentelemetry" }, { name = "azure-search-documents" }, { name = "fastapi" }, + { name = "mcp" }, { name = "openai" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, @@ -478,6 +479,7 @@ requires-dist = [ { name = "azure-monitor-opentelemetry", specifier = ">=1.6.8" }, { name = "azure-search-documents", specifier = ">=11.5.2" }, { name = "fastapi", specifier = ">=0.115.12" }, + { name = "mcp", specifier = ">=1.13.1" }, { name = "openai", specifier = ">=1.75.0" }, { name = "opentelemetry-api", specifier = ">=1.31.1" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.31.1" }, @@ -1053,6 +1055,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -1329,6 +1340,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "mcp" +version = "1.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/3c/82c400c2d50afdac4fbefb5b4031fd327e2ad1f23ccef8eee13c5909aa48/mcp-1.13.1.tar.gz", hash = "sha256:165306a8fd7991dc80334edd2de07798175a56461043b7ae907b279794a834c5", size = 438198, upload-time = "2025-08-22T09:22:16.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/3f/d085c7f49ade6d273b185d61ec9405e672b6433f710ea64a90135a8dd445/mcp-1.13.1-py3-none-any.whl", hash = "sha256:c314e7c8bd477a23ba3ef472ee5a32880316c42d03e06dcfa31a1cc7a73b65df", size = 161494, upload-time = "2025-08-22T09:22:14.705Z" }, +] + [[package]] name = "more-itertools" version = "10.7.0" @@ -2492,6 +2525,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -2924,6 +2976,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, +] + [[package]] name = "starlette" version = "0.47.3" From 9a647c7fd44a5029bde04e7c94043c6e9114ec1d Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 27 Aug 2025 17:36:38 -0700 Subject: [PATCH 10/41] end of day commit --- data/agent_teams/hr.json | 2 +- data/agent_teams/new 29.txt | 48 ++++++++++++++ src/backend/app_kernel.py | 51 +++++++++------ src/backend/v3/api/router.py | 3 +- src/backend/v3/models/messages.py | 65 +++++++++++++++++++ .../src/services/WebSocketService.tsx | 2 +- 6 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 data/agent_teams/new 29.txt create mode 100644 src/backend/v3/models/messages.py diff --git a/data/agent_teams/hr.json b/data/agent_teams/hr.json index 3e0086a7a..fbf5fe66e 100644 --- a/data/agent_teams/hr.json +++ b/data/agent_teams/hr.json @@ -1,7 +1,7 @@ { "id": "3", "team_id": "team-3", - "name": "Retail Customer Success Team", + "name": "Human Resources Team", "status": "visible", "created": "", "created_by": "", diff --git a/data/agent_teams/new 29.txt b/data/agent_teams/new 29.txt new file mode 100644 index 000000000..aa4da708d --- /dev/null +++ b/data/agent_teams/new 29.txt @@ -0,0 +1,48 @@ +Tasks: + +Done: Make 3 teams upload work to cosmos (HR, Marketing, Retail). We will load this Cosmos data on deploy as default teams. +Done: - call "/socket/{process_id}" (start_comms) to setup websocket +Done: Make sure that a team is always selected - start with the hr.json team + +call init_team API for the currently loaded team on App start - +Spinner / team-loading should display until this call returns (user should not be able to input tasks) + - say something like "team loading" with spinner +FE: send unload current team API and call init team for team switch - sending select_team(new team id) + spin while waiting for return of API +BE: unload old team - load new team - return status + +BE: For Francia - implement get_plans to fill in history from cosmos + +BE: Create a teams container in Cosmos and move all loaded team definitions there + +Implement saving of plan to cosmos -> history in... + +================ Request submit flow ====================== +on request submission call "/create_plan" (process_request) +This will return immediately - move to other page and display spinner -> "creating plan" +Socket will start receiving messages -> +Stream plan output into main window + +Will receive the PlanApprovalRequest message + Enable accept / reject UI +Send PlanApprovalResponse message when user answers + +If not approved + BE: plan will cancel on backend + FE: - enable input again for fresh request + Call input_request API on backend again (just like inputing any request) + +If approved: + Display plan steps in right pane if approved +============================================================= + +================== Message Streaming ======================== +Process socket message routing to display agent output + See message types in src\backend\v3\models\messages.py + for each message from agent - process stream then rollup + +On FinalResultMessage + display final result with all agent output in rollups by agent above +============================================================== + + diff --git a/src/backend/app_kernel.py b/src/backend/app_kernel.py index dae58955f..166ac51aa 100644 --- a/src/backend/app_kernel.py +++ b/src/backend/app_kernel.py @@ -102,27 +102,27 @@ # connection_config.add_connection(process_id=process_id, connection=websocket) @app.websocket("/socket/{process_id}") -async def process_outputs(websocket: WebSocket, process_id: str): +async def start_comms(websocket: WebSocket, process_id: str): """ Web-Socket endpoint for real-time process status updates. """ # Always accept the WebSocket connection first await websocket.accept() - # user_id = None - # try: - # # WebSocket headers are different, try to get user info - # headers = dict(websocket.headers) - # authenticated_user = get_authenticated_user_details(request_headers=headers) - # user_id = authenticated_user.get("user_principal_id") - # if not user_id: - # user_id = f"anonymous_{process_id}" - # except Exception as e: - # logging.warning(f"Could not extract user from WebSocket headers: {e}") - # user_id = f"anonymous_{user_id}" + user_id = None + try: + # WebSocket headers are different, try to get user info + headers = dict(websocket.headers) + authenticated_user = get_authenticated_user_details(request_headers=headers) + user_id = authenticated_user.get("user_principal_id") + if not user_id: + user_id = f"anonymous_{process_id}" + except Exception as e: + logging.warning(f"Could not extract user from WebSocket headers: {e}") + user_id = f"anonymous_{user_id}" # Add to the connection manager for backend updates - connection_config.add_connection(process_id, websocket) + connection_config.add_connection(user_id, websocket) track_event_if_configured("WebSocketConnectionAccepted", {"process_id": "user_id"}) # Keep the connection open - FastAPI will close the connection if this returns @@ -135,8 +135,8 @@ async def process_outputs(websocket: WebSocket, process_id: str): pass except WebSocketDisconnect: - track_event_if_configured("WebSocketDisconnect", {"process_id": user_id}) - logging.info(f"Client disconnected from batch {user_id}") + track_event_if_configured("WebSocketDisconnect", {"process_id": process_id}) + logging.info(f"Client disconnected from batch {process_id}") await connection_config.close_connection(user_id) except Exception as e: logging.error("Error in WebSocket connection", error=str(e)) @@ -726,10 +726,7 @@ async def get_plans( await connection_config.send_status_update_async("Test message from get_plans", user_id) - # Initialize agent team for this user session - #await OrchestrationManager.get_current_orchestration(user_id=user_id) - - # Replace the following with code to get plan run history from the database + #### Replace the following with code to get plan run history from the database # # Initialize memory context # memory_store = await DatabaseFactory.get_database(user_id=user_id) @@ -786,7 +783,21 @@ async def get_plans( return [] - +@app.get("/api/init_team") +async def init_team( + request: Request, +): + """ Initialize the team of agents """ + authenticated_user = get_authenticated_user_details(request_headers=request.headers) + user_id = authenticated_user["user_principal_id"] + if not user_id: + track_event_if_configured( + "UserIdNotFound", {"status_code": 400, "detail": "no user"} + ) + raise HTTPException(status_code=400, detail="no user") + # Initialize agent team for this user session + await OrchestrationManager.get_current_orchestration(user_id=user_id) + @app.get("/api/steps/{plan_id}", response_model=List[Step]) async def get_steps_by_plan(plan_id: str, request: Request) -> List[Step]: """ diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 5ff3aaca1..2e7d5bb8c 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -86,7 +86,7 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa type: string description: Error message """ - await connection_config.send_status_update_async(message="sending test from the server", process_id='12345') + # if not await rai_success(input_task.description, False): # track_event_if_configured( @@ -133,6 +133,7 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa try: #background_tasks.add_task(OrchestrationManager.run_orchestration, user_id, input_task) + await connection_config.send_status_update_async("Test message from process_request", user_id) return { "status": "Request started successfully", diff --git a/src/backend/v3/models/messages.py b/src/backend/v3/models/messages.py new file mode 100644 index 000000000..e53f56cf3 --- /dev/null +++ b/src/backend/v3/models/messages.py @@ -0,0 +1,65 @@ +"""Messages from the backend to the frontend via WebSocket.""" + +from dataclasses import dataclass + +from models import MPlan, PlanStatus + + +@dataclass(slots=True) +class AgentMessage: + """Message from the backend to the frontend via WebSocket.""" + agent_name: str + timestamp: str + content: str + +@dataclass(slots=True) +class AgentMessageStreaming: + """Streaming message from the backend to the frontend via WebSocket.""" + agent_name: str + content: str + is_final: bool = False + +@dataclass(slots=True) +class PlanApprovalRequest: + """Request for plan approval from the frontend.""" + plan: MPlan + status: PlanStatus + + context: dict | None = None + +@dataclass(slots=True) +class PlanApprovalResponse: + """Response for plan approval from the frontend.""" + approved: bool + feedback: str | None = None + +@dataclass(slots=True) +class ReplanApprovalRequest: + """Request for replan approval from the frontend.""" + reason: str + context: dict | None = None + +@dataclass(slots=True) +class ReplanApprovalResponse: + """Response for replan approval from the frontend.""" + approved: bool + feedback: str | None = None + +@dataclass(slots=True) +class UserClarificationRequest: + """Request for user clarification from the frontend.""" + question: str + context: dict | None = None + +@dataclass(slots=True) +class UserClarificationResponse: + """Response for user clarification from the frontend.""" + def __init__(self, answer: str): + self.answer = answer + +@dataclass(slots=True) +class FinalResultMessage: + """Final result message from the backend to the frontend.""" + result: str + summary: str | None = None + context: dict | None = None diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index 36f5ee544..7f6be5d48 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -37,7 +37,7 @@ class WebSocketService { // Get WebSocket URL from environment or default to localhost const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = process.env.REACT_APP_WS_HOST || '127.0.0.1:8000'; - const processId = '12345'; // Replace with actual process ID as needed' + const processId = crypto.randomUUID(); // Replace with actual process ID as needed' const wsUrl = `${wsProtocol}//${wsHost}/socket/${processId}`; console.log('Connecting to WebSocket:', wsUrl); From 3bc07478a6c1c56a2779f9a1598da470340806ec Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 28 Aug 2025 09:05:06 -0700 Subject: [PATCH 11/41] Adding messages --- src/backend/v3/models/messages.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/backend/v3/models/messages.py b/src/backend/v3/models/messages.py index e53f56cf3..7479830ba 100644 --- a/src/backend/v3/models/messages.py +++ b/src/backend/v3/models/messages.py @@ -12,6 +12,16 @@ class AgentMessage: timestamp: str content: str +@dataclass(slots=True) +class AgentStreamStart: + """Start of a streaming message from the backend to the frontend via WebSocket.""" + agent_name: str + +@dataclass(slots=True) +class AgentStreamEnd: + """End of a streaming message from the backend to the frontend via WebSocket.""" + agent_name: str + @dataclass(slots=True) class AgentMessageStreaming: """Streaming message from the backend to the frontend via WebSocket.""" @@ -19,6 +29,13 @@ class AgentMessageStreaming: content: str is_final: bool = False +@dataclass(slots=True) +class AgentToolMessage: + """Message from an agent using a tool.""" + agent_name: str + tool_name: str + input: str + @dataclass(slots=True) class PlanApprovalRequest: """Request for plan approval from the frontend.""" From 6d8fa874ea1c98565c9ad8ccfa054b2e36feca8d Mon Sep 17 00:00:00 2001 From: blessing-sanusi Date: Thu, 28 Aug 2025 14:38:42 -0500 Subject: [PATCH 12/41] ui websocket wip --- src/backend/app_kernel.py | 1 + src/backend/v3/api/router.py | 2 +- src/frontend/src/App.tsx | 2 +- src/frontend/src/api/apiService.tsx | 8 +- .../src/components/content/HomeInput.tsx | 8 +- src/frontend/src/pages/PlanCreatePage.tsx | 340 ------------------ src/frontend/src/pages/PlanPage.tsx | 85 ++++- src/frontend/src/pages/index.tsx | 1 - src/frontend/src/services/TaskService.tsx | 2 +- .../src/services/WebSocketService.tsx | 73 +++- 10 files changed, 156 insertions(+), 366 deletions(-) delete mode 100644 src/frontend/src/pages/PlanCreatePage.tsx diff --git a/src/backend/app_kernel.py b/src/backend/app_kernel.py index 166ac51aa..f3b38323a 100644 --- a/src/backend/app_kernel.py +++ b/src/backend/app_kernel.py @@ -74,6 +74,7 @@ "http://localhost:3000", # Add this for local development "https://localhost:3000", # Add this if using HTTPS locally "http://127.0.0.1:3000", + "http://127.0.0.1:3001", ], # Allow all origins for development; restrict in production allow_credentials=True, allow_methods=["*"], diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 2e7d5bb8c..298ca5bf1 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -132,7 +132,7 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa input_task.session_id = str(uuid.uuid4()) try: - #background_tasks.add_task(OrchestrationManager.run_orchestration, user_id, input_task) + background_tasks.add_task(OrchestrationManager.run_orchestration, user_id, input_task) await connection_config.send_status_update_async("Test message from process_request", user_id) return { diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index fef933a8f..79d54ede3 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,7 +1,7 @@ import React from 'react'; import './App.css'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import { HomePage, PlanPage, PlanCreatePage } from './pages'; +import { HomePage, PlanPage } from './pages'; import { useWebSocket } from './hooks/useWebSocket'; function App() { diff --git a/src/frontend/src/api/apiService.tsx b/src/frontend/src/api/apiService.tsx index 6f1b79361..0914083cc 100644 --- a/src/frontend/src/api/apiService.tsx +++ b/src/frontend/src/api/apiService.tsx @@ -114,8 +114,12 @@ export class APIService { * @param inputTask The task description and optional session ID * @returns Promise with the response containing plan ID and status */ - async createPlan(inputTask: InputTask): Promise<{ plan_id: string; status: string; session_id: string }> { - return apiClient.post(API_ENDPOINTS.CREATE_PLAN, inputTask); + // async createPlan(inputTask: InputTask): Promise<{ plan_id: string; status: string; session_id: string }> { + // return apiClient.post(API_ENDPOINTS.CREATE_PLAN, inputTask); + // } + + async createPlan(inputTask: InputTask): Promise<{ status: string; session_id: string }> { + return apiClient.post(API_ENDPOINTS.CREATE_PLAN, inputTask); } /** diff --git a/src/frontend/src/components/content/HomeInput.tsx b/src/frontend/src/components/content/HomeInput.tsx index 8ce4742f7..367cfc00f 100644 --- a/src/frontend/src/components/content/HomeInput.tsx +++ b/src/frontend/src/components/content/HomeInput.tsx @@ -17,6 +17,7 @@ import { TeamConfig } from "../../models/Team"; import { TaskService } from "../../services/TaskService"; import { NewTaskService } from "../../services/NewTaskService"; import { RAIErrorCard, RAIErrorData } from "../errors"; +import { apiService } from "../../api/apiService"; import ChatInput from "@/coral/modules/ChatInput"; import InlineToaster, { useInlineToaster } from "../toast/InlineToaster"; @@ -83,13 +84,16 @@ const HomeInput: React.FC = ({ textareaRef.current.style.height = "auto"; } - if (response.plan_id && response.plan_id !== null) { + if (response.session_id && response.session_id !== null) { showToast("Plan created!", "success"); dismissToast(id); // Navigate to create page (no team ID in URL anymore) console.log('HomeInput: Navigating to plan creation with team:', selectedTeam?.name); - navigate(`/plan/${response.plan_id}`); + console.log('HomeInput: Navigating to plan creation with session:', response.session_id); + console.log('HomeInput: Plan created with session:', response.session_id); + + navigate(`/plan/${response.session_id}`); } else { showToast("Failed to create plan", "error"); dismissToast(id); diff --git a/src/frontend/src/pages/PlanCreatePage.tsx b/src/frontend/src/pages/PlanCreatePage.tsx deleted file mode 100644 index d672c2895..000000000 --- a/src/frontend/src/pages/PlanCreatePage.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { useParams, useNavigate } from "react-router-dom"; -import { - Text, - ToggleButton, -} 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 { apiClient } from "../api/apiClient"; -import { TeamConfig } from "../models/Team"; -import { TeamService } from "../services/TeamService"; - -/** - * Page component for creating and viewing a plan being generated - */ -const PlanCreatePage: React.FC = () => { - const { planId, teamId } = useParams<{ planId: string; teamId?: string }>(); - const navigate = useNavigate(); - const { showToast, dismissToast } = useInlineToaster(); - - 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 [planGenerated, setPlanGenerated] = useState(false); - const [selectedTeam, setSelectedTeam] = useState(null); - - const [loadingMessage, setLoadingMessage] = useState(loadingMessages[0]); - - // 🌀 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 team data if teamId is provided - useEffect(() => { - const loadTeamData = async () => { - if (teamId) { - console.log('Loading team data for ID:', teamId); - try { - const team = await TeamService.getTeamById(teamId); - if (team) { - setSelectedTeam(team); - console.log('Team loaded for plan creation:', team.name); - } else { - console.warn('Team not found for ID:', teamId); - } - } catch (error) { - console.error('Error loading team data:', error); - } - } - }; - - loadTeamData(); - }, [teamId]); - - useEffect(() => { - const currentPlan = allPlans.find( - (plan) => plan.plan.id === planId - ); - setPlanData(currentPlan || null); - }, [allPlans, planId]); - - const generatePlan = useCallback(async () => { - if (!planId) return; - - try { - setLoading(true); - setError(null); - - let toastId = showToast("Generating plan steps...", "progress"); - - // Call the generate_plan endpoint using apiClient for proper authentication - const result = await apiClient.post('/generate_plan', { - plan_id: planId - }); - - dismissToast(toastId); - showToast("Plan generated successfully!", "success"); - setPlanGenerated(true); - - // Now load the plan data to display it - await loadPlanData(false); - - } catch (err) { - console.error("Failed to generate plan:", err); - setError( - err instanceof Error ? err : new Error("Failed to generate plan") - ); - setLoading(false); - } - }, [planId, showToast, dismissToast]); - - 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); - - // If plan has steps and we haven't generated yet, mark as generated - if (data.plan.steps && data.plan.steps.length > 0 && !planGenerated) { - setPlanGenerated(true); - } - - } 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, allPlans, planGenerated] - ); - - 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(() => { - const initializePage = async () => { - // Load the basic plan data first - await loadPlanData(true); - }; - - initializePage(); - }, []); - - // Separate effect for plan generation when plan data is loaded - useEffect(() => { - if (planData && (!planData.plan.steps || planData.plan.steps.length === 0) && !planGenerated && !loading) { - generatePlan(); - } - }, [planData, planGenerated, loading]); - - const handleNewTaskButton = () => { - NewTaskService.handleNewTaskFromPlan(navigate); - }; - - if (!planId) { - return ( -
- Error: No plan ID provided -
- ); - } - - return ( - - - setReloadLeftList(false)} - selectedTeam={selectedTeam} - /> - - - {/* 🐙 Only replaces content body, not page shell */} - {loading ? ( - <> - - - ) : ( - <> - - - } - /> - - - - {/* Show RAI error if present */} - {raiError && ( -
- { - setRAIError(null); - }} - onDismiss={() => setRAIError(null)} - /> -
- )} - - - - )} -
- - -
-
- ); -}; - -export default PlanCreatePage; diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index d4d0ea577..574c87147 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -8,6 +8,7 @@ import { ToastBody, useToastController, } from "@fluentui/react-components"; +import { apiService } from "../api/apiService"; import "../styles/PlanPage.css"; import CoralShellColumn from "../coral/components/Layout/CoralShellColumn"; import CoralShellRow from "../coral/components/Layout/CoralShellRow"; @@ -31,6 +32,7 @@ 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} @@ -122,8 +124,9 @@ const PlanPage: React.FC = () => { }, [planId, showToast]); // Subscribe to plan updates when planId changes - useEffect(() => { - if (planId && wsConnected) { + useEffect(() => { + if (planId && wsConnected && !planId.startsWith('sid_')) { + // Only subscribe if we have a real plan_id (not session_id) console.log('Subscribing to plan updates for:', planId); webSocketService.subscribeToPlan(planId); @@ -196,15 +199,17 @@ const PlanPage: React.FC = () => { setError(null); const data = await PlanDataService.fetchPlanData(planId,navigate); - let plans = [...allPlans]; + + setAllPlans(currentPlans => { + const plans = [...currentPlans]; 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); + return plans; + }); } catch (err) { console.log("Failed to load plan data:", err); setError( @@ -303,8 +308,74 @@ const PlanPage: React.FC = () => { useEffect(() => { - loadPlanData(true); - }, [loadPlanData]); + + const initializePlanLoading = async () => { + if (!planId) return; + + // Check if this looks like a session_id (starts with "sid_") + if (planId.startsWith('sid_')) { + console.log('Detected session_id, resolving to plan_id:', planId); + + try { + // Try to find the plan by session_id + const plans = await apiService.getPlans(); + const matchingPlan = plans.find(plan => plan.session_id === planId); + + if (matchingPlan) { + // Found the plan! Replace URL with correct plan_id + console.log('Resolved session_id to plan_id:', matchingPlan.id); + navigate(`/plan/${matchingPlan.id}`, { replace: true }); + return; // Navigation will trigger reload with correct ID + } else { + // Plan not created yet, start polling + console.log('Plan not found yet, starting polling for session:', planId); + let attempts = 0; + const maxAttempts = 20; // Poll for up to 20 seconds + + const pollForPlan = async () => { + attempts++; + if (attempts > maxAttempts) { + console.error('Plan creation timed out after polling'); + setError(new Error('Plan creation is taking longer than expected. Please check your task list or try creating a new plan.')); + setLoading(false); + return; + } + + try { + const plans = await apiService.getPlans(); + const plan = plans.find(p => p.session_id === planId); + + if (plan) { + console.log(`Found plan after ${attempts} attempts:`, plan.id); + navigate(`/plan/${plan.id}`, { replace: true }); + } else { + // Wait and try again + setTimeout(pollForPlan, 1000); // Poll every second + } + } catch (error) { + console.error('Polling error:', error); + if (attempts < maxAttempts) { + setTimeout(pollForPlan, 2000); // Wait longer on error + } + } + }; + + pollForPlan(); + } + } catch (error) { + console.error('Session resolution error:', error); + setError(error instanceof Error ? error : new Error('Failed to resolve plan from session')); + setLoading(false); + } + } else { + + console.log('Using plan_id directly:', planId); + loadPlanData(true); + } + }; + + initializePlanLoading (); +}, [planId, navigate, loadPlanData]); const handleNewTaskButton = () => { NewTaskService.handleNewTaskFromPlan(navigate); diff --git a/src/frontend/src/pages/index.tsx b/src/frontend/src/pages/index.tsx index 3c3853af6..2d4732818 100644 --- a/src/frontend/src/pages/index.tsx +++ b/src/frontend/src/pages/index.tsx @@ -1,3 +1,2 @@ export { default as HomePage } from './HomePage'; export { default as PlanPage } from './PlanPage'; -export { default as PlanCreatePage } from './PlanCreatePage'; \ No newline at end of file diff --git a/src/frontend/src/services/TaskService.tsx b/src/frontend/src/services/TaskService.tsx index 5cdb04249..7e164e0fd 100644 --- a/src/frontend/src/services/TaskService.tsx +++ b/src/frontend/src/services/TaskService.tsx @@ -206,7 +206,7 @@ export class TaskService { static async createPlan( description: string, teamId?: string - ): Promise<{ plan_id: string; status: string; session_id: string }> { + ): Promise<{ status: string; session_id: string }> { const sessionId = this.generateSessionId(); const inputTask: InputTask = { diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index 7f6be5d48..c455ba9a7 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -1,9 +1,5 @@ -/** - * WebSocket Service for real-time plan execution streaming - */ - export interface StreamMessage { - type: 'plan_update' | 'step_update' | 'agent_message' | 'error' | 'connection_status'; + type: 'plan_update' | 'step_update' | 'agent_message' | 'error' | 'connection_status' | 'plan_approval_request' | 'final_result'; plan_id?: string; session_id?: string; data?: any; @@ -16,8 +12,32 @@ export interface StreamingPlanUpdate { step_id?: string; agent_name?: string; content?: string; - status?: 'in_progress' | 'completed' | 'error'; - message_type?: 'thinking' | 'action' | 'result' | 'clarification_needed'; + status?: 'in_progress' | 'completed' | 'error' | 'creating_plan' | 'pending_approval'; + message_type?: 'thinking' | 'action' | 'result' | 'clarification_needed' | 'plan_approval_request'; +} + +// Add these new interfaces after StreamingPlanUpdate +export interface PlanApprovalRequestData { + plan_id: string; + session_id: string; + plan: { + steps: Array<{ + id: string; + description: string; + agent: string; + estimated_duration?: string; + }>; + total_steps: number; + estimated_completion?: string; + }; + status: 'PENDING_APPROVAL'; +} + +export interface PlanApprovalResponseData { + plan_id: string; + session_id: string; + approved: boolean; + feedback?: string; } class WebSocketService { @@ -37,7 +57,7 @@ class WebSocketService { // Get WebSocket URL from environment or default to localhost const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = process.env.REACT_APP_WS_HOST || '127.0.0.1:8000'; - const processId = crypto.randomUUID(); // Replace with actual process ID as needed' + const processId = crypto.randomUUID(); // Generate unique process ID for this session const wsUrl = `${wsProtocol}//${wsHost}/socket/${processId}`; console.log('Connecting to WebSocket:', wsUrl); @@ -53,11 +73,11 @@ class WebSocketService { this.ws.onmessage = (event) => { try { - //const message: StreamMessage = JSON.parse(event.data); - const message: StreamMessage = event.data; + const message: StreamMessage = JSON.parse(event.data); this.handleMessage(message); } catch (error) { - console.error('Error parsing WebSocket message:', error); + console.error('Error parsing WebSocket message:', error, 'Raw data:', event.data); + this.emit('error', { error: 'Failed to parse WebSocket message' }); } }; @@ -182,6 +202,9 @@ class WebSocketService { /** * Handle incoming WebSocket messages */ + /** + * Handle incoming WebSocket messages + */ private handleMessage(message: StreamMessage): void { console.log('WebSocket message received:', message); @@ -190,6 +213,11 @@ class WebSocketService { this.emit(message.type, message.data); } + // Handle plan approval requests specifically + if (message.type === 'plan_approval_request') { + console.log('Plan approval request received via WebSocket:', message.data); + } + // Emit to general message listeners this.emit('message', message); } @@ -240,6 +268,29 @@ class WebSocketService { console.warn('WebSocket is not connected. Cannot send message:', message); } } + + /** + * Send plan approval response + */ + sendPlanApprovalResponse(response: PlanApprovalResponseData): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.error('WebSocket not connected - cannot send plan approval response'); + this.emit('error', { error: 'Cannot send plan approval response - WebSocket not connected' }); + return; + } + + try { + const message = { + type: 'plan_approval_response', + data: response + }; + this.ws.send(JSON.stringify(message)); + console.log('Plan approval response sent:', response); + } catch (error) { + console.error('Failed to send plan approval response:', error); + this.emit('error', { error: 'Failed to send plan approval response' }); + } +} } // Export singleton instance From 7c4a25abe0aad0324d287d352fe375019796cfe8 Mon Sep 17 00:00:00 2001 From: blessing-sanusi Date: Thu, 28 Aug 2025 16:27:47 -0500 Subject: [PATCH 13/41] teams init --- src/frontend/src/pages/HomePage.tsx | 147 ++++++++++++++++++++++------ src/frontend/src/pages/PlanPage.tsx | 54 +++++----- 2 files changed, 144 insertions(+), 57 deletions(-) diff --git a/src/frontend/src/pages/HomePage.tsx b/src/frontend/src/pages/HomePage.tsx index 45fdef560..d2e30d789 100644 --- a/src/frontend/src/pages/HomePage.tsx +++ b/src/frontend/src/pages/HomePage.tsx @@ -21,6 +21,7 @@ import { TaskService } from '../services/TaskService'; import { TeamConfig } from '../models/Team'; import { TeamService } from '../services/TeamService'; import InlineToaster, { useInlineToaster } from "../components/toast/InlineToaster"; +import { initializeTeam } from '@/api/config'; /** * HomePage component - displays task lists and provides navigation @@ -35,43 +36,129 @@ const HomePage: React.FC = () => { /** * Load teams and set 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); + // useEffect(() => { + // const loadDefaultTeam = async () => { + // let defaultTeam = TeamService.getStoredTeam(); + // if (defaultTeam) { + // setSelectedTeam(defaultTeam); + // console.log('Default team loaded from storage:', defaultTeam.name); + + // setIsLoadingTeam(false); + // return true; + // } + // setIsLoadingTeam(true); + // try { + // const teams = await TeamService.getUserTeams(); + // console.log('All teams loaded:', teams); + // if (teams.length > 0) { + // // Always prioritize "Business Operations Team" as default + // const hrTeam = teams.find(team => team.name === "Human Resources Team"); + // defaultTeam = hrTeam || teams[0]; + + // TeamService.storageTeam(defaultTeam); + // setSelectedTeam(defaultTeam); + // console.log('Default team loaded:', defaultTeam.name, 'with', defaultTeam.starting_tasks?.length || 0, 'starting tasks'); + // console.log('Team logo:', defaultTeam.logo); + // console.log('Team description:', defaultTeam.description); + + // } else { + // console.log('No teams found - user needs to upload a team configuration'); + // // Even if no teams are found, we clear the loading state to show the "no team" message + // } + // } catch (error) { + // console.error('Error loading default team:', error); + // } finally { + // setIsLoadingTeam(false); + // } + // }; + + // loadDefaultTeam(); + // }, []); - setIsLoadingTeam(false); - return true; +useEffect(() => { + const initTeam = async () => { + setIsLoadingTeam(true); + + try { + console.log('Initializing team from backend...'); + + // Call the backend init_team endpoint (takes ~20 seconds) + const initResponse = await initializeTeam(); + + if (initResponse.status === 'Request started successfully' && initResponse.team_id) { + console.log('Team initialization completed:', initResponse.team_id); + + // Now fetch the actual team details using the team_id + const teams = await TeamService.getUserTeams(); + const initializedTeam = teams.find(team => team.team_id === initResponse.team_id); + + if (initializedTeam) { + setSelectedTeam(initializedTeam); + TeamService.storageTeam(initializedTeam); + + console.log('Team loaded successfully:', initializedTeam.name); + console.log('Team agents:', initializedTeam.agents?.length || 0); + + showToast( + `${initializedTeam.name} team initialized successfully with ${initializedTeam.agents?.length || 0} agents`, + "success" + ); + } else { + // Fallback: if we can't find the specific team, use HR team or first available + console.log('Specific team not found, using default selection logic'); + const hrTeam = teams.find(team => team.name === "Human Resources Team"); + const defaultTeam = hrTeam || teams[0]; + + if (defaultTeam) { + setSelectedTeam(defaultTeam); + TeamService.storageTeam(defaultTeam); + showToast( + `${defaultTeam.name} team loaded as default`, + "success" + ); + } + } + + } else { + throw new Error('Invalid response from init_team endpoint'); } - setIsLoadingTeam(true); + + } catch (error) { + console.error('Error initializing team from backend:', error); + showToast("Team initialization failed, using fallback", "warning"); + + // Fallback to the old client-side method try { + console.log('Using fallback: client-side team loading...'); 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); + const hrTeam = teams.find(team => team.name === "Human Resources Team"); + const defaultTeam = hrTeam || teams[0]; setSelectedTeam(defaultTeam); - console.log('Default team loaded:', defaultTeam.name, 'with', defaultTeam.starting_tasks?.length || 0, 'starting tasks'); - console.log('Team logo:', defaultTeam.logo); - console.log('Team description:', defaultTeam.description); - console.log('Is Business Operations Team:', defaultTeam.name === "Business Operations Team"); + TeamService.storageTeam(defaultTeam); + + showToast( + `${defaultTeam.name} team loaded (fallback mode)`, + "info" + ); } else { console.log('No teams found - user needs to upload a team configuration'); - // Even if no teams are found, we clear the loading state to show the "no team" message + showToast( + "No teams found. Please upload a team configuration.", + "warning" + ); } - } catch (error) { - console.error('Error loading default team:', error); - } finally { - setIsLoadingTeam(false); + } catch (fallbackError) { + console.error('Fallback team loading also failed:', fallbackError); + showToast("Failed to load team configuration", "error"); } - }; + } finally { + setIsLoadingTeam(false); + } + }; - loadDefaultTeam(); - }, []); + initTeam(); +}, [showToast]); /** * Handle new task creation from the "New task" button @@ -109,12 +196,12 @@ const HomePage: React.FC = () => { 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]; + // Always keep "Human Resources Team" as default, even after new uploads + const hrTeam = teams.find(team => team.name === "Human Resources Team"); + const defaultTeam = hrTeam || teams[0]; setSelectedTeam(defaultTeam); console.log('Default team after upload:', defaultTeam.name); - console.log('Business Operations Team remains default'); + console.log('Human Resources Team remains default'); showToast( `Team uploaded successfully! ${defaultTeam.name} remains your default team.`, "success" diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index 574c87147..f48cd6d79 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -162,8 +162,8 @@ const PlanPage: React.FC = () => { 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]; + const hrTeam = teams.find(team => team.name === "Human Resources Team"); + defaultTeam = hrTeam || teams[0]; TeamService.storageTeam(defaultTeam); setSelectedTeam(defaultTeam); console.log('Default team loaded:', defaultTeam.name); @@ -412,32 +412,32 @@ const PlanPage: React.FC = () => { /** * 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); + const handleTeamUpload = useCallback(async () => { + try { + const teams = await TeamService.getUserTeams(); + console.log('Teams refreshed after upload:', teams.length); + + if (teams.length > 0) { + // Always keep "Human Resources Team" as default, even after new uploads + const hrTeam = teams.find(team => team.name === "Human Resources Team"); + const defaultTeam = hrTeam || 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" } + ); } - }, [dispatchToast]); + } catch (error) { + console.error('Error refreshing teams after upload:', error); + } +}, [dispatchToast]); if (!planId) { return ( From 330c56c14d36d397b6e5080a36bde26255fd60b1 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 28 Aug 2025 16:19:51 -0700 Subject: [PATCH 14/41] team init changes --- src/backend/app_kernel.py | 304 +++++++----------- .../common/database/database_factory.py | 3 +- src/backend/common/models/messages_kernel.py | 3 + src/backend/v3/api/router.py | 172 ++++++---- .../v3/common/services/team_service.py | 24 +- src/backend/v3/config/settings.py | 18 ++ .../magentic_agents/magentic_agent_factory.py | 36 +-- src/backend/v3/models/messages.py | 32 +- .../v3/orchestration/orchestration_manager.py | 7 +- .../src/services/WebSocketService.tsx | 2 +- 10 files changed, 316 insertions(+), 285 deletions(-) diff --git a/src/backend/app_kernel.py b/src/backend/app_kernel.py index 166ac51aa..ab34fb062 100644 --- a/src/backend/app_kernel.py +++ b/src/backend/app_kernel.py @@ -86,63 +86,6 @@ app.include_router(app_v3) logging.info("Added health check middleware") - -# WebSocket streaming endpoint -# @app.websocket("/ws/streaming") -# async def websocket_endpoint(websocket: WebSocket): -# """WebSocket endpoint for real-time plan execution streaming""" -# await websocket_streaming_endpoint(websocket) - -# @app.websocket("/socket/{process_id}") -# async def process_outputs(websocket: WebSocket, process_id: str): -# """ Web-Socket endpoint for real-time process status updates. """ - -# # Always accept the WebSocket connection first -# await websocket.accept() -# connection_config.add_connection(process_id=process_id, connection=websocket) - -@app.websocket("/socket/{process_id}") -async def start_comms(websocket: WebSocket, process_id: str): - """ Web-Socket endpoint for real-time process status updates. """ - - # Always accept the WebSocket connection first - await websocket.accept() - - user_id = None - try: - # WebSocket headers are different, try to get user info - headers = dict(websocket.headers) - authenticated_user = get_authenticated_user_details(request_headers=headers) - user_id = authenticated_user.get("user_principal_id") - if not user_id: - user_id = f"anonymous_{process_id}" - except Exception as e: - logging.warning(f"Could not extract user from WebSocket headers: {e}") - user_id = f"anonymous_{user_id}" - - # Add to the connection manager for backend updates - - connection_config.add_connection(user_id, websocket) - track_event_if_configured("WebSocketConnectionAccepted", {"process_id": "user_id"}) - - # Keep the connection open - FastAPI will close the connection if this returns - while True: - # no expectation that we will receive anything from the client but this keeps - # the connection open and does not take cpu cycle - try: - await websocket.receive_text() - except asyncio.TimeoutError: - pass - - except WebSocketDisconnect: - track_event_if_configured("WebSocketDisconnect", {"process_id": process_id}) - logging.info(f"Client disconnected from batch {process_id}") - await connection_config.close_connection(user_id) - except Exception as e: - logging.error("Error in WebSocket connection", error=str(e)) - await connection_config.close_connection(user_id) - - @app.post("/api/user_browser_language") async def user_browser_language_endpoint(user_language: UserLanguage, request: Request): """ @@ -175,128 +118,128 @@ async def user_browser_language_endpoint(user_language: UserLanguage, request: R return {"status": "Language received successfully"} -@app.post("/api/input_task") -async def input_task_endpoint(input_task: InputTask, request: Request): - """ - Receive the initial input task from the user. - """ - # Fix 1: Properly await the async rai_success function - if not await rai_success(input_task.description, True): - print("RAI failed") - - track_event_if_configured( - "RAI failed", - { - "status": "Plan not created - RAI validation failed", - "description": input_task.description, - "session_id": input_task.session_id, - }, - ) - - return { - "status": "RAI_VALIDATION_FAILED", - "message": "Content Safety Check Failed", - "detail": "Your request contains content that doesn't meet our safety guidelines. Please modify your request to ensure it's appropriate and try again.", - "suggestions": [ - "Remove any potentially harmful, inappropriate, or unsafe content", - "Use more professional and constructive language", - "Focus on legitimate business or educational objectives", - "Ensure your request complies with content policies", - ], - } - authenticated_user = get_authenticated_user_details(request_headers=request.headers) - user_id = authenticated_user["user_principal_id"] - - if not user_id: - track_event_if_configured( - "UserIdNotFound", {"status_code": 400, "detail": "no user"} - ) - raise HTTPException(status_code=400, detail="no user") - - # Generate session ID if not provided - if not input_task.session_id: - input_task.session_id = str(uuid.uuid4()) - - try: - # Create all agents instead of just the planner agent - # This ensures other agents are created first and the planner has access to them - memory_store = await DatabaseFactory.get_database(user_id=user_id) - client = None - try: - client = config.get_ai_project_client() - except Exception as client_exc: - logging.error(f"Error creating AIProjectClient: {client_exc}") - - agents = await AgentFactory.create_all_agents( - session_id=input_task.session_id, - user_id=user_id, - memory_store=memory_store, - client=client, - ) - - group_chat_manager = agents[AgentType.GROUP_CHAT_MANAGER.value] +# @app.post("/api/input_task") +# async def input_task_endpoint(input_task: InputTask, request: Request): +# """ +# Receive the initial input task from the user. +# """ +# # Fix 1: Properly await the async rai_success function +# if not await rai_success(input_task.description, True): +# print("RAI failed") + +# track_event_if_configured( +# "RAI failed", +# { +# "status": "Plan not created - RAI validation failed", +# "description": input_task.description, +# "session_id": input_task.session_id, +# }, +# ) - # Convert input task to JSON for the kernel function, add user_id here +# return { +# "status": "RAI_VALIDATION_FAILED", +# "message": "Content Safety Check Failed", +# "detail": "Your request contains content that doesn't meet our safety guidelines. Please modify your request to ensure it's appropriate and try again.", +# "suggestions": [ +# "Remove any potentially harmful, inappropriate, or unsafe content", +# "Use more professional and constructive language", +# "Focus on legitimate business or educational objectives", +# "Ensure your request complies with content policies", +# ], +# } +# authenticated_user = get_authenticated_user_details(request_headers=request.headers) +# user_id = authenticated_user["user_principal_id"] - # Use the planner to handle the task - await group_chat_manager.handle_input_task(input_task) +# if not user_id: +# track_event_if_configured( +# "UserIdNotFound", {"status_code": 400, "detail": "no user"} +# ) +# raise HTTPException(status_code=400, detail="no user") - # Get plan from memory store - plan = await memory_store.get_plan_by_session(input_task.session_id) +# # Generate session ID if not provided +# if not input_task.session_id: +# input_task.session_id = str(uuid.uuid4()) - if not plan: # If the plan is not found, raise an error - track_event_if_configured( - "PlanNotFound", - { - "status": "Plan not found", - "session_id": input_task.session_id, - "description": input_task.description, - }, - ) - raise HTTPException(status_code=404, detail="Plan not found") - # Log custom event for successful input task processing - track_event_if_configured( - "InputTaskProcessed", - { - "status": f"Plan created with ID: {plan.id}", - "session_id": input_task.session_id, - "plan_id": plan.id, - "description": input_task.description, - }, - ) - if client: - try: - client.close() - except Exception as e: - logging.error(f"Error sending to AIProjectClient: {e}") - return { - "status": f"Plan created with ID: {plan.id}", - "session_id": input_task.session_id, - "plan_id": plan.id, - "description": input_task.description, - } +# try: +# # Create all agents instead of just the planner agent +# # This ensures other agents are created first and the planner has access to them +# memory_store = await DatabaseFactory.get_database(user_id=user_id) +# client = None +# try: +# client = config.get_ai_project_client() +# except Exception as client_exc: +# logging.error(f"Error creating AIProjectClient: {client_exc}") + +# agents = await AgentFactory.create_all_agents( +# session_id=input_task.session_id, +# user_id=user_id, +# memory_store=memory_store, +# client=client, +# ) - except Exception as e: - # Extract clean error message for rate limit errors - error_msg = str(e) - if "Rate limit is exceeded" in error_msg: - match = re.search( - r"Rate limit is exceeded\. Try again in (\d+) seconds?\.", error_msg - ) - if match: - error_msg = "Application temporarily unavailable due to quota limits. Please try again later." +# group_chat_manager = agents[AgentType.GROUP_CHAT_MANAGER.value] + +# # Convert input task to JSON for the kernel function, add user_id here + +# # Use the planner to handle the task +# await group_chat_manager.handle_input_task(input_task) + +# # Get plan from memory store +# plan = await memory_store.get_plan_by_session(input_task.session_id) + +# if not plan: # If the plan is not found, raise an error +# track_event_if_configured( +# "PlanNotFound", +# { +# "status": "Plan not found", +# "session_id": input_task.session_id, +# "description": input_task.description, +# }, +# ) +# raise HTTPException(status_code=404, detail="Plan not found") +# # Log custom event for successful input task processing +# track_event_if_configured( +# "InputTaskProcessed", +# { +# "status": f"Plan created with ID: {plan.id}", +# "session_id": input_task.session_id, +# "plan_id": plan.id, +# "description": input_task.description, +# }, +# ) +# if client: +# try: +# client.close() +# except Exception as e: +# logging.error(f"Error sending to AIProjectClient: {e}") +# return { +# "status": f"Plan created with ID: {plan.id}", +# "session_id": input_task.session_id, +# "plan_id": plan.id, +# "description": input_task.description, +# } - track_event_if_configured( - "InputTaskError", - { - "session_id": input_task.session_id, - "description": input_task.description, - "error": str(e), - }, - ) - raise HTTPException( - status_code=400, detail=f"Error creating plan: {error_msg}" - ) from e +# except Exception as e: +# # Extract clean error message for rate limit errors +# error_msg = str(e) +# if "Rate limit is exceeded" in error_msg: +# match = re.search( +# r"Rate limit is exceeded\. Try again in (\d+) seconds?\.", error_msg +# ) +# if match: +# error_msg = "Application temporarily unavailable due to quota limits. Please try again later." + +# track_event_if_configured( +# "InputTaskError", +# { +# "session_id": input_task.session_id, +# "description": input_task.description, +# "error": str(e), +# }, +# ) +# raise HTTPException( +# status_code=400, detail=f"Error creating plan: {error_msg}" +# ) from e @app.post("/api/human_feedback") @@ -782,21 +725,6 @@ async def get_plans( # list_of_plans_with_steps.append(plan_with_steps) return [] - -@app.get("/api/init_team") -async def init_team( - request: Request, -): - """ Initialize the team of agents """ - authenticated_user = get_authenticated_user_details(request_headers=request.headers) - user_id = authenticated_user["user_principal_id"] - if not user_id: - track_event_if_configured( - "UserIdNotFound", {"status_code": 400, "detail": "no user"} - ) - raise HTTPException(status_code=400, detail="no user") - # Initialize agent team for this user session - await OrchestrationManager.get_current_orchestration(user_id=user_id) @app.get("/api/steps/{plan_id}", response_model=List[Step]) async def get_steps_by_plan(plan_id: str, request: Request) -> List[Step]: diff --git a/src/backend/common/database/database_factory.py b/src/backend/common/database/database_factory.py index cc8586598..8c2f9fb0e 100644 --- a/src/backend/common/database/database_factory.py +++ b/src/backend/common/database/database_factory.py @@ -3,9 +3,10 @@ import logging from typing import Optional +from common.config.app_config import config + from .cosmosdb import CosmosDBClient from .database_base import DatabaseBase -from common.config.app_config import config class DatabaseFactory: diff --git a/src/backend/common/models/messages_kernel.py b/src/backend/common/models/messages_kernel.py index e0ec3f87a..8a2926ecd 100644 --- a/src/backend/common/models/messages_kernel.py +++ b/src/backend/common/models/messages_kernel.py @@ -156,12 +156,15 @@ class TeamAgent(KernelBaseModel): input_key: str type: str name: str + deployment_name: str system_message: str = "" description: str = "" icon: str index_name: str = "" use_rag: bool = False use_mcp: bool = False + use_bing: bool = False + use_reasoning: bool = False coding_tools: bool = False diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 2e7d5bb8c..38221da45 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -4,6 +4,7 @@ import uuid from typing import Optional +import v3.models.messages as messages from auth.auth_utils import get_authenticated_user_details from common.config.app_config import config from common.database.database_factory import DatabaseFactory @@ -18,7 +19,7 @@ from pydantic import BaseModel from semantic_kernel.agents.runtime import InProcessRuntime from v3.common.services.team_service import TeamService -from v3.config.settings import connection_config +from v3.config.settings import connection_config, team_config from v3.orchestration.orchestration_manager import OrchestrationManager router = APIRouter() @@ -35,6 +36,100 @@ class TeamSelectionRequest(BaseModel): responses={404: {"description": "Not found"}}, ) +@app_v3.websocket("/socket/{process_id}") +async def start_comms(websocket: WebSocket, process_id: str): + """ Web-Socket endpoint for real-time process status updates. """ + + # Always accept the WebSocket connection first + await websocket.accept() + + user_id = None + try: + # WebSocket headers are different, try to get user info + headers = dict(websocket.headers) + authenticated_user = get_authenticated_user_details(request_headers=headers) + user_id = authenticated_user.get("user_principal_id") + if not user_id: + user_id = f"anonymous_{process_id}" + except Exception as e: + logging.warning(f"Could not extract user from WebSocket headers: {e}") + user_id = f"anonymous_{user_id}" + + # Add to the connection manager for backend updates + + connection_config.add_connection(user_id, websocket) + track_event_if_configured("WebSocketConnectionAccepted", {"process_id": "user_id"}) + + # Keep the connection open - FastAPI will close the connection if this returns + while True: + # no expectation that we will receive anything from the client but this keeps + # the connection open and does not take cpu cycle + try: + await websocket.receive_text() + except asyncio.TimeoutError: + pass + + except WebSocketDisconnect: + track_event_if_configured("WebSocketDisconnect", {"process_id": process_id}) + logging.info(f"Client disconnected from batch {process_id}") + await connection_config.close_connection(user_id) + except Exception as e: + logging.error("Error in WebSocket connection", error=str(e)) + await connection_config.close_connection(user_id) + +@app_v3.get("/init_team") +async def init_team( + request: Request, +): + """ Initialize the user's current team of agents """ + + # Need to store this user state in cosmos db, retrieve it here, and initialize the team + # current in-memory store is in team_config from settings.py + # For now I will set the initial install team ids as 00000000-0000-0000-0000-000000000001 (HR), + # 00000000-0000-0000-0000-000000000002 (Marketing), and 00000000-0000-0000-0000-000000000003 (Retail), + # and use this value to initialize to HR each time. + init_team_id = "00000000-0000-0000-0000-000000000001" + + try: + authenticated_user = get_authenticated_user_details(request_headers=request.headers) + user_id = authenticated_user["user_principal_id"] + if not user_id: + track_event_if_configured( + "UserIdNotFound", {"status_code": 400, "detail": "no user"} + ) + raise HTTPException(status_code=400, detail="no user") + + # Initialize memory store and service + memory_store = await DatabaseFactory.get_database(user_id=user_id) + team_service = TeamService(memory_store) + + # Verify the team exists and user has access to it + team_configuration = await team_service.get_team_configuration(init_team_id, user_id) + if team_configuration is None: + raise HTTPException( + status_code=404, + detail=f"Team configuration '{init_team_id}' not found or access denied" + ) + + # Set as current team in memory + team_config.set_current_team(user_id=user_id, team_config=team_configuration) + + # Initialize agent team for this user session + await OrchestrationManager.get_current_or_new_orchestration(user_id=user_id, team_config=team_configuration) + + return { + "status": "Request started successfully", + "team_id": init_team_id + } + + except Exception as e: + track_event_if_configured( + "InitTeamFailed", + { + "error": str(e), + }, + ) + raise HTTPException(status_code=400, detail=f"Error starting request: {e}") from e @app_v3.post("/create_plan") async def process_request(background_tasks: BackgroundTasks, input_task: InputTask, request: Request): @@ -132,8 +227,8 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa input_task.session_id = str(uuid.uuid4()) try: - #background_tasks.add_task(OrchestrationManager.run_orchestration, user_id, input_task) - await connection_config.send_status_update_async("Test message from process_request", user_id) + background_tasks.add_task(OrchestrationManager.run_orchestration, user_id, input_task) + #await connection_config.send_status_update_async("Test message from process_request", user_id) return { "status": "Request started successfully", @@ -151,6 +246,10 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa ) raise HTTPException(status_code=400, detail=f"Error starting request: {e}") from e +@app_v3.post("/api/human_feedback") +async def human_feedback_endpoint(human_feedback: messages.HumanFeedback, request: Request): + pass + @app_v3.post("/upload_team_config") async def upload_team_config_endpoint(request: Request, file: UploadFile = File(...)): @@ -522,6 +621,9 @@ async def delete_team_config_endpoint(team_id: str, request: Request): ) try: + # To do: Check if the team is the users current team, or if it is + # used in any active sessions/plans. Refuse request if so. + # Initialize memory store and service memory_store = await DatabaseFactory.get_database(user_id=user_id) team_service = TeamService(memory_store) @@ -587,53 +689,7 @@ async def get_model_deployments_endpoint(request: Request): @app_v3.post("/select_team") async def select_team_endpoint(selection: TeamSelectionRequest, request: Request): """ - Update team selection for a plan or session. - - Used when users change teams on the plan page. - - --- - tags: - - Team Selection - parameters: - - name: user_principal_id - in: header - type: string - required: true - description: User ID extracted from the authentication header - - name: body - in: body - required: true - schema: - type: object - properties: - team_id: - type: string - description: The ID of the team to select - session_id: - type: string - description: Optional session ID to associate with the team selection - responses: - 200: - description: Team selection updated successfully - schema: - type: object - properties: - status: - type: string - message: - type: string - team_id: - type: string - team_name: - type: string - session_id: - type: string - 400: - description: Invalid request - 401: - description: Missing or invalid user information - 404: - description: Team configuration not found + Select the current team for the user session. """ # Validate user authentication authenticated_user = get_authenticated_user_details(request_headers=request.headers) @@ -652,7 +708,7 @@ async def select_team_endpoint(selection: TeamSelectionRequest, request: Request team_service = TeamService(memory_store) # Verify the team exists and user has access to it - team_config = await team_service.get_team_configuration(selection.team_id, user_id) + team_configuration = await team_service.get_team_configuration(selection.team_id, user_id) if team_config is None: raise HTTPException( status_code=404, @@ -662,8 +718,8 @@ async def select_team_endpoint(selection: TeamSelectionRequest, request: Request # Generate session ID if not provided session_id = selection.session_id or str(uuid.uuid4()) - # Here you could store the team selection in user preferences, session data, etc. - # For now, we'll just validate and return the selection + # save to in-memory config for current user + team_config.set_current_team(user_id=user_id, team_config=team_configuration) # Track the team selection event track_event_if_configured( @@ -671,7 +727,7 @@ async def select_team_endpoint(selection: TeamSelectionRequest, request: Request { "status": "success", "team_id": selection.team_id, - "team_name": team_config.name, + "team_name": team_configuration.name, "user_id": user_id, "session_id": session_id, }, @@ -679,12 +735,12 @@ async def select_team_endpoint(selection: TeamSelectionRequest, request: Request return { "status": "success", - "message": f"Team '{team_config.name}' selected successfully", + "message": f"Team '{team_configuration.name}' selected successfully", "team_id": selection.team_id, - "team_name": team_config.name, + "team_name": team_configuration.name, "session_id": session_id, - "agents_count": len(team_config.agents), - "team_description": team_config.description, + "agents_count": len(team_configuration.agents), + "team_description": team_configuration.description, } except HTTPException: diff --git a/src/backend/v3/common/services/team_service.py b/src/backend/v3/common/services/team_service.py index d163634be..7c5bde2f6 100644 --- a/src/backend/v3/common/services/team_service.py +++ b/src/backend/v3/common/services/team_service.py @@ -6,23 +6,14 @@ from typing import Any, Dict, List, Optional, Tuple from azure.core.credentials import AzureKeyCredential -from azure.core.exceptions import ( - ClientAuthenticationError, - HttpResponseError, - ResourceNotFoundError, -) +from azure.core.exceptions import (ClientAuthenticationError, + HttpResponseError, ResourceNotFoundError) from azure.identity import DefaultAzureCredential from azure.search.documents.indexes import SearchIndexClient - -from common.models.messages_kernel import ( - TeamConfiguration, - TeamAgent, - StartingTask, -) - - from common.config.app_config import config from common.database.database_base import DatabaseBase +from common.models.messages_kernel import (StartingTask, TeamAgent, + TeamConfiguration) from v3.common.services.foundry_service import FoundryService @@ -141,12 +132,15 @@ def _validate_and_parse_agent(self, agent_data: Dict[str, Any]) -> TeamAgent: input_key=agent_data["input_key"], type=agent_data["type"], name=agent_data["name"], + deployment_name=agent_data.get("deployment_name", ""), + icon=agent_data["icon"], system_message=agent_data.get("system_message", ""), description=agent_data.get("description", ""), - icon=agent_data["icon"], - index_name=agent_data.get("index_name", ""), use_rag=agent_data.get("use_rag", False), use_mcp=agent_data.get("use_mcp", False), + use_bing=agent_data.get("use_bing", False), + use_reasoning=agent_data.get("use_reasoning", False), + index_name=agent_data.get("index_name", ""), coding_tools=agent_data.get("coding_tools", False), ) diff --git a/src/backend/v3/config/settings.py b/src/backend/v3/config/settings.py index c8d4371bc..aebd621d5 100644 --- a/src/backend/v3/config/settings.py +++ b/src/backend/v3/config/settings.py @@ -9,6 +9,7 @@ from typing import Dict from common.config.app_config import config +from common.models.messages_kernel import TeamConfiguration from fastapi import WebSocket from semantic_kernel.agents.orchestration.magentic import MagenticOrchestration from semantic_kernel.connectors.ai.open_ai import ( @@ -131,9 +132,26 @@ def send_status_update(self, message: str, process_id: str): else: logger.warning("No connection found for batch ID: %s", process_id) +class TeamConfig: + """Team configuration for agents.""" + + def __init__(self): + self.teams: Dict[str, TeamConfiguration] = {} + + def set_current_team(self, user_id: str, team_config: TeamConfiguration): + """Add a new team configuration.""" + + # To do: close current team of agents if any + + self.teams[user_id] = team_config + + def get_current_team(self, user_id: str) -> TeamConfiguration: + """Get the current team configuration.""" + return self.teams.get(user_id, None) # Global config instances azure_config = AzureConfig() mcp_config = MCPConfig() orchestration_config = OrchestrationConfig() connection_config = ConnectionConfig() +team_config = TeamConfig() diff --git a/src/backend/v3/magentic_agents/magentic_agent_factory.py b/src/backend/v3/magentic_agents/magentic_agent_factory.py index e32170aa6..26a0935f7 100644 --- a/src/backend/v3/magentic_agents/magentic_agent_factory.py +++ b/src/backend/v3/magentic_agents/magentic_agent_factory.py @@ -8,6 +8,7 @@ from types import SimpleNamespace from typing import List, Union +from common.models.messages_kernel import TeamConfiguration from v3.magentic_agents.foundry_agent import FoundryAgentTemplate from v3.magentic_agents.models.agent_models import (BingConfig, MCPConfig, SearchConfig) @@ -32,12 +33,12 @@ def __init__(self): self.logger = logging.getLogger(__name__) self._agent_list: List = [] - @staticmethod - def parse_team_config(file_path: Union[str, Path]) -> SimpleNamespace: - """Parse JSON file into objects using SimpleNamespace.""" - with open(file_path, 'r') as f: - data = json.load(f) - return json.loads(json.dumps(data), object_hook=lambda d: SimpleNamespace(**d)) + # @staticmethod + # def parse_team_config(file_path: Union[str, Path]) -> SimpleNamespace: + # """Parse JSON file into objects using SimpleNamespace.""" + # with open(file_path, 'r') as f: + # data = json.load(f) + # return json.loads(json.dumps(data), object_hook=lambda d: SimpleNamespace(**d)) async def create_agent_from_config(self, agent_obj: SimpleNamespace) -> Union[FoundryAgentTemplate, ReasoningAgentTemplate, ProxyAgent]: """ @@ -119,31 +120,30 @@ async def create_agent_from_config(self, agent_obj: SimpleNamespace) -> Union[Fo self.logger.info(f"Successfully created and initialized agent '{agent_obj.name}'") return agent - async def get_agents(self, file_path: str) -> List: + async def get_agents(self, team_config_input: TeamConfiguration) -> List: """ Create and return a team of agents from JSON configuration. Args: - file_path: Path to the JSON team configuration file - team_model: Optional model override for all agents in the team + team_config_input: team configuration object from cosmos db Returns: List of initialized agent instances """ - self.logger.info(f"Loading team configuration from: {file_path}") + # self.logger.info(f"Loading team configuration from: {file_path}") try: - team = self.parse_team_config(file_path) - self.logger.info(f"Parsed team '{team.name}' with {len(team.agents)} agents") + # team = self.parse_team_config(file_path) + # self.logger.info(f"Parsed team '{team.name}' with {len(team.agents)} agents") - agents = [] + initalized_agents = [] - for i, agent_cfg in enumerate(team.agents, 1): + for i, agent_cfg in enumerate(team_config_input.agents, 1): try: - self.logger.info(f"Creating agent {i}/{len(team.agents)}: {agent_cfg.name}") + self.logger.info(f"Creating agent {i}/{len(team_config_input.agents)}: {agent_cfg.name}") agent = await self.create_agent_from_config(agent_cfg) - agents.append(agent) + initalized_agents.append(agent) self._agent_list.append(agent) # Keep track for cleanup self.logger.info(f"✅ Agent {i}/{len(team.agents)} created: {agent_cfg.name}") @@ -155,8 +155,8 @@ async def get_agents(self, file_path: str) -> List: self.logger.error(f"Failed to create agent {agent_cfg.name}: {e}") continue - self.logger.info(f"Successfully created {len(agents)}/{len(team.agents)} agents for team '{team.name}'") - return agents + self.logger.info(f"Successfully created {len(initalized_agents)}/{len(team.agents)} agents for team '{team.name}'") + return initalized_agents except Exception as e: self.logger.error(f"Failed to load team configuration: {e}") diff --git a/src/backend/v3/models/messages.py b/src/backend/v3/models/messages.py index 7479830ba..8ad28aba4 100644 --- a/src/backend/v3/models/messages.py +++ b/src/backend/v3/models/messages.py @@ -1,8 +1,10 @@ """Messages from the backend to the frontend via WebSocket.""" from dataclasses import dataclass +from typing import Any, Dict, List, Literal, Optional -from models import MPlan, PlanStatus +from semantic_kernel.kernel_pydantic import Field, KernelBaseModel +from v3.models.models import MPlan, PlanStatus @dataclass(slots=True) @@ -80,3 +82,31 @@ class FinalResultMessage: result: str summary: str | None = None context: dict | None = None + +class HumanFeedback(KernelBaseModel): + """Message containing human feedback on a step.""" + + step_id: Optional[str] = None + plan_id: str + session_id: str + approved: bool + human_feedback: Optional[str] = None + updated_action: Optional[str] = None + + +class HumanClarification(KernelBaseModel): + """Message containing human clarification on a plan.""" + + plan_id: str + session_id: str + human_clarification: str + +class ApprovalRequest(KernelBaseModel): + """Message sent to HumanAgent to request approval for a step.""" + + step_id: str + plan_id: str + session_id: str + user_id: str + action: str + agent_name: str diff --git a/src/backend/v3/orchestration/orchestration_manager.py b/src/backend/v3/orchestration/orchestration_manager.py index d27befed8..d5102f592 100644 --- a/src/backend/v3/orchestration/orchestration_manager.py +++ b/src/backend/v3/orchestration/orchestration_manager.py @@ -3,9 +3,10 @@ import os import uuid -from typing import List +from typing import List, Optional from azure.identity import DefaultAzureCredential as SyncDefaultAzureCredential +from common.models.messages_kernel import TeamConfiguration from semantic_kernel.agents.orchestration.magentic import MagenticOrchestration from semantic_kernel.agents.runtime import InProcessRuntime # Create custom execution settings to fix schema issues @@ -55,13 +56,13 @@ def get_token(): return magentic_orchestration @classmethod - async def get_current_orchestration(cls, user_id: str) -> MagenticOrchestration: + async def get_current_or_new_orchestration(cls, user_id: str, team_config: TeamConfiguration) -> MagenticOrchestration: """get existing orchestration instance.""" current_orchestration = orchestration_config.get_current_orchestration(user_id) if current_orchestration is None: factory = MagenticAgentFactory() # to do: change to parsing teams from cosmos db - agents = await factory.get_agents(config.AGENT_TEAM_FILE) + agents = await factory.get_agents(team_config_input=team_config) orchestration_config.orchestrations[user_id] = await cls.init_orchestration(agents) return orchestration_config.get_current_orchestration(user_id) diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index 7f6be5d48..f1fa5a303 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -38,7 +38,7 @@ class WebSocketService { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = process.env.REACT_APP_WS_HOST || '127.0.0.1:8000'; const processId = crypto.randomUUID(); // Replace with actual process ID as needed' - const wsUrl = `${wsProtocol}//${wsHost}/socket/${processId}`; + const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}`; console.log('Connecting to WebSocket:', wsUrl); From ba12990ab165c0211bea26a350430cd0ad6db115 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 28 Aug 2025 18:41:26 -0700 Subject: [PATCH 15/41] Team load functioning --- .../v3/common/services/team_service.py | 2 +- .../magentic_agents/magentic_agent_factory.py | 6 +-- src/frontend/src/pages/HomePage.tsx | 11 +++-- src/frontend/src/services/TeamService.tsx | 40 +++++++++++++++++++ .../src/services/WebSocketService.tsx | 2 +- 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/backend/v3/common/services/team_service.py b/src/backend/v3/common/services/team_service.py index 7c5bde2f6..cb95f5624 100644 --- a/src/backend/v3/common/services/team_service.py +++ b/src/backend/v3/common/services/team_service.py @@ -198,7 +198,7 @@ async def get_team_configuration( """ try: # Get the specific configuration using the team-specific method - team_config = await self.memory_context.get_team_by_id(team_id) + team_config = await self.memory_context.get_team(team_id) if team_config is None: return None diff --git a/src/backend/v3/magentic_agents/magentic_agent_factory.py b/src/backend/v3/magentic_agents/magentic_agent_factory.py index 26a0935f7..a46c6daf6 100644 --- a/src/backend/v3/magentic_agents/magentic_agent_factory.py +++ b/src/backend/v3/magentic_agents/magentic_agent_factory.py @@ -133,8 +133,6 @@ async def get_agents(self, team_config_input: TeamConfiguration) -> List: # self.logger.info(f"Loading team configuration from: {file_path}") try: - # team = self.parse_team_config(file_path) - # self.logger.info(f"Parsed team '{team.name}' with {len(team.agents)} agents") initalized_agents = [] @@ -146,7 +144,7 @@ async def get_agents(self, team_config_input: TeamConfiguration) -> List: initalized_agents.append(agent) self._agent_list.append(agent) # Keep track for cleanup - self.logger.info(f"✅ Agent {i}/{len(team.agents)} created: {agent_cfg.name}") + self.logger.info(f"✅ Agent {i}/{len(team_config_input.agents)} created: {agent_cfg.name}") except (UnsupportedModelError, InvalidConfigurationError) as e: self.logger.warning(f"Skipped agent {agent_cfg.name}: {e}") @@ -155,7 +153,7 @@ async def get_agents(self, team_config_input: TeamConfiguration) -> List: self.logger.error(f"Failed to create agent {agent_cfg.name}: {e}") continue - self.logger.info(f"Successfully created {len(initalized_agents)}/{len(team.agents)} agents for team '{team.name}'") + self.logger.info(f"Successfully created {len(initalized_agents)}/{len(team_config_input.agents)} agents for team '{team_config_input.name}'") return initalized_agents except Exception as e: diff --git a/src/frontend/src/pages/HomePage.tsx b/src/frontend/src/pages/HomePage.tsx index d2e30d789..fcd1f0c34 100644 --- a/src/frontend/src/pages/HomePage.tsx +++ b/src/frontend/src/pages/HomePage.tsx @@ -21,7 +21,6 @@ import { TaskService } from '../services/TaskService'; import { TeamConfig } from '../models/Team'; import { TeamService } from '../services/TeamService'; import InlineToaster, { useInlineToaster } from "../components/toast/InlineToaster"; -import { initializeTeam } from '@/api/config'; /** * HomePage component - displays task lists and provides navigation @@ -83,14 +82,14 @@ useEffect(() => { console.log('Initializing team from backend...'); // Call the backend init_team endpoint (takes ~20 seconds) - const initResponse = await initializeTeam(); + const initResponse = await TeamService.initializeTeam(); - if (initResponse.status === 'Request started successfully' && initResponse.team_id) { - console.log('Team initialization completed:', initResponse.team_id); + if (initResponse.data?.status === 'Request started successfully' && initResponse.data?.team_id) { + console.log('Team initialization completed:', initResponse.data?.team_id); // Now fetch the actual team details using the team_id const teams = await TeamService.getUserTeams(); - const initializedTeam = teams.find(team => team.team_id === initResponse.team_id); + const initializedTeam = teams.find(team => team.team_id === initResponse.data?.team_id); if (initializedTeam) { setSelectedTeam(initializedTeam); @@ -158,7 +157,7 @@ useEffect(() => { }; initTeam(); -}, [showToast]); +}, []); /** * Handle new task creation from the "New task" button diff --git a/src/frontend/src/services/TeamService.tsx b/src/frontend/src/services/TeamService.tsx index 065f6ec35..3791530b1 100644 --- a/src/frontend/src/services/TeamService.tsx +++ b/src/frontend/src/services/TeamService.tsx @@ -19,6 +19,46 @@ export class TeamService { } } + /** + * Initialize user's team with default HR team configuration + * This calls the backend /init_team endpoint which sets up the default team + */ + static async initializeTeam(): Promise<{ + success: boolean; + data?: { + status: string; + team_id: string; + }; + error?: string; + }> { + try { + console.log('Calling /v3/init_team endpoint...'); + const response = await apiClient.get('/v3/init_team'); + + console.log('Team initialization response:', response); + + return { + success: true, + data: response + }; + } catch (error: any) { + console.error('Team initialization failed:', error); + + let errorMessage = 'Failed to initialize team'; + + if (error.response?.data?.detail) { + errorMessage = error.response.data.detail; + } else if (error.message) { + errorMessage = error.message; + } + + return { + success: false, + error: errorMessage + }; + } + } + static getStoredTeam(): TeamConfig | null { if (typeof window === 'undefined' || !window.localStorage) return null; try { diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index c455ba9a7..bf4817591 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -58,7 +58,7 @@ class WebSocketService { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = process.env.REACT_APP_WS_HOST || '127.0.0.1:8000'; const processId = crypto.randomUUID(); // Generate unique process ID for this session - const wsUrl = `${wsProtocol}//${wsHost}/socket/${processId}`; + const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}`; console.log('Connecting to WebSocket:', wsUrl); From 6a2098126abeff4e9a740afb5400e714e662944f Mon Sep 17 00:00:00 2001 From: blessing-sanusi Date: Thu, 28 Aug 2025 21:37:06 -0500 Subject: [PATCH 16/41] teams init --- src/frontend/src/api/config.tsx | 49 ++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/api/config.tsx b/src/frontend/src/api/config.tsx index cd015245b..0e775fdb1 100644 --- a/src/frontend/src/api/config.tsx +++ b/src/frontend/src/api/config.tsx @@ -130,6 +130,52 @@ export function headerBuilder(headers?: Record): Record { + const apiUrl = getApiUrl(); + if (!apiUrl) { + throw new Error('API URL not configured'); + } + + const headers = headerBuilder({ + 'Content-Type': 'application/json', + }); + + console.log('initializeTeam: Starting team initialization...'); + + try { + const response = await fetch(`${apiUrl}/init_team`, { + method: 'GET', + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + console.log('initializeTeam: Team initialization completed:', data); + + // Validate the expected response format + if (data.status !== 'Request started successfully' || !data.team_id) { + throw new Error('Invalid response format from init_team endpoint'); + } + + return data; + } catch (error) { + console.error('initializeTeam: Error initializing team:', error); + throw error; + } +} + export const toBoolean = (value: any): boolean => { if (typeof value !== 'string') { return false; @@ -145,5 +191,6 @@ export default { setEnvData, config, USER_ID, - API_URL + API_URL, + initializeTeam }; \ No newline at end of file From 562a03e38c8af0d5d5cb36efc5cd46daa7375482 Mon Sep 17 00:00:00 2001 From: blessing-sanusi Date: Fri, 29 Aug 2025 13:01:09 -0500 Subject: [PATCH 17/41] ui ws wip --- data/agent_teams/hr.json | 2 +- .../src/components/common/SettingsButton.tsx | 17 +- .../src/components/content/PlanChat.tsx | 372 ++++++++++++++---- src/frontend/src/index.css | 46 +++ .../src/services/WebSocketService.tsx | 11 +- src/mcp_server/,env | 16 + src/mcp_server/services/data_tool_service.py | 7 +- 7 files changed, 374 insertions(+), 97 deletions(-) create mode 100644 src/mcp_server/,env diff --git a/data/agent_teams/hr.json b/data/agent_teams/hr.json index fbf5fe66e..e2142ddd8 100644 --- a/data/agent_teams/hr.json +++ b/data/agent_teams/hr.json @@ -43,7 +43,7 @@ "input_key": "", "type": "", "name": "ProxyAgent", - "deployment_name": "", + "deployment_name": "gpt-4.1", "icon": "", "system_message": "", "description": "", diff --git a/src/frontend/src/components/common/SettingsButton.tsx b/src/frontend/src/components/common/SettingsButton.tsx index 3b6eecfb4..d83ae67dc 100644 --- a/src/frontend/src/components/common/SettingsButton.tsx +++ b/src/frontend/src/components/common/SettingsButton.tsx @@ -41,6 +41,8 @@ import { import { TeamConfig } from '../../models/Team'; import { TeamService } from '../../services/TeamService'; +import '../../index.css' + // Icon mapping function to convert string icons to FluentUI icons const getIconFromString = (iconString: string): React.ReactNode => { const iconMap: Record = { @@ -514,7 +516,7 @@ const SettingsButton: React.FC = ({ appearance="subtle" size="small" onClick={(e) => handleDeleteTeam(team, e)} - style={{ color: '#d13438' }} + className="delete-team-button" />
@@ -751,16 +753,8 @@ const SettingsButton: React.FC = ({ {/* Delete Confirmation Dialog */} setDeleteConfirmOpen(data.open)}> - - + + ⚠️ Delete Team Configuration
@@ -802,6 +796,7 @@ const SettingsButton: React.FC = ({ disabled={deleteLoading} style={{ backgroundColor: '#d13438', color: 'white' }} onClick={confirmDeleteTeam} + data-testid="delete-team-confirm" > {deleteLoading ? 'Deleting...' : 'Delete for Everyone'} diff --git a/src/frontend/src/components/content/PlanChat.tsx b/src/frontend/src/components/content/PlanChat.tsx index 56ef652d0..d612be720 100644 --- a/src/frontend/src/components/content/PlanChat.tsx +++ b/src/frontend/src/components/content/PlanChat.tsx @@ -1,24 +1,13 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { DiamondRegular, CheckmarkCircleRegular, ClockRegular, ErrorCircleRegular, } from "@fluentui/react-icons"; +import { Body1, Button, Spinner, Tag, ToolbarDivider} from "@fluentui/react-components"; import HeaderTools from "@/coral/components/Header/HeaderTools"; import { Copy, Send } from "@/coral/imports/bundleicons"; import ChatInput from "@/coral/modules/ChatInput"; import remarkGfm from "remark-gfm"; import rehypePrism from "rehype-prism"; import { AgentType, ChatMessage, PlanChatProps, role } from "@/models"; -import { StreamingPlanUpdate } from "@/services/WebSocketService"; -import { - Body1, - Button, - Spinner, - Tag, - ToolbarDivider, -} from "@fluentui/react-components"; -import { DiamondRegular, HeartRegular } from "@fluentui/react-icons"; -import { useEffect, useRef, useState } from "react"; - -// Type guard to check if a message has streaming properties -const hasStreamingProperties = (msg: ChatMessage): msg is ChatMessage & { streaming?: boolean; status?: string; message_type?: string; } => { - return 'streaming' in msg || 'status' in msg || 'message_type' in msg; -}; +import { StreamingPlanUpdate, webSocketService } from "@/services/WebSocketService"; import ReactMarkdown from "react-markdown"; import "../../styles/PlanChat.css"; import "../../styles/Chat.css"; @@ -27,6 +16,26 @@ import { TaskService } from "@/services/TaskService"; import InlineToaster from "../toast/InlineToaster"; import ContentNotFound from "../NotFound/ContentNotFound"; + +// Type guard to check if a message has streaming properties +const hasStreamingProperties = (msg: ChatMessage): msg is ChatMessage & { + streaming?: boolean; + status?: string; + message_type?: string; + step_id?: string; +} => { + return 'streaming' in msg || 'status' in msg || 'message_type' in msg; +}; + +interface GroupedMessage { + id: string; + agent_name: string; + messages: StreamingPlanUpdate[]; + status: string; + latest_timestamp: string; + step_id?: string; +} + const PlanChat: React.FC = ({ planData, input, @@ -40,82 +49,281 @@ const PlanChat: React.FC = ({ const messages = planData?.messages || []; const [showScrollButton, setShowScrollButton] = useState(false); const [inputHeight, setInputHeight] = useState(0); + const [groupedStreamingMessages, setGroupedStreamingMessages] = useState([]); const messagesContainerRef = useRef(null); const inputContainerRef = useRef(null); - // Debug logging - console.log('PlanChat - planData:', planData); - console.log('PlanChat - messages:', messages); - console.log('PlanChat - messages.length:', messages.length); + // Helper function to normalize timestamp + const normalizeTimestamp = (timestamp?: string | number): string => { + if (!timestamp) return new Date().toISOString(); + if (typeof timestamp === 'number') { + // Backend sends float timestamp, convert to ISO string + return new Date(timestamp * 1000).toISOString(); + } + return timestamp; + }; - // Scroll to Bottom useEffect + // Group streaming messages by agent + const groupStreamingMessages = useCallback((messages: StreamingPlanUpdate[]): GroupedMessage[] => { + const groups: { [key: string]: GroupedMessage } = {}; - useEffect(() => { - scrollToBottom(); - }, [messages, streamingMessages]); + messages.forEach((msg) => { + // Create a unique key for grouping (agent + step) + const groupKey = `${msg.agent_name || 'system'}_${msg.step_id || 'general'}`; + + if (!groups[groupKey]) { + groups[groupKey] = { + id: groupKey, + agent_name: msg.agent_name || 'BOT', + messages: [], + status: msg.status || 'in_progress', + latest_timestamp: normalizeTimestamp(msg.timestamp), + step_id: msg.step_id, + }; + } + + groups[groupKey].messages.push(msg); + + // Update status to latest + const msgTimestamp = normalizeTimestamp(msg.timestamp); + const groupTimestamp = groups[groupKey].latest_timestamp; + if (msgTimestamp > groupTimestamp) { + groups[groupKey].status = msg.status || groups[groupKey].status; + groups[groupKey].latest_timestamp = msgTimestamp; + } + }); - //Scroll to Bottom Buttom + return Object.values(groups).sort((a, b) => + new Date(a.latest_timestamp).getTime() - new Date(b.latest_timestamp).getTime() + ); + }, []); + + // Update grouped messages when streaming messages change + useEffect(() => { + if (streamingMessages.length > 0) { + const grouped = groupStreamingMessages(streamingMessages); + setGroupedStreamingMessages(grouped); + } else { + // Clear grouped messages when no streaming messages + setGroupedStreamingMessages([]); + } + }, [streamingMessages, groupStreamingMessages]); + + // Auto-scroll behavior + useEffect(() => { + scrollToBottom(); + }, [messages, groupedStreamingMessages]); useEffect(() => { - const container = messagesContainerRef.current; - if (!container) return; + const container = messagesContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + setShowScrollButton(scrollTop + clientHeight < scrollHeight - 100); + }; + + container.addEventListener("scroll", handleScroll); + return () => container.removeEventListener("scroll", handleScroll); + }, []); - const handleScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = container; - setShowScrollButton(scrollTop + clientHeight < scrollHeight - 100); + useEffect(() => { + if (inputContainerRef.current) { + setInputHeight(inputContainerRef.current.offsetHeight); + } + }, [input]); + + const scrollToBottom = () => { + messagesContainerRef.current?.scrollTo({ + top: messagesContainerRef.current.scrollHeight, + behavior: "smooth", + }); + setShowScrollButton(false); }; - container.addEventListener("scroll", handleScroll); - return () => container.removeEventListener("scroll", handleScroll); - }, []); + // Get status icon for streaming messages + const getStatusIcon = (status: string) => { + switch (status) { + case 'completed': + return ; + case 'error': + case 'failed': + return ; + case 'in_progress': + return ; + default: + return ; + } + }; - useEffect(() => { - if (inputContainerRef.current) { - setInputHeight(inputContainerRef.current.offsetHeight); + // Get message type display text + const getMessageTypeText = (messageType?: string, status?: string) => { + if (status === 'completed') return 'Completed'; + if (status === 'error' || status === 'failed') return 'Failed'; + + switch (messageType) { + case 'thinking': + return 'Thinking...'; + case 'action': + return 'Working...'; + case 'result': + return 'Result'; + case 'clarification_needed': + return 'Needs Input'; + case 'plan_approval_request': + return 'Approval Required'; + default: + return status === 'in_progress' ? 'In Progress' : 'Live'; + } + }; + + if (!planData && !loading) { + return ( + + ); } - }, [input]); // or [inputValue, submittingChatDisableInput] - - const scrollToBottom = () => { - messagesContainerRef.current?.scrollTo({ - top: messagesContainerRef.current.scrollHeight, - behavior: "smooth", - }); - setShowScrollButton(false); - }; - if (!planData) + // Render a grouped streaming message + const renderGroupedStreamingMessage = (group: GroupedMessage) => { + const latestMessage = group.messages[group.messages.length - 1]; + const hasMultipleMessages = group.messages.length > 1; + return ( - +
+
+
+ + {TaskService.cleanTextToSpaces(group.agent_name)} + + + BOT + + + {getMessageTypeText(latestMessage.message_type, group.status)} + +
+
+ + +
+ {hasMultipleMessages ? ( + // Show combined content for multiple messages +
+ {group.messages.map((msg, idx) => ( +
+ {msg.content && ( + + {TaskService.cleanHRAgent(msg.content)} + + )} +
+ ))} +
+ ) : ( + // Single message content + + {TaskService.cleanHRAgent(latestMessage.content || "") || ""} + + )} + + +
+
+
+
+ + } + appearance="filled" + size="extra-small" + > + Live updates from agent + +
+
+
+
+
); + }; - // If no messages exist, show the initial task as the first message - const displayMessages = messages.length > 0 ? messages : [ - { - source: AgentType.HUMAN, - content: planData.plan?.initial_goal || "Task started", - timestamp: planData.plan?.timestamp || new Date().toISOString() - } - ]; + // Combine regular messages and streaming messages for display + // const allMessages = [ + // ...messages, + // // Add streaming messages as regular chat messages for display + // ...groupedStreamingMessages.map(group => ({ + // source: group.agent_name, + // content: group.messages.map(msg => msg.content).join('\n\n'), + // streaming: true, + // status: group.status, + // message_type: group.messages[group.messages.length - 1].message_type, + // step_id: group.step_id + // })) + // ]; - // Merge streaming messages with existing messages - const allMessages: ChatMessage[] = [...displayMessages]; +// Check if agents are actively working +const agentsWorking = streamingMessages.length > 0 && + groupedStreamingMessages.some(group => + group.status === 'in_progress' || + group.messages.some(msg => msg.status === 'in_progress') + ); + +// Get the name of the currently working agent +const getWorkingAgentName = (): string | null => { + if (!agentsWorking) return null; - // Add streaming messages as assistant messages - streamingMessages.forEach(streamMsg => { - if (streamMsg.content) { - allMessages.push({ - source: streamMsg.agent_name || 'AI Assistant', - content: streamMsg.content, - timestamp: new Date().toISOString(), - streaming: true, - status: streamMsg.status, - message_type: streamMsg.message_type - }); - } - }); + // Find the first agent that's currently in progress + const workingGroup = groupedStreamingMessages.find(group => + group.status === 'in_progress' || + group.messages.some(msg => msg.status === 'in_progress') + ); + + return workingGroup ? workingGroup.agent_name : null; +}; + +const workingAgentName = getWorkingAgentName(); - console.log('PlanChat - all messages including streaming:', allMessages); +// Disable input when agents are working +const shouldDisableInput = !planData?.enableChat || submittingChatDisableInput || agentsWorking; + +// Generate dynamic placeholder text +const getPlaceholderText = (): string => { + if (workingAgentName) { + return `${TaskService.cleanTextToSpaces(workingAgentName)} is working on your plan...`; + } + return "Add more info to this task..."; +}; + + console.log('PlanChat - streamingMessages:', streamingMessages); + console.log('PlanChat - submittingChatDisableInput:', submittingChatDisableInput); + console.log('PlanChat - shouldDisableInput:', shouldDisableInput); return (
@@ -135,12 +343,13 @@ const PlanChat: React.FC = ({ )}
- {allMessages.map((msg, index) => { + {/* Render regular messages */} + {messages.map((msg, index) => { const isHuman = msg.source === AgentType.HUMAN; return (
{!isHuman && ( @@ -213,6 +422,9 @@ const PlanChat: React.FC = ({
); })} + + {/* Render streaming messages */} + {groupedStreamingMessages.map(group => renderGroupedStreamingMessage(group))}
@@ -223,8 +435,8 @@ const PlanChat: React.FC = ({ shape="circular" style={{ bottom: inputHeight, - position: "absolute", // ensure this or your class handles it - right: 16, // optional, for right alignment + position: "absolute", + right: 16, zIndex: 5, }} > @@ -238,18 +450,14 @@ const PlanChat: React.FC = ({ value={input} onChange={setInput} onEnter={() => OnChatSubmit(input)} - disabledChat={ - planData?.enableChat ? submittingChatDisableInput : true - } - placeholder="Add more info to this task..." + disabledChat={shouldDisableInput} + placeholder={getPlaceholderText()} >
@@ -258,4 +466,4 @@ const PlanChat: React.FC = ({ ); }; -export default PlanChat; +export default PlanChat; \ No newline at end of file diff --git a/src/frontend/src/index.css b/src/frontend/src/index.css index d9a069000..47b8fea14 100644 --- a/src/frontend/src/index.css +++ b/src/frontend/src/index.css @@ -44,3 +44,49 @@ body, html, :root { --chartPointColor: var(--colorBrandBackground); --chartPointBorderColor: var(--colorBrandForeground1); } + + +/* Delete dialog layout override */ +.fui-Dialog__content[data-testid="delete-dialog"] { + display: flex !important; + flex-direction: column !important; + grid-template-columns: none !important; + grid-template-rows: none !important; + grid-template: none !important; +} + +.fui-Dialog__content[data-testid="delete-dialog"] .fui-DialogBody { + display: flex !important; + flex-direction: column !important; + grid-template-columns: none !important; + grid-template-rows: none !important; + grid-template: none !important; +} + +/* Alternative approach - target all dialog content that contains delete buttons */ +/* .fui-Dialog__content:has(button[data-testid="delete-team-confirm"]) { + display: flex !important; + flex-direction: column !important; + grid-template-columns: none !important; + grid-template-rows: none !important; +} + +.fui-Dialog__content:has(button[data-testid="delete-team-confirm"]) .fui-DialogBody { + display: flex !important; + flex-direction: column !important; + grid-template-columns: none !important; + grid-template-rows: none !important; +} */ + +/* Delete button red color override */ +.delete-team-button, +.delete-team-button .fui-Button__icon { + color: #d13438 !important; + background-color: transparent !important; +} + +.delete-team-button:hover, +.delete-team-button:hover .fui-Button__icon { + background-color: #fdf2f2 !important; + color: #a4262c !important; +} \ No newline at end of file diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index bf4817591..e7b2b6ef6 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -1,3 +1,5 @@ +import { getUserId } from '../api/config'; + export interface StreamMessage { type: 'plan_update' | 'step_update' | 'agent_message' | 'error' | 'connection_status' | 'plan_approval_request' | 'final_result'; plan_id?: string; @@ -8,12 +10,13 @@ export interface StreamMessage { export interface StreamingPlanUpdate { plan_id: string; - session_id: string; + session_id?: string; step_id?: string; agent_name?: string; content?: string; status?: 'in_progress' | 'completed' | 'error' | 'creating_plan' | 'pending_approval'; message_type?: 'thinking' | 'action' | 'result' | 'clarification_needed' | 'plan_approval_request'; + timestamp?: number; } // Add these new interfaces after StreamingPlanUpdate @@ -58,7 +61,11 @@ class WebSocketService { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = process.env.REACT_APP_WS_HOST || '127.0.0.1:8000'; const processId = crypto.randomUUID(); // Generate unique process ID for this session - const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}`; + + // const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}`; + // Build WebSocket URL with authentication headers as query parameters + const userId = getUserId(); // Import this from config + const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}?user_id=${encodeURIComponent(userId)}`; console.log('Connecting to WebSocket:', wsUrl); diff --git a/src/mcp_server/,env b/src/mcp_server/,env new file mode 100644 index 000000000..be5cf0e39 --- /dev/null +++ b/src/mcp_server/,env @@ -0,0 +1,16 @@ +# MCP Server Configuration + +# Server Settings +MCP_HOST=0.0.0.0 +MCP_PORT=9000 +MCP_DEBUG=false +MCP_SERVER_NAME=MACAE MCP Server + +# Authentication Settings +MCP_ENABLE_AUTH=true +AZURE_TENANT_ID=your-tenant-id-here +AZURE_CLIENT_ID=your-client-id-here +AZURE_JWKS_URI=https://login.microsoftonline.com/your-tenant-id/discovery/v2.0/keys +AZURE_ISSUER=https://sts.windows.net/your-tenant-id/ +AZURE_AUDIENCE=api://your-client-id +DATASET_PATH=./datasets \ No newline at end of file diff --git a/src/mcp_server/services/data_tool_service.py b/src/mcp_server/services/data_tool_service.py index ccae5ca2f..fb38db1ef 100644 --- a/src/mcp_server/services/data_tool_service.py +++ b/src/mcp_server/services/data_tool_service.py @@ -1,7 +1,7 @@ import os import logging from typing import List -from ..core.factory import MCPToolBase, Domain +from core.factory import MCPToolBase, Domain ALLOWED_FILES = [ "competitor_Pricing_Analysis.csv", @@ -29,6 +29,11 @@ def __init__(self, dataset_path: str): self.dataset_path = dataset_path self.allowed_files = set(ALLOWED_FILES) + @property + def tool_count(self) -> int: + """Return the number of tools provided by this service.""" + return 2 + def _find_file(self, filename: str) -> str: """ Searches recursively within the dataset_path for an exact filename match (case-sensitive). From 67733d37381bd1636200259b1e1529c652b8dfb2 Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 29 Aug 2025 11:29:31 -0700 Subject: [PATCH 18/41] Websocket context solution - draft --- src/backend/app_kernel.py | 3 +- src/backend/common/models/messages_kernel.py | 4 + src/backend/v3/api/router.py | 116 +++++++----------- src/backend/v3/callbacks/response_handlers.py | 22 +++- src/backend/v3/config/settings.py | 113 +++++++++++++---- .../orchestration/human_approval_manager.py | 77 +++++++----- .../v3/orchestration/orchestration_manager.py | 32 ++++- src/frontend/src/pages/PlanPage.tsx | 18 ++- .../src/services/WebSocketService.tsx | 32 ++++- 9 files changed, 268 insertions(+), 149 deletions(-) diff --git a/src/backend/app_kernel.py b/src/backend/app_kernel.py index 7c1b23863..38afbadec 100644 --- a/src/backend/app_kernel.py +++ b/src/backend/app_kernel.py @@ -667,8 +667,7 @@ async def get_plans( "UserIdNotFound", {"status_code": 400, "detail": "no user"} ) raise HTTPException(status_code=400, detail="no user") - - await connection_config.send_status_update_async("Test message from get_plans", user_id) + #### Replace the following with code to get plan run history from the database diff --git a/src/backend/common/models/messages_kernel.py b/src/backend/common/models/messages_kernel.py index 8a2926ecd..095f53dc0 100644 --- a/src/backend/common/models/messages_kernel.py +++ b/src/backend/common/models/messages_kernel.py @@ -178,6 +178,10 @@ class StartingTask(KernelBaseModel): creator: str logo: str +class TeamSelectionRequest(KernelBaseModel): + """Request model for team selection.""" + team_id: str + session_id: Optional[str] = None class TeamConfiguration(BaseDataModel): """Represents a team configuration stored in the database.""" diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 38221da45..001715879 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -1,4 +1,5 @@ import asyncio +import contextvars import json import logging import uuid @@ -9,28 +10,21 @@ from common.config.app_config import config from common.database.database_factory import DatabaseFactory from common.models.messages_kernel import (GeneratePlanRequest, InputTask, - Plan, PlanStatus) + TeamSelectionRequest) from common.utils.event_utils import track_event_if_configured from common.utils.utils_kernel import rai_success, rai_validate_team_config from fastapi import (APIRouter, BackgroundTasks, Depends, FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect) from kernel_agents.agent_factory import AgentFactory -from pydantic import BaseModel from semantic_kernel.agents.runtime import InProcessRuntime from v3.common.services.team_service import TeamService -from v3.config.settings import connection_config, team_config +from v3.config.settings import connection_config, current_user_id, team_config from v3.orchestration.orchestration_manager import OrchestrationManager router = APIRouter() logger = logging.getLogger(__name__) -class TeamSelectionRequest(BaseModel): - """Request model for team selection.""" - team_id: str - session_id: Optional[str] = None - - app_v3 = APIRouter( prefix="/api/v3", responses={404: {"description": "Not found"}}, @@ -50,32 +44,38 @@ async def start_comms(websocket: WebSocket, process_id: str): authenticated_user = get_authenticated_user_details(request_headers=headers) user_id = authenticated_user.get("user_principal_id") if not user_id: - user_id = f"anonymous_{process_id}" + user_id = "00000000-0000-0000-0000-000000000000" except Exception as e: logging.warning(f"Could not extract user from WebSocket headers: {e}") - user_id = f"anonymous_{user_id}" + user_id = "00000000-0000-0000-0000-000000000000" - # Add to the connection manager for backend updates + current_user_id.set(user_id) - connection_config.add_connection(user_id, websocket) - track_event_if_configured("WebSocketConnectionAccepted", {"process_id": "user_id"}) + # Add to the connection manager for backend updates + connection_config.add_connection(process_id=process_id, connection=websocket, user_id=user_id) + track_event_if_configured("WebSocketConnectionAccepted", {"process_id": process_id, "user_id": user_id}) # Keep the connection open - FastAPI will close the connection if this returns - while True: - # no expectation that we will receive anything from the client but this keeps - # the connection open and does not take cpu cycle - try: - await websocket.receive_text() - except asyncio.TimeoutError: - pass - - except WebSocketDisconnect: - track_event_if_configured("WebSocketDisconnect", {"process_id": process_id}) - logging.info(f"Client disconnected from batch {process_id}") - await connection_config.close_connection(user_id) - except Exception as e: - logging.error("Error in WebSocket connection", error=str(e)) - await connection_config.close_connection(user_id) + try: + # Keep the connection open - FastAPI will close the connection if this returns + while True: + # no expectation that we will receive anything from the client but this keeps + # the connection open and does not take cpu cycle + try: + message = await websocket.receive_text() + logging.debug(f"Received WebSocket message from {user_id}: {message}") + except asyncio.TimeoutError: + pass + except WebSocketDisconnect: + track_event_if_configured("WebSocketDisconnect", {"process_id": process_id, "user_id": user_id}) + logging.info(f"Client disconnected from batch {process_id}") + break + except Exception as e: + # Fixed logging syntax - removed the error= parameter + logging.error(f"Error in WebSocket connection: {str(e)}") + finally: + # Always clean up the connection + await connection_config.close_connection(user_id) @app_v3.get("/init_team") async def init_team( @@ -112,7 +112,7 @@ async def init_team( ) # Set as current team in memory - team_config.set_current_team(user_id=user_id, team_config=team_configuration) + team_config.set_current_team(user_id=user_id, team_configuration=team_configuration) # Initialize agent team for this user session await OrchestrationManager.get_current_or_new_orchestration(user_id=user_id, team_config=team_configuration) @@ -227,8 +227,16 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa input_task.session_id = str(uuid.uuid4()) try: - background_tasks.add_task(OrchestrationManager.run_orchestration, user_id, input_task) - #await connection_config.send_status_update_async("Test message from process_request", user_id) + current_user_id.set(user_id) # Set context + current_context = contextvars.copy_context() # Capture context + # background_tasks.add_task( + # lambda: current_context.run(lambda:OrchestrationManager().run_orchestration, user_id, input_task) + # ) + + async def run_with_context(): + return await current_context.run(OrchestrationManager().run_orchestration, user_id, input_task) + + background_tasks.add_task(run_with_context) return { "status": "Request started successfully", @@ -788,46 +796,4 @@ async def get_search_indexes_endpoint(request: Request): return {"search_summary": summary} except Exception as e: logging.error(f"Error retrieving search indexes: {str(e)}") - raise HTTPException(status_code=500, detail="Internal server error occurred") - - -# @app_v3.websocket("/socket/{process_id}") -# async def process_outputs(websocket: WebSocket, process_id: str): -# """ Web-Socket endpoint for real-time process status updates. """ - -# # Always accept the WebSocket connection first -# await websocket.accept() - -# user_id = None -# try: -# # WebSocket headers are different, try to get user info -# headers = dict(websocket.headers) -# authenticated_user = get_authenticated_user_details(request_headers=headers) -# user_id = authenticated_user.get("user_principal_id") -# if not user_id: -# user_id = f"anonymous_{process_id}" -# except Exception as e: -# logger.warning(f"Could not extract user from WebSocket headers: {e}") -# # user_id = f"anonymous_{user_id}" - -# # Add to the connection manager for backend updates - -# connection_config.add_connection(user_id, websocket) -# track_event_if_configured("WebSocketConnectionAccepted", {"process_id": user_id}) - -# # Keep the connection open - FastAPI will close the connection if this returns -# while True: -# # no expectation that we will receive anything from the client but this keeps -# # the connection open and does not take cpu cycle -# try: -# await websocket.receive_text() -# except asyncio.TimeoutError: -# pass - -# except WebSocketDisconnect: -# track_event_if_configured("WebSocketDisconnect", {"process_id": user_id}) -# logger.info(f"Client disconnected from batch {user_id}") -# await connection_config.close_connection(user_id) -# except Exception as e: -# logger.error("Error in WebSocket connection", error=str(e)) -# await connection_config.close_connection(user_id) \ No newline at end of file + raise HTTPException(status_code=500, detail="Internal server error occurred") \ No newline at end of file diff --git a/src/backend/v3/callbacks/response_handlers.py b/src/backend/v3/callbacks/response_handlers.py index 800f4a6ae..50e1bd8eb 100644 --- a/src/backend/v3/callbacks/response_handlers.py +++ b/src/backend/v3/callbacks/response_handlers.py @@ -2,14 +2,23 @@ Enhanced response callbacks for employee onboarding agent system. Provides detailed monitoring and response handling for different agent types. """ - +import asyncio import sys from semantic_kernel.contents import (ChatMessageContent, StreamingChatMessageContent) +from v3.config.settings import connection_config, current_user_id coderagent = False +# Module-level variable to store current user_id +_current_user_id: str = None + +def set_user_context(user_id: str): + """Set the user context for callbacks in this module.""" + global _current_user_id + _current_user_id = user_id + def agent_response_callback(message: ChatMessageContent) -> None: """Observer function to print detailed information about streaming messages.""" global coderagent @@ -31,8 +40,19 @@ def agent_response_callback(message: ChatMessageContent) -> None: return elif coderagent == True: coderagent = False + role = getattr(message, 'role', 'unknown') + # Send to WebSocket + if _current_user_id: + try: + asyncio.create_task(connection_config.send_status_update_async({ + "type": "agent_message", + "data": {"agent_name": agent_name, "content": message.content, "role": role} + }, _current_user_id)) + except Exception as e: + print(f"Error sending WebSocket message: {e}") + print(f"\n🧠 **{agent_name}** [{message_type}] (role: {role})") print("-" * (len(agent_name) + len(message_type) + 10)) if message.items[-1].content_type == 'function_call': diff --git a/src/backend/v3/config/settings.py b/src/backend/v3/config/settings.py index aebd621d5..71db8648d 100644 --- a/src/backend/v3/config/settings.py +++ b/src/backend/v3/config/settings.py @@ -4,9 +4,11 @@ """ import asyncio +import contextvars import json import logging -from typing import Dict +import uuid +from typing import Dict, Optional from common.config.app_config import config from common.models.messages_kernel import TeamConfiguration @@ -17,6 +19,9 @@ logger = logging.getLogger(__name__) +# Create a context variable to track current user +current_user_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar('current_user_id', default=None) + class AzureConfig: """Azure OpenAI and authentication configuration.""" @@ -29,7 +34,7 @@ def __init__(self): # Create credential self.credential = config.get_azure_credentials() - def create_chat_completion_service(self, use_reasoning_model=False): + def create_chat_completion_service(self, use_reasoning_model: bool=False): """Create Azure Chat Completion service.""" model_name = ( self.reasoning_model if use_reasoning_model else self.standard_model @@ -54,7 +59,7 @@ def __init__(self): self.name = "MCPGreetingServer" self.description = "MCP server with greeting and planning tools" - def get_headers(self, token): + def get_headers(self, token: str): """Get MCP headers with authentication token.""" return ( {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} @@ -81,15 +86,51 @@ class ConnectionConfig: def __init__(self): self.connections: Dict[str, WebSocket] = {} + # Map user_id to process_id for context-based messaging + self.user_to_process: Dict[str, str] = {} - def add_connection(self, process_id, connection): + def add_connection(self, process_id: str, connection: WebSocket, user_id: str = None): """Add a new connection.""" + # Close existing connection if it exists + if process_id in self.connections: + try: + asyncio.create_task(self.connections[process_id].close()) + except Exception as e: + logger.error(f"Error closing existing connection for user {process_id}: {e}") + self.connections[process_id] = connection + # Map user to process for context-based messaging + if user_id: + user_id = str(user_id) + # If this user already has a different process mapped, close that old connection + old_process_id = self.user_to_process.get(user_id) + if old_process_id and old_process_id != process_id: + old_connection = self.connections.get(old_process_id) + if old_connection: + try: + asyncio.create_task(old_connection.close()) + del self.connections[old_process_id] + logger.info(f"Closed old connection {old_process_id} for user {user_id}") + except Exception as e: + logger.error(f"Error closing old connection for user {user_id}: {e}") + + self.user_to_process[user_id] = process_id + logger.info(f"WebSocket connection added for process: {process_id} (user: {user_id})") + else: + logger.info(f"WebSocket connection added for process: {process_id}") def remove_connection(self, process_id): """Remove a connection.""" + process_id = str(process_id) if process_id in self.connections: del self.connections[process_id] + + # Remove from user mapping if exists + for user_id, mapped_process_id in list(self.user_to_process.items()): + if mapped_process_id == process_id: + del self.user_to_process[user_id] + logger.debug(f"Removed user mapping: {user_id} -> {process_id}") + break def get_connection(self, process_id): """Get a connection.""" @@ -99,38 +140,62 @@ async def close_connection(self, process_id): """Remove a connection.""" connection = self.get_connection(process_id) if connection: - asyncio.run_coroutine_threadsafe(connection.close(), asyncio.get_event_loop()) - logger.info("Connection closed for batch ID: %s", process_id) + try: + await connection.close() + logger.info("Connection closed for batch ID: %s", process_id) + except Exception as e: + logger.error(f"Error closing connection for {process_id}: {e}") else: logger.warning("No connection found for batch ID: %s", process_id) - connection_config.remove_connection(process_id) + + # Always remove from connections dict + self.remove_connection(process_id) logger.info("Connection removed for batch ID: %s", process_id) - async def send_status_update_async(self, message: str, process_id: str): + async def send_status_update_async(self, message: any, user_id: Optional[str] = None): """Send a status update to a specific client.""" + # If no process_id provided, get from context + if user_id is None: + user_id = current_user_id.get() + + if not user_id: + logger.warning("No user_id available for WebSocket message") + return + + process_id = self.user_to_process.get(user_id) + if not process_id: + logger.warning("No active WebSocket process found for user ID: %s", user_id) + logger.debug(f"Available user mappings: {list(self.user_to_process.keys())}") + return + connection = self.get_connection(process_id) if connection: - await connection.send_text(message) + try: + str_message = json.dumps(message, default=str) + await connection.send_text(str_message) + logger.debug(f"Message sent to user {user_id} via process {process_id}") + except Exception as e: + logger.error(f"Failed to send message to user {user_id}: {e}") + # Clean up stale connection + self.remove_connection(process_id) else: - logger.warning("No connection found for batch ID: %s", process_id) - + logger.warning("No connection found for process ID: %s (user: %s)", process_id, user_id) + # Clean up stale mapping + if user_id in self.user_to_process: + del self.user_to_process[user_id] def send_status_update(self, message: str, process_id: str): - """Send a status update to a specific client.""" - connection = self.get_connection(str(process_id)) + """Send a status update to a specific client (sync wrapper).""" + process_id = str(process_id) + connection = self.get_connection(process_id) if connection: try: - # Directly send the message using this connection object - asyncio.run_coroutine_threadsafe( - connection.send_text( - message - ), - asyncio.get_event_loop(), - ) + # Use asyncio.create_task instead of run_coroutine_threadsafe + asyncio.create_task(connection.send_text(message)) except Exception as e: - logger.error("Failed to send message: %s", e) + logger.error(f"Failed to send message to process {process_id}: {e}") else: - logger.warning("No connection found for batch ID: %s", process_id) + logger.warning("No connection found for process ID: %s", process_id) class TeamConfig: """Team configuration for agents.""" @@ -138,12 +203,12 @@ class TeamConfig: def __init__(self): self.teams: Dict[str, TeamConfiguration] = {} - def set_current_team(self, user_id: str, team_config: TeamConfiguration): + def set_current_team(self, user_id: str, team_configuration: TeamConfiguration): """Add a new team configuration.""" # To do: close current team of agents if any - self.teams[user_id] = team_config + self.teams[user_id] = team_configuration def get_current_team(self, user_id: str) -> TeamConfiguration: """Get the current team configuration.""" diff --git a/src/backend/v3/orchestration/human_approval_manager.py b/src/backend/v3/orchestration/human_approval_manager.py index 0ebc3abaf..65b420d7a 100644 --- a/src/backend/v3/orchestration/human_approval_manager.py +++ b/src/backend/v3/orchestration/human_approval_manager.py @@ -6,10 +6,12 @@ import re from typing import Any, List, Optional +import v3.models.messages as messages from semantic_kernel.agents import Agent from semantic_kernel.agents.orchestration.magentic import ( MagenticContext, StandardMagenticManager) from semantic_kernel.contents import ChatMessageContent +from v3.config.settings import connection_config, current_user_id from v3.models.models import MPlan, MStep @@ -22,12 +24,16 @@ class HumanApprovalMagenticManager(StandardMagenticManager): # Define Pydantic fields to avoid validation errors approval_enabled: bool = True magentic_plan: Optional[MPlan] = None + current_user_id: Optional[str] = None + def __init__(self, *args, **kwargs): # Remove any custom kwargs before passing to parent super().__init__(*args, **kwargs) + # Use object.__setattr__ to bypass Pydantic validation + # object.__setattr__(self, 'current_user_id', None) - async def plan(self, magentic_context) -> Any: + async def plan(self, magentic_context: MagenticContext) -> Any: """ Override the plan method to create the plan first, then ask for approval before execution. """ @@ -45,45 +51,48 @@ async def plan(self, magentic_context) -> Any: # First, let the parent create the actual plan print("📋 Creating execution plan...") plan = await super().plan(magentic_context) - self.magentic_plan = self.plan_to_obj( magentic_context, self.task_ledger) + + # Request approval from the user before executing the plan + approval_message = messages.PlanApprovalRequest( + plan=self.magentic_plan, + status="PENDING_APPROVAL", + context={ + "task": task_text, + "participant_descriptions": magentic_context.participant_descriptions + } if hasattr(magentic_context, 'participant_descriptions') else {} + ) + + # Send the current plan to the frontend via WebSocket + #await connection_config.send_status_update_async(approval_message,) - # If planning failed or returned early, just return the result - if isinstance(plan, ChatMessageContent): - # Now show the actual plan and ask for approval - plan_approved = await self._get_plan_approval_with_details( - task_text, - magentic_context.participant_descriptions, - plan - ) - if not plan_approved: - print("❌ Plan execution cancelled by user") - return ChatMessageContent( - role="assistant", - content="Plan execution was cancelled by the user." - ) - - # If we get here, plan is approved - return the plan for execution + # Send the approval request to the user's WebSocket + # The user_id will be automatically retrieved from context + await connection_config.send_status_update_async({ + "type": "plan_approval_request", + "data": approval_message + }) + + # Wait for user approval (you'll need to implement this) + approval_response = await self._wait_for_user_approval() + + if approval_response and approval_response.approved: print("✅ Plan approved - proceeding with execution...") return plan - - # If plan is not a ChatMessageContent, still show it and ask for approval - if self._approval_settings['enabled']: - plan_approved = await self._get_plan_approval_with_details( - task_text, - magentic_context.participant_descriptions, - plan + else: + print("❌ Plan execution cancelled by user") + return ChatMessageContent( + role="assistant", + content="Plan execution was cancelled by the user." ) - if not plan_approved: - print("❌ Plan execution cancelled by user") - return ChatMessageContent( - role="assistant", - content="Plan execution was cancelled by the user." - ) + + async def _wait_for_user_approval(self) -> Optional[messages.PlanApprovalResponse]: + """Wait for user approval response.""" + user_id = current_user_id.get() + print(f"🔍 DEBUG: user_id from context = {user_id}") # <-- PUT BREAKPOINT HERE - # If we get here, plan is approved - return the plan for execution - print("✅ Plan approved - proceeding with execution...") - return plan + # Return None to cancel plan (for now, just to test context) + return None async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessageContent: """ diff --git a/src/backend/v3/orchestration/orchestration_manager.py b/src/backend/v3/orchestration/orchestration_manager.py index d5102f592..902495abf 100644 --- a/src/backend/v3/orchestration/orchestration_manager.py +++ b/src/backend/v3/orchestration/orchestration_manager.py @@ -1,8 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. """ Orchestration manager to handle the orchestration logic. """ +import contextvars import os import uuid +from contextvars import ContextVar from typing import List, Optional from azure.identity import DefaultAzureCredential as SyncDefaultAzureCredential @@ -13,15 +15,22 @@ from semantic_kernel.connectors.ai.open_ai import ( AzureChatCompletion, OpenAIChatPromptExecutionSettings) from v3.callbacks.response_handlers import (agent_response_callback, + set_user_context, streaming_agent_response_callback) -from v3.config.settings import config, orchestration_config +from v3.config.settings import config, current_user_id, orchestration_config from v3.magentic_agents.magentic_agent_factory import MagenticAgentFactory from v3.orchestration.human_approval_manager import \ HumanApprovalMagenticManager +# Context variable to hold the current user ID +current_user_id: ContextVar[Optional[str]] = contextvars.ContextVar("current_user_id", default=None) class OrchestrationManager: """Manager for handling orchestration logic.""" + + def __init__(self): + self.user_id: Optional[str] = None + @classmethod async def init_orchestration(cls, agents: List)-> MagenticOrchestration: """Main function to run the agents.""" @@ -56,19 +65,21 @@ def get_token(): return magentic_orchestration @classmethod - async def get_current_or_new_orchestration(cls, user_id: str, team_config: TeamConfiguration) -> MagenticOrchestration: + async def get_current_or_new_orchestration(self, user_id: str, team_config: TeamConfiguration) -> MagenticOrchestration: """get existing orchestration instance.""" current_orchestration = orchestration_config.get_current_orchestration(user_id) if current_orchestration is None: factory = MagenticAgentFactory() # to do: change to parsing teams from cosmos db agents = await factory.get_agents(team_config_input=team_config) - orchestration_config.orchestrations[user_id] = await cls.init_orchestration(agents) + orchestration_config.orchestrations[user_id] = await self.init_orchestration(agents) return orchestration_config.get_current_orchestration(user_id) - @classmethod - async def run_orchestration(cls, user_id, input_task) -> None: + async def run_orchestration(self, user_id, input_task) -> None: """ Run the orchestration with user input loop.""" + token = current_user_id.set(user_id) + + set_user_context(user_id) job_id = str(uuid.uuid4()) orchestration_config.approvals[job_id] = None @@ -78,6 +89,15 @@ async def run_orchestration(cls, user_id, input_task) -> None: if magentic_orchestration is None: raise ValueError("Orchestration not initialized for user.") + try: + # ADD THIS: Set user_id on the approval manager before invoke + if hasattr(magentic_orchestration, '_manager') and hasattr(magentic_orchestration._manager, 'current_user_id'): + #object.__setattr__(magentic_orchestration._manager, 'current_user_id', user_id) + magentic_orchestration._manager.current_user_id = user_id + print(f"🔍 DEBUG: Set user_id on manager = {user_id}") + except Exception as e: + print(f"Error setting user_id on manager: {e}") + runtime = InProcessRuntime() runtime.start() @@ -104,3 +124,5 @@ async def run_orchestration(cls, user_id, input_task) -> None: print(f"Unexpected error: {e}") finally: await runtime.stop_when_idle() + current_user_id.reset(token) + diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index f48cd6d79..2a87687ea 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -65,12 +65,18 @@ const PlanPage: React.FC = () => { // WebSocket connection and streaming setup useEffect(() => { const initializeWebSocket = async () => { - try { - await webSocketService.connect(); + // Only connect if not already connected + if (!webSocketService.isConnected()) { + try { + await webSocketService.connect(); + setWsConnected(true); + } catch (error) { + console.error('Failed to connect to WebSocket:', error); + setWsConnected(false); + } + } else { + // Already connected setWsConnected(true); - } catch (error) { - console.error('Failed to connect to WebSocket:', error); - setWsConnected(false); } }; @@ -121,7 +127,7 @@ const PlanPage: React.FC = () => { unsubscribeError(); webSocketService.disconnect(); }; - }, [planId, showToast]); + }, [planId]); // Subscribe to plan updates when planId changes useEffect(() => { diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index bf4817591..63dff3ee4 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -42,6 +42,7 @@ export interface PlanApprovalResponseData { class WebSocketService { private ws: WebSocket | null = null; + private processId: string | null = null; // Add this to store the process ID private reconnectAttempts = 0; private maxReconnectAttempts = 5; private reconnectDelay = 1000; @@ -52,13 +53,40 @@ class WebSocketService { * Connect to WebSocket server */ connect(): Promise { + // If already connected, return resolved promise + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + console.log('WebSocket already connected'); + return Promise.resolve(); + } + + // If already connecting, wait for that connection + if (this.ws && this.ws.readyState === WebSocket.CONNECTING) { + console.log('WebSocket connection already in progress'); + return new Promise((resolve, reject) => { + const checkConnection = () => { + if (this.ws?.readyState === WebSocket.OPEN) { + resolve(); + } else if (this.ws?.readyState === WebSocket.CLOSED) { + reject(new Error('Connection failed')); + } else { + setTimeout(checkConnection, 100); + } + }; + checkConnection(); + }); + } return new Promise((resolve, reject) => { try { // Get WebSocket URL from environment or default to localhost const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = process.env.REACT_APP_WS_HOST || '127.0.0.1:8000'; - const processId = crypto.randomUUID(); // Generate unique process ID for this session - const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}`; + + // Generate process ID only once per service instance, or reuse existing + if (!this.processId) { + this.processId = crypto.randomUUID(); + } + + const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${this.processId}`; console.log('Connecting to WebSocket:', wsUrl); From 065f152fea6bfb17acdde1919a582909df28cdcf Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 29 Aug 2025 13:11:09 -0700 Subject: [PATCH 19/41] clean up user safe websocket calling across contexts --- src/backend/v3/callbacks/response_handlers.py | 60 +++++++++---------- src/backend/v3/magentic_agents/proxy_agent.py | 2 +- .../v3/orchestration/orchestration_manager.py | 30 ++++++---- 3 files changed, 51 insertions(+), 41 deletions(-) diff --git a/src/backend/v3/callbacks/response_handlers.py b/src/backend/v3/callbacks/response_handlers.py index 50e1bd8eb..f863b379f 100644 --- a/src/backend/v3/callbacks/response_handlers.py +++ b/src/backend/v3/callbacks/response_handlers.py @@ -9,61 +9,61 @@ StreamingChatMessageContent) from v3.config.settings import connection_config, current_user_id -coderagent = False -# Module-level variable to store current user_id -_current_user_id: str = None - -def set_user_context(user_id: str): - """Set the user context for callbacks in this module.""" - global _current_user_id - _current_user_id = user_id - -def agent_response_callback(message: ChatMessageContent) -> None: +def agent_response_callback(message: ChatMessageContent, user_id: str = None) -> None: """Observer function to print detailed information about streaming messages.""" - global coderagent # import sys # Get agent name to determine handling agent_name = message.name or "Unknown Agent" + # Debug information about the message - message_type = type(message).__name__ - metadata = getattr(message, 'metadata', {}) - # when streaming code - list the coder info first once - - if 'code' in metadata and metadata['code'] is True: - if coderagent == False: - print(f"\n🧠 **{agent_name}** [{message_type}]") - print("-" * (len(agent_name) + len(message_type) + 10)) - coderagent = True - print(message.content, end='', flush=False) - return - elif coderagent == True: - coderagent = False + # message_type = type(message).__name__ + # metadata = getattr(message, 'metadata', {}) + # # when streaming code - list the coder info first once - + # if 'code' in metadata and metadata['code'] is True: + # if coderagent == False: + # print(f"\n **{agent_name}** [{message_type}]") + # print("-" * (len(agent_name) + len(message_type) + 10)) + # coderagent = True + # print(message.content, end='', flush=False) + # return + # elif coderagent == True: + # coderagent = False role = getattr(message, 'role', 'unknown') # Send to WebSocket - if _current_user_id: + if user_id: try: asyncio.create_task(connection_config.send_status_update_async({ "type": "agent_message", "data": {"agent_name": agent_name, "content": message.content, "role": role} - }, _current_user_id)) + }, user_id)) except Exception as e: print(f"Error sending WebSocket message: {e}") - print(f"\n🧠 **{agent_name}** [{message_type}] (role: {role})") - print("-" * (len(agent_name) + len(message_type) + 10)) + print(f"\n **{agent_name}** (role: {role})") + if message.items[-1].content_type == 'function_call': print(f"🔧 Function call: {message.items[-1].function_name}, Arguments: {message.items[-1].arguments}") - if metadata: - print(f"📋 Metadata: {metadata}") + # Add this function after your agent_response_callback function -async def streaming_agent_response_callback(streaming_message: StreamingChatMessageContent, is_final: bool) -> None: +async def streaming_agent_response_callback(streaming_message: StreamingChatMessageContent, is_final: bool, user_id: str = None) -> None: """Simple streaming callback to show real-time agent responses.""" if streaming_message.name != "CoderAgent": # Print streaming content as it arrives if hasattr(streaming_message, 'content') and streaming_message.content: print(streaming_message.content, end='', flush=False) + + # Send to WebSocket + if user_id: + try: + await connection_config.send_status_update_async({ + "type": "streaming_message", + "data": {"agent_name": streaming_message.name or "Unknown Agent", "content": streaming_message.content, "is_final": is_final} + }, user_id) + except Exception as e: + print(f"Error sending streaming WebSocket message: {e}") \ No newline at end of file diff --git a/src/backend/v3/magentic_agents/proxy_agent.py b/src/backend/v3/magentic_agents/proxy_agent.py index d2f7bf0a9..4b71d1540 100644 --- a/src/backend/v3/magentic_agents/proxy_agent.py +++ b/src/backend/v3/magentic_agents/proxy_agent.py @@ -102,7 +102,7 @@ async def invoke(self, message: str,*, thread: AgentThread | None = None,**kwarg expected_type=DummyAgentThread, ) # Replace with websocket call when available - print(f"\n🤔 ProxyAgent: Another agent is asking for clarification about:") + print(f"\nProxyAgent: Another agent is asking for clarification about:") print(f" Request: {message}") print("-" * 60) diff --git a/src/backend/v3/orchestration/orchestration_manager.py b/src/backend/v3/orchestration/orchestration_manager.py index 902495abf..f44b9e78a 100644 --- a/src/backend/v3/orchestration/orchestration_manager.py +++ b/src/backend/v3/orchestration/orchestration_manager.py @@ -14,8 +14,9 @@ # Create custom execution settings to fix schema issues from semantic_kernel.connectors.ai.open_ai import ( AzureChatCompletion, OpenAIChatPromptExecutionSettings) +from semantic_kernel.contents import (ChatMessageContent, + StreamingChatMessageContent) from v3.callbacks.response_handlers import (agent_response_callback, - set_user_context, streaming_agent_response_callback) from v3.config.settings import config, current_user_id, orchestration_config from v3.magentic_agents.magentic_agent_factory import MagenticAgentFactory @@ -32,7 +33,7 @@ def __init__(self): self.user_id: Optional[str] = None @classmethod - async def init_orchestration(cls, agents: List)-> MagenticOrchestration: + async def init_orchestration(cls, agents: List, user_id: str = None)-> MagenticOrchestration: """Main function to run the agents.""" # Custom execution settings that should work with Azure OpenAI @@ -59,28 +60,39 @@ def get_token(): ), execution_settings=execution_settings ), - agent_response_callback=agent_response_callback, - streaming_agent_response_callback=streaming_agent_response_callback, # Add streaming callback + agent_response_callback=cls._user_aware_agent_callback(user_id), + streaming_agent_response_callback=cls._user_aware_streaming_callback(user_id) ) return magentic_orchestration + @staticmethod + def _user_aware_agent_callback(user_id: str): + """Factory method that creates a callback with captured user_id""" + def callback(message: ChatMessageContent): + return agent_response_callback(message, user_id) + return callback + + @staticmethod + def _user_aware_streaming_callback(user_id: str): + """Factory method that creates a streaming callback with captured user_id""" + async def callback(streaming_message: StreamingChatMessageContent, is_final: bool): + return await streaming_agent_response_callback(streaming_message, is_final, user_id) + return callback + @classmethod async def get_current_or_new_orchestration(self, user_id: str, team_config: TeamConfiguration) -> MagenticOrchestration: """get existing orchestration instance.""" current_orchestration = orchestration_config.get_current_orchestration(user_id) if current_orchestration is None: factory = MagenticAgentFactory() - # to do: change to parsing teams from cosmos db agents = await factory.get_agents(team_config_input=team_config) - orchestration_config.orchestrations[user_id] = await self.init_orchestration(agents) + orchestration_config.orchestrations[user_id] = await self.init_orchestration(agents, user_id) return orchestration_config.get_current_orchestration(user_id) async def run_orchestration(self, user_id, input_task) -> None: """ Run the orchestration with user input loop.""" token = current_user_id.set(user_id) - set_user_context(user_id) - job_id = str(uuid.uuid4()) orchestration_config.approvals[job_id] = None @@ -90,9 +102,7 @@ async def run_orchestration(self, user_id, input_task) -> None: raise ValueError("Orchestration not initialized for user.") try: - # ADD THIS: Set user_id on the approval manager before invoke if hasattr(magentic_orchestration, '_manager') and hasattr(magentic_orchestration._manager, 'current_user_id'): - #object.__setattr__(magentic_orchestration._manager, 'current_user_id', user_id) magentic_orchestration._manager.current_user_id = user_id print(f"🔍 DEBUG: Set user_id on manager = {user_id}") except Exception as e: From 16445236c55c5b9d5858e6a55f34bab260b00a08 Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 29 Aug 2025 16:37:00 -0700 Subject: [PATCH 20/41] Adding support for proxy agent messages --- .../magentic_agents/magentic_agent_factory.py | 4 +- src/backend/v3/magentic_agents/proxy_agent.py | 88 +++++++++++++------ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/backend/v3/magentic_agents/magentic_agent_factory.py b/src/backend/v3/magentic_agents/magentic_agent_factory.py index a46c6daf6..e332f0ff1 100644 --- a/src/backend/v3/magentic_agents/magentic_agent_factory.py +++ b/src/backend/v3/magentic_agents/magentic_agent_factory.py @@ -9,6 +9,7 @@ from typing import List, Union from common.models.messages_kernel import TeamConfiguration +from v3.config.settings import current_user_id from v3.magentic_agents.foundry_agent import FoundryAgentTemplate from v3.magentic_agents.models.agent_models import (BingConfig, MCPConfig, SearchConfig) @@ -60,7 +61,8 @@ async def create_agent_from_config(self, agent_obj: SimpleNamespace) -> Union[Fo if not deployment_name and agent_obj.name.lower() == "proxyagent": self.logger.info("Creating ProxyAgent") - return ProxyAgent() + user_id = current_user_id.get() + return ProxyAgent(user_id=user_id) # Validate supported models supported_models = json.loads(os.getenv("SUPPORTED_MODELS")) diff --git a/src/backend/v3/magentic_agents/proxy_agent.py b/src/backend/v3/magentic_agents/proxy_agent.py index 4b71d1540..b0d63e582 100644 --- a/src/backend/v3/magentic_agents/proxy_agent.py +++ b/src/backend/v3/magentic_agents/proxy_agent.py @@ -4,18 +4,23 @@ import logging import uuid from collections.abc import AsyncIterable -from typing import AsyncIterator +from typing import AsyncIterator, Optional +from pydantic import Field from semantic_kernel.agents import ( # pylint: disable=no-name-in-module AgentResponseItem, AgentThread) from semantic_kernel.agents.agent import Agent -from semantic_kernel.contents import AuthorRole, ChatMessageContent +from semantic_kernel.contents import (AuthorRole, ChatMessageContent, + StreamingChatMessageContent) from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.contents.history_reducer.chat_history_reducer import \ ChatHistoryReducer from semantic_kernel.exceptions.agent_exceptions import \ AgentThreadOperationException from typing_extensions import override +from v3.callbacks.response_handlers import (agent_response_callback, + streaming_agent_response_callback) +from v3.config.settings import current_user_id class DummyAgentThread(AgentThread): @@ -82,15 +87,48 @@ def __init__(self, message: ChatMessageContent, thread: AgentThread): class ProxyAgent(Agent): """Simple proxy agent that prompts for human clarification.""" + + # Declare as Pydantic field + user_id: Optional[str] = Field(default=None, description="User ID for WebSocket messaging") - def __init__(self): + def __init__(self, user_id: str = None, **kwargs): + # Get user_id from parameter or context, fallback to empty string + effective_user_id = user_id or current_user_id.get() or "" super().__init__( - name="ProxyAgent", + name="ProxyAgent", description="""Call this agent when you need to clarify requests by asking the human user for more information. Ask it for more details about any unclear requirements, missing information, - or if you need the user to elaborate on any aspect of the task.""" + or if you need the user to elaborate on any aspect of the task.""", + user_id=effective_user_id, + **kwargs ) self.instructions = "" + + self.user_id = user_id or current_user_id.get() + + def _create_message_content(self, content: str, thread_id: str = None) -> ChatMessageContent: + """Create a ChatMessageContent with proper metadata.""" + return ChatMessageContent( + role=AuthorRole.ASSISTANT, + content=content, + name=self.name, + metadata={"thread_id": thread_id} if thread_id else {} + ) + + async def _trigger_response_callbacks(self, message_content: ChatMessageContent): + """Manually trigger the same response callbacks used by other agents.""" + # Trigger the standard agent response callback + agent_response_callback(message_content, self.user_id) + + async def _trigger_streaming_callbacks(self, content: str, is_final: bool = False): + """Manually trigger streaming callbacks for real-time updates.""" + streaming_message = StreamingChatMessageContent( + role=AuthorRole.ASSISTANT, + content=content, + name=self.name, + choice_index=0 + ) + await streaming_agent_response_callback(streaming_message, is_final, self.user_id) async def invoke(self, message: str,*, thread: AgentThread | None = None,**kwargs) -> AsyncIterator[ChatMessageContent]: """Ask human user for clarification about the message.""" @@ -101,10 +139,10 @@ async def invoke(self, message: str,*, thread: AgentThread | None = None,**kwarg construct_thread=lambda: DummyAgentThread(), expected_type=DummyAgentThread, ) - # Replace with websocket call when available - print(f"\nProxyAgent: Another agent is asking for clarification about:") - print(f" Request: {message}") - print("-" * 60) + # Send clarification request via response handlers + clarification_request = f"I need clarification about: {message}" + clarification_message = self._create_message_content(clarification_request, thread.id) + # await self._trigger_response_callbacks(clarification_message) # Get human input human_response = input("Please provide clarification: ").strip() @@ -114,12 +152,11 @@ async def invoke(self, message: str,*, thread: AgentThread | None = None,**kwarg response = f"Human clarification: {human_response}" - chat_message = ChatMessageContent( - role=AuthorRole.ASSISTANT, - content=response, - name=self.name, - metadata={"thread_id": thread.id} - ) + # Send response via response handlers + response_message = self._create_message_content(response, thread.id) + #await self._trigger_response_callbacks(response_message) + + chat_message = response_message yield AgentResponseItem( message=chat_message, @@ -144,10 +181,9 @@ async def invoke_stream(self, messages, thread=None, **kwargs) -> AsyncIterator[ else: message = str(messages) - # Replace with websocket call when available - print(f"\nProxyAgent: Another agent is asking for clarification about:") - print(f" Request: {message}") - print("-" * 60) + # Send clarification request via streaming callbacks + clarification_request = f"I need clarification about: {message}" + #await self._trigger_streaming_callbacks(clarification_request) # Get human input - replace with websocket call when available human_response = input("Please provide clarification: ").strip() @@ -157,12 +193,10 @@ async def invoke_stream(self, messages, thread=None, **kwargs) -> AsyncIterator[ response = f"Human clarification: {human_response}" - chat_message = ChatMessageContent( - role=AuthorRole.ASSISTANT, - content=response, - name=self.name, - metadata={"thread_id": thread.id} - ) + # Send response via streaming callbacks + #await self._trigger_streaming_callbacks(response, is_final=True) + + chat_message = self._create_message_content(response, thread.id) yield AgentResponseItem( message=chat_message, @@ -184,6 +218,6 @@ async def get_response(self, chat_history, **kwargs): content="No clarification provided." ) -async def create_proxy_agent(): +async def create_proxy_agent(user_id: str = None): """Factory function for human proxy agent.""" - return ProxyAgent() \ No newline at end of file + return ProxyAgent(user_id=user_id) \ No newline at end of file From e95a02a2613043c29f4992d98877196dfc85b810 Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 29 Aug 2025 18:01:16 -0700 Subject: [PATCH 21/41] Updates for socket messages from proxy agent --- src/backend/v3/callbacks/response_handlers.py | 15 --------- src/backend/v3/magentic_agents/proxy_agent.py | 23 ++++++------- .../orchestration/human_approval_manager.py | 32 +++++++++++++------ 3 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/backend/v3/callbacks/response_handlers.py b/src/backend/v3/callbacks/response_handlers.py index f863b379f..c6e8d7773 100644 --- a/src/backend/v3/callbacks/response_handlers.py +++ b/src/backend/v3/callbacks/response_handlers.py @@ -16,21 +16,6 @@ def agent_response_callback(message: ChatMessageContent, user_id: str = None) -> # Get agent name to determine handling agent_name = message.name or "Unknown Agent" - - - # Debug information about the message - # message_type = type(message).__name__ - # metadata = getattr(message, 'metadata', {}) - # # when streaming code - list the coder info first once - - # if 'code' in metadata and metadata['code'] is True: - # if coderagent == False: - # print(f"\n **{agent_name}** [{message_type}]") - # print("-" * (len(agent_name) + len(message_type) + 10)) - # coderagent = True - # print(message.content, end='', flush=False) - # return - # elif coderagent == True: - # coderagent = False role = getattr(message, 'role', 'unknown') diff --git a/src/backend/v3/magentic_agents/proxy_agent.py b/src/backend/v3/magentic_agents/proxy_agent.py index b0d63e582..d6feab062 100644 --- a/src/backend/v3/magentic_agents/proxy_agent.py +++ b/src/backend/v3/magentic_agents/proxy_agent.py @@ -6,6 +6,7 @@ from collections.abc import AsyncIterable from typing import AsyncIterator, Optional +import v3.models.messages as agent_messages from pydantic import Field from semantic_kernel.agents import ( # pylint: disable=no-name-in-module AgentResponseItem, AgentThread) @@ -104,8 +105,6 @@ def __init__(self, user_id: str = None, **kwargs): ) self.instructions = "" - self.user_id = user_id or current_user_id.get() - def _create_message_content(self, content: str, thread_id: str = None) -> ChatMessageContent: """Create a ChatMessageContent with proper metadata.""" return ChatMessageContent( @@ -117,18 +116,23 @@ def _create_message_content(self, content: str, thread_id: str = None) -> ChatMe async def _trigger_response_callbacks(self, message_content: ChatMessageContent): """Manually trigger the same response callbacks used by other agents.""" + # Get current user_id dynamically instead of using stored value + current_user = current_user_id.get() or self.user_id or "" + # Trigger the standard agent response callback - agent_response_callback(message_content, self.user_id) + agent_response_callback(message_content, current_user) async def _trigger_streaming_callbacks(self, content: str, is_final: bool = False): """Manually trigger streaming callbacks for real-time updates.""" + # Get current user_id dynamically instead of using stored value + current_user = current_user_id.get() or self.user_id or "" streaming_message = StreamingChatMessageContent( role=AuthorRole.ASSISTANT, content=content, name=self.name, choice_index=0 ) - await streaming_agent_response_callback(streaming_message, is_final, self.user_id) + await streaming_agent_response_callback(streaming_message, is_final, current_user) async def invoke(self, message: str,*, thread: AgentThread | None = None,**kwargs) -> AsyncIterator[ChatMessageContent]: """Ask human user for clarification about the message.""" @@ -142,9 +146,9 @@ async def invoke(self, message: str,*, thread: AgentThread | None = None,**kwarg # Send clarification request via response handlers clarification_request = f"I need clarification about: {message}" clarification_message = self._create_message_content(clarification_request, thread.id) - # await self._trigger_response_callbacks(clarification_message) + await self._trigger_response_callbacks(clarification_message) - # Get human input + # Get human input - replace this with awaiting a websocket call or API handler when available human_response = input("Please provide clarification: ").strip() if not human_response: @@ -154,7 +158,6 @@ async def invoke(self, message: str,*, thread: AgentThread | None = None,**kwarg # Send response via response handlers response_message = self._create_message_content(response, thread.id) - #await self._trigger_response_callbacks(response_message) chat_message = response_message @@ -183,7 +186,8 @@ async def invoke_stream(self, messages, thread=None, **kwargs) -> AsyncIterator[ # Send clarification request via streaming callbacks clarification_request = f"I need clarification about: {message}" - #await self._trigger_streaming_callbacks(clarification_request) + self._create_message_content(clarification_request, thread.id) + await self._trigger_streaming_callbacks(clarification_request) # Get human input - replace with websocket call when available human_response = input("Please provide clarification: ").strip() @@ -192,9 +196,6 @@ async def invoke_stream(self, messages, thread=None, **kwargs) -> AsyncIterator[ human_response = "No additional clarification provided." response = f"Human clarification: {human_response}" - - # Send response via streaming callbacks - #await self._trigger_streaming_callbacks(response, is_final=True) chat_message = self._create_message_content(response, thread.id) diff --git a/src/backend/v3/orchestration/human_approval_manager.py b/src/backend/v3/orchestration/human_approval_manager.py index 65b420d7a..3780ca45a 100644 --- a/src/backend/v3/orchestration/human_approval_manager.py +++ b/src/backend/v3/orchestration/human_approval_manager.py @@ -77,22 +77,34 @@ async def plan(self, magentic_context: MagenticContext) -> Any: approval_response = await self._wait_for_user_approval() if approval_response and approval_response.approved: - print("✅ Plan approved - proceeding with execution...") + print("Plan approved - proceeding with execution...") return plan else: - print("❌ Plan execution cancelled by user") - return ChatMessageContent( - role="assistant", - content="Plan execution was cancelled by the user." - ) + print("Plan execution cancelled by user") + await connection_config.send_status_update_async({ + "type": "plan_approval_response", + "data": approval_response + }) + raise Exception("Plan execution cancelled by user") + # return ChatMessageContent( + # role="assistant", + # content="Plan execution was cancelled by the user." + # ) + async def _wait_for_user_approval(self) -> Optional[messages.PlanApprovalResponse]: """Wait for user approval response.""" user_id = current_user_id.get() - print(f"🔍 DEBUG: user_id from context = {user_id}") # <-- PUT BREAKPOINT HERE - - # Return None to cancel plan (for now, just to test context) - return None + # Temporarily use console input for approval - will switch to WebSocket or API in future + response = input("\nApprove this execution plan? [y/n]: ").strip().lower() + if response in ['y', 'yes']: + return messages.PlanApprovalResponse(approved=True) + elif response in ['n', 'no']: + return messages.PlanApprovalResponse(approved=False) + else: + print("Invalid input. Please enter 'y' for yes or 'n' for no.") + return await self._wait_for_user_approval() + async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessageContent: """ From b8f11cd423903a7938efb3e6bb8999fb47c522cd Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 30 Aug 2025 00:10:32 -0700 Subject: [PATCH 22/41] Remove but document mcp auth --- docs/mcp_server.md | 34 +++++++++++++++++++ src/backend/v3/api/router.py | 2 +- .../v3/magentic_agents/common/lifecycle.py | 30 ++++++++-------- src/mcp_server/my_mcp_server/my_mcp_server.py | 18 +++++----- 4 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 docs/mcp_server.md diff --git a/docs/mcp_server.md b/docs/mcp_server.md new file mode 100644 index 000000000..16c6e7268 --- /dev/null +++ b/docs/mcp_server.md @@ -0,0 +1,34 @@ +Capturing the notes from auth install before deleting for docs... + +### Auth section: +Requires and app registration as in azure_app_service_auth_setup.md so not deployed by default. + +To setup basic auth with FastMCP - bearer token - you can integrate with Azure by using it as your token provider. + +``` from fastmcp.server.auth import JWTVerifier``` + +``` +auth = JWTVerifier( + jwks_uri="https://login.microsoftonline.com/52b39610-0746-4c25-a83d-d4f89fadedfe/discovery/v2.0/keys", + #issuer="https://login.microsoftonline.com/52b39610-0746-4c25-a83d-d4f89fadedfe/v2.0", + # This issuer is not correct in the docs. Found by decoding the token. + issuer="https://sts.windows.net/52b39610-0746-4c25-a83d-d4f89fadedfe/", + algorithm="RS256", + audience="api://7a95e70b-062e-4cd3-a88c-603fc70e1c73" +) +``` + +Requires env vars: +``` +export MICROSOFT_CLIENT_ID="your-client-id" +export MICROSOFT_CLIENT_SECRET="your-client-secret" +export MICROSOFT_TENANT="common" # Or your tenant ID +``` + +```mcp = FastMCP("My MCP Server", auth=auth)``` + +For more complex and production - supports OAuth and PKCE + +Enabled through MCP enabled base - see lifecycle.py + + diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 001715879..4e30f43ca 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -727,7 +727,7 @@ async def select_team_endpoint(selection: TeamSelectionRequest, request: Request session_id = selection.session_id or str(uuid.uuid4()) # save to in-memory config for current user - team_config.set_current_team(user_id=user_id, team_config=team_configuration) + team_config.set_current_team(user_id=user_id, team_configuration=team_configuration) # Track the team selection event track_event_if_configured( diff --git a/src/backend/v3/magentic_agents/common/lifecycle.py b/src/backend/v3/magentic_agents/common/lifecycle.py index 1f7742797..36acada97 100644 --- a/src/backend/v3/magentic_agents/common/lifecycle.py +++ b/src/backend/v3/magentic_agents/common/lifecycle.py @@ -60,29 +60,29 @@ async def _after_open(self) -> None: """Subclasses must build self._agent here.""" raise NotImplementedError - # Internals - def _build_mcp_headers(self) -> dict: - if not self.mcp_cfg.client_id: - return {} - self.cred = InteractiveBrowserCredential( - tenant_id=self.mcp_cfg.tenant_id or None, - client_id=self.mcp_cfg.client_id, - ) - tok = self.cred.get_token(f"api://{self.mcp_cfg.client_id}/access_as_user") - return { - "Authorization": f"Bearer {tok.token}", - "Content-Type": "application/json", - } + # For use when implementing bearer token auth + # def _build_mcp_headers(self) -> dict: + # if not self.mcp_cfg.client_id: + # return {} + # self.cred = InteractiveBrowserCredential( + # tenant_id=self.mcp_cfg.tenant_id or None, + # client_id=self.mcp_cfg.client_id, + # ) + # tok = self.cred.get_token(f"api://{self.mcp_cfg.client_id}/access_as_user") + # return { + # "Authorization": f"Bearer {tok.token}", + # "Content-Type": "application/json", + # } async def _enter_mcp_if_configured(self) -> None: if not self.mcp_cfg: return - headers = self._build_mcp_headers() + #headers = self._build_mcp_headers() plugin = MCPStreamableHttpPlugin( name=self.mcp_cfg.name, description=self.mcp_cfg.description, url=self.mcp_cfg.url, - headers=headers, + #headers=headers, ) # Enter MCP async context via the stack to ensure correct LIFO cleanup if self._stack is None: diff --git a/src/mcp_server/my_mcp_server/my_mcp_server.py b/src/mcp_server/my_mcp_server/my_mcp_server.py index ca45518fc..e48a4755e 100644 --- a/src/mcp_server/my_mcp_server/my_mcp_server.py +++ b/src/mcp_server/my_mcp_server/my_mcp_server.py @@ -4,14 +4,14 @@ from fastmcp.server.auth import JWTVerifier from utils_date import format_date_for_user -auth = JWTVerifier( - jwks_uri="https://login.microsoftonline.com/52b39610-0746-4c25-a83d-d4f89fadedfe/discovery/v2.0/keys", - #issuer="https://login.microsoftonline.com/52b39610-0746-4c25-a83d-d4f89fadedfe/v2.0", - # This issuer is not correct in the docs. Found by decoding the token. - issuer="https://sts.windows.net/52b39610-0746-4c25-a83d-d4f89fadedfe/", - algorithm="RS256", - audience="api://7a95e70b-062e-4cd3-a88c-603fc70e1c73" -) +# auth = JWTVerifier( +# jwks_uri="https://login.microsoftonline.com/52b39610-0746-4c25-a83d-d4f89fadedfe/discovery/v2.0/keys", +# #issuer="https://login.microsoftonline.com/52b39610-0746-4c25-a83d-d4f89fadedfe/v2.0", +# # This issuer is not correct in the docs. Found by decoding the token. +# issuer="https://sts.windows.net/52b39610-0746-4c25-a83d-d4f89fadedfe/", +# algorithm="RS256", +# audience="api://7a95e70b-062e-4cd3-a88c-603fc70e1c73" +# ) class Domain(Enum): HR = "hr" @@ -21,7 +21,7 @@ class Domain(Enum): TECH_SUPPORT = "tech_support" RETAIL = "Retail" -mcp = FastMCP("My MCP Server", auth=auth) +mcp = FastMCP("My MCP Server") formatting_instructions = "Instructions: returning the output of this function call verbatim to the user in markdown. Then write AGENT SUMMARY: and then include a summary of what you did." From abca384a33f988fb2581dc1a94fdcc38097573da Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 30 Aug 2025 00:51:21 -0700 Subject: [PATCH 23/41] Added prompt for tools use with HIL --- .../orchestration/human_approval_manager.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/backend/v3/orchestration/human_approval_manager.py b/src/backend/v3/orchestration/human_approval_manager.py index 3780ca45a..7ff632bae 100644 --- a/src/backend/v3/orchestration/human_approval_manager.py +++ b/src/backend/v3/orchestration/human_approval_manager.py @@ -10,6 +10,8 @@ from semantic_kernel.agents import Agent from semantic_kernel.agents.orchestration.magentic import ( MagenticContext, StandardMagenticManager) +from semantic_kernel.agents.orchestration.prompts._magentic_prompts import \ + ORCHESTRATOR_TASK_LEDGER_FACTS_PROMPT from semantic_kernel.contents import ChatMessageContent from v3.config.settings import connection_config, current_user_id from v3.models.models import MPlan, MStep @@ -28,10 +30,22 @@ class HumanApprovalMagenticManager(StandardMagenticManager): def __init__(self, *args, **kwargs): # Remove any custom kwargs before passing to parent - super().__init__(*args, **kwargs) + # Use object.__setattr__ to bypass Pydantic validation # object.__setattr__(self, 'current_user_id', None) + + custom_addition = """ + +ADDITIONAL INSTRUCTIONS: +To address this request we have assembled the following team: + +{{$team}} + +Please check with the team members to list all relevant tools they have access to, and their required parameters.""" + + kwargs['task_ledger_facts_prompt'] = ORCHESTRATOR_TASK_LEDGER_FACTS_PROMPT + custom_addition + super().__init__(*args, **kwargs) async def plan(self, magentic_context: MagenticContext) -> Any: """ @@ -44,12 +58,12 @@ async def plan(self, magentic_context: MagenticContext) -> Any: elif not isinstance(task_text, str): task_text = str(task_text) - print(f"\n🎯 Human-in-the-Loop Magentic Manager Creating Plan:") + print(f"\n Human-in-the-Loop Magentic Manager Creating Plan:") print(f" Task: {task_text}") print("-" * 60) # First, let the parent create the actual plan - print("📋 Creating execution plan...") + print(" Creating execution plan...") plan = await super().plan(magentic_context) self.magentic_plan = self.plan_to_obj( magentic_context, self.task_ledger) @@ -110,19 +124,19 @@ async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatM """ Override to ensure final answer is prepared after all steps are executed. """ - print("\n📝 Magentic Manager - Preparing final answer...") + print("\n Magentic Manager - Preparing final answer...") return await super().prepare_final_answer(magentic_context) async def _get_plan_approval_with_details(self, task: str, participant_descriptions: dict, plan: Any) -> bool: while True: - approval = input("\n❓ Approve this execution plan? [y/n/details]: ").strip().lower() + approval = input("\ Approve this execution plan? [y/n/details]: ").strip().lower() if approval in ['y', 'yes']: - print("✅ Plan approved by user") + print(" Plan approved by user") return True elif approval in ['n', 'no']: - print("❌ Plan rejected by user") + print(" Plan rejected by user") return False # elif approval in ['d', 'details']: # self._show_detailed_plan_info(task, participant_descriptions, plan) From c69bb7d8da0fc30cb0f28c146943d04e2d5794c1 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 30 Aug 2025 01:11:10 -0700 Subject: [PATCH 24/41] Send final answer over websockets --- .../v3/orchestration/orchestration_manager.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/backend/v3/orchestration/orchestration_manager.py b/src/backend/v3/orchestration/orchestration_manager.py index f44b9e78a..a04b215b3 100644 --- a/src/backend/v3/orchestration/orchestration_manager.py +++ b/src/backend/v3/orchestration/orchestration_manager.py @@ -18,7 +18,8 @@ StreamingChatMessageContent) from v3.callbacks.response_handlers import (agent_response_callback, streaming_agent_response_callback) -from v3.config.settings import config, current_user_id, orchestration_config +from v3.config.settings import (config, connection_config, current_user_id, + orchestration_config) from v3.magentic_agents.magentic_agent_factory import MagenticAgentFactory from v3.orchestration.human_approval_manager import \ HumanApprovalMagenticManager @@ -123,6 +124,17 @@ async def run_orchestration(self, user_id, input_task) -> None: value = await orchestration_result.get() print(f"\nFinal result:\n{value}") print("=" * 50) + + # Send final result via WebSocket + await connection_config.send_status_update_async({ + "type": "final_result", + "data": { + "content": str(value), + "status": "completed", + "timestamp": str(uuid.uuid4()) # or use actual timestamp + } + }, user_id) + print(f"Final result sent via WebSocket to user {user_id}") except Exception as e: print(f"Error: {e}") print(f"Error type: {type(e).__name__}") From 0eece253f1bb889157953a32a3761e37e8a4cd41 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 30 Aug 2025 01:13:24 -0700 Subject: [PATCH 25/41] modifying prompt addition --- src/backend/v3/orchestration/human_approval_manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/backend/v3/orchestration/human_approval_manager.py b/src/backend/v3/orchestration/human_approval_manager.py index 7ff632bae..2e358fe51 100644 --- a/src/backend/v3/orchestration/human_approval_manager.py +++ b/src/backend/v3/orchestration/human_approval_manager.py @@ -35,8 +35,6 @@ def __init__(self, *args, **kwargs): # object.__setattr__(self, 'current_user_id', None) custom_addition = """ - -ADDITIONAL INSTRUCTIONS: To address this request we have assembled the following team: {{$team}} From 4793119539389df537676aa6e2755bdf32a40700 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 31 Aug 2025 14:03:22 -0400 Subject: [PATCH 26/41] Delete connection_manager.py --- src/backend/v3/common/services/connection_manager.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/backend/v3/common/services/connection_manager.py diff --git a/src/backend/v3/common/services/connection_manager.py b/src/backend/v3/common/services/connection_manager.py deleted file mode 100644 index e69de29bb..000000000 From 4ed204c457e26f79468f4b5b0c9d553b8139d693 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Sun, 31 Aug 2025 15:12:39 -0400 Subject: [PATCH 27/41] Remove onboarding scenarios module Deleted the scenarios package and onboarding_cases.py, which contained employee onboarding test cases and examples. This cleanup removes unused or obsolete scenario definitions from the backend. --- src/backend/v3/scenarios/__init__.py | 1 - src/backend/v3/scenarios/onboarding_cases.py | 141 ------------------- 2 files changed, 142 deletions(-) delete mode 100644 src/backend/v3/scenarios/__init__.py delete mode 100644 src/backend/v3/scenarios/onboarding_cases.py diff --git a/src/backend/v3/scenarios/__init__.py b/src/backend/v3/scenarios/__init__.py deleted file mode 100644 index 99ae28fd1..000000000 --- a/src/backend/v3/scenarios/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Scenarios package for employee onboarding test cases and examples diff --git a/src/backend/v3/scenarios/onboarding_cases.py b/src/backend/v3/scenarios/onboarding_cases.py deleted file mode 100644 index 435856e63..000000000 --- a/src/backend/v3/scenarios/onboarding_cases.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -Employee onboarding and other scenarios and test cases for the Magentic orchestration system. -Provides realistic use cases to demonstrate multi-agent collaboration. -""" - -class MagenticScenarios: - """Collection of employee onboarding scenarios for testing and demonstration.""" - - # Basic onboarding scenarios - WELCOME_NEW_HIRE = """ - A new software engineer named Sarah is starting at our tech company next Monday. - Help create a comprehensive first-week onboarding plan that includes: - - Welcome activities and team introductions - - Required training modules and timeline - - Equipment setup and access provisioning - - Key company policies to review - - First-week goals and expectations - - Research current best practices for remote software engineer onboarding in 2025. - """ - - ONBOARDING_METRICS_ANALYSIS = """ - Analyze our employee onboarding effectiveness using this sample data: - - Average time to first meaningful contribution: 45 days - - 90-day retention rate: 85% - - Employee satisfaction score (1-10): 7.2 - - Training completion rate: 78% - - Manager feedback score: 8.1 - - Compare these metrics to industry benchmarks and recommend improvements. - Create visualizations showing our performance vs. industry standards. - """ - - COMPLIANCE_RESEARCH = """ - Research the latest compliance requirements for employee onboarding in the technology sector for 2025. - Focus on: - - Data privacy and security training requirements - - Remote work compliance considerations - - Diversity, equity, and inclusion training mandates - - Industry-specific certifications needed - - Provide a comprehensive compliance checklist with implementation timeline. - """ - - REMOTE_ONBOARDING_OPTIMIZATION = """ - Our company is transitioning to a fully remote workforce. Design an optimized remote onboarding experience that addresses: - - Virtual team integration strategies - - Digital tool training and setup - - Remote culture building activities - - Asynchronous learning paths - - Virtual mentorship programs - - Research the most effective remote onboarding tools and platforms available in 2025. - """ - - ONBOARDING_COST_ANALYSIS = """ - Calculate the total cost of our current onboarding program and identify optimization opportunities: - - HR staff time allocation (40 hours per new hire) - - Training material costs ($500 per hire) - - Technology setup and licensing ($1,200 per hire) - - Manager mentoring time (20 hours per hire) - - Lost productivity during ramp-up period - - Research cost-effective alternatives and calculate potential ROI improvements. - """ - - MANAGER_TRAINING_PROGRAM = """ - Design a comprehensive training program for managers who will be onboarding new team members: - - Essential management skills for onboarding - - Communication best practices for new hires - - Goal setting and expectation management - - Cultural integration techniques - - Performance tracking during probation period - - Include current research on effective management practices for Gen Z employees entering the workforce. - """ - - TECH_STACK_ONBOARDING = """ - Create a detailed technical onboarding plan for a new developer joining our team that uses: - - React/TypeScript frontend - - Python/Django backend - - PostgreSQL database - - AWS cloud infrastructure - - Docker containerization - - Git/GitHub workflow - - Include learning resources, hands-on exercises, and milestone checkpoints for each technology. - Research the latest learning resources and tutorials for 2025. - """ - - FEEDBACK_ANALYSIS = """ - Analyze this employee feedback from our recent onboarding survey and recommend improvements: - - Positive feedback: - - "Great team culture and welcoming environment" - - "Clear initial project assignments" - - "Excellent technical mentorship" - - Areas for improvement: - - "Administrative processes were confusing" - - "Too much information in first week" - - "Unclear long-term career path discussion" - - "Limited social interaction opportunities" - - Provide specific, actionable recommendations with implementation priorities. - """ - - OFFICIAL_DEMO = """ - I am preparing a report on the energy efficiency of different machine learning model architectures. - Compare the estimated training and inference energy consumption of ResNet-50, BERT-base, and GPT-2 - on standard datasets (e.g., ImageNet for ResNet, GLUE for BERT, WebText for GPT-2). - Then, estimate the CO2 emissions associated with each, assuming training on an Azure Standard_NC6s_v3 VM - for 24 hours. Provide tables for clarity, and recommend the most energy-efficient model - per task type (image classification, text classification, and text generation). - """ - - @classmethod - def get_all_scenarios(cls): - """Get all onboarding scenarios as a dictionary.""" - return { - "welcome_new_hire": cls.WELCOME_NEW_HIRE, - "metrics_analysis": cls.ONBOARDING_METRICS_ANALYSIS, - "compliance_research": cls.COMPLIANCE_RESEARCH, - "remote_optimization": cls.REMOTE_ONBOARDING_OPTIMIZATION, - "cost_analysis": cls.ONBOARDING_COST_ANALYSIS, - "manager_training": cls.MANAGER_TRAINING_PROGRAM, - "tech_stack": cls.TECH_STACK_ONBOARDING, - "feedback_analysis": cls.FEEDBACK_ANALYSIS, - "official_demo": cls.OFFICIAL_DEMO, - } - - @classmethod - def get_scenario_names(cls): - """Get list of available scenario names.""" - return list(cls.get_all_scenarios().keys()) - - @classmethod - def get_scenario(cls, name: str): - """Get a specific scenario by name.""" - scenarios = cls.get_all_scenarios() - return scenarios.get(name, None) \ No newline at end of file From b338389eeb3a522ee69a5049f9d204aff6e6a38b Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 14:26:24 -0400 Subject: [PATCH 28/41] Remove MCP server and date utility modules Deleted my_mcp_server.py and utils_date.py from the MCP server source. This removes the main server implementation and the date formatting utility, likely as part of a refactor or deprecation. --- src/mcp_server/my_mcp_server/my_mcp_server.py | 150 ------------------ src/mcp_server/my_mcp_server/utils_date.py | 24 --- 2 files changed, 174 deletions(-) delete mode 100644 src/mcp_server/my_mcp_server/my_mcp_server.py delete mode 100644 src/mcp_server/my_mcp_server/utils_date.py diff --git a/src/mcp_server/my_mcp_server/my_mcp_server.py b/src/mcp_server/my_mcp_server/my_mcp_server.py deleted file mode 100644 index e48a4755e..000000000 --- a/src/mcp_server/my_mcp_server/my_mcp_server.py +++ /dev/null @@ -1,150 +0,0 @@ -from enum import Enum - -from fastmcp import FastMCP -from fastmcp.server.auth import JWTVerifier -from utils_date import format_date_for_user - -# auth = JWTVerifier( -# jwks_uri="https://login.microsoftonline.com/52b39610-0746-4c25-a83d-d4f89fadedfe/discovery/v2.0/keys", -# #issuer="https://login.microsoftonline.com/52b39610-0746-4c25-a83d-d4f89fadedfe/v2.0", -# # This issuer is not correct in the docs. Found by decoding the token. -# issuer="https://sts.windows.net/52b39610-0746-4c25-a83d-d4f89fadedfe/", -# algorithm="RS256", -# audience="api://7a95e70b-062e-4cd3-a88c-603fc70e1c73" -# ) - -class Domain(Enum): - HR = "hr" - MARKETING = "marketing" - PROCUREMENT = "procurement" - PRODUCT = "product" - TECH_SUPPORT = "tech_support" - RETAIL = "Retail" - -mcp = FastMCP("My MCP Server") - -formatting_instructions = "Instructions: returning the output of this function call verbatim to the user in markdown. Then write AGENT SUMMARY: and then include a summary of what you did." - -@mcp.tool -def greet(name: str) -> str: - """ Greets the user with the provided name.""" - return f"Hello from MCP, {name}!" - -@mcp.tool(tags={Domain.HR.value}) -async def schedule_orientation_session(employee_name: str, date: str) -> str: - """Schedule an orientation session for a new employee.""" - formatted_date = format_date_for_user(date) - - return ( - f"##### Orientation Session Scheduled\n" - f"**Employee Name:** {employee_name}\n" - f"**Date:** {formatted_date}\n\n" - f"Your orientation session has been successfully scheduled. " - f"Please mark your calendar and be prepared for an informative session.\n" - f"AGENT SUMMARY: I scheduled the orientation session for {employee_name} on {formatted_date}, as part of her onboarding process.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.HR.value}) -async def assign_mentor(employee_name: str) -> str: - """Assign a mentor to a new employee.""" - return ( - f"##### Mentor Assigned\n" - f"**Employee Name:** {employee_name}\n\n" - f"A mentor has been assigned to you. They will guide you through your onboarding process and help you settle into your new role.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.HR.value}) -async def register_for_benefits(employee_name: str) -> str: - """Register a new employee for benefits.""" - return ( - f"##### Benefits Registration\n" - f"**Employee Name:** {employee_name}\n\n" - f"You have been successfully registered for benefits. " - f"Please review your benefits package and reach out if you have any questions.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.HR.value}) -async def provide_employee_handbook(employee_name: str) -> str: - """Provide the employee handbook to a new employee.""" - return ( - f"##### Employee Handbook Provided\n" - f"**Employee Name:** {employee_name}\n\n" - f"The employee handbook has been provided to you. " - f"Please review it to familiarize yourself with company policies and procedures.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.HR.value}) -async def initiate_background_check(employee_name: str) -> str: - """Initiate a background check for a new employee.""" - return ( - f"##### Background Check Initiated\n" - f"**Employee Name:** {employee_name}\n\n" - f"A background check has been initiated for {employee_name}. " - f"You will be notified once the check is complete.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.HR.value}) -async def request_id_card(employee_name: str) -> str: - """Request an ID card for a new employee.""" - return ( - f"##### ID Card Request\n" - f"**Employee Name:** {employee_name}\n\n" - f"Your request for an ID card has been successfully submitted. " - f"Please allow 3-5 business days for processing. You will be notified once your ID card is ready for pickup.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.HR.value}) -async def set_up_payroll(employee_name: str) -> str: - """Set up payroll for a new employee.""" - return ( - f"##### Payroll Setup\n" - f"**Employee Name:** {employee_name}\n\n" - f"Your payroll has been successfully set up. " - f"Please review your payroll details and ensure everything is correct.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.TECH_SUPPORT.value}) -async def send_welcome_email(employee_name: str, email_address: str) -> str: - """Send a welcome email to a new employee as part of onboarding.""" - return ( - f"##### Welcome Email Sent\n" - f"**Employee Name:** {employee_name}\n" - f"**Email Address:** {email_address}\n\n" - f"A welcome email has been successfully sent to {employee_name} at {email_address}.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.TECH_SUPPORT.value}) -async def set_up_office_365_account(employee_name: str, email_address: str) -> str: - """Set up an Office 365 account for an employee.""" - return ( - f"##### Office 365 Account Setup\n" - f"**Employee Name:** {employee_name}\n" - f"**Email Address:** {email_address}\n\n" - f"An Office 365 account has been successfully set up for {employee_name} at {email_address}.\n" - f"{formatting_instructions}" - ) - -@mcp.tool(tags={Domain.TECH_SUPPORT.value}) -async def configure_laptop(employee_name: str, laptop_model: str) -> str: - """Configure a laptop for a new employee.""" - return ( - f"##### Laptop Configuration\n" - f"**Employee Name:** {employee_name}\n" - f"**Laptop Model:** {laptop_model}\n\n" - f"The laptop {laptop_model} has been successfully configured for {employee_name}.\n" - f"{formatting_instructions}" - ) - -if __name__ == "__main__": - mcp.run() - -# Start as http server: -# fastmcp run my_mcp_server.py -t streamable-http -l DEBUG -p 8080 \ No newline at end of file diff --git a/src/mcp_server/my_mcp_server/utils_date.py b/src/mcp_server/my_mcp_server/utils_date.py deleted file mode 100644 index d346e3cd0..000000000 --- a/src/mcp_server/my_mcp_server/utils_date.py +++ /dev/null @@ -1,24 +0,0 @@ -import locale -from datetime import datetime -import logging -from typing import Optional - - -def format_date_for_user(date_str: str, user_locale: Optional[str] = None) -> str: - """ - Format date based on user's desktop locale preference. - - Args: - date_str (str): Date in ISO format (YYYY-MM-DD). - user_locale (str, optional): User's locale string, e.g., 'en_US', 'en_GB'. - - Returns: - str: Formatted date respecting locale or raw date if formatting fails. - """ - try: - date_obj = datetime.strptime(date_str, "%Y-%m-%d") - locale.setlocale(locale.LC_TIME, user_locale or '') - return date_obj.strftime("%B %d, %Y") - except Exception as e: - logging.warning(f"Date formatting failed for '{date_str}': {e}") - return date_str From cba9d9869cb07ff7533b9bfa05e18fd05756ad70 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 14:26:28 -0400 Subject: [PATCH 29/41] Update mcp_server.py --- src/mcp_server/mcp_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_server/mcp_server.py b/src/mcp_server/mcp_server.py index dab1e22e2..72be4cb43 100644 --- a/src/mcp_server/mcp_server.py +++ b/src/mcp_server/mcp_server.py @@ -31,7 +31,7 @@ factory.register_service(GeneralService()) # Register DataToolService with the dataset path -factory.register_service(DataToolService(dataset_path="data/datasets")) +factory.register_service(DataToolService(dataset_path="datasets")) def create_fastmcp_server(): From 7244708560894d82ac4dd69b8be9a92b17d1752b Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 16:08:01 -0400 Subject: [PATCH 30/41] Refactor config usage to use AppConfig instance Replaced direct os.getenv calls with values from the AppConfig instance throughout backend modules. Updated environment variable loading and usage in agent models, agent factory, and orchestration manager for consistency and improved maintainability. Added new config options to .env.sample and AppConfig for MCP, Azure Search, and Bing integration. --- src/backend/.env.sample | 12 ++++++++- src/backend/app_kernel.py | 7 +++-- src/backend/common/config/app_config.py | 11 +++++++- src/backend/v3/api/router.py | 1 - .../v3/common/services/foundry_service.py | 3 +-- .../magentic_agents/magentic_agent_factory.py | 6 ++--- .../v3/magentic_agents/models/agent_models.py | 26 +++++++++---------- .../v3/magentic_agents/reasoning_agent.py | 4 +-- .../v3/orchestration/orchestration_manager.py | 13 +++------- 9 files changed, 47 insertions(+), 36 deletions(-) diff --git a/src/backend/.env.sample b/src/backend/.env.sample index 33c1c4267..4e3dc0a48 100644 --- a/src/backend/.env.sample +++ b/src/backend/.env.sample @@ -21,5 +21,15 @@ AZURE_BING_CONNECTION_NAME= REASONING_MODEL_NAME=o3 APP_ENV=dev MCP_SERVER_ENDPOINT=http://localhost:9000/mcp +MCP_SERVER_NAME=MyMC +MCP_SERVER_DESCRIPTION=My MCP Server +TENANT_ID= +CLIENT_ID= BACKEND_API_URL=http://localhost:8000 -FRONTEND_SITE_NAME=* \ No newline at end of file +FRONTEND_SITE_NAME=* +SUPPORTED_MODELS= +AZURE_AI_SEARCH_CONNECTION_NAME= +AZURE_AI_SEARCH_INDEX_NAME= +AZURE_AI_SEARCH_ENDPOINT= +AZURE_AI_SEARCH_API_KEY= +BING_CONNECTION_NAME= diff --git a/src/backend/app_kernel.py b/src/backend/app_kernel.py index 38afbadec..dae304f01 100644 --- a/src/backend/app_kernel.py +++ b/src/backend/app_kernel.py @@ -17,11 +17,10 @@ InputTask, Plan, PlanStatus, PlanWithSteps, Step, UserLanguage) from common.utils.event_utils import track_event_if_configured -from common.utils.utils_date import format_dates_in_messages + # Updated import for KernelArguments from common.utils.utils_kernel import rai_success -from common.utils.websocket_streaming import (websocket_streaming_endpoint, - ws_manager) + # FastAPI imports from fastapi import (FastAPI, HTTPException, Query, Request, WebSocket, WebSocketDisconnect) @@ -30,7 +29,7 @@ # Local imports from middleware.health_check import HealthCheckMiddleware from v3.api.router import app_v3 -from v3.config.settings import connection_config + # Semantic Kernel imports from v3.orchestration.orchestration_manager import OrchestrationManager diff --git a/src/backend/common/config/app_config.py b/src/backend/common/config/app_config.py index ff8306b9a..e105d1d5b 100644 --- a/src/backend/common/config/app_config.py +++ b/src/backend/common/config/app_config.py @@ -55,7 +55,7 @@ def __init__(self): self.AZURE_BING_CONNECTION_NAME = self._get_optional( "AZURE_BING_CONNECTION_NAME" ) - + self.SUPPORTED_MODELS = self._get_optional("SUPPORTED_MODELS") # Frontend settings self.FRONTEND_SITE_NAME = self._get_optional( "FRONTEND_SITE_NAME", "http://127.0.0.1:3000" @@ -74,6 +74,15 @@ def __init__(self): # Optional MCP server endpoint (for local MCP server or remote) # Example: http://127.0.0.1:8000/mcp self.MCP_SERVER_ENDPOINT = self._get_optional("MCP_SERVER_ENDPOINT") + self.MCP_SERVER_NAME = self._get_optional("MCP_SERVER_NAME") + self.MCP_SERVER_DESCRIPTION = self._get_optional("MCP_SERVER_DESCRIPTION") + self.TENANT_ID = self._get_optional("TENANT_ID") + self.CLIENT_ID = self._get_optional("CLIENT_ID") + self.AZURE_AI_SEARCH_CONNECTION_NAME = self._get_optional("AZURE_AI_SEARCH_CONNECTION_NAME") + self.AZURE_AI_SEARCH_INDEX_NAME = self._get_optional("AZURE_AI_SEARCH_INDEX_NAME") + self.AZURE_AI_SEARCH_ENDPOINT = self._get_optional("AZURE_AI_SEARCH_ENDPOINT") + self.AZURE_AI_SEARCH_API_KEY = self._get_optional("AZURE_AI_SEARCH_API_KEY") + self.BING_CONNECTION_NAME = self._get_optional("BING_CONNECTION_NAME") test_team_json = self._get_optional("TEST_TEAM_JSON") diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 4e30f43ca..10fcdcfeb 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -7,7 +7,6 @@ import v3.models.messages as messages from auth.auth_utils import get_authenticated_user_details -from common.config.app_config import config from common.database.database_factory import DatabaseFactory from common.models.messages_kernel import (GeneratePlanRequest, InputTask, TeamSelectionRequest) diff --git a/src/backend/v3/common/services/foundry_service.py b/src/backend/v3/common/services/foundry_service.py index 9a09aadd9..258bf568c 100644 --- a/src/backend/v3/common/services/foundry_service.py +++ b/src/backend/v3/common/services/foundry_service.py @@ -54,8 +54,7 @@ async def list_model_deployments(self) -> List[Dict[str, Any]]: try: # Get Azure Management API token (not Cognitive Services token) - credential = config.get_azure_credentials() - token = credential.get_token(config.AZURE_MANAGEMENT_SCOPE) + token = config.get_access_token() # Extract Azure OpenAI resource name from endpoint URL openai_endpoint = config.AZURE_OPENAI_ENDPOINT diff --git a/src/backend/v3/magentic_agents/magentic_agent_factory.py b/src/backend/v3/magentic_agents/magentic_agent_factory.py index e332f0ff1..e60b7d390 100644 --- a/src/backend/v3/magentic_agents/magentic_agent_factory.py +++ b/src/backend/v3/magentic_agents/magentic_agent_factory.py @@ -15,7 +15,7 @@ SearchConfig) from v3.magentic_agents.proxy_agent import ProxyAgent from v3.magentic_agents.reasoning_agent import ReasoningAgentTemplate - +from common.config.app_config import config class UnsupportedModelError(Exception): """Raised when an unsupported model is specified.""" @@ -65,7 +65,7 @@ async def create_agent_from_config(self, agent_obj: SimpleNamespace) -> Union[Fo return ProxyAgent(user_id=user_id) # Validate supported models - supported_models = json.loads(os.getenv("SUPPORTED_MODELS")) + supported_models = json.loads(config.SUPPORTED_MODELS) if deployment_name not in supported_models: raise UnsupportedModelError(f"Model '{deployment_name}' not supported. Supported: {supported_models}") @@ -95,7 +95,7 @@ async def create_agent_from_config(self, agent_obj: SimpleNamespace) -> Union[Fo # Create appropriate agent if use_reasoning: # Get reasoning specific configuration - azure_openai_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") + azure_openai_endpoint = config.AZURE_OPENAI_ENDPOINT agent = ReasoningAgentTemplate( agent_name=agent_obj.name, diff --git a/src/backend/v3/magentic_agents/models/agent_models.py b/src/backend/v3/magentic_agents/models/agent_models.py index e3c0d9187..9afdcf0fe 100644 --- a/src/backend/v3/magentic_agents/models/agent_models.py +++ b/src/backend/v3/magentic_agents/models/agent_models.py @@ -2,7 +2,7 @@ import os from dataclasses import dataclass - +from common.config.app_config import config @dataclass(slots=True) class MCPConfig: @@ -15,12 +15,12 @@ class MCPConfig: @classmethod def from_env(cls) -> "MCPConfig": - url = os.getenv("MCP_SERVER_ENDPOINT") - name = os.getenv("MCP_SERVER_NAME") - description = os.getenv("MCP_SERVER_DESCRIPTION") - tenant_id = os.getenv("TENANT_ID") - client_id = os.getenv("CLIENT_ID") - + url = config.MCP_SERVER_ENDPOINT + name = config.MCP_SERVER_NAME + description = config.MCP_SERVER_DESCRIPTION + tenant_id = config.TENANT_ID + client_id = config.CLIENT_ID + # Raise exception if any required environment variable is missing if not all([url, name, description, tenant_id, client_id]): raise ValueError(f"{cls.__name__} Missing required environment variables") @@ -40,7 +40,7 @@ class BingConfig: @classmethod def from_env(cls) -> "BingConfig": - connection_name = os.getenv("BING_CONNECTION_NAME") + connection_name = config.BING_CONNECTION_NAME # Raise exception if required environment variable is missing if not connection_name: @@ -60,11 +60,11 @@ class SearchConfig: @classmethod def from_env(cls) -> "SearchConfig": - connection_name = os.getenv("AZURE_AI_SEARCH_CONNECTION_NAME") - index_name = os.getenv("AZURE_AI_SEARCH_INDEX_NAME") - endpoint = os.getenv("AZURE_AI_SEARCH_ENDPOINT") - api_key = os.getenv("AZURE_AI_SEARCH_API_KEY") - + connection_name = config.AZURE_AI_SEARCH_CONNECTION_NAME + index_name = config.AZURE_AI_SEARCH_INDEX_NAME + endpoint = config.AZURE_AI_SEARCH_ENDPOINT + api_key = config.AZURE_AI_SEARCH_API_KEY + # Raise exception if any required environment variable is missing if not all([connection_name, index_name, endpoint]): raise ValueError(f"{cls.__name__} Missing required Azure Search environment variables") diff --git a/src/backend/v3/magentic_agents/reasoning_agent.py b/src/backend/v3/magentic_agents/reasoning_agent.py index 8dcc41540..de0c37781 100644 --- a/src/backend/v3/magentic_agents/reasoning_agent.py +++ b/src/backend/v3/magentic_agents/reasoning_agent.py @@ -9,7 +9,7 @@ from v3.magentic_agents.common.lifecycle import MCPEnabledBase from v3.magentic_agents.models.agent_models import MCPConfig, SearchConfig from v3.magentic_agents.reasoning_search import ReasoningSearch - +from common.config.app_config import config class ReasoningAgentTemplate(MCPEnabledBase): """ @@ -47,7 +47,7 @@ def ad_token_provider() -> str: chat = AzureChatCompletion( deployment_name=self._model_deployment_name, endpoint=self._openai_endpoint, - ad_token_provider=ad_token_provider + ad_token_provider=config.get_access_token() ) self.kernel.add_service(chat) diff --git a/src/backend/v3/orchestration/orchestration_manager.py b/src/backend/v3/orchestration/orchestration_manager.py index a04b215b3..1c17aad7c 100644 --- a/src/backend/v3/orchestration/orchestration_manager.py +++ b/src/backend/v3/orchestration/orchestration_manager.py @@ -20,6 +20,7 @@ streaming_agent_response_callback) from v3.config.settings import (config, connection_config, current_user_id, orchestration_config) +from common.config.app_config import config from v3.magentic_agents.magentic_agent_factory import MagenticAgentFactory from v3.orchestration.human_approval_manager import \ HumanApprovalMagenticManager @@ -43,21 +44,15 @@ async def init_orchestration(cls, agents: List, user_id: str = None)-> MagenticO temperature=0.1 ) - # Create a token provider function for Azure OpenAI - credential = SyncDefaultAzureCredential() - - def get_token(): - token = credential.get_token("https://cognitiveservices.azure.com/.default") - return token.token # 1. Create a Magentic orchestration with Azure OpenAI magentic_orchestration = MagenticOrchestration( members=agents, manager=HumanApprovalMagenticManager( chat_completion_service=AzureChatCompletion( - deployment_name=os.getenv("AZURE_OPENAI_MODEL_NAME"), - endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), - ad_token_provider=get_token # Use token provider function + deployment_name=config.AZURE_OPENAI_DEPLOYMENT_NAME, + endpoint=config.AZURE_OPENAI_ENDPOINT, + ad_token_provider=config.get_access_token() ), execution_settings=execution_settings ), From c09db808c08908d81aab2d38076689dd2bdced8b Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 16:08:23 -0400 Subject: [PATCH 31/41] Update reasoning_agent.py --- src/backend/v3/magentic_agents/reasoning_agent.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/backend/v3/magentic_agents/reasoning_agent.py b/src/backend/v3/magentic_agents/reasoning_agent.py index de0c37781..51eb9b684 100644 --- a/src/backend/v3/magentic_agents/reasoning_agent.py +++ b/src/backend/v3/magentic_agents/reasoning_agent.py @@ -37,12 +37,6 @@ def __init__(self, agent_name: str, async def _after_open(self) -> None: self.kernel = Kernel() - # Token provider for SK chat completion - sync_cred = SyncDefaultAzureCredential() - - def ad_token_provider() -> str: - token = sync_cred.get_token("https://cognitiveservices.azure.com/.default") - return token.token chat = AzureChatCompletion( deployment_name=self._model_deployment_name, From 59777853b5abf904727690eaa16e444a430e8799 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 16:13:01 -0400 Subject: [PATCH 32/41] Use configurable MCP server settings MCP server name and description now default to 'MCPGreetingServer' and a descriptive string if not set in environment. MCPConfig now reads endpoint, name, and description from app config for improved flexibility. --- src/backend/common/config/app_config.py | 4 ++-- src/backend/v3/config/settings.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/backend/common/config/app_config.py b/src/backend/common/config/app_config.py index e105d1d5b..ba01909fa 100644 --- a/src/backend/common/config/app_config.py +++ b/src/backend/common/config/app_config.py @@ -74,8 +74,8 @@ def __init__(self): # Optional MCP server endpoint (for local MCP server or remote) # Example: http://127.0.0.1:8000/mcp self.MCP_SERVER_ENDPOINT = self._get_optional("MCP_SERVER_ENDPOINT") - self.MCP_SERVER_NAME = self._get_optional("MCP_SERVER_NAME") - self.MCP_SERVER_DESCRIPTION = self._get_optional("MCP_SERVER_DESCRIPTION") + self.MCP_SERVER_NAME = self._get_optional("MCP_SERVER_NAME", "MCPGreetingServer") + self.MCP_SERVER_DESCRIPTION = self._get_optional("MCP_SERVER_DESCRIPTION", "MCP server with greeting and planning tools") self.TENANT_ID = self._get_optional("TENANT_ID") self.CLIENT_ID = self._get_optional("CLIENT_ID") self.AZURE_AI_SEARCH_CONNECTION_NAME = self._get_optional("AZURE_AI_SEARCH_CONNECTION_NAME") diff --git a/src/backend/v3/config/settings.py b/src/backend/v3/config/settings.py index 71db8648d..4acf15351 100644 --- a/src/backend/v3/config/settings.py +++ b/src/backend/v3/config/settings.py @@ -55,9 +55,9 @@ class MCPConfig: """MCP server configuration.""" def __init__(self): - self.url = "http://127.0.0.1:8000/mcp/" - self.name = "MCPGreetingServer" - self.description = "MCP server with greeting and planning tools" + self.url = config.MCP_SERVER_ENDPOINT + self.name = config.MCP_SERVER_NAME + self.description = config.MCP_SERVER_DESCRIPTION def get_headers(self, token: str): """Get MCP headers with authentication token.""" From c3472c8f5f2455c591e709e5dce076ed56f5d9b9 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 16:20:02 -0400 Subject: [PATCH 33/41] Remove all sample datasets from data/datasets Deleted multiple CSV and JSON files containing sample datasets from the data/datasets directory. This cleanup may be in preparation for new data sources, restructuring, or to remove obsolete test data. --- data/datasets/Competitor_Pricing_Analysis.csv | 5 ----- data/datasets/Customer_Churn_Analysis.csv | 6 ------ data/datasets/Email_Marketing_Engagement.csv | 6 ------ data/datasets/Loyalty_Program_Overview.csv | 2 -- data/datasets/Subscription_benefits_utilization.csv | 5 ----- data/datasets/Unauthorized_Access_Attempts.csv | 4 ---- data/datasets/Warehouse_Incident_Reports.csv | 4 ---- data/datasets/customer_feedback_surveys.csv | 3 --- data/datasets/customer_profile.csv | 2 -- data/datasets/customer_service_interactions.json | 3 --- data/datasets/delivery_performance_metrics.csv | 8 -------- data/datasets/product_return_rates.csv | 6 ------ data/datasets/product_table.csv | 6 ------ data/datasets/purchase_history.csv | 8 -------- data/datasets/social_media_sentiment_analysis.csv | 8 -------- data/datasets/store_visit_history.csv | 4 ---- data/datasets/website_activity_log.csv | 6 ------ 17 files changed, 86 deletions(-) delete mode 100644 data/datasets/Competitor_Pricing_Analysis.csv delete mode 100644 data/datasets/Customer_Churn_Analysis.csv delete mode 100644 data/datasets/Email_Marketing_Engagement.csv delete mode 100644 data/datasets/Loyalty_Program_Overview.csv delete mode 100644 data/datasets/Subscription_benefits_utilization.csv delete mode 100644 data/datasets/Unauthorized_Access_Attempts.csv delete mode 100644 data/datasets/Warehouse_Incident_Reports.csv delete mode 100644 data/datasets/customer_feedback_surveys.csv delete mode 100644 data/datasets/customer_profile.csv delete mode 100644 data/datasets/customer_service_interactions.json delete mode 100644 data/datasets/delivery_performance_metrics.csv delete mode 100644 data/datasets/product_return_rates.csv delete mode 100644 data/datasets/product_table.csv delete mode 100644 data/datasets/purchase_history.csv delete mode 100644 data/datasets/social_media_sentiment_analysis.csv delete mode 100644 data/datasets/store_visit_history.csv delete mode 100644 data/datasets/website_activity_log.csv diff --git a/data/datasets/Competitor_Pricing_Analysis.csv b/data/datasets/Competitor_Pricing_Analysis.csv deleted file mode 100644 index 79c8aeedc..000000000 --- a/data/datasets/Competitor_Pricing_Analysis.csv +++ /dev/null @@ -1,5 +0,0 @@ -ProductCategory,ContosoAveragePrice,CompetitorAveragePrice -Dresses,120,100 -Shoes,100,105 -Accessories,60,55 -Sportswear,80,85 diff --git a/data/datasets/Customer_Churn_Analysis.csv b/data/datasets/Customer_Churn_Analysis.csv deleted file mode 100644 index eaa4c9c24..000000000 --- a/data/datasets/Customer_Churn_Analysis.csv +++ /dev/null @@ -1,6 +0,0 @@ -ReasonForCancellation,Percentage -Service Dissatisfaction,40 -Financial Reasons,3 -Competitor Offer,15 -Moving to a Non-Service Area,5 -Other,37 diff --git a/data/datasets/Email_Marketing_Engagement.csv b/data/datasets/Email_Marketing_Engagement.csv deleted file mode 100644 index 5d89be28c..000000000 --- a/data/datasets/Email_Marketing_Engagement.csv +++ /dev/null @@ -1,6 +0,0 @@ -Campaign,Opened,Clicked,Unsubscribed -Summer Sale,Yes,Yes,No -New Arrivals,Yes,No,No -Exclusive Member Offers,No,No,No -Personal Styling Invite,No,No,No -Autumn Collection Preview,Yes,Yes,No diff --git a/data/datasets/Loyalty_Program_Overview.csv b/data/datasets/Loyalty_Program_Overview.csv deleted file mode 100644 index 334261e34..000000000 --- a/data/datasets/Loyalty_Program_Overview.csv +++ /dev/null @@ -1,2 +0,0 @@ -TotalPointsEarned,PointsRedeemed,CurrentPointBalance,PointsExpiringNextMonth -4800,3600,1200,1200 diff --git a/data/datasets/Subscription_benefits_utilization.csv b/data/datasets/Subscription_benefits_utilization.csv deleted file mode 100644 index c8f07966b..000000000 --- a/data/datasets/Subscription_benefits_utilization.csv +++ /dev/null @@ -1,5 +0,0 @@ -Benefit,UsageFrequency -Free Shipping,7 -Early Access to Collections,2 -Exclusive Discounts,1 -Personalized Styling Sessions,0 diff --git a/data/datasets/Unauthorized_Access_Attempts.csv b/data/datasets/Unauthorized_Access_Attempts.csv deleted file mode 100644 index 2b66bc4b2..000000000 --- a/data/datasets/Unauthorized_Access_Attempts.csv +++ /dev/null @@ -1,4 +0,0 @@ -Date,IPAddress,Location,SuccessfulLogin -2023-06-20,192.168.1.1,Home Network,Yes -2023-07-22,203.0.113.45,Unknown,No -2023-08-15,198.51.100.23,Office Network,Yes diff --git a/data/datasets/Warehouse_Incident_Reports.csv b/data/datasets/Warehouse_Incident_Reports.csv deleted file mode 100644 index e7440fcb2..000000000 --- a/data/datasets/Warehouse_Incident_Reports.csv +++ /dev/null @@ -1,4 +0,0 @@ -Date,IncidentDescription,AffectedOrders -2023-06-15,Inventory system outage,100 -2023-07-18,Logistics partner strike,250 -2023-08-25,Warehouse flooding due to heavy rain,150 diff --git a/data/datasets/customer_feedback_surveys.csv b/data/datasets/customer_feedback_surveys.csv deleted file mode 100644 index 126f0ca64..000000000 --- a/data/datasets/customer_feedback_surveys.csv +++ /dev/null @@ -1,3 +0,0 @@ -SurveyID,Date,SatisfactionRating,Comments -O5678,2023-03-16,5,"Loved the summer dress! Fast delivery." -O5970,2023-09-13,4,"Happy with the sportswear. Quick delivery." diff --git a/data/datasets/customer_profile.csv b/data/datasets/customer_profile.csv deleted file mode 100644 index 88bc93b9d..000000000 --- a/data/datasets/customer_profile.csv +++ /dev/null @@ -1,2 +0,0 @@ -CustomerID,Name,Age,MembershipDuration,TotalSpend,AvgMonthlySpend,PreferredCategories -C1024,Emily Thompson,35,24,4800,200,"Dresses, Shoes, Accessories" diff --git a/data/datasets/customer_service_interactions.json b/data/datasets/customer_service_interactions.json deleted file mode 100644 index f8345bff2..000000000 --- a/data/datasets/customer_service_interactions.json +++ /dev/null @@ -1,3 +0,0 @@ -{"InteractionID":"1","Channel":"Live Chat","Date":"2023-06-20","Customer":"Emily Thompson","OrderID":"O5789","Content":["Agent: Hello Emily, how can I assist you today?","Emily: Hi, I just received my order O5789, and wanted to swap it for another colour","Agent: Sure, that's fine- feel free to send it back or change it in store.","Emily: Ok, I'll just send it back then","Agent: Certainly. I've initiated the return process. You'll receive an email with the return instructions.","Emily: Thank you."]} -{"InteractionID":"2","Channel":"Phone Call","Date":"2023-07-25","Customer":"Emily Thompson","OrderID":"O5890","Content":["Agent: Good afternoon, this is Contoso customer service. How may I help you?","Emily: I'm calling about my order O5890. I need the gown for an event this weekend, and just want to make sure it will be delivered on time as it's really important.","Agent: Let me check... it seems like the delivery is on track. It should be there on time.","Emily: Ok thanks."]} -{"InteractionID":"3","Channel":"Email","Date":"2023-09-15","Customer":"Emily Thompson","OrderID":"","Content":["Subject: Membership Cancellation Request","Body: Hello, I want to cancel my Contoso Plus subscription. The cost is becoming too high for me."]} diff --git a/data/datasets/delivery_performance_metrics.csv b/data/datasets/delivery_performance_metrics.csv deleted file mode 100644 index 9678102bb..000000000 --- a/data/datasets/delivery_performance_metrics.csv +++ /dev/null @@ -1,8 +0,0 @@ -Month,AverageDeliveryTime,OnTimeDeliveryRate,CustomerComplaints -March,3,98,15 -April,4,95,20 -May,5,92,30 -June,6,88,50 -July,7,85,70 -August,4,94,25 -September,3,97,10 diff --git a/data/datasets/product_return_rates.csv b/data/datasets/product_return_rates.csv deleted file mode 100644 index 6c5c4c3f3..000000000 --- a/data/datasets/product_return_rates.csv +++ /dev/null @@ -1,6 +0,0 @@ -Category,ReturnRate -Dresses,15 -Shoes,10 -Accessories,8 -Outerwear,12 -Sportswear,9 diff --git a/data/datasets/product_table.csv b/data/datasets/product_table.csv deleted file mode 100644 index 79037292c..000000000 --- a/data/datasets/product_table.csv +++ /dev/null @@ -1,6 +0,0 @@ -ProductCategory,ReturnRate,ContosoAveragePrice,CompetitorAveragePrice -Dresses,15,120,100 -Shoes,10,100,105 -Accessories,8,60,55 -Outerwear,12,, -Sportswear,9,80,85 diff --git a/data/datasets/purchase_history.csv b/data/datasets/purchase_history.csv deleted file mode 100644 index ebc4c312e..000000000 --- a/data/datasets/purchase_history.csv +++ /dev/null @@ -1,8 +0,0 @@ -OrderID,Date,ItemsPurchased,TotalAmount,DiscountApplied,DateDelivered,ReturnFlag -O5678,2023-03-15,"Summer Floral Dress, Sun Hat",150,10,2023-03-19,No -O5721,2023-04-10,"Leather Ankle Boots",120,15,2023-04-13,No -O5789,2023-05-05,Silk Scarf,80,0,2023-05-25,Yes -O5832,2023-06-18,Casual Sneakers,90,5,2023-06-21,No -O5890,2023-07-22,"Evening Gown, Clutch Bag",300,20,2023-08-05,No -O5935,2023-08-30,Denim Jacket,110,0,2023-09-03,Yes -O5970,2023-09-12,"Fitness Leggings, Sports Bra",130,25,2023-09-18,No diff --git a/data/datasets/social_media_sentiment_analysis.csv b/data/datasets/social_media_sentiment_analysis.csv deleted file mode 100644 index 78ed2ec2d..000000000 --- a/data/datasets/social_media_sentiment_analysis.csv +++ /dev/null @@ -1,8 +0,0 @@ -Month,PositiveMentions,NegativeMentions,NeutralMentions -March,500,50,200 -April,480,60,220 -May,450,80,250 -June,400,120,300 -July,350,150,320 -August,480,70,230 -September,510,40,210 diff --git a/data/datasets/store_visit_history.csv b/data/datasets/store_visit_history.csv deleted file mode 100644 index de5b300a7..000000000 --- a/data/datasets/store_visit_history.csv +++ /dev/null @@ -1,4 +0,0 @@ -Date,StoreLocation,Purpose,Outcome -2023-05-12,Downtown Outlet,Browsing,"Purchased a Silk Scarf (O5789)" -2023-07-20,Uptown Mall,Personal Styling,"Booked a session but didn't attend" -2023-08-05,Midtown Boutique,Browsing,"No purchase" diff --git a/data/datasets/website_activity_log.csv b/data/datasets/website_activity_log.csv deleted file mode 100644 index 0f7f6c557..000000000 --- a/data/datasets/website_activity_log.csv +++ /dev/null @@ -1,6 +0,0 @@ -Date,PagesVisited,TimeSpent -2023-09-10,"Homepage, New Arrivals, Dresses",15 -2023-09-11,"Account Settings, Subscription Details",5 -2023-09-12,"FAQ, Return Policy",3 -2023-09-13,"Careers Page, Company Mission",2 -2023-09-14,"Sale Items, Accessories",10 From d37be31343a5d94b8f1797eb160cae3832d3f4ae Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 18:59:10 -0400 Subject: [PATCH 34/41] dataset back --- data/datasets/competitor_pricing_analysis.csv | 5 +++++ data/datasets/customer_churn_analysis.csv | 6 ++++++ data/datasets/customer_feedback_surveys.csv | 3 +++ data/datasets/customer_profile.csv | 2 ++ data/datasets/customer_service_interactions.json | 3 +++ data/datasets/delivery_performance_metrics.csv | 8 ++++++++ data/datasets/email_marketing_engagement.csv | 6 ++++++ data/datasets/loyalty_program_overview.csv | 2 ++ data/datasets/product_return_rates.csv | 6 ++++++ data/datasets/product_table.csv | 6 ++++++ data/datasets/purchase_history.csv | 8 ++++++++ data/datasets/social_media_sentiment_analysis.csv | 8 ++++++++ data/datasets/store_visit_history.csv | 4 ++++ data/datasets/subscription_benefits_utilization.csv | 5 +++++ data/datasets/unauthorized_access_attempts.csv | 4 ++++ data/datasets/warehouse_incident_reports.csv | 4 ++++ data/datasets/website_activity_log.csv | 6 ++++++ 17 files changed, 86 insertions(+) create mode 100644 data/datasets/competitor_pricing_analysis.csv create mode 100644 data/datasets/customer_churn_analysis.csv create mode 100644 data/datasets/customer_feedback_surveys.csv create mode 100644 data/datasets/customer_profile.csv create mode 100644 data/datasets/customer_service_interactions.json create mode 100644 data/datasets/delivery_performance_metrics.csv create mode 100644 data/datasets/email_marketing_engagement.csv create mode 100644 data/datasets/loyalty_program_overview.csv create mode 100644 data/datasets/product_return_rates.csv create mode 100644 data/datasets/product_table.csv create mode 100644 data/datasets/purchase_history.csv create mode 100644 data/datasets/social_media_sentiment_analysis.csv create mode 100644 data/datasets/store_visit_history.csv create mode 100644 data/datasets/subscription_benefits_utilization.csv create mode 100644 data/datasets/unauthorized_access_attempts.csv create mode 100644 data/datasets/warehouse_incident_reports.csv create mode 100644 data/datasets/website_activity_log.csv diff --git a/data/datasets/competitor_pricing_analysis.csv b/data/datasets/competitor_pricing_analysis.csv new file mode 100644 index 000000000..79c8aeedc --- /dev/null +++ b/data/datasets/competitor_pricing_analysis.csv @@ -0,0 +1,5 @@ +ProductCategory,ContosoAveragePrice,CompetitorAveragePrice +Dresses,120,100 +Shoes,100,105 +Accessories,60,55 +Sportswear,80,85 diff --git a/data/datasets/customer_churn_analysis.csv b/data/datasets/customer_churn_analysis.csv new file mode 100644 index 000000000..eaa4c9c24 --- /dev/null +++ b/data/datasets/customer_churn_analysis.csv @@ -0,0 +1,6 @@ +ReasonForCancellation,Percentage +Service Dissatisfaction,40 +Financial Reasons,3 +Competitor Offer,15 +Moving to a Non-Service Area,5 +Other,37 diff --git a/data/datasets/customer_feedback_surveys.csv b/data/datasets/customer_feedback_surveys.csv new file mode 100644 index 000000000..126f0ca64 --- /dev/null +++ b/data/datasets/customer_feedback_surveys.csv @@ -0,0 +1,3 @@ +SurveyID,Date,SatisfactionRating,Comments +O5678,2023-03-16,5,"Loved the summer dress! Fast delivery." +O5970,2023-09-13,4,"Happy with the sportswear. Quick delivery." diff --git a/data/datasets/customer_profile.csv b/data/datasets/customer_profile.csv new file mode 100644 index 000000000..88bc93b9d --- /dev/null +++ b/data/datasets/customer_profile.csv @@ -0,0 +1,2 @@ +CustomerID,Name,Age,MembershipDuration,TotalSpend,AvgMonthlySpend,PreferredCategories +C1024,Emily Thompson,35,24,4800,200,"Dresses, Shoes, Accessories" diff --git a/data/datasets/customer_service_interactions.json b/data/datasets/customer_service_interactions.json new file mode 100644 index 000000000..f8345bff2 --- /dev/null +++ b/data/datasets/customer_service_interactions.json @@ -0,0 +1,3 @@ +{"InteractionID":"1","Channel":"Live Chat","Date":"2023-06-20","Customer":"Emily Thompson","OrderID":"O5789","Content":["Agent: Hello Emily, how can I assist you today?","Emily: Hi, I just received my order O5789, and wanted to swap it for another colour","Agent: Sure, that's fine- feel free to send it back or change it in store.","Emily: Ok, I'll just send it back then","Agent: Certainly. I've initiated the return process. You'll receive an email with the return instructions.","Emily: Thank you."]} +{"InteractionID":"2","Channel":"Phone Call","Date":"2023-07-25","Customer":"Emily Thompson","OrderID":"O5890","Content":["Agent: Good afternoon, this is Contoso customer service. How may I help you?","Emily: I'm calling about my order O5890. I need the gown for an event this weekend, and just want to make sure it will be delivered on time as it's really important.","Agent: Let me check... it seems like the delivery is on track. It should be there on time.","Emily: Ok thanks."]} +{"InteractionID":"3","Channel":"Email","Date":"2023-09-15","Customer":"Emily Thompson","OrderID":"","Content":["Subject: Membership Cancellation Request","Body: Hello, I want to cancel my Contoso Plus subscription. The cost is becoming too high for me."]} diff --git a/data/datasets/delivery_performance_metrics.csv b/data/datasets/delivery_performance_metrics.csv new file mode 100644 index 000000000..9678102bb --- /dev/null +++ b/data/datasets/delivery_performance_metrics.csv @@ -0,0 +1,8 @@ +Month,AverageDeliveryTime,OnTimeDeliveryRate,CustomerComplaints +March,3,98,15 +April,4,95,20 +May,5,92,30 +June,6,88,50 +July,7,85,70 +August,4,94,25 +September,3,97,10 diff --git a/data/datasets/email_marketing_engagement.csv b/data/datasets/email_marketing_engagement.csv new file mode 100644 index 000000000..5d89be28c --- /dev/null +++ b/data/datasets/email_marketing_engagement.csv @@ -0,0 +1,6 @@ +Campaign,Opened,Clicked,Unsubscribed +Summer Sale,Yes,Yes,No +New Arrivals,Yes,No,No +Exclusive Member Offers,No,No,No +Personal Styling Invite,No,No,No +Autumn Collection Preview,Yes,Yes,No diff --git a/data/datasets/loyalty_program_overview.csv b/data/datasets/loyalty_program_overview.csv new file mode 100644 index 000000000..334261e34 --- /dev/null +++ b/data/datasets/loyalty_program_overview.csv @@ -0,0 +1,2 @@ +TotalPointsEarned,PointsRedeemed,CurrentPointBalance,PointsExpiringNextMonth +4800,3600,1200,1200 diff --git a/data/datasets/product_return_rates.csv b/data/datasets/product_return_rates.csv new file mode 100644 index 000000000..6c5c4c3f3 --- /dev/null +++ b/data/datasets/product_return_rates.csv @@ -0,0 +1,6 @@ +Category,ReturnRate +Dresses,15 +Shoes,10 +Accessories,8 +Outerwear,12 +Sportswear,9 diff --git a/data/datasets/product_table.csv b/data/datasets/product_table.csv new file mode 100644 index 000000000..79037292c --- /dev/null +++ b/data/datasets/product_table.csv @@ -0,0 +1,6 @@ +ProductCategory,ReturnRate,ContosoAveragePrice,CompetitorAveragePrice +Dresses,15,120,100 +Shoes,10,100,105 +Accessories,8,60,55 +Outerwear,12,, +Sportswear,9,80,85 diff --git a/data/datasets/purchase_history.csv b/data/datasets/purchase_history.csv new file mode 100644 index 000000000..ebc4c312e --- /dev/null +++ b/data/datasets/purchase_history.csv @@ -0,0 +1,8 @@ +OrderID,Date,ItemsPurchased,TotalAmount,DiscountApplied,DateDelivered,ReturnFlag +O5678,2023-03-15,"Summer Floral Dress, Sun Hat",150,10,2023-03-19,No +O5721,2023-04-10,"Leather Ankle Boots",120,15,2023-04-13,No +O5789,2023-05-05,Silk Scarf,80,0,2023-05-25,Yes +O5832,2023-06-18,Casual Sneakers,90,5,2023-06-21,No +O5890,2023-07-22,"Evening Gown, Clutch Bag",300,20,2023-08-05,No +O5935,2023-08-30,Denim Jacket,110,0,2023-09-03,Yes +O5970,2023-09-12,"Fitness Leggings, Sports Bra",130,25,2023-09-18,No diff --git a/data/datasets/social_media_sentiment_analysis.csv b/data/datasets/social_media_sentiment_analysis.csv new file mode 100644 index 000000000..78ed2ec2d --- /dev/null +++ b/data/datasets/social_media_sentiment_analysis.csv @@ -0,0 +1,8 @@ +Month,PositiveMentions,NegativeMentions,NeutralMentions +March,500,50,200 +April,480,60,220 +May,450,80,250 +June,400,120,300 +July,350,150,320 +August,480,70,230 +September,510,40,210 diff --git a/data/datasets/store_visit_history.csv b/data/datasets/store_visit_history.csv new file mode 100644 index 000000000..de5b300a7 --- /dev/null +++ b/data/datasets/store_visit_history.csv @@ -0,0 +1,4 @@ +Date,StoreLocation,Purpose,Outcome +2023-05-12,Downtown Outlet,Browsing,"Purchased a Silk Scarf (O5789)" +2023-07-20,Uptown Mall,Personal Styling,"Booked a session but didn't attend" +2023-08-05,Midtown Boutique,Browsing,"No purchase" diff --git a/data/datasets/subscription_benefits_utilization.csv b/data/datasets/subscription_benefits_utilization.csv new file mode 100644 index 000000000..c8f07966b --- /dev/null +++ b/data/datasets/subscription_benefits_utilization.csv @@ -0,0 +1,5 @@ +Benefit,UsageFrequency +Free Shipping,7 +Early Access to Collections,2 +Exclusive Discounts,1 +Personalized Styling Sessions,0 diff --git a/data/datasets/unauthorized_access_attempts.csv b/data/datasets/unauthorized_access_attempts.csv new file mode 100644 index 000000000..2b66bc4b2 --- /dev/null +++ b/data/datasets/unauthorized_access_attempts.csv @@ -0,0 +1,4 @@ +Date,IPAddress,Location,SuccessfulLogin +2023-06-20,192.168.1.1,Home Network,Yes +2023-07-22,203.0.113.45,Unknown,No +2023-08-15,198.51.100.23,Office Network,Yes diff --git a/data/datasets/warehouse_incident_reports.csv b/data/datasets/warehouse_incident_reports.csv new file mode 100644 index 000000000..e7440fcb2 --- /dev/null +++ b/data/datasets/warehouse_incident_reports.csv @@ -0,0 +1,4 @@ +Date,IncidentDescription,AffectedOrders +2023-06-15,Inventory system outage,100 +2023-07-18,Logistics partner strike,250 +2023-08-25,Warehouse flooding due to heavy rain,150 diff --git a/data/datasets/website_activity_log.csv b/data/datasets/website_activity_log.csv new file mode 100644 index 000000000..0f7f6c557 --- /dev/null +++ b/data/datasets/website_activity_log.csv @@ -0,0 +1,6 @@ +Date,PagesVisited,TimeSpent +2023-09-10,"Homepage, New Arrivals, Dresses",15 +2023-09-11,"Account Settings, Subscription Details",5 +2023-09-12,"FAQ, Return Policy",3 +2023-09-13,"Careers Page, Company Mission",2 +2023-09-14,"Sale Items, Accessories",10 From 2d17c33466128c4f4b23753f8fe0ff6df72f9d04 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 20:57:06 -0400 Subject: [PATCH 35/41] Add plan_id to InputTask and update API logic Introduces a plan_id field to the InputTask model and ensures it is set if missing in the process_request API endpoint. Also updates response to include plan_id, modifies .env.sample, switches dependency from fastmcp to mcp in pyproject.toml, and comments out user ownership check in team_service. --- src/backend/.env.sample | 4 ++-- src/backend/common/models/messages_kernel.py | 1 + src/backend/pyproject.toml | 2 +- src/backend/v3/api/router.py | 5 ++++- src/backend/v3/common/services/team_service.py | 14 +++++++------- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/backend/.env.sample b/src/backend/.env.sample index 4e3dc0a48..d1dba1ea1 100644 --- a/src/backend/.env.sample +++ b/src/backend/.env.sample @@ -26,8 +26,8 @@ MCP_SERVER_DESCRIPTION=My MCP Server TENANT_ID= CLIENT_ID= BACKEND_API_URL=http://localhost:8000 -FRONTEND_SITE_NAME=* -SUPPORTED_MODELS= +FRONTEND_SITE_NAME= +SUPPORTED_MODELS='["o3","o4-mini","gpt-4.1","gpt-4.1-mini"]' AZURE_AI_SEARCH_CONNECTION_NAME= AZURE_AI_SEARCH_INDEX_NAME= AZURE_AI_SEARCH_ENDPOINT= diff --git a/src/backend/common/models/messages_kernel.py b/src/backend/common/models/messages_kernel.py index 095f53dc0..5f609e6a8 100644 --- a/src/backend/common/models/messages_kernel.py +++ b/src/backend/common/models/messages_kernel.py @@ -248,6 +248,7 @@ class InputTask(KernelBaseModel): """Message representing the initial input task from the user.""" session_id: str + plan_id: str description: str # Initial goal team_id: str diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index ee307a854..9d0379dea 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -31,5 +31,5 @@ dependencies = [ "uvicorn>=0.34.2", "pylint-pydantic>=0.3.5", "pexpect>=4.9.0", - "fastmcp==2.11.3", + "mcp>=1.13.1" ] diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 10fcdcfeb..c3e3f3fb9 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -54,7 +54,7 @@ async def start_comms(websocket: WebSocket, process_id: str): connection_config.add_connection(process_id=process_id, connection=websocket, user_id=user_id) track_event_if_configured("WebSocketConnectionAccepted", {"process_id": process_id, "user_id": user_id}) - # Keep the connection open - FastAPI will close the connection if this returns + # Keep the connection open - FastAPI will close the connection if this returns try: # Keep the connection open - FastAPI will close the connection if this returns while True: @@ -224,6 +224,8 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa if not input_task.session_id: input_task.session_id = str(uuid.uuid4()) + if not input_task.plan_id: + input_task.plan_id = str(uuid.uuid4()) try: current_user_id.set(user_id) # Set context @@ -240,6 +242,7 @@ async def run_with_context(): return { "status": "Request started successfully", "session_id": input_task.session_id, + "plan_id": input_task.plan_id, } except Exception as e: diff --git a/src/backend/v3/common/services/team_service.py b/src/backend/v3/common/services/team_service.py index cb95f5624..7974599f3 100644 --- a/src/backend/v3/common/services/team_service.py +++ b/src/backend/v3/common/services/team_service.py @@ -204,13 +204,13 @@ async def get_team_configuration( return None # Verify the configuration belongs to the user - if team_config.user_id != user_id: - self.logger.warning( - "Access denied: config %s does not belong to user %s", - team_id, - user_id, - ) - return None + # if team_config.user_id != user_id: + # self.logger.warning( + # "Access denied: config %s does not belong to user %s", + # team_id, + # user_id, + # ) + # return None return team_config From 0b9515be3cc9b7ccb9bcce03dcc7ba1529cbea4d Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 21:26:08 -0400 Subject: [PATCH 36/41] Add UserCurrentTeam model and update plan creation Introduces the UserCurrentTeam model to represent a user's current team. Updates the API router to generate a new plan with a unique ID and store it in the database, removing plan_id and team_id from InputTask. Plan creation now includes initial goal and status. --- src/backend/common/models/messages_kernel.py | 9 +++++++-- src/backend/v3/api/router.py | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/backend/common/models/messages_kernel.py b/src/backend/common/models/messages_kernel.py index 5f609e6a8..629272446 100644 --- a/src/backend/common/models/messages_kernel.py +++ b/src/backend/common/models/messages_kernel.py @@ -98,6 +98,12 @@ class Session(BaseDataModel): current_status: str message_to_user: Optional[str] = None +class UserCurrentTeam(BaseDataModel): + """Represents the current team of a user.""" + + data_type: Literal["user_current_team"] = Field("user_current_team", Literal=True) + user_id: str + team_id: str class Plan(BaseDataModel): """Represents a plan containing multiple steps.""" @@ -248,9 +254,8 @@ class InputTask(KernelBaseModel): """Message representing the initial input task from the user.""" session_id: str - plan_id: str description: str # Initial goal - team_id: str + # team_id: str class UserLanguage(KernelBaseModel): diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index c3e3f3fb9..ab2949e1c 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -8,8 +8,8 @@ import v3.models.messages as messages from auth.auth_utils import get_authenticated_user_details from common.database.database_factory import DatabaseFactory -from common.models.messages_kernel import (GeneratePlanRequest, InputTask, - TeamSelectionRequest) +from common.models.messages_kernel import (GeneratePlanRequest, InputTask, PlanStatus, + TeamSelectionRequest, Plan) from common.utils.event_utils import track_event_if_configured from common.utils.utils_kernel import rai_success, rai_validate_team_config from fastapi import (APIRouter, BackgroundTasks, Depends, FastAPI, File, @@ -224,8 +224,8 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa if not input_task.session_id: input_task.session_id = str(uuid.uuid4()) - if not input_task.plan_id: - input_task.plan_id = str(uuid.uuid4()) + + plan_id = str(uuid.uuid4()) try: current_user_id.set(user_id) # Set context @@ -233,7 +233,15 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa # background_tasks.add_task( # lambda: current_context.run(lambda:OrchestrationManager().run_orchestration, user_id, input_task) # ) - + memory_store = await DatabaseFactory.get_database(user_id=user_id) + plan = Plan( + id=plan_id, + user_id=user_id, + team_id="", #TODO add current_team_id + initial_goal=input_task.description, + overall_status=PlanStatus.in_progress, + ) + await memory_store.add_plan(plan) async def run_with_context(): return await current_context.run(OrchestrationManager().run_orchestration, user_id, input_task) From 3a52cfe4376c078c1e1a880738beb31de9b98ce2 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Mon, 1 Sep 2025 21:43:20 -0400 Subject: [PATCH 37/41] Refactor plan creation and make team_id optional Changed Plan model to use plan_id and made team_id optional. Refactored plan creation logic in router.py to handle plan creation errors, removed mandatory team_id check, and added event tracking for plan creation success and failure. --- src/backend/common/models/messages_kernel.py | 3 +- src/backend/v3/api/router.py | 58 +++++++++++++++----- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/backend/common/models/messages_kernel.py b/src/backend/common/models/messages_kernel.py index 629272446..e2f2910ef 100644 --- a/src/backend/common/models/messages_kernel.py +++ b/src/backend/common/models/messages_kernel.py @@ -109,13 +109,14 @@ class Plan(BaseDataModel): """Represents a plan containing multiple steps.""" data_type: Literal["plan"] = Field("plan", Literal=True) - team_id: str + plan_id: str session_id: str user_id: str initial_goal: str overall_status: PlanStatus = PlanStatus.in_progress source: str = AgentType.PLANNER.value summary: Optional[str] = None + team_id: Optional[str] = None human_clarification_request: Optional[str] = None human_clarification_response: Optional[str] = None diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index ab2949e1c..4f2160656 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -216,32 +216,62 @@ async def process_request(background_tasks: BackgroundTasks, input_task: InputTa ) raise HTTPException(status_code=400, detail="no user") - if not input_task.team_id: - track_event_if_configured( - "TeamIDNofound", {"status_code": 400, "detail": "no team id"} - ) - raise HTTPException(status_code=400, detail="no team id") + # if not input_task.team_id: + # track_event_if_configured( + # "TeamIDNofound", {"status_code": 400, "detail": "no team id"} + # ) + # raise HTTPException(status_code=400, detail="no team id") if not input_task.session_id: input_task.session_id = str(uuid.uuid4()) - - plan_id = str(uuid.uuid4()) - try: - current_user_id.set(user_id) # Set context - current_context = contextvars.copy_context() # Capture context - # background_tasks.add_task( - # lambda: current_context.run(lambda:OrchestrationManager().run_orchestration, user_id, input_task) - # ) + plan_id = str(uuid.uuid4()) + # Initialize memory store and service memory_store = await DatabaseFactory.get_database(user_id=user_id) plan = Plan( id=plan_id, + plan_id=plan_id, user_id=user_id, - team_id="", #TODO add current_team_id + session_id=input_task.session_id, + team_id=None, #TODO add current_team_id initial_goal=input_task.description, overall_status=PlanStatus.in_progress, ) await memory_store.add_plan(plan) + + + track_event_if_configured( + "PlanCreated", + { + "status": "success", + "plan_id": plan.plan_id, + "session_id": input_task.session_id, + "user_id": user_id, + "team_id": "", #TODO add current_team_id + "description": input_task.description, + }, + ) + except Exception as e: + print(f"Error creating plan: {e}") + track_event_if_configured( + "PlanCreationFailed", + { + "status": "error", + "description": input_task.description, + "session_id": input_task.session_id, + "user_id": user_id, + "error": str(e), + }, + ) + raise HTTPException(status_code=500, detail="Failed to create plan") + + try: + current_user_id.set(user_id) # Set context + current_context = contextvars.copy_context() # Capture context + # background_tasks.add_task( + # lambda: current_context.run(lambda:OrchestrationManager().run_orchestration, user_id, input_task) + # ) + async def run_with_context(): return await current_context.run(OrchestrationManager().run_orchestration, user_id, input_task) From a07c39222b3faebad0a96b5c4e1a30289015a716 Mon Sep 17 00:00:00 2001 From: Francia Riesco Date: Tue, 2 Sep 2025 12:28:46 -0400 Subject: [PATCH 38/41] Update MCP server endpoint and fix plan_id response Changed MCP_SERVER_ENDPOINT port in .env.sample from 9000 to 8080. Fixed API response in router.py to return the correct plan_id variable instead of input_task.plan_id. --- src/backend/.env.sample | 2 +- src/backend/v3/api/router.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/.env.sample b/src/backend/.env.sample index d1dba1ea1..965d3768e 100644 --- a/src/backend/.env.sample +++ b/src/backend/.env.sample @@ -20,7 +20,7 @@ AZURE_AI_AGENT_ENDPOINT= AZURE_BING_CONNECTION_NAME= REASONING_MODEL_NAME=o3 APP_ENV=dev -MCP_SERVER_ENDPOINT=http://localhost:9000/mcp +MCP_SERVER_ENDPOINT=http://localhost:8080/mcp MCP_SERVER_NAME=MyMC MCP_SERVER_DESCRIPTION=My MCP Server TENANT_ID= diff --git a/src/backend/v3/api/router.py b/src/backend/v3/api/router.py index 4f2160656..229058a95 100644 --- a/src/backend/v3/api/router.py +++ b/src/backend/v3/api/router.py @@ -280,7 +280,7 @@ async def run_with_context(): return { "status": "Request started successfully", "session_id": input_task.session_id, - "plan_id": input_task.plan_id, + "plan_id": plan_id, } except Exception as e: From 71b9f33b442cee8a1bce7e9380c3c6baa7455438 Mon Sep 17 00:00:00 2001 From: blessing-sanusi Date: Tue, 2 Sep 2025 12:15:20 -0500 Subject: [PATCH 39/41] websocket timer change --- .../src/services/WebSocketService.tsx | 112 ++++++++++++------ 1 file changed, 78 insertions(+), 34 deletions(-) diff --git a/src/frontend/src/services/WebSocketService.tsx b/src/frontend/src/services/WebSocketService.tsx index e7b2b6ef6..4889abd58 100644 --- a/src/frontend/src/services/WebSocketService.tsx +++ b/src/frontend/src/services/WebSocketService.tsx @@ -47,40 +47,56 @@ class WebSocketService { private ws: WebSocket | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; - private reconnectDelay = 1000; + private reconnectDelay = 12000; // Changed from 1000ms to 12000ms (12 seconds) private listeners: Map void>> = new Map(); private planSubscriptions: Set = new Set(); + private reconnectTimer: NodeJS.Timeout | null = null; // Add timer tracking + private isConnecting = false; // Add connection state tracking /** * Connect to WebSocket server */ connect(): Promise { return new Promise((resolve, reject) => { + // Prevent multiple simultaneous connection attempts + if (this.isConnecting) { + console.log('Connection attempt already in progress'); + return; + } + + // Clear any existing reconnection timer + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + try { + this.isConnecting = true; + // Get WebSocket URL from environment or default to localhost const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = process.env.REACT_APP_WS_HOST || '127.0.0.1:8000'; const processId = crypto.randomUUID(); // Generate unique process ID for this session - // const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}`; // Build WebSocket URL with authentication headers as query parameters - const userId = getUserId(); // Import this from config - const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}?user_id=${encodeURIComponent(userId)}`; + const userId = getUserId(); // Import this from config + const wsUrl = `${wsProtocol}//${wsHost}/api/v3/socket/${processId}?user_id=${encodeURIComponent(userId)}`; console.log('Connecting to WebSocket:', wsUrl); this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { - console.log('WebSocket connected'); + console.log('WebSocket connected successfully'); this.reconnectAttempts = 0; + this.isConnecting = false; this.emit('connection_status', { connected: true }); resolve(); }; this.ws.onmessage = (event) => { try { - const message: StreamMessage = JSON.parse(event.data); + const message: StreamMessage = JSON.parse(event.data); this.handleMessage(message); } catch (error) { console.error('Error parsing WebSocket message:', error, 'Raw data:', event.data); @@ -88,19 +104,26 @@ class WebSocketService { } }; - this.ws.onclose = () => { - console.log('WebSocket disconnected'); + this.ws.onclose = (event) => { + console.log('WebSocket disconnected', event.code, event.reason); + this.isConnecting = false; this.emit('connection_status', { connected: false }); - this.attemptReconnect(); + + // Only attempt reconnect if it wasn't a manual disconnect + if (event.code !== 1000) { // 1000 = normal closure + this.attemptReconnect(); + } }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); + this.isConnecting = false; this.emit('error', { error: 'WebSocket connection failed' }); reject(error); }; } catch (error) { + this.isConnecting = false; reject(error); } }); @@ -110,11 +133,22 @@ class WebSocketService { * Disconnect from WebSocket server */ disconnect(): void { + console.log('Manually disconnecting WebSocket'); + + // Clear any pending reconnection attempts + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection + if (this.ws) { - this.ws.close(); + this.ws.close(1000, 'Manual disconnect'); // Use normal closure code this.ws = null; } this.planSubscriptions.clear(); + this.isConnecting = false; } /** @@ -209,9 +243,6 @@ class WebSocketService { /** * Handle incoming WebSocket messages */ - /** - * Handle incoming WebSocket messages - */ private handleMessage(message: StreamMessage): void { console.log('WebSocket message received:', message); @@ -234,19 +265,30 @@ class WebSocketService { */ private attemptReconnect(): void { if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.log('Max reconnection attempts reached'); + console.log('Max reconnection attempts reached - stopping reconnect attempts'); this.emit('error', { error: 'Max reconnection attempts reached' }); return; } + // Prevent multiple simultaneous reconnection attempts + if (this.isConnecting || this.reconnectTimer) { + console.log('Reconnection attempt already in progress'); + return; + } + this.reconnectAttempts++; + // Use exponential backoff: 12s, 24s, 48s, 96s, 192s const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); - console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`); + console.log(`Scheduling reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay/1000}s`); - setTimeout(() => { + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + console.log(`Attempting reconnection (attempt ${this.reconnectAttempts})`); + this.connect() .then(() => { + console.log('Reconnection successful - re-subscribing to plans'); // Re-subscribe to all plans this.planSubscriptions.forEach(planId => { this.subscribeToPlan(planId); @@ -254,6 +296,8 @@ class WebSocketService { }) .catch((error) => { console.error('Reconnection failed:', error); + // The connect() method will trigger another reconnection attempt + // through the onclose handler if needed }); }, delay); } @@ -277,28 +321,28 @@ class WebSocketService { } /** - * Send plan approval response - */ + * Send plan approval response + */ sendPlanApprovalResponse(response: PlanApprovalResponseData): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - console.error('WebSocket not connected - cannot send plan approval response'); - this.emit('error', { error: 'Cannot send plan approval response - WebSocket not connected' }); - return; - } + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.error('WebSocket not connected - cannot send plan approval response'); + this.emit('error', { error: 'Cannot send plan approval response - WebSocket not connected' }); + return; + } - try { - const message = { - type: 'plan_approval_response', - data: response - }; - this.ws.send(JSON.stringify(message)); - console.log('Plan approval response sent:', response); - } catch (error) { - console.error('Failed to send plan approval response:', error); - this.emit('error', { error: 'Failed to send plan approval response' }); + try { + const message = { + type: 'plan_approval_response', + data: response + }; + this.ws.send(JSON.stringify(message)); + console.log('Plan approval response sent:', response); + } catch (error) { + console.error('Failed to send plan approval response:', error); + this.emit('error', { error: 'Failed to send plan approval response' }); + } } } -} // Export singleton instance export const webSocketService = new WebSocketService(); From 9bb827e05ffaa4e875cd9cb71b265a02260c26fd Mon Sep 17 00:00:00 2001 From: Markus Date: Tue, 2 Sep 2025 11:41:55 -0700 Subject: [PATCH 40/41] Changes from Francia plus fix --- src/backend/v3/common/services/foundry_service.py | 7 ++++--- src/backend/v3/config/settings.py | 4 ++-- src/backend/v3/magentic_agents/reasoning_agent.py | 5 +++-- src/backend/v3/orchestration/orchestration_manager.py | 9 +++++++-- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/backend/v3/common/services/foundry_service.py b/src/backend/v3/common/services/foundry_service.py index 258bf568c..84d554dc0 100644 --- a/src/backend/v3/common/services/foundry_service.py +++ b/src/backend/v3/common/services/foundry_service.py @@ -1,9 +1,10 @@ -from typing import Any, Dict, List import logging import re -from azure.ai.projects.aio import AIProjectClient +from typing import Any, Dict, List + #from git import List import aiohttp +from azure.ai.projects.aio import AIProjectClient from common.config.app_config import config @@ -54,7 +55,7 @@ async def list_model_deployments(self) -> List[Dict[str, Any]]: try: # Get Azure Management API token (not Cognitive Services token) - token = config.get_access_token() + token = await config.get_access_token() # Extract Azure OpenAI resource name from endpoint URL openai_endpoint = config.AZURE_OPENAI_ENDPOINT diff --git a/src/backend/v3/config/settings.py b/src/backend/v3/config/settings.py index 4acf15351..945993ac9 100644 --- a/src/backend/v3/config/settings.py +++ b/src/backend/v3/config/settings.py @@ -34,7 +34,7 @@ def __init__(self): # Create credential self.credential = config.get_azure_credentials() - def create_chat_completion_service(self, use_reasoning_model: bool=False): + async def create_chat_completion_service(self, use_reasoning_model: bool=False): """Create Azure Chat Completion service.""" model_name = ( self.reasoning_model if use_reasoning_model else self.standard_model @@ -43,7 +43,7 @@ def create_chat_completion_service(self, use_reasoning_model: bool=False): return AzureChatCompletion( deployment_name=model_name, endpoint=self.endpoint, - ad_token_provider=config.get_access_token(), + ad_token_provider= await config.get_access_token(), ) def create_execution_settings(self): diff --git a/src/backend/v3/magentic_agents/reasoning_agent.py b/src/backend/v3/magentic_agents/reasoning_agent.py index 51eb9b684..615b423fc 100644 --- a/src/backend/v3/magentic_agents/reasoning_agent.py +++ b/src/backend/v3/magentic_agents/reasoning_agent.py @@ -2,6 +2,7 @@ import os from azure.identity import DefaultAzureCredential as SyncDefaultAzureCredential +from common.config.app_config import config from semantic_kernel import Kernel from semantic_kernel.agents import ChatCompletionAgent # pylint: disable=E0611 from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion @@ -9,7 +10,7 @@ from v3.magentic_agents.common.lifecycle import MCPEnabledBase from v3.magentic_agents.models.agent_models import MCPConfig, SearchConfig from v3.magentic_agents.reasoning_search import ReasoningSearch -from common.config.app_config import config + class ReasoningAgentTemplate(MCPEnabledBase): """ @@ -41,7 +42,7 @@ async def _after_open(self) -> None: chat = AzureChatCompletion( deployment_name=self._model_deployment_name, endpoint=self._openai_endpoint, - ad_token_provider=config.get_access_token() + ad_token_provider= await config.get_access_token() ) self.kernel.add_service(chat) diff --git a/src/backend/v3/orchestration/orchestration_manager.py b/src/backend/v3/orchestration/orchestration_manager.py index 1c17aad7c..a2f31b25d 100644 --- a/src/backend/v3/orchestration/orchestration_manager.py +++ b/src/backend/v3/orchestration/orchestration_manager.py @@ -8,6 +8,7 @@ from typing import List, Optional from azure.identity import DefaultAzureCredential as SyncDefaultAzureCredential +from common.config.app_config import config from common.models.messages_kernel import TeamConfiguration from semantic_kernel.agents.orchestration.magentic import MagenticOrchestration from semantic_kernel.agents.runtime import InProcessRuntime @@ -20,7 +21,6 @@ streaming_agent_response_callback) from v3.config.settings import (config, connection_config, current_user_id, orchestration_config) -from common.config.app_config import config from v3.magentic_agents.magentic_agent_factory import MagenticAgentFactory from v3.orchestration.human_approval_manager import \ HumanApprovalMagenticManager @@ -44,7 +44,12 @@ async def init_orchestration(cls, agents: List, user_id: str = None)-> MagenticO temperature=0.1 ) + credential = SyncDefaultAzureCredential() + def get_token(): + token = credential.get_token("https://cognitiveservices.azure.com/.default") + return token.token + # 1. Create a Magentic orchestration with Azure OpenAI magentic_orchestration = MagenticOrchestration( members=agents, @@ -52,7 +57,7 @@ async def init_orchestration(cls, agents: List, user_id: str = None)-> MagenticO chat_completion_service=AzureChatCompletion( deployment_name=config.AZURE_OPENAI_DEPLOYMENT_NAME, endpoint=config.AZURE_OPENAI_ENDPOINT, - ad_token_provider=config.get_access_token() + ad_token_provider=get_token # Use token provider function ), execution_settings=execution_settings ), From 70254827fd365a98de1ec3554e05fd65a8a985aa Mon Sep 17 00:00:00 2001 From: Markus Date: Tue, 2 Sep 2025 12:23:01 -0700 Subject: [PATCH 41/41] remove headerbuilder logging --- src/frontend/src/api/config.tsx | 80 ++++++++++++++++----------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src/frontend/src/api/config.tsx b/src/frontend/src/api/config.tsx index 0e775fdb1..586c16890 100644 --- a/src/frontend/src/api/config.tsx +++ b/src/frontend/src/api/config.tsx @@ -120,11 +120,11 @@ export function getUserId(): string { */ export function headerBuilder(headers?: Record): Record { let userId = getUserId(); - console.log('headerBuilder: Using user ID:', userId); + //console.log('headerBuilder: Using user ID:', userId); let defaultHeaders = { "x-ms-client-principal-id": String(userId) || "", // Custom header }; - console.log('headerBuilder: Created headers:', defaultHeaders); + //console.log('headerBuilder: Created headers:', defaultHeaders); return { ...defaultHeaders, ...(headers ? headers : {}) @@ -135,46 +135,46 @@ export function headerBuilder(headers?: Record): Record { - const apiUrl = getApiUrl(); - if (!apiUrl) { - throw new Error('API URL not configured'); - } - - const headers = headerBuilder({ - 'Content-Type': 'application/json', - }); - - console.log('initializeTeam: Starting team initialization...'); +// export async function initializeTeam(): Promise<{ +// status: string; +// team_id: string; +// }> { +// const apiUrl = getApiUrl(); +// if (!apiUrl) { +// throw new Error('API URL not configured'); +// } + +// const headers = headerBuilder({ +// 'Content-Type': 'application/json', +// }); + +// console.log('initializeTeam: Starting team initialization...'); - try { - const response = await fetch(`${apiUrl}/init_team`, { - method: 'GET', - headers, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || `HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - console.log('initializeTeam: Team initialization completed:', data); +// try { +// const response = await fetch(`${apiUrl}/init_team`, { +// method: 'GET', +// headers, +// }); + +// if (!response.ok) { +// const errorText = await response.text(); +// throw new Error(errorText || `HTTP error! status: ${response.status}`); +// } + +// const data = await response.json(); +// console.log('initializeTeam: Team initialization completed:', data); - // Validate the expected response format - if (data.status !== 'Request started successfully' || !data.team_id) { - throw new Error('Invalid response format from init_team endpoint'); - } +// // Validate the expected response format +// if (data.status !== 'Request started successfully' || !data.team_id) { +// throw new Error('Invalid response format from init_team endpoint'); +// } - return data; - } catch (error) { - console.error('initializeTeam: Error initializing team:', error); - throw error; - } -} +// return data; +// } catch (error) { +// console.error('initializeTeam: Error initializing team:', error); +// throw error; +// } +// } export const toBoolean = (value: any): boolean => { if (typeof value !== 'string') { @@ -192,5 +192,5 @@ export default { config, USER_ID, API_URL, - initializeTeam + //initializeTeam }; \ No newline at end of file