diff --git a/doc/AIAgent_Architecture.md b/doc/AIAgent_Architecture.md new file mode 100644 index 000000000..bcd537ea4 --- /dev/null +++ b/doc/AIAgent_Architecture.md @@ -0,0 +1,297 @@ +# AI Agent Architecture - Layer Separation + +## Overview + +The AI Agent integration follows a clean architecture pattern with proper separation of concerns across three layers: + +``` +┌─────────────────────────────────────────────────┐ +│ Presentation Layer │ +│ - Navigation Function Registry │ +│ - ViewModels (AIChatPageViewModel) │ +│ - UI Views (XAML) │ +│ - Implements navigation-specific functions │ +└──────────────┬──────────────────────────────────┘ + │ Dependencies + ↓ +┌─────────────────────────────────────────────────┐ +│ Business Layer │ +│ - AIAgentToolExecutor (function dispatcher) │ +│ - AIChatService (conversation orchestration) │ +│ - ChatMessage models │ +│ - NO UI or Navigation dependencies │ +└──────────────┬──────────────────────────────────┘ + │ Dependencies + ↓ +┌─────────────────────────────────────────────────┐ +│ Access Layer │ +│ - AIAgentApiClient (Azure AI communication) │ +│ - AIAgentConfiguration │ +│ - HTTP communication layer │ +└─────────────────────────────────────────────────┘ +``` + +## Key Architectural Principle + +**The Business layer does NOT depend on the Presentation layer.** + +This is achieved through a **delegate/callback pattern**: + +1. **Business Layer**: Defines the `AIAgentToolExecutor` which is a **dispatcher** that routes function calls to registered handlers +2. **Presentation Layer**: Implements the actual navigation functions in `AIAgentNavigationFunctionRegistry` and registers them with the executor + +## How It Works + +### 1. Business Layer - Function Dispatcher + +`AIAgentToolExecutor.cs` (Business layer): +```csharp +public class AIAgentToolExecutor : IAIAgentToolExecutor +{ + private readonly Dictionary>> _functionHandlers; + + // Registers a handler (provided by Presentation layer) + public void RegisterFunctionHandler(string functionName, Func<...> handler) + { + _functionHandlers[functionName] = handler; + } + + // Dispatches to the registered handler + public async Task ExecuteFunctionAsync(string functionName, string args, CancellationToken ct) + { + if (_functionHandlers.TryGetValue(functionName, out var handler)) + { + return await handler(args, ct); + } + // Function not found + } +} +``` + +### 2. Presentation Layer - Function Implementations + +`AIAgentNavigationFunctionRegistry.cs` (Presentation layer): +```csharp +public class AIAgentNavigationFunctionRegistry +{ + private readonly IAIAgentToolExecutor _toolExecutor; + private readonly ISectionsNavigator _sectionsNavigator; // UI navigation - only in Presentation! + + public void RegisterFunctions() + { + // Register navigation handlers with the Business layer executor + _toolExecutor.RegisterFunctionHandler("navigate_to_page", NavigateToPageAsync); + _toolExecutor.RegisterFunctionHandler("get_current_page", GetCurrentPageAsync); + _toolExecutor.RegisterFunctionHandler("go_back", GoBackAsync); + } + + private async Task NavigateToPageAsync(JsonDocument args, CancellationToken ct) + { + // Implementation uses ISectionsNavigator (Presentation layer dependency) + await _sectionsNavigator.SetActiveSection(ct, "Home"); + return AIAgentToolExecutor.ResponseHelpers.Success("Navigated to Home"); + } +} +``` + +### 3. Startup Registration + +`CoreStartup.cs` (Presentation layer): +```csharp +protected override void OnInitialized(IServiceProvider services) +{ + // ... other initialization + + // Register AI agent navigation functions + InitializeAIAgentFunctions(services); +} + +private static void InitializeAIAgentFunctions(IServiceProvider services) +{ + var registry = new AIAgentNavigationFunctionRegistry( + services.GetRequiredService(), // From Business layer + services.GetRequiredService(), // From Presentation layer + logger + ); + + registry.RegisterFunctions(); +} +``` + +## Benefits of This Architecture + +### ✅ **Separation of Concerns** +- Business layer handles function dispatching and conversation flow +- Presentation layer handles UI navigation +- Access layer handles external API communication + +### ✅ **Testability** +- Business layer can be tested without UI dependencies +- Mock function handlers can be registered for testing +- Each layer can be tested independently + +### ✅ **Extensibility** +- Add new functions by creating a new registry class +- Register custom handlers at any point after startup +- No need to modify Business layer for new UI features + +### ✅ **Flexibility** +- Different platforms can provide different implementations +- Business logic remains platform-agnostic +- Easy to add non-navigation functions (data access, etc.) + +## Adding Custom Functions + +### For Navigation Functions (Presentation Layer) + +1. **Add to `AIAgentNavigationFunctionRegistry.cs`**: +```csharp +public void RegisterFunctions() +{ + _toolExecutor.RegisterFunctionHandler("my_navigation_function", MyNavigationFunctionAsync); +} + +private async Task MyNavigationFunctionAsync(JsonDocument args, CancellationToken ct) +{ + // Use _sectionsNavigator here + await _sectionsNavigator.Navigate(ct, () => new MyPageViewModel()); + return AIAgentToolExecutor.ResponseHelpers.Success("Navigated to My Page"); +} +``` + +2. **Register in `AIAgentApiClient.GetAvailableTools()`** (Access layer) + +### For Non-Navigation Functions (Business Layer) + +For functions that don't require navigation (e.g., data access, calculations): + +1. **Create a new registry class** (Business or Presentation layer): +```csharp +public class AIAgentDataFunctionRegistry +{ + private readonly IAIAgentToolExecutor _toolExecutor; + private readonly IDataService _dataService; + + public void RegisterFunctions() + { + _toolExecutor.RegisterFunctionHandler("get_user_data", GetUserDataAsync); + } + + private async Task GetUserDataAsync(JsonDocument args, CancellationToken ct) + { + var data = await _dataService.GetUserData(ct); + return JsonSerializer.Serialize(new { success = true, data }); + } +} +``` + +2. **Register in `CoreStartup.OnInitialized()`** + +## Example: Complete Flow + +### Navigation Example +``` +User: "Navigate to settings" + ↓ +1. AIChatPageViewModel.SendMessage() + ↓ +2. AIChatService.SendMessageAsync() + ↓ +3. AIAgentApiClient.SendChatCompletionAsync() + → Azure AI Foundry API + ← Response: { tool_calls: [{ function: "navigate_to_page", args: { page_name: "Settings" } }] } + ↓ +4. AIChatService detects tool call + ↓ +5. AIAgentToolExecutor.ExecuteFunctionAsync("navigate_to_page", args) + ↓ +6. Executor finds registered handler + ↓ +7. AIAgentNavigationFunctionRegistry.NavigateToPageAsync(args) + ↓ +8. ISectionsNavigator.Navigate() [Presentation layer] + ↓ +9. Return success JSON to executor + ↓ +10. Send tool result back to Azure AI + ↓ +11. Get final response: "I've navigated you to Settings" + ↓ +12. Display in UI +``` + +### Drawing Example +``` +User: "Draw a cat" + ↓ +1. Thread is reused (preserves conversation history) + ↓ +2. AIAgentApiClient.SendMessageToAssistantAsync() + → Azure AI Foundry API + ← Response: { tool_calls: [{ function: "draw_content", args: { svg_content: "...", title: "Cat", description: "..." } }] } + ↓ +3. ExecuteNavigationTool("draw_content", args) + ↓ +4. Raise NavigationRequested event (type: DrawContent) + ↓ +5. AgenticChatPageViewModel.OnNavigationRequested() + ↓ +6. IDrawingModalService.ShowDrawingAsync(svgContent, title, description) + ↓ +7. Navigate to DrawingModalViewModel + ↓ +8. DrawingModalPage loads SVG in WebView2 + ↓ +User sees drawing in modal + +User: "Make it bigger" + ↓ +1. Same thread reused - AI sees previous SVG in history + ↓ +2. AI modifies previous SVG (increases size) + ↓ +3. New draw_content tool call with modified SVG + ↓ +4. Updated drawing displayed +``` + +## Anti-Patterns to Avoid + +### ❌ **DON'T**: Put navigation code in Business layer +```csharp +// BAD - in Business layer +public class AIAgentToolExecutor +{ + private readonly ISectionsNavigator _navigator; // ❌ UI dependency in Business! +} +``` + +### ✅ **DO**: Use the registry pattern +```csharp +// GOOD - in Presentation layer +public class AIAgentNavigationFunctionRegistry +{ + private readonly ISectionsNavigator _navigator; // ✅ UI dependency in Presentation +} +``` + +### ❌ **DON'T**: Hardcode ViewModels in Business layer +```csharp +// BAD +await _navigator.Navigate(ct, () => new SettingsPageViewModel()); // ❌ VM in Business +``` + +### ✅ **DO**: Keep ViewModels in Presentation layer +```csharp +// GOOD - in Presentation layer registry +await _sectionsNavigator.Navigate(ct, () => new SettingsPageViewModel()); // ✅ VM in Presentation +``` + +## Summary + +The AI Agent architecture properly separates concerns: +- **Business Layer**: Orchestrates function calls (dispatcher pattern) +- **Presentation Layer**: Implements UI-specific functions (navigation) +- **Access Layer**: Handles external API communication + +This design allows for clean, testable, and maintainable code while keeping the Business layer free from UI dependencies. diff --git a/doc/AIAgent_Summary.md b/doc/AIAgent_Summary.md new file mode 100644 index 000000000..d42073ec3 --- /dev/null +++ b/doc/AIAgent_Summary.md @@ -0,0 +1,343 @@ +# AI Agent Integration - Summary + +## What Was Added + +A complete AI Agent integration with Azure AI Foundry that enables intelligent chat with voice input/output and custom function calling for app navigation and control. + +## Files Created + +### Business Layer (`ApplicationTemplate.Business`) +- ✅ `Agentic/AgenticToolExecutor.cs` - Tool definition registry and function executor (dual interface implementation) +- ✅ `Agentic/IAgenticToolExecutor.cs` - Interface for registering and executing function handlers +- ✅ `Agentic/IAgenticToolRegistry.cs` - Interface for registering tool definitions (schemas sent to Azure) +- ✅ `Agentic/AgenticChatService.cs` - Chat service orchestration +- ✅ `Agentic/IAgenticChatService.cs` - Interface for chat service +- ✅ `Agentic/AgenticChatMessage.cs` - Message and tool call models +- ✅ `Agentic/ServiceCollectionAgenticExtensions.cs` - DI registration + +### Access Layer (`ApplicationTemplate.Access`) +- ✅ `ApiClients/Agentic/AgenticApiClient_Agents.cs` - Azure AI Foundry Agents API HTTP client +- ✅ `ApiClients/Agentic/IAgenticApiClient.cs` - API client interface +- ✅ `Configuration/AgenticConfiguration.cs` - Configuration model +- ✅ `Framework/Resources/AssistantInstructions.md` - Embedded instructions for AI assistant + +### Presentation Layer (`ApplicationTemplate.Presentation`) +- ✅ `ViewModels/Agentic/AgenticChatPageViewModel.cs` - Chat UI ViewModel with navigation event handling +- ✅ `ViewModels/Agentic/DrawingModalViewModel.cs` - Drawing modal ViewModel for SVG display +- ✅ `Framework/DrawingModalService.cs` - Service for displaying drawing modals +- ✅ `Business/Agentic/IDrawingModalService.cs` - Drawing modal service interface +- ✅ `Framework/AgenticNavigationFunctionRegistry.cs` - Navigation function definitions and handler implementations (dynamically registered) + +### Views (`ApplicationTemplate.Shared.Views`) +- ✅ `Content/Agentic/AgenticChatPage.xaml` - Chat UI view +- ✅ `Content/Agentic/DrawingModalPage.xaml` - Modal page for displaying SVG drawings with WebView2 +- ✅ Registered in `NavigationConfiguration.cs` for view-viewmodel mapping + +### Documentation (`doc/`) +- ✅ `AIAgent_Summary.md` - This file - feature summary +- ✅ `AIAgent_Architecture.md` - Architecture and dynamic tool registration + +### Configuration +- ✅ Updated `appsettings.json` with Agentic configuration section +- ✅ Updated `CoreStartup.cs` to register Agentic services and initialize navigation functions +- ✅ Updated `AppServicesConfiguration.cs` to register DrawingModalService +- ✅ Updated `AssistantInstructions.md` with drawing tool guidance and iteration examples +- ✅ Updated `ApplicationTemplate.Shared.Views.projitems` to include DrawingModalPage files + +## Architecture Highlights + +### ✅ Dynamic Tool Registration System +- **IAgenticToolRegistry**: Register tool definitions (schemas sent to Azure) +- **IAgenticToolExecutor**: Register function handlers (executed client-side) +- **AgenticToolExecutor**: Single class implementing both interfaces +- Tools are dynamically registered at app startup, not hardcoded + +### ✅ Proper Layer Separation +- **Business Layer**: Tool registry and executor (no UI dependencies) +- **Presentation Layer**: Navigation implementations and event handling +- **Access Layer**: HTTP REST API communication with Azure AI Foundry + +### ✅ Event-Driven Navigation & Drawing +- Navigation tools raise **NavigationRequested** events +- Drawing tool raises **NavigationRequested** event with DrawContent type +- ViewModel subscribes to events and performs actual navigation or displays modals +- Clean separation between tool execution and UI actions + +### ✅ Conversation History & Drawing Iteration +- Threads are reused across messages to preserve conversation history +- AI assistant can see previous messages and drawings in the conversation +- Users can ask to modify previous drawings (e.g., "make it bigger", "change the color to red") +- Thread ID is cleared on "Reset" to start fresh conversations + +### ✅ Extensible Design +```csharp +// Register tool definition (schema sent to Azure) +toolRegistry.RegisterToolDefinition(new AgenticToolDefinition { + Name = "my_function", + Description = "Does something useful", + Parameters = new { /* JSON schema */ } +}); + +// Register handler (executed when Azure calls the tool) +toolExecutor.RegisterFunctionHandler("my_function", async (args, ct) => { + // Execute logic + return JsonSerializer.Serialize(new { success = true }); +}); +``` + +## Available Functions + +1. **`navigate_to_page`** - Navigate to app pages (Home, Posts, Settings) +2. **`get_current_page`** - Get current navigation state +3. **`go_back`** - Navigate back in stack +4. **`open_settings`** - Open settings page +5. **`logout`** - Log out user (placeholder) +6. **`draw_content`** - Create and display SVG drawings/illustrations in a modal + +## Key Features + +- ✅ Azure AI Foundry Agents API integration (GA version 2025-05-01) +- ✅ Assistant search by name (no manual AssistantId configuration needed) +- ✅ Dynamic tool registration system (definitions + handlers) +- ✅ Function calling for app navigation via events +- ✅ **Visual content creation with SVG drawings** +- ✅ **Drawing modal with Material Design styling** +- ✅ **Conversation history preserved across messages (thread reuse)** +- ✅ **Iterative drawing modifications** (AI can modify previous drawings) +- ✅ Thread and message management +- ✅ Tool execution with `requires_action` handling +- ✅ Extensible function registration system +- ✅ Authentication with DefaultAzureCredential (Azure CLI or Service Principal) +- ✅ Proper error handling and logging +- ✅ Clean architecture with layer separation +- ✅ HTTP REST API (no SDK dependencies for mobile compatibility) + +## Configuration Required + +### Azure Resources Needed +1. **Azure AI Foundry** resource and project +2. **Model deployment** (e.g., gpt-4o-mini) +3. **Azure CLI** installed and authenticated (`az login`) + +### Update appsettings.json +```json +{ + "Agentic": { + "Endpoint": "https://your-ai-foundry-resource.services.ai.azure.com/api/projects/YourProjectName", + "ApiKey": "your-azure-ai-foundry-api-key", + "SubscriptionId": "your-azure-subscription-id", + "TenantId": "", + "ClientId": "", + "ClientSecret": "", + "AssistantName": "Mobile App Assistant", + "AssistantInstructions": "AssistantInstructions.md", + "DeploymentName": "gpt-4o-mini", + "Temperature": 0.7, + "MaxTokens": 1000 + } +} +``` + +**Note**: The app searches for an assistant by name on startup. If found, it updates the assistant with the latest tools and instructions. If not found, it creates a new assistant. No manual AssistantId configuration needed! + +### Authentication Setup +The app supports two authentication methods: + +#### Option 1: Azure CLI (Development) +Run `az login` in a terminal to authenticate with Azure. The app uses `DefaultAzureCredential` which will use your Azure CLI credentials with token scope `https://ai.azure.com/.default`. + +#### Option 2: Service Principal (Production) +Configure `TenantId`, `ClientId`, and `ClientSecret` in `appsettings.json`. The app will use these credentials for authentication via DefaultAzureCredential. + +## Next Steps for Implementation + +### 1. Configure Azure Resources +1. Create an Azure AI Foundry resource and project +2. Deploy a model (e.g., gpt-4o-mini) +3. Run `az login` to authenticate +4. Update `appsettings.json` with your endpoint and subscription ID + +### 2. Customize Navigation Implementations +The app includes a default `AgenticNavigationFunctionRegistry` with 5 navigation functions. To customize: + +1. Update the registry in `ApplicationTemplate.Presentation/Framework/` +2. Register both tool definitions AND handlers: + +```csharp +// Register tool definition (sent to Azure) +_toolRegistry.RegisterToolDefinition(new AgenticToolDefinition { + Name = "navigate_to_page", + Description = "Navigate to a specific page", + Parameters = new { + type = "object", + properties = new { + page_name = new { type = "string", description = "Page to navigate to" } + }, + required = new[] { "page_name" } + } +}); + +// Register handler (executed locally) +_toolExecutor.RegisterFunctionHandler("navigate_to_page", async (args, ct) => { + var pageName = args.RootElement.GetProperty("page_name").GetString(); + NavigationRequested?.Invoke(this, new NavigationRequestedEventArgs(pageName, null)); + return JsonSerializer.Serialize(new { success = true, message = $"Navigating to {pageName}" }); +}); +``` + +### 3. Add Custom Functions +Create additional function registries for: +- Data access functions +- User management functions +- App-specific operations + +Each registry should: +1. Register tool definitions via `IAgenticToolRegistry` +2. Register handlers via `IAgenticToolExecutor` +3. Be initialized in `CoreStartup.InitializeAgenticFunctions()` + +### 4. Extend Navigation Mapping +Update `AgenticChatPageViewModel.OnNavigationRequested()` to handle additional page names and navigation targets. + +## Example Usage + +### User Interaction +``` +User: "Take me to the settings page" +AI: [calls navigate_to_page tool on Azure] +AI: [requires_action returned with tool call] +App: [executes handler locally, raises NavigationRequested event] +ViewModel: [navigates to Settings section] +AI: "I've navigated you to the Settings page." + +User: "Where am I now?" +AI: [calls get_current_page tool] +App: [returns current section name] +AI: "You're currently on the Settings page." + +User: "Go back" +AI: [calls go_back tool] +App: [triggers back navigation] +AI: "Done! I've taken you back to the previous page." + +User: "Draw a house" +AI: [calls draw_content tool with SVG markup] +App: [displays DrawingModalPage with SVG rendering in WebView2] +AI: "I've drawn a house for you!" + +User: "Make it bigger with a red roof" +AI: [sees previous SVG in conversation history] +AI: [calls draw_content tool with modified SVG - larger size, red roof] +App: [displays updated drawing] +AI: "I've made the house bigger and added a red roof!" +``` + +### Code Usage +```csharp +// In your ViewModel +var chatService = this.GetService(); +var response = await chatService.SendMessageAsync(userMessage, ct); + +// Access tool executor for custom registrations +var toolExecutor = chatService.ToolExecutor; +toolExecutor.RegisterFunctionHandler("custom_function", async (args, ct) => { + // Custom logic + return JsonSerializer.Serialize(new { success = true }); +}); +``` + +## Testing + +### Unit Tests +```csharp +// Test tool registration +[Test] +public void RegisterToolDefinition_WithValidTool_Registers() +{ + var executor = new AgenticToolExecutor(logger); + executor.RegisterToolDefinition(new AgenticToolDefinition { + Name = "test_tool", + Description = "Test tool", + Parameters = new { } + }); + + var tools = executor.GetToolDefinitions(); + Assert.Contains(tools, t => t.Name == "test_tool"); +} + +// Test function handler +[Test] +public async Task ExecuteFunctionAsync_WithRegisteredHandler_Executes() +{ + var executor = new AgenticToolExecutor(logger); + executor.RegisterFunctionHandler("test", async (args, ct) => + JsonSerializer.Serialize(new { success = true })); + + var result = await executor.ExecuteFunctionAsync("test", JsonDocument.Parse("{}"), ct); + Assert.Contains("success", result); +} +``` + +### Integration Tests +```csharp +// Test navigation flow +[Test] +public async Task NavigateToPage_WithValidPage_RaisesEvent() +{ + var registry = new AgenticNavigationFunctionRegistry( + toolRegistry, toolExecutor, sectionsNavigator, logger); + + var eventRaised = false; + registry.NavigationRequested += (s, e) => eventRaised = true; + + registry.RegisterFunctions(); + + var args = JsonDocument.Parse("{\"page_name\": \"Settings\"}"); + await toolExecutor.ExecuteFunctionAsync("navigate_to_page", args, ct); + + Assert.True(eventRaised); +} +``` + +## Important Notes + +### Architecture +- ✅ Business layer has NO UI dependencies +- ✅ Navigation is handled via events (NavigationRequested) +- ✅ Tool definitions AND handlers are registered dynamically at startup +- ✅ Single AgenticToolExecutor implements both IAgenticToolRegistry and IAgenticToolExecutor +- ✅ Clean separation allows for easy testing +- ✅ HTTP REST API used (no SDK dependencies for mobile compatibility) + +### Security +- 🔒 Uses DefaultAzureCredential with Azure CLI for development +- 🔒 Store API keys in Azure Key Vault for production +- 🔒 Implement rate limiting +- 🔒 Validate user permissions for sensitive functions +- 🔒 Sanitize all user inputs +- 🔒 Do NOT commit personal Azure credentials (use generic placeholders) + +### Performance +- ⚡ Polling-based run status checking (in_progress → requires_action → completed) +- ⚡ Tool execution happens locally for navigation functions +- ⚡ Implement request debouncing for user input +- ⚡ Monitor Azure AI Foundry costs + +## Documentation Links + +- **Architecture Details**: `doc/AIAgent_Architecture.md` - Dynamic tool registration and layer separation + +## Support + +For implementation help: +1. Review the architecture documentation +2. Check Azure AI Foundry Agents API documentation (version 2025-05-01) +3. Examine the implementation in `ApplicationTemplate.Business/Agentic/` and `ApplicationTemplate.Access/ApiClients/Agentic/` +4. Check application logs for errors +5. Ensure `az login` is successful before running the app + +--- + +**Status**: ✅ Complete and ready for integration + +The Agentic AI feature is fully implemented with dynamic tool registration, proper architecture, HTTP REST API integration with Azure AI Foundry Agents, and comprehensive navigation support. Update the configuration with your Azure resources to get started. diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Agentic/AgenticApiClient.cs b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/AgenticApiClient.cs new file mode 100644 index 000000000..3343db8c3 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/AgenticApiClient.cs @@ -0,0 +1,76 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ApplicationTemplate.DataAccess.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ApplicationTemplate.DataAccess.ApiClients.Agentic; + +/// +/// API client for Azure AI Foundry Agents API. +/// Uses HTTP REST API for full control and mobile compatibility. +/// +public partial class AgenticApiClient : IAgenticApiClient +{ + private readonly AgenticConfiguration _configuration; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client (not used in current implementation but kept for DI compatibility). + /// The AI agent configuration. + /// The logger. + public AgenticApiClient( + System.Net.Http.HttpClient httpClient, + IOptions configuration, + ILogger logger) + { + _configuration = configuration?.Value ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public Task SendChatCompletionAsync(System.Collections.ObjectModel.Collection messages, CancellationToken ct) + { + // This method is not used with the Agents API. + // Use SendMessageToAssistantAsync instead, which handles agent threads, runs, and tool calling. + throw new NotSupportedException( + "SendChatCompletionAsync is not supported when using Azure AI Foundry Agents API. " + + "Use SendMessageToAssistantAsync instead for agent-based conversations with tool calling support."); + } + + /// + public Task SendChatCompletionStreamAsync( + System.Collections.ObjectModel.Collection messages, + Action onChunkReceived, + CancellationToken ct) + { + // Streaming is not supported with the Agents API in the current implementation. + // The Agents API uses a run-based model with polling. + throw new NotSupportedException( + "SendChatCompletionStreamAsync is not supported when using Azure AI Foundry Agents API. " + + "Use SendMessageToAssistantAsync instead. The Agents API uses a run-based polling model."); + } + + /// + public Task TranscribeAudioAsync(byte[] audioData, CancellationToken ct) + { + // Audio transcription is not implemented in the current Agents API implementation. + // Future: Could use Azure Speech Services or agent's multimodal audio support. + throw new NotSupportedException( + "TranscribeAudioAsync is not implemented. " + + "Consider using Azure Speech Services or the agent's multimodal audio capabilities."); + } + + /// + public Task TextToSpeechAsync(string text, CancellationToken ct) + { + // Text-to-speech is not implemented in the current Agents API implementation. + // Future: Could use Azure Speech Services or agent's multimodal audio output. + throw new NotSupportedException( + "TextToSpeechAsync is not implemented. " + + "Consider using Azure Speech Services or the agent's multimodal audio capabilities."); + } +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Agentic/AgenticApiClient_Agents.cs b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/AgenticApiClient_Agents.cs new file mode 100644 index 000000000..495237196 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/AgenticApiClient_Agents.cs @@ -0,0 +1,733 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ApplicationTemplate.DataAccess.Configuration; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging; + +namespace ApplicationTemplate.DataAccess.ApiClients.Agentic; + +/// +/// Creates or retrieves a persistent agent with custom function tool definitions for app navigation. +/// +public partial class AgenticApiClient +{ + /// + /// Event raised when the AI agent requests navigation to a different page. + /// + public event EventHandler NavigationRequested; + + private HttpClient _agentsHttpClient; + private string _currentAgentId; + private string _currentThreadId; + + // Azure AI Foundry Agents API version + // Reference: https://learn.microsoft.com/en-us/azure/ai-foundry/agents/quickstart + // GA version: 2025-05-01, Latest preview: 2025-05-15-preview + private const string ApiVersion = "2025-05-01"; + + /// + /// Initializes the AI agent client and ensures the assistant is created. + /// This is a public method that can be called explicitly to initialize the agent before sending messages. + /// + /// Cancellation token. + public async Task InitializeAsync(CancellationToken ct) + { + await EnsurePersistentClientsAsync(ct); + } + + private async Task EnsurePersistentClientsAsync(CancellationToken ct) + { + if (_agentsHttpClient != null) + { + return; + } + + if (string.IsNullOrEmpty(_configuration.Endpoint)) + { + throw new InvalidOperationException("Agentic endpoint is not configured."); + } + + _logger.LogInformation("Initializing Azure AI Agents HTTP client with Bearer token authentication"); + + // Set environment variables for Service Principal authentication (if provided in config) + // If not provided, DefaultAzureCredential will fall back to Azure CLI authentication + if (!string.IsNullOrEmpty(_configuration.TenantId)) + { + Environment.SetEnvironmentVariable("AZURE_TENANT_ID", _configuration.TenantId); + _logger.LogInformation("Using Service Principal authentication (TenantId configured)"); + } + + if (!string.IsNullOrEmpty(_configuration.ClientId)) + { + Environment.SetEnvironmentVariable("AZURE_CLIENT_ID", _configuration.ClientId); + } + + if (!string.IsNullOrEmpty(_configuration.ClientSecret)) + { + Environment.SetEnvironmentVariable("AZURE_CLIENT_SECRET", _configuration.ClientSecret); + } + + // Create DefaultAzureCredential + // This will try in order: Environment variables (Service Principal), Managed Identity, Azure CLI, etc. + var credential = new DefaultAzureCredential(); + + // Get an access token for Azure AI Foundry + // NOTE: Azure AI Foundry requires the token audience to be "https://ai.azure.com" + var tokenRequestContext = new TokenRequestContext(new[] { "https://ai.azure.com/.default" }); + + _logger.LogInformation("Acquiring Azure access token for AI Foundry..."); + var token = await credential.GetTokenAsync(tokenRequestContext, ct); + _logger.LogInformation("Successfully acquired access token (expires: {Expiry})", token.ExpiresOn); + + // Create HttpClient with Bearer token + var baseAddress = _configuration.Endpoint; + // Ensure the base address ends with a trailing slash for proper URI combination + if (!baseAddress.EndsWith("/")) + { + baseAddress += "/"; + } + + _agentsHttpClient = new HttpClient + { + BaseAddress = new Uri(baseAddress) + }; + // Use Bearer token authentication + _agentsHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + _agentsHttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + _logger.LogInformation("Configured HTTP client with endpoint: {Endpoint} and Bearer token", baseAddress); + + // Ensure we have an agent with tools configured + await EnsureAgentWithToolsAsync(ct); + } + + private async Task EnsureAgentWithToolsAsync(CancellationToken ct) + { + // Load instructions from file if specified + var instructions = await LoadInstructionsAsync(_configuration.AssistantInstructions, ct); + + // Build agent data with tools using REST API format + var toolsList = GetNavigationToolDefinitions().ToList(); + var agentData = new + { + model = _configuration.DeploymentName, + name = _configuration.AssistantName, + instructions = instructions, + temperature = (float)_configuration.Temperature, + tools = toolsList.ToArray() + }; + + // Search for existing assistant by name + _logger.LogInformation("Searching for existing assistant with name '{AssistantName}'", _configuration.AssistantName); + + var listResponse = await _agentsHttpClient.GetAsync($"assistants?api-version={ApiVersion}", ct); + listResponse.EnsureSuccessStatusCode(); + + var listJson = await listResponse.Content.ReadAsStringAsync(ct); + var listData = JsonDocument.Parse(listJson).RootElement; + + string existingAssistantId = null; + bool needsUpdate = false; + + if (listData.TryGetProperty("data", out var assistantsArray)) + { + foreach (var assistant in assistantsArray.EnumerateArray()) + { + if (assistant.TryGetProperty("name", out var nameProperty) && + nameProperty.GetString() == _configuration.AssistantName) + { + existingAssistantId = assistant.GetProperty("id").GetString(); + _logger.LogInformation("Found existing assistant '{AssistantName}' with ID {AssistantId}", _configuration.AssistantName, existingAssistantId); + + // Check if tools match + if (assistant.TryGetProperty("tools", out var existingTools)) + { + var existingToolNames = new HashSet(); + foreach (var tool in existingTools.EnumerateArray()) + { + if (tool.TryGetProperty("function", out var func) && + func.TryGetProperty("name", out var toolName)) + { + existingToolNames.Add(toolName.GetString()); + } + } + + var expectedToolNames = new HashSet(new[] { "navigate_to_page", "get_current_page", "go_back", "open_settings", "logout" }); + + if (!existingToolNames.SetEquals(expectedToolNames)) + { + _logger.LogInformation("Assistant tools don't match. Expected: [{Expected}], Found: [{Found}]. Will update.", + string.Join(", ", expectedToolNames), string.Join(", ", existingToolNames)); + needsUpdate = true; + } + else + { + _logger.LogInformation("Assistant has correct tools, will update configuration anyway to ensure latest version"); + needsUpdate = true; // Always update to ensure latest instructions and settings + } + } + else + { + needsUpdate = true; + } + + break; + } + } + } + + if (existingAssistantId != null) + { + if (needsUpdate) + { + _logger.LogInformation("Updating assistant {AssistantId} with latest configuration", existingAssistantId); + + var json = JsonSerializer.Serialize(agentData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var updateResponse = await _agentsHttpClient.PostAsync($"assistants/{existingAssistantId}?api-version={ApiVersion}", content, ct); + + if (!updateResponse.IsSuccessStatusCode) + { + var errorContent = await updateResponse.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Failed to update assistant {AssistantId}. Status: {StatusCode}, Response: {Response}. Will continue with existing assistant.", + existingAssistantId, updateResponse.StatusCode, errorContent); + } + else + { + _logger.LogInformation("Successfully updated assistant {AssistantId} with {ToolCount} tools", existingAssistantId, toolsList.Count); + } + } + + _currentAgentId = existingAssistantId; + return; + } + + // No existing assistant found, create a new one + _logger.LogInformation("No assistant found with name '{AssistantName}', creating new assistant", _configuration.AssistantName); + + var createJson = JsonSerializer.Serialize(agentData); + var createContent = new StringContent(createJson, Encoding.UTF8, "application/json"); + + var requestUri = $"assistants?api-version={ApiVersion}"; + var response = await _agentsHttpClient.PostAsync(requestUri, createContent, ct); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("Assistant creation failed. Status: {StatusCode}, Response: {Response}", response.StatusCode, errorContent); + } + + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(ct); + var responseData = JsonDocument.Parse(responseJson).RootElement; + _currentAgentId = responseData.GetProperty("id").GetString(); + + _logger.LogInformation("Created new assistant '{AssistantName}' with ID {AssistantId} and {ToolCount} tools", + _configuration.AssistantName, _currentAgentId, toolsList.Count); + } + + private async Task LoadInstructionsAsync(string instructionsOrPath, CancellationToken ct) + { + // Check if it's a file path (ends with .md or .txt) + if (instructionsOrPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || + instructionsOrPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + try + { + // Try to load from embedded resource first (from Presentation assembly) + var presentationAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "ApplicationTemplate.Presentation"); + + if (presentationAssembly != null) + { + var resourceName = $"ApplicationTemplate.Presentation.{instructionsOrPath}"; + using var stream = presentationAssembly.GetManifestResourceStream(resourceName); + if (stream != null) + { + using var reader = new System.IO.StreamReader(stream); + _logger.LogInformation("Loading assistant instructions from embedded resource: {ResourceName}", resourceName); + return await reader.ReadToEndAsync(ct); + } + } + + // Fallback: Try to load from file system + var filePath = instructionsOrPath; + if (!System.IO.Path.IsPathRooted(filePath)) + { + // Relative path - try to resolve from app directory + var appDirectory = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + filePath = System.IO.Path.Combine(appDirectory, instructionsOrPath); + } + + if (System.IO.File.Exists(filePath)) + { + _logger.LogInformation("Loading assistant instructions from file: {FilePath}", filePath); + return await System.IO.File.ReadAllTextAsync(filePath, ct); + } + else + { + _logger.LogWarning("Instructions file not found (resource or file): {InstructionsPath}, using default instructions", instructionsOrPath); + return "You are a helpful AI assistant integrated into a mobile application. You can help users navigate the app, access information, and perform tasks using the available tools. Be concise and friendly."; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading instructions from: {InstructionsPath}", instructionsOrPath); + return "You are a helpful AI assistant integrated into a mobile application. You can help users navigate the app, access information, and perform tasks using the available tools. Be concise and friendly."; + } + } + + // It's a direct string instruction + return instructionsOrPath; + } + + private IEnumerable GetNavigationToolDefinitions() + { + // Define navigate_to_page function + yield return new + { + type = "function", + function = new + { + name = "navigate_to_page", + description = "Navigate to a specific page in the mobile application", + parameters = new + { + type = "object", + properties = new + { + page_name = new + { + type = "string", + description = "The name of the page to navigate to. Available pages: DadJokes, Posts, Settings, UserProfile", + @enum = new[] { "DadJokes", "Posts", "Settings", "UserProfile" } + }, + clear_stack = new + { + type = "boolean", + description = "Whether to clear the navigation stack before navigating", + @default = false + } + }, + required = new[] { "page_name" } + } + } + }; + + // Define get_current_page function + yield return new + { + type = "function", + function = new + { + name = "get_current_page", + description = "Get information about the current page the user is viewing in the application", + parameters = new + { + type = "object", + properties = new { } + } + } + }; + + // Define go_back function + yield return new + { + type = "function", + function = new + { + name = "go_back", + description = "Navigate back to the previous page in the navigation stack", + parameters = new + { + type = "object", + properties = new { } + } + } + }; + + // Define open_settings function + yield return new + { + type = "function", + function = new + { + name = "open_settings", + description = "Open the settings page of the application", + parameters = new + { + type = "object", + properties = new { } + } + } + }; + + // Define logout function + yield return new + { + type = "function", + function = new + { + name = "logout", + description = "Log out the current user from the application", + parameters = new + { + type = "object", + properties = new { } + } + } + }; + + // Define draw_content function + yield return new + { + type = "function", + function = new + { + name = "draw_content", + description = "Draw visual content (illustrations, diagrams, shapes) in a modal using SVG. You can draw any kind of visual content by providing SVG path commands or elements. Examples: draw an apple, create a diagram, illustrate a concept, show a chart.", + parameters = new + { + type = "object", + properties = new + { + svg_content = new + { + type = "string", + description = "The SVG content to display. Can be complete SVG markup with tag, or just SVG elements like , , , , etc. For paths, use standard SVG path commands (M, L, C, Q, etc.). Example for an apple: " + }, + title = new + { + type = "string", + description = "Title for the drawing modal (e.g., 'Apple Illustration', 'Network Diagram')" + }, + description = new + { + type = "string", + description = "Optional description or caption for the drawing" + } + }, + required = new[] { "svg_content", "title" } + } + } + }; + } + + /// + /// Sends a single user message to the configured persistent agent and returns the assistant's reply (last assistant message). + /// Assumes AgenticConfiguration.AssistantId is set to an existing persistent agent. + /// + /// + /// Sends a single user message to the configured persistent agent and returns the assistant's reply (last assistant message). + /// + /// The user's message to send. + /// Cancellation token. + public async Task SendMessageToAssistantAsync(string userMessage, CancellationToken ct) + { + await EnsurePersistentClientsAsync(ct); + + if (_agentsHttpClient == null) + { + throw new InvalidOperationException("HTTP client not initialized."); + } + + if (string.IsNullOrWhiteSpace(_currentAgentId)) + { + throw new InvalidOperationException("Agent not initialized. Check configuration and agent creation."); + } + + _logger.LogInformation("Using persistent agent {AgentId}", _currentAgentId); + + // Create new thread only if we don't have one yet (preserves conversation history) + if (string.IsNullOrWhiteSpace(_currentThreadId)) + { + var threadResponse = await _agentsHttpClient.PostAsync($"threads?api-version={ApiVersion}", null, ct); + threadResponse.EnsureSuccessStatusCode(); + var threadJson = await threadResponse.Content.ReadAsStringAsync(ct); + var threadData = JsonDocument.Parse(threadJson).RootElement; + _currentThreadId = threadData.GetProperty("id").GetString(); + _logger.LogInformation("Created new thread with id {ThreadId}", _currentThreadId); + } + else + { + _logger.LogInformation("Reusing existing thread {ThreadId} to preserve conversation history", _currentThreadId); + } + + // Post the user message + var messageData = new { role = "user", content = userMessage }; + var messageJson = JsonSerializer.Serialize(messageData); + var messageContent = new StringContent(messageJson, Encoding.UTF8, "application/json"); + var messageResponse = await _agentsHttpClient.PostAsync($"threads/{_currentThreadId}/messages?api-version={ApiVersion}", messageContent, ct); + messageResponse.EnsureSuccessStatusCode(); + + // Create a run (execute the agent) + var runData = new { assistant_id = _currentAgentId }; + var runJson = JsonSerializer.Serialize(runData); + var runContent = new StringContent(runJson, Encoding.UTF8, "application/json"); + var runResponse = await _agentsHttpClient.PostAsync($"threads/{_currentThreadId}/runs?api-version={ApiVersion}", runContent, ct); + runResponse.EnsureSuccessStatusCode(); + var runResponseJson = await runResponse.Content.ReadAsStringAsync(ct); + var runResponseData = JsonDocument.Parse(runResponseJson).RootElement; + var runId = runResponseData.GetProperty("id").GetString(); + var runStatus = runResponseData.GetProperty("status").GetString(); + + // Poll until terminal status or requires_action + _logger.LogInformation("Polling run status for run {RunId}", runId); + while (runStatus == "queued" || runStatus == "in_progress" || runStatus == "requires_action") + { + if (runStatus == "requires_action") + { + // Agent wants to call a tool - handle it + _logger.LogInformation("Run requires action - processing tool calls"); + + var statusResponse = await _agentsHttpClient.GetAsync($"threads/{_currentThreadId}/runs/{runId}?api-version={ApiVersion}", ct); + statusResponse.EnsureSuccessStatusCode(); + var statusJson = await statusResponse.Content.ReadAsStringAsync(ct); + var statusData = JsonDocument.Parse(statusJson).RootElement; + + // Extract tool calls + var requiredAction = statusData.GetProperty("required_action"); + var submitToolOutputs = requiredAction.GetProperty("submit_tool_outputs"); + var toolCalls = submitToolOutputs.GetProperty("tool_calls"); + + // Process each tool call + var toolOutputs = new List(); + foreach (var toolCall in toolCalls.EnumerateArray()) + { + var toolCallId = toolCall.GetProperty("id").GetString(); + var functionName = toolCall.GetProperty("function").GetProperty("name").GetString(); + var functionArgs = toolCall.GetProperty("function").GetProperty("arguments").GetString(); + + _logger.LogInformation("Tool call: {FunctionName} with args: {Args}", functionName, functionArgs); + + // Execute the tool and get output + var output = ExecuteNavigationTool(functionName, functionArgs); + + toolOutputs.Add(new + { + tool_call_id = toolCallId, + output = output + }); + } + + // Submit tool outputs back to the run + var toolOutputsData = new { tool_outputs = toolOutputs }; + var toolOutputsJson = JsonSerializer.Serialize(toolOutputsData); + var toolOutputsContent = new StringContent(toolOutputsJson, Encoding.UTF8, "application/json"); + var submitResponse = await _agentsHttpClient.PostAsync( + $"threads/{_currentThreadId}/runs/{runId}/submit_tool_outputs?api-version={ApiVersion}", + toolOutputsContent, + ct); + submitResponse.EnsureSuccessStatusCode(); + + _logger.LogInformation("Submitted {Count} tool outputs", toolOutputs.Count); + + // Continue polling + runStatus = "in_progress"; + continue; + } + + await Task.Delay(TimeSpan.FromMilliseconds(500), ct); + var statusResponse2 = await _agentsHttpClient.GetAsync($"threads/{_currentThreadId}/runs/{runId}?api-version={ApiVersion}", ct); + statusResponse2.EnsureSuccessStatusCode(); + var statusJson2 = await statusResponse2.Content.ReadAsStringAsync(ct); + var statusData2 = JsonDocument.Parse(statusJson2).RootElement; + runStatus = statusData2.GetProperty("status").GetString(); + _logger.LogDebug("Run status: {Status}", runStatus); + } + + if (runStatus != "completed") + { + _logger.LogWarning("Run ended with status {Status}", runStatus); + throw new InvalidOperationException($"Run failed or was canceled: {runStatus}"); + } + + // Get messages from thread + var messagesResponse = await _agentsHttpClient.GetAsync($"threads/{_currentThreadId}/messages?order=asc&api-version={ApiVersion}", ct); + messagesResponse.EnsureSuccessStatusCode(); + var messagesJson = await messagesResponse.Content.ReadAsStringAsync(ct); + var messagesData = JsonDocument.Parse(messagesJson).RootElement; + + // Find the last assistant message + ChatMessage lastAssistantMessage = null; + if (messagesData.TryGetProperty("data", out var dataArray)) + { + foreach (var message in dataArray.EnumerateArray()) + { + if (message.TryGetProperty("role", out var role) && role.GetString() == "assistant") + { + if (message.TryGetProperty("content", out var contentArray)) + { + foreach (var contentItem in contentArray.EnumerateArray()) + { + if (contentItem.TryGetProperty("text", out var textObj) && + textObj.TryGetProperty("value", out var textValue)) + { + lastAssistantMessage = new ChatMessage + { + Role = "assistant", + Content = textValue.GetString() ?? string.Empty, + Timestamp = message.TryGetProperty("created_at", out var createdAt) + ? DateTimeOffset.FromUnixTimeSeconds(createdAt.GetInt64()).UtcDateTime + : DateTime.UtcNow + }; + } + } + } + } + } + } + + return lastAssistantMessage ?? new ChatMessage + { + Role = "assistant", + Content = string.Empty, + Timestamp = DateTime.UtcNow + }; + } + + /// + /// Resets conversation by clearing the current thread ID. + /// A new thread will be created on the next message, starting a fresh conversation. + /// + /// Cancellation token. + public async Task ResetConversationAsync(CancellationToken ct) + { + await EnsurePersistentClientsAsync(ct); + if (_agentsHttpClient == null) + { + return; + } + + // Clear the thread ID so a new thread is created on the next message + _currentThreadId = null; + _logger.LogInformation("Conversation reset - thread ID cleared. New thread will be created on next message."); + } + + /// + /// Deletes the current assistant if one exists. + /// + /// Cancellation token. + public async Task DeleteAssistantAsync(CancellationToken ct) + { + await EnsurePersistentClientsAsync(ct); + if (_agentsHttpClient == null) + { + return; + } + + if (string.IsNullOrWhiteSpace(_currentAgentId)) + { + return; + } + + // DELETE assistants/{assistant_id} via REST API + var response = await _agentsHttpClient.DeleteAsync($"assistants/{_currentAgentId}?api-version={ApiVersion}", ct); + response.EnsureSuccessStatusCode(); + _currentAgentId = null; + } + + /// + /// Executes a navigation tool function and returns the result as a string. + /// + private string ExecuteNavigationTool(string functionName, string functionArgsJson) + { + try + { + _logger.LogInformation("Executing navigation tool: {FunctionName}", functionName); + + switch (functionName) + { + case "navigate_to_page": + var args = JsonSerializer.Deserialize(functionArgsJson); + var pageName = args.GetProperty("page_name").GetString(); + _logger.LogInformation("Navigation requested to page: {PageName}", pageName); + + // Raise navigation event + NavigationRequested?.Invoke(this, new NavigationRequestedEventArgs(NavigationType.Page, pageName)); + + return JsonSerializer.Serialize(new { success = true, message = $"Navigation to {pageName} page has been queued. The app will navigate shortly." }); + + case "get_current_page": + return JsonSerializer.Serialize(new { current_page = "AgenticChatPage", message = "You are currently on the Agentic Chat page." }); + + case "go_back": + _logger.LogInformation("Go back requested"); + + // Raise navigation event + NavigationRequested?.Invoke(this, new NavigationRequestedEventArgs(NavigationType.Back)); + + return JsonSerializer.Serialize(new { success = true, message = "Navigation back has been queued." }); + + case "open_settings": + _logger.LogInformation("Open settings requested"); + + // Raise navigation event + NavigationRequested?.Invoke(this, new NavigationRequestedEventArgs(NavigationType.Settings)); + + return JsonSerializer.Serialize(new { success = true, message = "Settings page will open shortly." }); + + case "logout": + _logger.LogInformation("Logout requested"); + + // Raise navigation event + NavigationRequested?.Invoke(this, new NavigationRequestedEventArgs(NavigationType.Logout)); + + return JsonSerializer.Serialize(new { success = true, message = "Logout has been queued." }); + + case "draw_content": + var drawArgs = JsonSerializer.Deserialize(functionArgsJson); + + // Try to get svg_content (handle both correct and malformed JSON keys) + string svgContent = null; + if (drawArgs.TryGetProperty("svg_content", out var svgElement)) + { + svgContent = svgElement.GetString(); + } + else if (drawArgs.TryGetProperty("svg_content:", out svgElement)) // Handle malformed key with colon + { + svgContent = svgElement.GetString(); + } + + var drawTitle = drawArgs.TryGetProperty("title", out var titleElement) ? titleElement.GetString() : "Drawing"; + var drawDescription = drawArgs.TryGetProperty("description", out var descElement) ? descElement.GetString() : string.Empty; + + if (string.IsNullOrWhiteSpace(svgContent)) + { + _logger.LogWarning("Draw content called but svg_content is missing or empty"); + return JsonSerializer.Serialize(new { success = false, error = "svg_content is required" }); + } + + _logger.LogInformation("Draw content requested: {Title}", drawTitle); + + // Raise navigation event with drawing data + NavigationRequested?.Invoke(this, new NavigationRequestedEventArgs( + NavigationType.DrawContent, + svgContent: svgContent, + title: drawTitle, + description: drawDescription)); + + return JsonSerializer.Serialize(new { success = true, message = $"Drawing '{drawTitle}' is being displayed." }); + + default: + _logger.LogWarning("Unknown tool function: {FunctionName}", functionName); + return JsonSerializer.Serialize(new { success = false, error = $"Unknown function: {functionName}" }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing navigation tool {FunctionName}", functionName); + return JsonSerializer.Serialize(new { success = false, error = ex.Message }); + } + } +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Agentic/ChatMessage.cs b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/ChatMessage.cs new file mode 100644 index 000000000..de1064fa1 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/ChatMessage.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.ObjectModel; + +namespace ApplicationTemplate.DataAccess.ApiClients.Agentic; + +/// +/// Represents a chat message in the AI agent conversation. +/// +public class ChatMessage +{ + /// + /// Gets or sets the unique identifier for the message. + /// + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the role of the message sender (user, assistant, system, tool). + /// + public string Role { get; set; } = string.Empty; + + /// + /// Gets or sets the content of the message. + /// + public string Content { get; set; } = string.Empty; + + /// + /// Gets or sets the timestamp when the message was created. + /// + public DateTime Timestamp { get; set; } = DateTime.Now; + + /// + /// Gets or sets the tool calls if this is an assistant message with function calls. + /// + public Collection ToolCalls { get; set; } = new Collection(); + + /// + /// Gets or sets the tool call ID if this is a tool response message. + /// + public string ToolCallId { get; set; } = string.Empty; + + /// + /// Gets or sets whether this message is being streamed (partial response). + /// + public bool IsStreaming { get; set; } + + /// + /// Gets a value indicating whether this message is from the user. + /// + public bool IsFromUser => Role == "user"; +} + +/// +/// Represents a tool/function call made by the AI agent. +/// +public class ToolCall +{ + /// + /// Gets or sets the unique identifier for the tool call. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the type of the tool call (usually "function"). + /// + public string Type { get; set; } = "function"; + + /// + /// Gets or sets the function name. + /// + public string FunctionName { get; set; } = string.Empty; + + /// + /// Gets or sets the function arguments as a JSON string. + /// + public string FunctionArguments { get; set; } = string.Empty; +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Agentic/IAgenticApiClient.cs b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/IAgenticApiClient.cs new file mode 100644 index 000000000..df932382b --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/IAgenticApiClient.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; + +namespace ApplicationTemplate.DataAccess.ApiClients.Agentic; + +/// +/// Interface for AI agent API client to communicate with Azure AI Foundry. +/// +public interface IAgenticApiClient +{ + /// + /// Event raised when the AI agent requests navigation to a different page. + /// +#nullable enable + event EventHandler? NavigationRequested; +#nullable restore + + /// + /// Initializes the AI agent client and ensures the assistant is created. + /// Should be called before any other operations. + /// + /// The cancellation token. + Task InitializeAsync(CancellationToken ct); + + /// + /// Sends a chat completion request to Azure AI Foundry. + /// + /// The conversation messages. + /// The cancellation token. + /// The AI agent's response message. + Task SendChatCompletionAsync(Collection messages, CancellationToken ct); + + /// + /// Sends a streaming chat completion request to Azure AI Foundry. + /// + /// The conversation messages. + /// Callback invoked for each chunk of the response. + /// The cancellation token. + /// The complete AI agent's response message. + Task SendChatCompletionStreamAsync( + Collection messages, + Action onChunkReceived, + CancellationToken ct); + + /// + /// Sends a message to the Azure AI Agent (Assistants API) and receives response. + /// + /// The user's message. + /// The cancellation token. + /// The assistant's response message. + Task SendMessageToAssistantAsync(string userMessage, CancellationToken ct); + + /// + /// Resets the conversation thread. + /// + /// The cancellation token. + Task ResetConversationAsync(CancellationToken ct); + + /// + /// Deletes the assistant (cleanup). + /// + /// The cancellation token. + Task DeleteAssistantAsync(CancellationToken ct); + + /// + /// Transcribes audio to text using Azure Speech Services. + /// + /// The audio data to transcribe. + /// The cancellation token. + /// The transcribed text. + Task TranscribeAudioAsync(byte[] audioData, CancellationToken ct); + + /// + /// Converts text to speech using Azure Speech Services. + /// + /// The text to convert to speech. + /// The cancellation token. + /// The audio data. + Task TextToSpeechAsync(string text, CancellationToken ct); +} diff --git a/src/app/ApplicationTemplate.Access/ApiClients/Agentic/NavigationRequestedEventArgs.cs b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/NavigationRequestedEventArgs.cs new file mode 100644 index 000000000..17b07f03d --- /dev/null +++ b/src/app/ApplicationTemplate.Access/ApiClients/Agentic/NavigationRequestedEventArgs.cs @@ -0,0 +1,87 @@ +using System; + +namespace ApplicationTemplate.DataAccess.ApiClients.Agentic; + +/// +/// Event arguments for navigation requests from the AI agent. +/// +public class NavigationRequestedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The type of navigation requested. + /// The name of the page to navigate to (for Page navigation type). + /// The SVG content to display (for DrawContent navigation type). + /// The title for the drawing modal (for DrawContent navigation type). + /// The description for the drawing modal (for DrawContent navigation type). + public NavigationRequestedEventArgs( + NavigationType navigationType, + string pageName = "", + string svgContent = "", + string title = "", + string description = "") + { + NavigationType = navigationType; + PageName = pageName; + SvgContent = svgContent; + Title = title; + Description = description; + } + + /// + /// Gets the type of navigation requested. + /// + public NavigationType NavigationType { get; } + + /// + /// Gets the name of the page to navigate to (if applicable). + /// + public string PageName { get; } + + /// + /// Gets the SVG content to display (for DrawContent navigation type). + /// + public string SvgContent { get; } + + /// + /// Gets the title for the drawing modal (for DrawContent navigation type). + /// + public string Title { get; } + + /// + /// Gets the description for the drawing modal (for DrawContent navigation type). + /// + public string Description { get; } +} + +/// +/// Types of navigation actions the AI agent can request. +/// +public enum NavigationType +{ + /// + /// Navigate to a specific page by name. + /// + Page, + + /// + /// Navigate back to the previous page. + /// + Back, + + /// + /// Open the settings page. + /// + Settings, + + /// + /// Log out the user. + /// + Logout, + + /// + /// Display drawing content in a modal. + /// + DrawContent +} diff --git a/src/app/ApplicationTemplate.Access/ApplicationTemplate.Access.csproj b/src/app/ApplicationTemplate.Access/ApplicationTemplate.Access.csproj index 00b06adcc..1e7a4202e 100644 --- a/src/app/ApplicationTemplate.Access/ApplicationTemplate.Access.csproj +++ b/src/app/ApplicationTemplate.Access/ApplicationTemplate.Access.csproj @@ -18,6 +18,8 @@ + + diff --git a/src/app/ApplicationTemplate.Access/Configuration/AgenticConfiguration.cs b/src/app/ApplicationTemplate.Access/Configuration/AgenticConfiguration.cs new file mode 100644 index 000000000..e2f7620e3 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/Configuration/AgenticConfiguration.cs @@ -0,0 +1,96 @@ +namespace ApplicationTemplate.DataAccess.Configuration; + +/// +/// Configuration for Agentic (Azure AI Foundry Agents). +/// +public class AgenticConfiguration +{ + /// + /// Gets or sets the Azure AI Foundry endpoint URL. + /// + public string Endpoint { get; set; } = string.Empty; + + /// + /// Gets or sets the Azure AI Foundry API key. + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// Gets or sets the Azure subscription ID (required for connection string). + /// + public string SubscriptionId { get; set; } = string.Empty; + + /// + /// Gets or sets the Azure AD Tenant ID for Service Principal authentication. + /// + public string TenantId { get; set; } = string.Empty; + + /// + /// Gets or sets the Azure AD Client ID (Application ID) for Service Principal authentication. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the Azure AD Client Secret for Service Principal authentication. + /// + public string ClientSecret { get; set; } = string.Empty; + + /// + /// Gets or sets the Azure resource group name (required for connection string). + /// + public string ResourceGroup { get; set; } = string.Empty; + + /// + /// Gets or sets the Azure resource name (required for connection string). + /// + public string ResourceName { get; set; } = string.Empty; + + /// + /// Gets or sets the deployment name or model name. + /// + public string DeploymentName { get; set; } + + + /// + /// Gets or sets the temperature for the AI model (0.0 - 1.0). + /// + public double Temperature { get; set; } = 0.7; + + /// + /// Gets or sets the maximum number of tokens in the response. + /// + public int MaxTokens { get; set; } = 1000; + + /// + /// Gets or sets a value indicating whether voice input is enabled (uses GPT-4o multimodal audio). + /// + public bool VoiceInputEnabled { get; set; } = true; + + /// + /// Gets or sets a value indicating whether voice output is enabled (uses GPT-4o multimodal audio). + /// + public bool VoiceOutputEnabled { get; set; } = true; + + /// + /// Gets or sets the audio format for multimodal audio input/output (wav, mp3, etc.). + /// + public string AudioFormat { get; set; } = "wav"; + + /// + /// Gets or sets the voice to use for audio output (alloy, echo, fable, onyx, nova, shimmer). + /// + public string Voice { get; set; } = "alloy"; + + /// + /// Gets or sets the assistant name to search for or create in Azure AI Foundry. + /// The application will search for an existing assistant with this name and use it if found, + /// or create a new one if not found. + /// + public string AssistantName { get; set; } = "Mobile App Assistant"; + + /// + /// Gets or sets the assistant instructions (system prompt). + /// Can be a direct string or a file path (relative to the app directory) ending in .md or .txt. + /// + public string AssistantInstructions { get; set; } = "AssistantInstructions.md"; +} diff --git a/src/app/ApplicationTemplate.Access/Framework/AIAgent b/src/app/ApplicationTemplate.Access/Framework/AIAgent new file mode 100644 index 000000000..ded6f6513 --- /dev/null +++ b/src/app/ApplicationTemplate.Access/Framework/AIAgent @@ -0,0 +1,71 @@ +using System; +using System.Collections.ObjectModel; + +namespace ApplicationTemplate.Business.AIAgent; + +/// +/// Represents a chat message in the AI agent conversation. +/// +public class ChatMessage +{ + /// + /// Gets or sets the unique identifier for the message. + /// + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the role of the message sender (user, assistant, system, tool). + /// + public string Role { get; set; } = string.Empty; + + /// + /// Gets or sets the content of the message. + /// + public string Content { get; set; } = string.Empty; + + /// + /// Gets or sets the timestamp when the message was created. + /// + public DateTime Timestamp { get; set; } = DateTime.Now; + + /// + /// Gets or sets the tool calls if this is an assistant message with function calls. + /// + public Collection ToolCalls { get; set; } = new Collection(); + + /// + /// Gets or sets the tool call ID if this is a tool response message. + /// + public string ToolCallId { get; set; } = string.Empty; + + /// + /// Gets or sets whether this message is being streamed (partial response). + /// + public bool IsStreaming { get; set; } +} + +/// +/// Represents a tool/function call made by the AI agent. +/// +public class ToolCall +{ + /// + /// Gets or sets the unique identifier for the tool call. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the type of the tool call (usually "function"). + /// + public string Type { get; set; } = "function"; + + /// + /// Gets or sets the function name. + /// + public string FunctionName { get; set; } = string.Empty; + + /// + /// Gets or sets the function arguments as a JSON string. + /// + public string FunctionArguments { get; set; } = string.Empty; +} diff --git a/src/app/ApplicationTemplate.Access/Framework/Configuration/IServiceCollection.Extensions.cs b/src/app/ApplicationTemplate.Access/Framework/Configuration/IServiceCollection.Extensions.cs index 1f7c1f80a..13d494bbf 100644 --- a/src/app/ApplicationTemplate.Access/Framework/Configuration/IServiceCollection.Extensions.cs +++ b/src/app/ApplicationTemplate.Access/Framework/Configuration/IServiceCollection.Extensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Reflection; using System.Text; diff --git a/src/app/ApplicationTemplate.Business/Agentic/AgenticChatService.cs b/src/app/ApplicationTemplate.Business/Agentic/AgenticChatService.cs new file mode 100644 index 000000000..80cbbd775 --- /dev/null +++ b/src/app/ApplicationTemplate.Business/Agentic/AgenticChatService.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ApplicationTemplate.DataAccess.ApiClients.Agentic; +using Microsoft.Extensions.Logging; + +namespace ApplicationTemplate.Business.Agentic; + +/// +/// Service for AI chat with Azure AI Foundry integration. +/// Handles conversation flow with Azure AI Foundry Agents API (server-side tool execution). +/// +public class AgenticChatService : IAgenticChatService +{ + private readonly IAgenticApiClient _apiClient; + private readonly IAgenticToolExecutor _toolExecutor; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The AI agent API client. + /// The tool executor for client-side function registration. + /// The logger. + public AgenticChatService( + IAgenticApiClient apiClient, + IAgenticToolExecutor toolExecutor, + ILogger logger) + { + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _toolExecutor = toolExecutor ?? throw new ArgumentNullException(nameof(toolExecutor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IAgenticApiClient ApiClient => _apiClient; + + /// + public IAgenticToolExecutor ToolExecutor => _toolExecutor; + + /// + public async Task InitializeAsync(CancellationToken ct) + { + try + { + _logger.LogInformation("Initializing AI Assistant"); + + // This will ensure the HTTP client is created and the assistant exists + // (or creates a new one if AssistantId is empty in config) + await _apiClient.InitializeAsync(ct); + + _logger.LogInformation("AI Assistant initialized successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing AI Assistant"); + throw; + } + } + + /// + public async Task SendMessageAsync(string message, Collection conversationHistory, CancellationToken ct) + { + try + { + _logger.LogInformation("Sending message to AI Assistant: {Message}", message); + + // Use the Assistants API (with automatic tool handling on server-side) + var response = await _apiClient.SendMessageToAssistantAsync(message, ct); + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message to AI assistant"); + throw; + } + } + + /// + public async Task SendMessageStreamAsync( + string message, + Collection conversationHistory, + Action onChunkReceived, + CancellationToken ct) + { + try + { + _logger.LogInformation("Sending streaming message to AI agent: {Message}", message); + + // Convert conversation history to API format + var apiMessages = ConvertToApiMessages(conversationHistory); + + // Stream the response + var response = await _apiClient.SendChatCompletionStreamAsync(apiMessages, onChunkReceived, ct); + + // Check if the response contains tool calls + if (response.ToolCalls != null && response.ToolCalls.Count > 0) + { + _logger.LogInformation("AI agent requested {Count} tool call(s) in stream", response.ToolCalls.Count); + + // Execute tool calls + var toolResults = new Collection(); + foreach (var toolCall in response.ToolCalls) + { + var toolResult = await _toolExecutor.ExecuteFunctionAsync( + toolCall.FunctionName, + toolCall.FunctionArguments, + ct); + + toolResults.Add(new ChatMessage + { + Role = "tool", + Content = toolResult, + ToolCallId = toolCall.Id, + Timestamp = DateTime.Now + }); + } + + // Add the assistant's tool call message to history + conversationHistory.Add(response); + + // Add tool results to history + foreach (var result in toolResults) + { + conversationHistory.Add(result); + } + + // Get the final response from the AI agent after tool execution + var finalApiMessages = ConvertToApiMessages(conversationHistory); + var finalResponse = await _apiClient.SendChatCompletionStreamAsync(finalApiMessages, onChunkReceived, ct); + + return finalResponse; + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending streaming message to AI agent"); + throw; + } + } + + /// + public async Task TranscribeAudioAsync(byte[] audioData, CancellationToken ct) + { + try + { + _logger.LogInformation("Transcribing audio, size: {Size} bytes", audioData.Length); + return await _apiClient.TranscribeAudioAsync(audioData, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error transcribing audio"); + throw; + } + } + + /// + public async Task TextToSpeechAsync(string text, CancellationToken ct) + { + try + { + _logger.LogInformation("Converting text to speech: {Text}", text); + return await _apiClient.TextToSpeechAsync(text, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error converting text to speech"); + throw; + } + } + + private Collection ConvertToApiMessages(Collection messages) + { + // In this implementation, we're using the same ChatMessage model + // In a real implementation, you might need to convert between different models + return messages; + } +} diff --git a/src/app/ApplicationTemplate.Business/Agentic/AgenticToolExecutor.cs b/src/app/ApplicationTemplate.Business/Agentic/AgenticToolExecutor.cs new file mode 100644 index 000000000..dad9fca02 --- /dev/null +++ b/src/app/ApplicationTemplate.Business/Agentic/AgenticToolExecutor.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace ApplicationTemplate.Business.Agentic; + +/// +/// Executes function tool calls from the Azure AI agent. +/// This is a dispatcher that allows dynamic registration of function handlers. +/// The actual implementations are provided by the Presentation layer or other services. +/// +public class AgenticToolExecutor : IAgenticToolExecutor, IAgenticToolRegistry +{ + private readonly ILogger _logger; + private readonly Dictionary>> _functionHandlers; + private readonly List _toolDefinitions; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public AgenticToolExecutor(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _functionHandlers = new Dictionary>>(); + _toolDefinitions = new List(); + } + + /// + public void RegisterFunctionHandler(string functionName, Func> handler) + { + if (string.IsNullOrWhiteSpace(functionName)) + { + throw new ArgumentException("Function name cannot be null or empty", nameof(functionName)); + } + + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + _functionHandlers[functionName] = handler; + _logger.LogInformation("Registered function handler: {FunctionName}", functionName); + } + + /// + public async Task ExecuteFunctionAsync(string functionName, string argumentsJson, CancellationToken ct) + { + _logger.LogInformation("Executing function: {FunctionName} with args: {Arguments}", functionName, argumentsJson); + + if (!_functionHandlers.TryGetValue(functionName, out var handler)) + { + var error = $"Function '{functionName}' is not registered. Available functions: {string.Join(", ", _functionHandlers.Keys)}"; + _logger.LogWarning(error); + return JsonSerializer.Serialize(new { success = false, error }); + } + + try + { + var args = JsonDocument.Parse(argumentsJson); + var result = await handler(args, ct); + _logger.LogInformation("Function {FunctionName} executed successfully", functionName); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing function {FunctionName}", functionName); + return JsonSerializer.Serialize(new { success = false, error = ex.Message }); + } + } + + /// + public IEnumerable GetRegisteredFunctions() + { + return _functionHandlers.Keys; + } + + /// + public void RegisterToolDefinition(AgenticToolDefinition toolDefinition) + { + if (toolDefinition == null) + { + throw new ArgumentNullException(nameof(toolDefinition)); + } + + _toolDefinitions.Add(toolDefinition); + _logger.LogInformation("Registered tool definition: {ToolName}", toolDefinition.Name); + } + + /// + public IEnumerable GetToolDefinitions() + { + return _toolDefinitions; + } +} diff --git a/src/app/ApplicationTemplate.Business/Agentic/IAgenticChatService.cs b/src/app/ApplicationTemplate.Business/Agentic/IAgenticChatService.cs new file mode 100644 index 000000000..c1f3046fa --- /dev/null +++ b/src/app/ApplicationTemplate.Business/Agentic/IAgenticChatService.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using ApplicationTemplate.DataAccess.ApiClients.Agentic; + +namespace ApplicationTemplate.Business.Agentic; + +/// +/// Interface for AI chat service with Azure AI Foundry integration. +/// +public interface IAgenticChatService +{ + /// + /// Gets the underlying API client for advanced scenarios (e.g., subscribing to navigation events). + /// + IAgenticApiClient ApiClient { get; } + + /// + /// Gets the tool executor for registering custom function handlers. + /// + IAgenticToolExecutor ToolExecutor { get; } + + /// + /// Initializes the AI agent and creates the assistant if needed. + /// This should be called when the chat interface is first loaded. + /// + /// The cancellation token. + Task InitializeAsync(CancellationToken ct); + + /// + /// Sends a chat message and gets a response from the AI agent. + /// + /// The user message to send. + /// The conversation history. + /// The cancellation token. + /// The AI agent's response message. + Task SendMessageAsync(string message, Collection conversationHistory, CancellationToken ct); + + /// + /// Sends a chat message and streams the response from the AI agent. + /// + /// The user message to send. + /// The conversation history. + /// Callback invoked for each chunk of the response. + /// The cancellation token. + /// The complete AI agent's response message. + Task SendMessageStreamAsync( + string message, + Collection conversationHistory, + System.Action onChunkReceived, + CancellationToken ct); + + /// + /// Transcribes audio to text using speech-to-text. + /// + /// The audio data to transcribe. + /// The cancellation token. + /// The transcribed text. + Task TranscribeAudioAsync(byte[] audioData, CancellationToken ct); + + /// + /// Converts text to speech. + /// + /// The text to convert to speech. + /// The cancellation token. + /// The audio data. + Task TextToSpeechAsync(string text, CancellationToken ct); +} diff --git a/src/app/ApplicationTemplate.Business/Agentic/IAgenticToolExecutor.cs b/src/app/ApplicationTemplate.Business/Agentic/IAgenticToolExecutor.cs new file mode 100644 index 000000000..d891461ec --- /dev/null +++ b/src/app/ApplicationTemplate.Business/Agentic/IAgenticToolExecutor.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace ApplicationTemplate.Business.Agentic; + +/// +/// Interface for executing AI agent function tool calls. +/// Allows registration of custom function handlers that can be called by the AI agent. +/// +public interface IAgenticToolExecutor +{ + /// + /// Registers a function handler that can be called by the AI agent. + /// + /// The name of the function (must match the tool definition sent to Azure). + /// The handler function that executes the tool logic. + void RegisterFunctionHandler(string functionName, Func> handler); + + /// + /// Executes a registered function by name with the provided arguments. + /// + /// The name of the function to execute. + /// The JSON string containing the function arguments. + /// Cancellation token. + /// The JSON result of the function execution. + Task ExecuteFunctionAsync(string functionName, string argumentsJson, CancellationToken ct); + + /// + /// Gets all registered function names. + /// + /// Collection of registered function names. + IEnumerable GetRegisteredFunctions(); +} diff --git a/src/app/ApplicationTemplate.Business/Agentic/IAgenticToolRegistry.cs b/src/app/ApplicationTemplate.Business/Agentic/IAgenticToolRegistry.cs new file mode 100644 index 000000000..66904a0f3 --- /dev/null +++ b/src/app/ApplicationTemplate.Business/Agentic/IAgenticToolRegistry.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace ApplicationTemplate.Business.Agentic; + +/// +/// Represents a tool definition for Azure AI Foundry Agents. +/// +public class AgenticToolDefinition +{ + /// + /// Gets or sets the tool name. + /// + public string Name { get; set; } + + /// + /// Gets or sets the tool description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the parameter schema (JSON schema format). + /// + public object Parameters { get; set; } +} + +/// +/// Interface for registering tool definitions with the AI agent. +/// Allows the app to dynamically define what tools are available to the AI. +/// +public interface IAgenticToolRegistry +{ + /// + /// Registers a tool definition that will be sent to Azure AI Foundry. + /// + /// The tool definition. + void RegisterToolDefinition(AgenticToolDefinition toolDefinition); + + /// + /// Gets all registered tool definitions. + /// + /// Collection of tool definitions. + IEnumerable GetToolDefinitions(); +} diff --git a/src/app/ApplicationTemplate.Business/Agentic/IDrawingModalService.cs b/src/app/ApplicationTemplate.Business/Agentic/IDrawingModalService.cs new file mode 100644 index 000000000..90d4fa845 --- /dev/null +++ b/src/app/ApplicationTemplate.Business/Agentic/IDrawingModalService.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace ApplicationTemplate.Business.Agentic; + +/// +/// Service for displaying drawing content in a modal. +/// Allows the AI agent to visualize drawings, diagrams, or illustrations. +/// +public interface IDrawingModalService +{ + /// + /// Shows a modal with SVG drawing content. + /// + /// The SVG content to display (can be path commands or full SVG markup). + /// The title of the drawing modal. + /// Optional description or caption for the drawing. + /// Cancellation token. + /// A task representing the asynchronous operation. + Task ShowDrawingAsync(string svgContent, string title, string description = null, CancellationToken ct = default); +} diff --git a/src/app/ApplicationTemplate.Business/ServiceCollectionAgenticExtensions.cs b/src/app/ApplicationTemplate.Business/ServiceCollectionAgenticExtensions.cs new file mode 100644 index 000000000..dc5df716d --- /dev/null +++ b/src/app/ApplicationTemplate.Business/ServiceCollectionAgenticExtensions.cs @@ -0,0 +1,38 @@ +using ApplicationTemplate.Business.Agentic; +using ApplicationTemplate.DataAccess.ApiClients.Agentic; +using ApplicationTemplate.DataAccess.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ApplicationTemplate.Business; + +/// +/// Extension methods for registering Agentic services. +/// +public static class ServiceCollectionAgenticExtensions +{ + /// + /// Adds Agentic services to the service collection. + /// + /// The service collection. + /// The configuration. + /// The service collection. + public static IServiceCollection AddAgentic(this IServiceCollection services, IConfiguration configuration) + { + // Register configuration + services.Configure(configuration.GetSection("Agentic")); + + // Register services + // Tool executor allows dynamic registration of function handlers AND tool definitions from the app + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + + // Register API client with HttpClient + services.AddHttpClient(); + + return services; + } +} diff --git a/src/app/ApplicationTemplate.Presentation/ApplicationTemplate.Presentation.csproj b/src/app/ApplicationTemplate.Presentation/ApplicationTemplate.Presentation.csproj index 3d1bf9ff6..659af39b8 100644 --- a/src/app/ApplicationTemplate.Presentation/ApplicationTemplate.Presentation.csproj +++ b/src/app/ApplicationTemplate.Presentation/ApplicationTemplate.Presentation.csproj @@ -12,6 +12,7 @@ + diff --git a/src/app/ApplicationTemplate.Presentation/AssistantInstructions.md b/src/app/ApplicationTemplate.Presentation/AssistantInstructions.md new file mode 100644 index 000000000..15b26c6da --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/AssistantInstructions.md @@ -0,0 +1,91 @@ +# Mobile App Assistant Instructions + +You are a helpful AI assistant integrated into a mobile application. Your role is to help users navigate the app, access information, perform tasks, and create visual content using the available tools. + +## Available Tools + +You have access to the following tools: + +### Navigation Tools + +- **navigate_to_page**: Navigate to specific pages in the app + - Available pages: **Home** (Dad Jokes), **Posts** (Blog Posts), **Settings** (App Settings and Profile) + - Aliases you can use: + - For Home section: "Home", "DadJokes", "Jokes" + - For Posts section: "Posts" + - For Settings (including Profile): "Settings", "Profile", "UserProfile", "EditProfile" +- **get_current_page**: Get information about the current page the user is viewing +- **go_back**: Navigate back to the previous page +- **open_settings**: Open the settings page (same as navigating to Settings) +- **logout**: Log out the current user + +### Visual Content Tools + +- **draw_content**: Create and display visual illustrations, diagrams, or drawings using SVG + - Use this when users ask you to "draw", "illustrate", "show", or "create a diagram" + - You can draw anything: objects, diagrams, charts, icons, illustrations, shapes + - Provide SVG elements (circles, rectangles, paths, text, etc.) or complete SVG markup + - **You have full conversation history** - you can see previous drawings you created + - When users ask to modify a drawing, reference your previous SVG and make the requested changes + - Examples: + - Simple shapes: `` + - Complex illustrations: Combine multiple SVG elements + - Text labels: `Label` + - Paths for custom shapes: `` + - Modifying drawings: Look at the SVG you created earlier and adjust colors, sizes, add elements, etc. + - Always provide a descriptive title and optional description + +## Important Notes + +- **Profile pages are in the Settings section**. When users ask for "profile", "my profile", or "user profile", navigate to Settings. +- The Posts section shows blog posts, NOT user profiles. +- **Be creative with drawings**! Use colors, combine shapes, add labels, and make illustrations clear and visually appealing. +- For drawings, think about the viewBox as a 500x500 canvas (coordinates 0-500 on both axes). + +## Communication Style + +- Be concise and friendly +- Provide clear, actionable responses +- When navigating, confirm the action you're taking +- When drawing, briefly describe what you're creating +- If the user's request is unclear, ask for clarification + +## Examples + +### Navigation Examples + +**User**: "Show me some jokes" +**You**: "I'll take you to the Dad Jokes page." [then call navigate_to_page with page_name="Home"] + +**User**: "Navigate to posts" +**You**: "Taking you to the Posts page." [then call navigate_to_page with page_name="Posts"] + +**User**: "Show me my profile" or "Edit my profile" +**You**: "Opening your profile settings." [then call navigate_to_page with page_name="Settings"] + +**User**: "Where am I?" +**You**: [call get_current_page] "You're currently on the [Page Name] page." + +### Drawing Examples + +**User**: "Can you draw an apple?" +**You**: "I'll draw a red apple for you!" [then call draw_content with SVG showing a red circle for the apple body and green ellipse for the leaf] + +**User**: "Show me a diagram of a client-server architecture" +**You**: "I'll create a simple client-server diagram." [then call draw_content with rectangles, arrows, and labels] + +**User**: "Illustrate a house" +**You**: "Drawing a simple house illustration!" [then call draw_content with SVG showing a polygon for roof, rectangle for walls, etc.] + +**User**: "Create a chart showing the process flow" +**You**: "Creating a process flow diagram for you." [then call draw_content with connected shapes and arrows] + +**User**: "Draw a circle" +**You**: "Drawing a blue circle for you!" [then call draw_content with ``] + +**User**: "Make it bigger and red" +**You**: "Making the circle bigger and changing it to red!" [then call draw_content with `` - you remembered the previous circle and increased the radius and changed color] + +**User**: "Add a yellow border" +**You**: "Adding a yellow border to the red circle!" [then call draw_content with ``] + diff --git a/src/app/ApplicationTemplate.Presentation/Configuration/AppServicesConfiguration.cs b/src/app/ApplicationTemplate.Presentation/Configuration/AppServicesConfiguration.cs index 2475600f2..71baf6396 100644 --- a/src/app/ApplicationTemplate.Presentation/Configuration/AppServicesConfiguration.cs +++ b/src/app/ApplicationTemplate.Presentation/Configuration/AppServicesConfiguration.cs @@ -1,8 +1,10 @@ using System; using System.Reactive.Concurrency; using ApplicationTemplate.Business; +using ApplicationTemplate.Business.Agentic; using ApplicationTemplate.DataAccess; using ApplicationTemplate.Presentation; +using ApplicationTemplate.Presentation.Framework; using MessageDialogService; using Microsoft.Extensions.DependencyInjection; @@ -33,6 +35,7 @@ public static IServiceCollection AddAppServices(this IServiceCollection services .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton(); } } diff --git a/src/app/ApplicationTemplate.Presentation/Configuration/NavigationCoreConfiguration.cs b/src/app/ApplicationTemplate.Presentation/Configuration/NavigationCoreConfiguration.cs index 2defd70de..5081e122c 100644 --- a/src/app/ApplicationTemplate.Presentation/Configuration/NavigationCoreConfiguration.cs +++ b/src/app/ApplicationTemplate.Presentation/Configuration/NavigationCoreConfiguration.cs @@ -24,7 +24,7 @@ public static IServiceCollection AddNavigationCore(this IServiceCollection servi { return services .AddSingleton() - .AddSingleton(s => new BlindSectionsNavigator("Login", "Home", "Posts", "Settings")) + .AddSingleton(s => new BlindSectionsNavigator("Login", "Home", "Posts", "Settings", "Agentic")) .AddSingleton(s => new SectionsNavigatorToStackNavigatorAdapter(s.GetService())) .AddSingleton(s => { diff --git a/src/app/ApplicationTemplate.Presentation/CoreStartup.cs b/src/app/ApplicationTemplate.Presentation/CoreStartup.cs index 8c04e9cd0..9257ec23a 100644 --- a/src/app/ApplicationTemplate.Presentation/CoreStartup.cs +++ b/src/app/ApplicationTemplate.Presentation/CoreStartup.cs @@ -51,6 +51,7 @@ protected override IHostBuilder InitializeServices(IHostBuilder hostBuilder, str .AddReviewServices() .AddAppServices() .AddAnalytics() + .AddAgentic(context.Configuration) ); } @@ -65,6 +66,8 @@ protected override void OnInitialized(IServiceProvider services) HandleUnhandledExceptions(services); ValidatorOptions.Global.LanguageManager = new FluentValidationLanguageManager(); + + } protected override async Task StartServices(IServiceProvider services, bool isFirstStart) @@ -72,6 +75,8 @@ protected override async Task StartServices(IServiceProvider services, bool isFi if (isFirstStart) { // TODO: Start your core services and customize the initial navigation logic here. + + InitializeAgenticFunctions(services); StartAutomaticAnalyticsCollection(services); await services.GetRequiredService().TrackApplicationLaunched(CancellationToken.None); NotifyUserOnSessionExpired(services); @@ -306,6 +311,21 @@ void OnSectionsNavigatorStateChanged(object sender, SectionsNavigatorEventArgs a } } + /// + /// Initializes AI agent functions by registering navigation handlers. + /// Note: With Azure AI Foundry Agents API, tools are executed server-side. + /// Navigation is triggered via NavigationRequested event - see AgenticChatPageViewModel.OnNavigationRequested(). + /// + /// The service provider. + private static void InitializeAgenticFunctions(IServiceProvider services) + { + // Server-side tool execution is used (Azure AI Foundry Agents API) + // Tools are defined in AgenticApiClient_Agents.cs and executed on the Azure server + // Navigation is triggered via NavigationRequested event from AgenticApiClient + // The AgenticChatPageViewModel subscribes to this event and performs actual navigation + // No client-side function registration is needed + } + protected override ILogger GetOrCreateLogger(IServiceProvider serviceProvider) { return serviceProvider.GetRequiredService>(); diff --git a/src/app/ApplicationTemplate.Presentation/Framework/AgenticNavigationFunctionRegistry.cs b/src/app/ApplicationTemplate.Presentation/Framework/AgenticNavigationFunctionRegistry.cs new file mode 100644 index 000000000..7fed64708 --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Framework/AgenticNavigationFunctionRegistry.cs @@ -0,0 +1,229 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ApplicationTemplate.Business.Agentic; +using Chinook.SectionsNavigation; +using Microsoft.Extensions.Logging; + +namespace ApplicationTemplate.Presentation.Framework; + +/// +/// Registers navigation-related Agentic functions. +/// This class bridges the Business layer (AgenticToolExecutor) with the Presentation layer (Navigation). +/// +public class AgenticNavigationFunctionRegistry +{ + private readonly IAgenticToolExecutor _toolExecutor; + private readonly ISectionsNavigator _sectionsNavigator; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The Agentic tool executor. + /// The sections navigator. + /// The logger. + public AgenticNavigationFunctionRegistry( + IAgenticToolExecutor toolExecutor, + ISectionsNavigator sectionsNavigator, + ILogger logger) + { + _toolExecutor = toolExecutor ?? throw new ArgumentNullException(nameof(toolExecutor)); + _sectionsNavigator = sectionsNavigator ?? throw new ArgumentNullException(nameof(sectionsNavigator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Registers all navigation-related functions with the AI agent tool executor. + /// Call this method during app startup after services are configured. + /// + public void RegisterFunctions() + { + _toolExecutor.RegisterFunctionHandler("navigate_to_page", NavigateToPageAsync); + _toolExecutor.RegisterFunctionHandler("get_current_page", GetCurrentPageAsync); + _toolExecutor.RegisterFunctionHandler("go_back", GoBackAsync); + _toolExecutor.RegisterFunctionHandler("open_settings", OpenSettingsAsync); + + _logger.LogInformation("Registered {Count} navigation functions with AI agent", 4); + } + + private async Task NavigateToPageAsync(JsonDocument args, CancellationToken ct) + { + try + { + // Extract page name (required) + if (!args.RootElement.TryGetProperty("pageName", out var pageNameElement)) + { + return JsonSerializer.Serialize(new + { + success = false, + error = "Missing required parameter: pageName. Available pages: DadJokes, Posts, Settings, UserProfile" + }); + } + + var pageName = pageNameElement.GetString(); + if (string.IsNullOrWhiteSpace(pageName)) + { + return JsonSerializer.Serialize(new + { + success = false, + error = "pageName cannot be empty" + }); + } + + // Extract optional parameters + var clearStack = false; + if (args.RootElement.TryGetProperty("clearStack", out var clearStackElement)) + { + clearStack = clearStackElement.GetBoolean(); + } + + // Convert page name to section name (if applicable) + var normalizedPageName = pageName.Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); + + switch (normalizedPageName) + { + case "dadjokes": + case "jokes": + case "home": + await _sectionsNavigator.SetActiveSection(ct, "Home"); + return JsonSerializer.Serialize(new + { + success = true, + message = "Navigated to DadJokes page", + page_name = pageName + }); + + case "posts": + // Example: You would implement this based on your navigation structure + // await _sectionsNavigator.Navigate(ct, () => new PostsPageViewModel()); + return JsonSerializer.Serialize(new + { + success = false, + error = "Posts page navigation not yet implemented in your navigation structure" + }); + + case "settings": + // Example: Navigate to settings + // await _sectionsNavigator.Navigate(ct, () => new SettingsPageViewModel()); + return JsonSerializer.Serialize(new + { + success = false, + error = "Settings navigation requires ViewModel instantiation. Update this method to include your SettingsPageViewModel." + }); + + case "userprofile": + case "profile": + // Example: Navigate to user profile + return JsonSerializer.Serialize(new + { + success = false, + error = "UserProfile page navigation not yet implemented. Update this method to include your UserProfilePageViewModel." + }); + + default: + return JsonSerializer.Serialize(new + { + success = false, + error = $"Unknown page name: {pageName}. Available pages: DadJokes, Posts, Settings, UserProfile" + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in NavigateToPageAsync"); + return JsonSerializer.Serialize(new { success = false, error = $"Navigation error: {ex.Message}" }); + } + } + + private async Task GetCurrentPageAsync(JsonDocument args, CancellationToken ct) + { + try + { + await Task.CompletedTask; // For async consistency + + var activeViewModel = _sectionsNavigator.GetActiveViewModel(); + var activeStackNavigator = _sectionsNavigator.GetActiveStackNavigator(); + + var pageName = activeViewModel?.GetType().Name ?? "Unknown"; + + // Find the active section name by checking which section contains the active stack navigator + var sectionName = "Unknown"; + foreach (var section in _sectionsNavigator.State.Sections) + { + if (section.Value == activeStackNavigator) + { + sectionName = section.Key; + break; + } + } + + var stackDepth = activeStackNavigator?.State.Stack.Count ?? 0; + + return JsonSerializer.Serialize(new + { + success = true, + current_page = pageName, + current_section = sectionName, + stack_depth = stackDepth, + timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in GetCurrentPageAsync"); + return JsonSerializer.Serialize(new { success = false, error = $"Error getting current page: {ex.Message}" }); + } + } + + private async Task GoBackAsync(JsonDocument args, CancellationToken ct) + { + try + { + var activeStackNavigator = _sectionsNavigator.GetActiveStackNavigator(); + + if (activeStackNavigator == null) + { + return JsonSerializer.Serialize(new { success = false, error = "No active stack navigator" }); + } + + if (activeStackNavigator.State.Stack.Count <= 1) + { + return JsonSerializer.Serialize(new { success = false, error = "Cannot go back, already at root page" }); + } + + await activeStackNavigator.NavigateBack(ct); + + return JsonSerializer.Serialize(new { success = true, message = "Navigated back successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in GoBackAsync"); + return JsonSerializer.Serialize(new { success = false, error = $"Back navigation error: {ex.Message}" }); + } + } + + private async Task OpenSettingsAsync(JsonDocument args, CancellationToken ct) + { + try + { + // In a real implementation, you would navigate to the settings page + // Example: await _sectionsNavigator.Navigate(ct, () => new SettingsPageViewModel()); + + await Task.CompletedTask; // For async consistency + + return JsonSerializer.Serialize(new + { + success = false, + error = "Settings page navigation requires ViewModel factory implementation. Update OpenSettingsAsync in AgenticNavigationFunctionRegistry.cs" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in OpenSettingsAsync"); + return JsonSerializer.Serialize(new { success = false, error = $"Settings navigation error: {ex.Message}" }); + } + } +} diff --git a/src/app/ApplicationTemplate.Presentation/Framework/DrawingModalService.cs b/src/app/ApplicationTemplate.Presentation/Framework/DrawingModalService.cs new file mode 100644 index 000000000..4939155ed --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Framework/DrawingModalService.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ApplicationTemplate.Presentation.ViewModels.Agentic; +using Chinook.SectionsNavigation; +using Microsoft.Extensions.Logging; + +namespace ApplicationTemplate.Presentation.Framework; + +/// +/// Service for displaying drawing content in a modal. +/// +public class DrawingModalService : Business.Agentic.IDrawingModalService +{ + private readonly ISectionsNavigator _sectionsNavigator; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The sections navigator. + /// The logger. + public DrawingModalService( + ISectionsNavigator sectionsNavigator, + ILogger logger) + { + _sectionsNavigator = sectionsNavigator ?? throw new ArgumentNullException(nameof(sectionsNavigator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task ShowDrawingAsync(string svgContent, string title, string description = null, CancellationToken ct = default) + { + try + { + _logger.LogInformation("Showing drawing modal: {Title}", title); + + // Create and configure ViewModel + var viewModel = new DrawingModalViewModel + { + SvgContent = svgContent, + Title = title ?? "Drawing", + Description = description + }; + + // Navigate to the drawing modal page using the sections navigator + await _sectionsNavigator.Navigate(ct, () => viewModel); + + _logger.LogInformation("Drawing modal displayed successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error showing drawing modal"); + throw; + } + } +} diff --git a/src/app/ApplicationTemplate.Presentation/Framework/Startup/CoreStartupBase.cs b/src/app/ApplicationTemplate.Presentation/Framework/Startup/CoreStartupBase.cs index 73dbbe2e5..70194d3d9 100644 --- a/src/app/ApplicationTemplate.Presentation/Framework/Startup/CoreStartupBase.cs +++ b/src/app/ApplicationTemplate.Presentation/Framework/Startup/CoreStartupBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; diff --git a/src/app/ApplicationTemplate.Presentation/ViewModels/Agentic/AgenticChatPageViewModel.cs b/src/app/ApplicationTemplate.Presentation/ViewModels/Agentic/AgenticChatPageViewModel.cs new file mode 100644 index 000000000..270b35b34 --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/ViewModels/Agentic/AgenticChatPageViewModel.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using ApplicationTemplate.Business.Agentic; +using ApplicationTemplate.DataAccess.ApiClients.Agentic; +using Chinook.DynamicMvvm; +using Chinook.SectionsNavigation; +using Chinook.StackNavigation; +using Microsoft.Extensions.Logging; + +namespace ApplicationTemplate.Presentation; + +/// +/// ViewModel for the Agentic Chat page with voice integration. +/// +public class AgenticChatPageViewModel : ViewModel +{ + private readonly IAgenticChatService _chatService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public AgenticChatPageViewModel() + { + ResolveService(out _chatService); + ResolveService(out _logger); + + SendMessage = this.GetCommandFromTask(OnSendMessage); + StartVoiceInput = this.GetCommandFromTask(OnStartVoiceInput); + StopVoiceInput = this.GetCommandFromTask(OnStopVoiceInput); + PlayVoiceOutput = this.GetCommandFromTask(OnPlayVoiceOutput); + ToggleVoiceOutputCommand = this.GetCommand(OnToggleVoiceOutput); + + InitializeConversation(); + + // Initialize the AI agent when the ViewModel is created + _ = InitializeAgentAsync(); + } + + /// + /// Gets the collection of chat messages. + /// + public ObservableCollection Messages { get; } = new ObservableCollection(); + + /// + /// Gets or sets the current message being typed by the user. + /// + public string CurrentMessage + { + get => this.Get(); + set => this.Set(value); + } + + /// + /// Gets or sets a value indicating whether a message is being sent. + /// + public bool IsSending + { + get => this.Get(); + set => this.Set(value); + } + + /// + /// Gets or sets a value indicating whether the UI is busy (sending message or processing). + /// + public bool IsBusy + { + get => this.Get(); + set => this.Set(value); + } + + /// + /// Gets or sets a value indicating whether voice input is active. + /// + public bool IsVoiceInputActive + { + get => this.Get(); + set => this.Set(value); + } + + /// + /// Gets or sets a value indicating whether voice output is enabled. + /// + public bool IsVoiceOutputEnabled + { + get => this.Get(initialValue: true); + set => this.Set(value); + } + + /// + /// Gets or sets a value indicating whether the assistant is speaking. + /// + public bool IsSpeaking + { + get => this.Get(); + set => this.Set(value); + } + + /// + /// Gets or sets the current streaming message from the assistant. + /// + public string StreamingMessage + { + get => this.Get(); + set => this.Set(value); + } + + /// + /// Gets the command to send a message. + /// + public ICommand SendMessage { get; } + + /// + /// Gets the command to send a message (alias for XAML binding). + /// + public ICommand SendMessageCommand => SendMessage; + + /// + /// Gets the command to start voice input. + /// + public ICommand StartVoiceInput { get; } + + /// + /// Gets the command to start voice input (alias for XAML binding). + /// + public ICommand StartVoiceInputCommand => StartVoiceInput; + + /// + /// Gets the command to stop voice input. + /// + public ICommand StopVoiceInput { get; } + + /// + /// Gets the command to play voice output for a message. + /// + public ICommand PlayVoiceOutput { get; } + + /// + /// Gets the command to toggle voice output. + /// + public ICommand ToggleVoiceOutputCommand { get; } + + private void InitializeConversation() + { + // Add a welcome message from the assistant + Messages.Add(new ChatMessage + { + Role = "assistant", + Content = "Hello! I'm your AI assistant. I can help you navigate the app and answer your questions. How can I help you today?", + Timestamp = DateTime.Now + }); + } + + private async Task InitializeAgentAsync() + { + try + { + _logger.LogInformation("Initializing AI Agent on page load"); + IsBusy = true; + + // Initialize the agent (creates HTTP client and assistant if needed) + await _chatService.InitializeAsync(CancellationToken.None); + + // Subscribe to navigation events from the AI agent + _chatService.ApiClient.NavigationRequested += OnNavigationRequested; + + _logger.LogInformation("AI Agent initialized successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing AI Agent"); + + // Add error message to chat + await RunOnDispatcher(CancellationToken.None, async ct => + { + Messages.Add(new ChatMessage + { + Role = "assistant", + Content = "I'm sorry, I encountered an error during initialization. Please try reloading the page.", + Timestamp = DateTime.Now + }); + await Task.CompletedTask; + }); + } + finally + { + IsBusy = false; + } + } + + private async Task OnSendMessage(CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(CurrentMessage) || IsSending) + { + return; + } + + var userMessage = CurrentMessage; + + try + { + IsSending = true; + IsBusy = true; + + // Clear input field immediately + CurrentMessage = string.Empty; + + // Create user message + var userChatMessage = new ChatMessage + { + Role = "user", + Content = userMessage, + Timestamp = DateTime.Now + }; + + // Add user message to collection on UI thread + await RunOnDispatcher(ct, async ct2 => + { + Messages.Add(userChatMessage); + await Task.CompletedTask; + }); + + // Send message to the assistant (uses Persistent Agents API with threads) + var response = await _chatService.SendMessageAsync( + userMessage, + new ObservableCollection(Messages), + ct); + + // Add assistant's response on UI thread + await RunOnDispatcher(ct, async ct2 => + { + Messages.Add(response); + await Task.CompletedTask; + }); + + _logger.LogInformation("Message sent and response received"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message"); + + // Add error message on UI thread + await RunOnDispatcher(ct, async ct2 => + { + Messages.Add(new ChatMessage + { + Role = "assistant", + Content = "I'm sorry, I encountered an error processing your request. Please try again.", + Timestamp = DateTime.Now + }); + await Task.CompletedTask; + }); + } + finally + { + IsSending = false; + IsBusy = false; + } + } + + private async Task OnStartVoiceInput(CancellationToken ct) + { + try + { + IsVoiceInputActive = true; + _logger.LogInformation("Voice input started"); + + // In a real implementation, you would: + // 1. Request microphone permissions + // 2. Start recording audio + // 3. Store the audio data + // This is a placeholder implementation + + // TODO: Implement platform-specific audio recording + await Task.Delay(100, ct); // Placeholder + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting voice input"); + IsVoiceInputActive = false; + } + } + + private async Task OnStopVoiceInput(CancellationToken ct) + { + try + { + IsVoiceInputActive = false; + _logger.LogInformation("Voice input stopped"); + + // In a real implementation, you would: + // 1. Stop recording audio + // 2. Send the audio to the transcription service + // 3. Display the transcribed text + // 4. Optionally send it automatically + + // Placeholder implementation + IsSending = true; + + // TODO: Get the recorded audio data + byte[] audioData = Array.Empty(); + + if (audioData.Length > 0) + { + // Transcribe the audio + var transcribedText = await _chatService.TranscribeAudioAsync(audioData, ct); + + if (!string.IsNullOrWhiteSpace(transcribedText)) + { + CurrentMessage = transcribedText; + // Optionally send automatically + // OnSendMessage(ct); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping voice input"); + } + finally + { + IsSending = false; + } + } + + private async Task OnPlayVoiceOutput(CancellationToken ct, ChatMessage message) + { + try + { + if (string.IsNullOrWhiteSpace(message.Content)) + { + return; + } + + IsSpeaking = true; + _logger.LogInformation("Playing voice output"); + + // Convert text to speech + var audioData = await _chatService.TextToSpeechAsync(message.Content, ct); + + // In a real implementation, you would: + // 1. Play the audio using platform-specific audio player + // This is a placeholder implementation + + // TODO: Implement platform-specific audio playback + await Task.Delay(1000, ct); // Placeholder + + _logger.LogInformation("Voice output played"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error playing voice output"); + } + finally + { + IsSpeaking = false; + } + } + + private void OnToggleVoiceOutput() + { + IsVoiceOutputEnabled = !IsVoiceOutputEnabled; + _logger.LogInformation("Voice output {Status}", IsVoiceOutputEnabled ? "enabled" : "disabled"); + } + + private void OnNavigationRequested(object sender, NavigationRequestedEventArgs e) + { + // Handle navigation on the UI thread + _ = RunOnDispatcher(CancellationToken.None, async ct => + { + try + { + _logger.LogInformation("Navigation requested: {Type}, Page: {Page}", e.NavigationType, e.PageName); + + var sectionsNavigator = this.GetService(); + + switch (e.NavigationType) + { + case NavigationType.Page: + // Navigate to the appropriate section with proper ViewModel factory + switch (e.PageName) + { + case "Posts": + await sectionsNavigator.SetActiveSection(ct, nameof(MenuViewModel.Section.Posts), () => new PostsPageViewModel()); + break; + + case "Home" or "DadJokes" or "Jokes": + await sectionsNavigator.SetActiveSection(ct, nameof(MenuViewModel.Section.Home), () => new DadJokesPageViewModel()); + break; + + case "Settings" or "UserProfile" or "Profiles" or "Profile" or "EditProfile": + await sectionsNavigator.SetActiveSection(ct, nameof(MenuViewModel.Section.Settings), () => new SettingsPageViewModel()); + break; + + case "Agentic" or "AgenticChat" or "Chat": + await sectionsNavigator.SetActiveSection(ct, nameof(MenuViewModel.Section.Agentic), () => new AgenticChatPageViewModel()); + break; + + default: + _logger.LogWarning("Unknown page name: {PageName}", e.PageName); + break; + } + break; + + case NavigationType.Back: + // Navigate to Home as a fallback for back navigation + await sectionsNavigator.SetActiveSection(ct, nameof(MenuViewModel.Section.Home), () => new DadJokesPageViewModel()); + break; + + case NavigationType.Settings: + await sectionsNavigator.SetActiveSection(ct, nameof(MenuViewModel.Section.Settings), () => new SettingsPageViewModel()); + break; + + case NavigationType.Logout: + // TODO: Implement logout logic + _logger.LogWarning("Logout requested but not implemented yet"); + break; + + case NavigationType.DrawContent: + var drawingService = this.GetService(); + await drawingService.ShowDrawingAsync(e.SvgContent, e.Title, e.Description, ct); + break; + } + + _logger.LogInformation("Navigation completed successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling navigation request"); + } + }); + } +} diff --git a/src/app/ApplicationTemplate.Presentation/ViewModels/Agentic/DrawingModalViewModel.cs b/src/app/ApplicationTemplate.Presentation/ViewModels/Agentic/DrawingModalViewModel.cs new file mode 100644 index 000000000..c407fcc0c --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/ViewModels/Agentic/DrawingModalViewModel.cs @@ -0,0 +1,90 @@ +using System; +using Chinook.DynamicMvvm; +using Chinook.SectionsNavigation; + +namespace ApplicationTemplate.Presentation.ViewModels.Agentic; + +/// +/// ViewModel for the drawing modal that displays SVG content. +/// +public class DrawingModalViewModel : ViewModel +{ + /// + /// Initializes a new instance of the class. + /// + public DrawingModalViewModel() + { + } + + /// + /// Gets or sets the SVG content to display. + /// + public string SvgContent + { + get => this.Get(); + set + { + this.Set(value); + // Regenerate full markup when content changes + GenerateFullSvgMarkup(); + } + } + + /// + /// Gets or sets the title of the drawing. + /// + public string Title + { + get => this.Get(); + set => this.Set(value); + } + + /// + /// Gets or sets the description or caption of the drawing. + /// + public string Description + { + get => this.Get(); + set => this.Set(value); + } + + /// + /// Gets the full SVG markup (auto-generated from SvgContent). + /// + public string FullSvgMarkup + { + get => this.Get(); + private set => this.Set(value); + } + + /// + /// Command to close the modal. + /// + public IDynamicCommand CloseCommand => this.GetCommandFromTask(async ct => + { + // Navigate back to close the modal + var sectionsNavigator = this.GetService(); + await sectionsNavigator.NavigateBackOrCloseModal(ct); + }); + + private void GenerateFullSvgMarkup() + { + if (string.IsNullOrWhiteSpace(SvgContent)) + { + FullSvgMarkup = string.Empty; + return; + } + + // If content already contains , use it as-is + if (SvgContent.TrimStart().StartsWith(" + {SvgContent} +"; + } +} diff --git a/src/app/ApplicationTemplate.Presentation/ViewModels/MenuViewModel.cs b/src/app/ApplicationTemplate.Presentation/ViewModels/MenuViewModel.cs index b28177ed9..fcbf5bcb0 100644 --- a/src/app/ApplicationTemplate.Presentation/ViewModels/MenuViewModel.cs +++ b/src/app/ApplicationTemplate.Presentation/ViewModels/MenuViewModel.cs @@ -16,7 +16,8 @@ public enum Section { Home, Posts, - Settings + Settings, + Agentic } private readonly ISectionsNavigator _sectionsNavigator; @@ -31,11 +32,12 @@ public MenuViewModel() /// /// The list of ViewModel types on which the bottom menu should be visible. /// - private static Type[] _viewModelsWithBottomMenu = new Type[] + private static readonly Type[] _viewModelsWithBottomMenu = new[] { typeof(DadJokesPageViewModel), typeof(PostsPageViewModel), typeof(SettingsPageViewModel), + typeof(AgenticChatPageViewModel), }; public string MenuState => this.GetFromObservable( @@ -57,6 +59,9 @@ public MenuViewModel() public IDynamicCommand ShowSettingsSection => this.GetCommandFromTask(async ct => await _sectionsNavigator.SetActiveSection(ct, nameof(Section.Settings), () => new SettingsPageViewModel())); + public IDynamicCommand ShowAgenticSection => this.GetCommandFromTask(async ct => + await _sectionsNavigator.SetActiveSection(ct, nameof(Section.Agentic), () => new AgenticChatPageViewModel())); + private IObservable ObserveMenuState() => _sectionsNavigator .ObserveCurrentState() @@ -80,6 +85,7 @@ private IObservable ObserveSelectedIndex() => nameof(Section.Home) => 0, nameof(Section.Posts) => 1, nameof(Section.Settings) => 2, + nameof(Section.Agentic) => 3, _ => 0, }; }) diff --git a/src/app/ApplicationTemplate.Presentation/appsettings.json b/src/app/ApplicationTemplate.Presentation/appsettings.json index 27a91d5c7..9ffb0c7d2 100644 --- a/src/app/ApplicationTemplate.Presentation/appsettings.json +++ b/src/app/ApplicationTemplate.Presentation/appsettings.json @@ -16,8 +16,24 @@ "IsDelayForSimulatedApiCallsEnabled": true }, "ApplicationStoreUrls": { - // TODO: Update the URLs with the actual URLs. "IOS": "https://apps.apple.com/us/app/uno-calculator/id1464736591", - "Android": "https://play.google.com/store/apps/details?id=uno.platform.calculator", - } + "Android": "https://play.google.com/store/apps/details?id=uno.platform.calculator" + }, + "Agentic": { + "Endpoint": "put _your_foundry_endpoint_here", + "ApiKey": "put_your_foundry_api_key_here", + "SubscriptionId": "put_your_foundry_subscription_id_here", + "TenantId": "", + "ClientId": "", + "ClientSecret": "", + "AssistantName": "Mobile App Assistant", + "AssistantInstructions": "AssistantInstructions.md", + "DeploymentName": "gpt-4o-mini", + "Temperature": 0.7, + "MaxTokens": 1000, + "VoiceInputEnabled": false, + "VoiceOutputEnabled": true, + "AudioFormat": "wav", + "Voice": "alloy" + } } \ No newline at end of file diff --git a/src/app/ApplicationTemplate.Shared.Views/ApplicationTemplate.Shared.Views.projitems b/src/app/ApplicationTemplate.Shared.Views/ApplicationTemplate.Shared.Views.projitems index fae90ec99..444b3a4e1 100644 --- a/src/app/ApplicationTemplate.Shared.Views/ApplicationTemplate.Shared.Views.projitems +++ b/src/app/ApplicationTemplate.Shared.Views/ApplicationTemplate.Shared.Views.projitems @@ -22,6 +22,12 @@ + + AgenticChatPage.xaml + + + DrawingModalPage.xaml + CreateAccountPage.xaml @@ -191,6 +197,14 @@ + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/src/app/ApplicationTemplate.Shared.Views/Configuration/NavigationConfiguration.cs b/src/app/ApplicationTemplate.Shared.Views/Configuration/NavigationConfiguration.cs index 896bf404f..022366205 100644 --- a/src/app/ApplicationTemplate.Shared.Views/Configuration/NavigationConfiguration.cs +++ b/src/app/ApplicationTemplate.Shared.Views/Configuration/NavigationConfiguration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ApplicationTemplate.Presentation; +using ApplicationTemplate.Presentation.ViewModels.Agentic; using ApplicationTemplate.Views.Content; using Chinook.SectionsNavigation; using Microsoft.Extensions.DependencyInjection; @@ -44,6 +45,8 @@ public static IServiceCollection AddNavigation(this IServiceCollection services) { typeof(ResetPasswordPageViewModel), typeof(ResetPasswordPage) }, { typeof(ForcedUpdatePageViewModel), typeof(ForcedUpdatePage) }, { typeof(KillSwitchPageViewModel), typeof(KillSwitchPage) }, + { typeof(AgenticChatPageViewModel), typeof(AgenticChatPage) }, + { typeof(DrawingModalViewModel), typeof(DrawingModalPage) }, }; /// diff --git a/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/AgenticChatPage.xaml b/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/AgenticChatPage.xaml new file mode 100644 index 000000000..fe89c48c3 --- /dev/null +++ b/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/AgenticChatPage.xaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/AgenticChatPage.xaml.cs b/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/AgenticChatPage.xaml.cs new file mode 100644 index 000000000..665913aea --- /dev/null +++ b/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/AgenticChatPage.xaml.cs @@ -0,0 +1,11 @@ +using Microsoft.UI.Xaml.Controls; + +namespace ApplicationTemplate.Views.Content; + +public sealed partial class AgenticChatPage : Page +{ + public AgenticChatPage() + { + InitializeComponent(); + } +} diff --git a/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/DrawingModalPage.xaml b/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/DrawingModalPage.xaml new file mode 100644 index 000000000..3cef79bdd --- /dev/null +++ b/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/DrawingModalPage.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/DrawingModalPage.xaml.cs b/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/DrawingModalPage.xaml.cs new file mode 100644 index 000000000..578ceec6e --- /dev/null +++ b/src/app/ApplicationTemplate.Shared.Views/Content/Agentic/DrawingModalPage.xaml.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading.Tasks; +using ApplicationTemplate.Presentation.ViewModels.Agentic; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace ApplicationTemplate.Views.Content; + +/// +/// Page that displays SVG drawing content in a modal. +/// +public sealed partial class DrawingModalPage : Page +{ + /// + /// Initializes a new instance of the class. + /// + public DrawingModalPage() + { + this.InitializeComponent(); + this.Loaded += OnPageLoaded; + } + + private void OnPageLoaded(object sender, RoutedEventArgs e) + { + if (DataContext is DrawingModalViewModel viewModel) + { + // Load SVG content into WebView2 + if (!string.IsNullOrWhiteSpace(viewModel.FullSvgMarkup)) + { + _ = LoadSvgContent(viewModel.FullSvgMarkup); + } + } + } + + private async Task LoadSvgContent(string svgMarkup) + { + try + { + // Ensure WebView2 is initialized + await SvgWebView.EnsureCoreWebView2Async(); + + // Create HTML wrapper for SVG + var html = $@" + + + + + + + + {svgMarkup} + +"; + + // Navigate to the HTML content + SvgWebView.NavigateToString(html); + } + catch (Exception ex) + { + // Log error + System.Diagnostics.Debug.WriteLine($"Error loading SVG content: {ex.Message}"); + } + } +} diff --git a/src/app/ApplicationTemplate.Shared.Views/Content/Menu.xaml b/src/app/ApplicationTemplate.Shared.Views/Content/Menu.xaml index b4de0fcf3..7cbf85d5a 100644 --- a/src/app/ApplicationTemplate.Shared.Views/Content/Menu.xaml +++ b/src/app/ApplicationTemplate.Shared.Views/Content/Menu.xaml @@ -92,6 +92,12 @@ Content="Profile" x:Uid="Menu_Settings" /> + + + diff --git a/src/app/ApplicationTemplate.Shared.Views/Shell.xaml b/src/app/ApplicationTemplate.Shared.Views/Shell.xaml index c91a1d720..3010015be 100644 --- a/src/app/ApplicationTemplate.Shared.Views/Shell.xaml +++ b/src/app/ApplicationTemplate.Shared.Views/Shell.xaml @@ -17,7 +17,7 @@ + CommaSeparatedSectionsFrameNames="Login,Home,Posts,Settings,Agentic" /> Profile + + Agentic + Welcome on board! @@ -413,6 +416,12 @@ it's happening! Jokes + + Agentic Chat + + + Type a message... + 8 characters minimum diff --git a/src/app/ApplicationTemplate.Shared.Views/Strings/fr/Resources.resw b/src/app/ApplicationTemplate.Shared.Views/Strings/fr/Resources.resw index d893514f9..b5387827e 100644 --- a/src/app/ApplicationTemplate.Shared.Views/Strings/fr/Resources.resw +++ b/src/app/ApplicationTemplate.Shared.Views/Strings/fr/Resources.resw @@ -246,6 +246,9 @@ Profil + + Agentique + Bienvenue à bord ! @@ -413,6 +416,12 @@ Je pense que nous savons tous pourquoi nous sommes ici." Blagues + + Chat Agentique + + + Tapez un message... + 8 caractères minimum diff --git a/src/app/ApplicationTemplate.Shared.Views/Styles/Application/Icons.xaml b/src/app/ApplicationTemplate.Shared.Views/Styles/Application/Icons.xaml index 4b5cc388c..a448700e0 100644 --- a/src/app/ApplicationTemplate.Shared.Views/Styles/Application/Icons.xaml +++ b/src/app/ApplicationTemplate.Shared.Views/Styles/Application/Icons.xaml @@ -19,6 +19,7 @@ M21.99 4.5C21.99 3.4 21.1 2.5 20 2.5H4C2.9 2.5 2 3.4 2 4.5V16.5C2 17.6 2.9 18.5 4 18.5H18L22 22.5L21.99 4.5ZM18 14.5H6V12.5H18V14.5ZM18 11.5H6V9.5H18V11.5ZM18 8.5H6V6.5H18V8.5Z M0 14.7501V18.5001H3.75L14.81 7.44006L11.06 3.69006L0 14.7501ZM17.71 4.54006C18.1 4.15006 18.1 3.52006 17.71 3.13006L15.37 0.790059C14.98 0.400059 14.35 0.400059 13.96 0.790059L12.13 2.62006L15.88 6.37006L17.71 4.54006Z M8 8.5C10.21 8.5 12 6.71 12 4.5C12 2.29 10.21 0.5 8 0.5C5.79 0.5 4 2.29 4 4.5C4 6.71 5.79 8.5 8 8.5ZM8 10.5C5.33 10.5 0 11.84 0 14.5V16.5H16V14.5C16 11.84 10.67 10.5 8 10.5Z + M20 2H4C2.9 2 2.01 2.9 2.01 4L2 22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2ZM6 9H18V11H6V9ZM14 14H6V12H14V14ZM18 8H6V6H18V8Z M6.99998 0.833336C3.31998 0.833336 0.333313 3.82 0.333313 7.5C0.333313 11.18 3.31998 14.1667 6.99998 14.1667C10.68 14.1667 13.6666 11.18 13.6666 7.5C13.6666 3.82 10.68 0.833336 6.99998 0.833336ZM5.66665 10.8333L2.33331 7.5L3.27331 6.56L5.66665 8.94667L10.7266 3.88667L11.6666 4.83334L5.66665 10.8333Z M6.99998 0.833334C3.31331 0.833334 0.333313 3.81333 0.333313 7.5C0.333313 11.1867 3.31331 14.1667 6.99998 14.1667C10.6866 14.1667 13.6666 11.1867 13.6666 7.5C13.6666 3.81333 10.6866 0.833334 6.99998 0.833334ZM10.3333 9.89333L9.39331 10.8333L6.99998 8.44L4.60665 10.8333L3.66665 9.89333L6.05998 7.5L3.66665 5.10667L4.60665 4.16667L6.99998 6.56L9.39331 4.16667L10.3333 5.10667L7.93998 7.5L10.3333 9.89333Z M-4,0a4,4 0 1,0 8,0a4,4 0 1,0 -8,0 diff --git a/src/app/ApplicationTemplate.Shared.Views/Styles/Controls/PathControl.xaml b/src/app/ApplicationTemplate.Shared.Views/Styles/Controls/PathControl.xaml index 44934dae2..ce6e2d264 100644 --- a/src/app/ApplicationTemplate.Shared.Views/Styles/Controls/PathControl.xaml +++ b/src/app/ApplicationTemplate.Shared.Views/Styles/Controls/PathControl.xaml @@ -58,6 +58,23 @@ + + +