diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9082da6 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": { + "ghcr.io/azure/azure-dev/azd:latest": {}, + "ghcr.io/devcontainers/features/azure-cli:latest": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csdevkit", + "EditorConfig.EditorConfig", + "GitHub.copilot" + ] + } + } +} diff --git a/.gitignore b/.gitignore index a5c1681..5491291 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,6 @@ node_modules/ parts/ sdist/ var/ -package*.json *.egg-info/ .installed.cfg *.egg @@ -69,3 +68,23 @@ ENV/ # MkDocs documentation site*/ .vscode/ + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Frontend/react/npm files +appsettings.json +.env +package-lock.json diff --git a/docs/wksp/05-semantic-kernel-workshop/advanced-ai-orchestration/index.md b/docs/wksp/05-semantic-kernel-workshop/advanced-ai-orchestration/index.md new file mode 100644 index 0000000..5b7581a --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/advanced-ai-orchestration/index.md @@ -0,0 +1 @@ +# Advanced AI Orchestration using agents and planners diff --git a/docs/wksp/05-semantic-kernel-workshop/index.md b/docs/wksp/05-semantic-kernel-workshop/index.md new file mode 100644 index 0000000..e07751d --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/index.md @@ -0,0 +1,6 @@ +# Hands-on AI Orchestration using Semantic Kernel + +This workshop is broken into 2 main sections: + +1. [Create simple AI Orchestration using Sematic Kernel](simple-ai-orchestration/index.md) +1. [Create advanced AI Orchestration using Sematik Kernel with agents and planners](advanced-ai-orchestration/index.md) diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/azd-infra.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/azd-infra.md new file mode 100644 index 0000000..b173324 --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/azd-infra.md @@ -0,0 +1,94 @@ +# Azure Developer CLI (AZD) and Bicep templates for Infrastructure as Code deployment + +In this section we go over the creation of `azd` [(Azure Developer CLI)]((https://aka.ms/azure-dev/install)) +files and corresponding bicep templates to be able to build, provision infrastructure and +deploy our solution into Azure. + +## Azure Developer CLI files overview + +The following components are needed to deploy via AZD + +### `azure.yaml` file + +This file specifies the components to be deployed. Our file specifies two components to be deployed: + +1. Application backend - .NET deployed as Azure Container App +1. Web frontend - Typescript deployed as Azure Container App + +For each component we need to provide the path to the corresponding `Dockerfile` which will be used +to build and package the application: + +```yaml +name: semantic-kernel-workshop-csharp +metadata: + template: semantic-kernel-workshop-csharp@0.0.1-beta +services: + api: + project: ./App/backend/ + host: containerapp + language: dotnet + docker: + path: ../Dockerfile + context: ../../ + web: + project: ../frontend/ + host: containerapp + language: ts + docker: + path: ./Dockerfile + context: ./ +``` + +### `infra` directory + +Within the `infra` directory you have the option to provide either `bicep` or `terraform` templates +to deploy the required infrastructure for our application to run. In this example we +use `bicep` templates which are organized as follows: + +* **infra** + * `main.bicep` - contains the bicep parameters and modules to deploy + * `main.paramters.json` - parameter values to be used during deployment + * `abbreviations.json` - optional file to specify suffix abbreviations for each resource type + * app - subdirectory with application related templates + * `api.bicep` - bicep template for backend application infrastructure + * `web.bicep` - bicep templated for web application infrastructure + * core - subdirectory with templates for core infrastructure components + * **ai** - subdirectory for AI related components + * **host** - subdirectory for container app, environment and registry components + * **monitor** - subdirectory for monitoring components (e.g. application insights) + * **security** - subdirectory for security components (e.g. keyvault) + * **storage** - subdirectory for storage components (e.g. storage account) + +The `azd init` command can be used to generate a starter template, however the quickest way +to generate an existing template is to find a template that uses similar components from +[awesome-azd](https://azure.github.io/awesome-azd/). + +## Deploying using AZD CLI + +You can build, provision all resources and deploy by following these steps: + +1. Switch to `workshop/donet` directory. +1. Ensure Docker desktop is running (if not using Github Codespace). +1. Run `azd auth login` to login to your Azure account. +1. Run `azd up` to provision Azure resources and deploy this sample to those resources. + You will be prompted for the following parameters: + * Environment name: sk-test + * Select an Azure subscription to use from list + * Select an Azure location to use: e.g. (US) East US 2 (eastus2) + * Enter a value for the infrastructure parameters: + * **openAIApiKey** + * **openAiChatGptDeployment**: e.g. gpt-4o + * **openAiEndpoint** + * **stockServiceApiKey** +1. After the application has been successfully deployed you will see the API and Web Service URLs printed in the console. + Click the Web Service URL to interact with the application in your browser. + + **NOTE:** It may take a few minutes for the application to be fully deployed. + +## Deployment removal + +In order to remove all resources deployed, use this command: + +```bash +azd down --purge +``` diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/backend-api.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/backend-api.md new file mode 100644 index 0000000..ae3c35c --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/backend-api.md @@ -0,0 +1,255 @@ +# Creating the Backend API + +These changes are already available in the repository. These instructions walk +you through the process followed to create the backend API from the Console application: + +1. Start by creating a new directory: + + ```bash + mkdir -p workshop/dotnet/App/backend + ``` + +1. Next create a new SDK .NET project: + + ```bash + cd workshop/dotnet/App/ + dotnet new webapi -n backend --no-openapi --force + cd backend + ``` + +1. Build project to confirm it is successful: + + ```txt + dotnet build + + Build succeeded. + 0 Warning(s) + 0 Error(s) + ``` + +1. Add the following nuget packages: + + ```bash + dotnet add package Microsoft.AspNetCore.Mvc + dotnet add package Swashbuckle.AspNetCore + ``` + +1. Replace the contents of `Program.cs` in the project directory with the following code. This file initializes and loads + the required services and configuration for the API, namely configuring CORS protection, + enabling controllers for the API and exposing Swagger document: + + ```csharp + using Microsoft.AspNetCore.Antiforgery; + using Extensions; + using System.Text.Json.Serialization; + + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + // See: https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + // Required to generate enumeration values in Swagger doc + builder.Services.AddControllersWithViews().AddJsonOptions(options => + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); + builder.Services.AddOutputCache(); + builder.Services.AddAntiforgery(options => { + options.HeaderName = "X-CSRF-TOKEN-HEADER"; + options.FormFieldName = "X-CSRF-TOKEN-FORM"; }); + builder.Services.AddHttpClient(); + builder.Services.AddDistributedMemoryCache(); + // Add Semantic Kernel services + builder.Services.AddSkServices(); + + // Load user secrets + builder.Configuration.AddUserSecrets(); + + var app = builder.Build(); + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseOutputCache(); + app.UseRouting(); + app.UseCors(); + app.UseAntiforgery(); + app.MapControllers(); + + app.Use(next => context => + { + var antiforgery = app.Services.GetRequiredService(); + var tokens = antiforgery.GetAndStoreTokens(context); + context.Response.Cookies.Append("XSRF-TOKEN", tokens?.RequestToken ?? string.Empty, new CookieOptions() { HttpOnly = false }); + return next(context); + }); + + app.Map("/", () => Results.Redirect("/swagger")); + + app.MapControllerRoute( + "default", + "{controller=ChatController}"); + + app.Run(); + ``` + +1. Next we need to create `Extensions` directory to and add service extensions: + + ```bash + mkdir Extensions + cd Extensions + ``` + +1. In the `Extensions` directory create a `ServiceExtensions.cs` class with the following code + to initialie the semantic kernel: + + ```csharp + using Core.Utilities.Config; + using Core.Utilities.Models; + // Add import for Plugins + using Core.Utilities.Plugins; + // Add import required for StockService + using Core.Utilities.Services; + using Microsoft.SemanticKernel; + + namespace Extensions; + + public static class ServiceExtensions + { + public static void AddSkServices(this IServiceCollection services) + { + services.AddSingleton(_ => + { + IKernelBuilder builder = KernelBuilderProvider.CreateKernelWithChatCompletion(); + // Enable tracing + builder.Services.AddLogging(services => services.AddConsole().SetMinimumLevel(LogLevel.Trace)); + Kernel kernel = builder.Build(); + + // Step 2 - Initialize Time plugin and registration in the kernel + kernel.Plugins.AddFromObject(new TimeInformationPlugin()); + + // Step 6 - Initialize Stock Data Plugin and register it in the kernel + HttpClient httpClient = new(); + StockDataPlugin stockDataPlugin = new(new StocksService(httpClient)); + kernel.Plugins.AddFromObject(stockDataPlugin); + + return kernel; + }); + } + + } + ``` + +1. Next we need to create a `Controllers` directory to add REST API controller classes: + + ```bash + cd .. + mkdir Controllers + cd Controllers + ``` + +1. Within the `Controllers` directory create a `ChatController.cs` file which exposes a reply + method mapped to the `chat` path and the HTTP Post method: + + ```csharp + using Core.Utilities.Models; + using Core.Utilities.Extensions; + // Add import required for StockService + using Microsoft.SemanticKernel; + using Microsoft.SemanticKernel.Connectors.OpenAI; + // Add ChatCompletion import + using Microsoft.SemanticKernel.ChatCompletion; + // Temporarily added to enable Semantic Kernel tracing + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + + using Microsoft.AspNetCore.Mvc; + + namespace Controllers; + + [ApiController] + [Route("sk")] + public class ChatController : ControllerBase { + + private readonly Kernel _kernel; + private readonly OpenAIPromptExecutionSettings _promptExecutionSettings; + + public ChatController(Kernel kernel) + { + _kernel = kernel; + _promptExecutionSettings = new() + { + // Step 3 - Add Auto invoke kernel functions as the tool call behavior + ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions + }; + + } + + [HttpPost("/chat")] + public async Task ReplyAsync([FromBody]ChatRequest request) + { + // Get chatCompletionService and initialize chatHistory wiht system prompt + var chatCompletionService = _kernel.GetRequiredService(); + var chatHistory = new ChatHistory(); + if (request.MessageHistory.Count == 0) { + chatHistory.AddSystemMessage("You are a friendly financial advisor that only emits financial advice in a creative and funny tone"); + } + else { + chatHistory = request.ToChatHistory(); + } + + // Initialize fullMessage variable and add user input to chat history + string fullMessage = ""; + if (request.InputMessage != null) + { + chatHistory.AddUserMessage(request.InputMessage); + + // Step 4 - Provide promptExecutionSettings and kernel arguments + await foreach (var chatUpdate in chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory, _promptExecutionSettings, _kernel)) + { + Console.Write(chatUpdate.Content); + fullMessage += chatUpdate.Content ?? ""; + } + chatHistory.AddAssistantMessage(fullMessage); + } + var chatResponse = new ChatResponse(fullMessage, chatHistory.FromChatHistory()); + return chatResponse; + } + + + } + ``` + +## Running the Backend API locally + +1. To run API locally first copy valid `appsettings.json` from completed `Lessons/Lesson3` into `backend` directory: + + ```bash + #cd into backend directory + cd ../ + cp ../../Lessons/Lesson3/appsettings.json . + ``` + +1. Next run using `dotnet run`: + + ```bash + dotnet run + ... + info: Microsoft.Hosting.Lifetime[14] + Now listening on: http://localhost:5000 + ``` + +1. Application will start on specified port (port may be different). Navigate to or corresponding [forwarded address](https://docs.github.com/en/codespaces/developing-in-a-codespace/forwarding-ports-in-your-codespace) (if using Github CodeSpace) and it should redirect you to the swagger UI page. + +1. You can either test the API using the "Try it out" feature from within Swagger UI, or via command line using `curl` command: + + ```bash + curl -X 'POST' \ + 'http://localhost:5000/chat' \ + -H 'accept: text/plain' \ + -H 'Content-Type: application/json' \ + -d '{ + "inputMessage": "what is Microsoft price?", + "messageHistory": [ + ] + }' + ``` diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/index.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/index.md new file mode 100644 index 0000000..18732e6 --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/index.md @@ -0,0 +1,9 @@ +# Turning the console application into a Deployable application + +In this lesson we go over the steps on how to create a deployable version of the +semantic kernel console application by creating 3 main components: + +1. [Backend API (C#) using ASP .NET Core libraries](backend-api.md) +1. [React JS Web application (with Node Proxy)](web-app.md) +1. [AZD + Bicep templates for Infrastructure as Code deployment](azd-infra.md) + diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/web-app.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/web-app.md new file mode 100644 index 0000000..e6bb9b2 --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/web-app.md @@ -0,0 +1,61 @@ +# Creating simple Web UI + +The UI was created using React JS and a Node JS proxy to the API. Here is a highlight of key files: + +* `workshop/frontend` + * `Dockerfile` - Dockerfile for building and deploying web app + * `.env` - local file used to provide configuration values (e.g. url) + * `package.json` - required package dependencies + * `server.js` - NodeJS application code + * `src` - React JS application source code directory + * `App.tsx` - main application code + * `index.tsx` - application entry point + +## Running Web UI locally + +### Build Web UI + +1. Go to the frontend directory + + ```bash + cd workshop/frontend + ``` + +1. Run `npm install` to get required dependencies + +1. Run `npm run build` to build the React application + +### Run Web UI + +1. Create `.env` file in `frontend` directory and provide the following required values: + 1. `PORT` - port where React app is running + 1. `REACT_APP_PROXY_URL` - url to the Node JS proxy + + ```shell + export PORT=3001 + export REACT_APP_PROXY_URL=/api/chat + ``` + +1. On a separate terminal export the following required variables for NodeJS proxy application + (note `API_URL` will be different if using Github Codespace, e.g. `https://awesome-journey-65pj9v9pw52rrrx-5020.app.github.dev/chat`): + + ```bash + export PORT=3001 + export API_URL=http://localhost:5000/chat + ``` + + 1. From `workshop/frontend` directory start the NodeJS proxy application using `node server.js` + +1. On a separate terminal start backend API using: + + ```bash + cd workshop/dotnet/App/backend/ + dotnet run + ``` + + 1. If testing from GitHub Codespace, the port forwarded for .NET application must have a port visibility of Public: + 1. To change this, click on the **PORTS** tab in Visual Studio Code and then right click running .NET application row and hover over **Port Visibility** and click **Public** + and click on **Public** + ![change-port-visibility](./../images/public-port.jpg) + +1. Navigate to browser on or forwarded address (if using Github Codespace) and test the chat application. diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/deploy-model.jpg b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/deploy-model.jpg new file mode 100644 index 0000000..75d0ff9 Binary files /dev/null and b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/deploy-model.jpg differ diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/keys-and-endpoint.jpg b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/keys-and-endpoint.jpg new file mode 100644 index 0000000..ac44566 Binary files /dev/null and b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/keys-and-endpoint.jpg differ diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/model-deployments.jpg b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/model-deployments.jpg new file mode 100644 index 0000000..42f5262 Binary files /dev/null and b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/model-deployments.jpg differ diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/public-port.jpg b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/public-port.jpg new file mode 100644 index 0000000..038b4be Binary files /dev/null and b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/images/public-port.jpg differ diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/index.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/index.md new file mode 100644 index 0000000..e4f528b --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/index.md @@ -0,0 +1,15 @@ +# Simple AI Orchestration using Semantic Kernel + +This hands on workshop goes through the following lessons for creating a simple Semantic Kernel +chatbot as a console application: + +1. [Lesson 1: Create Simple Semantic Kernel chatbot](lesson1.md) +1. [Lesson 2: Create Simple Semantic Kernel chatbot with history](lesson2.md) +1. [Lesson 3: Create Simple Semantic Kernel chatbot with plugins](lesson3.md) + +Subsequently the following lessons guide you through converting that chatbot application +into a deployable application with a backend API and Web UI frontend and deploying into Azure: + +1. [Create Backend API](create-deployable-app/backend-api.md) +1. [Create Web application](create-deployable-app/web-app.md) +1. [Deploy application into Azure](create-deployable-app/azd-infra.md) diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson1.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson1.md new file mode 100644 index 0000000..f5f4915 --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson1.md @@ -0,0 +1,76 @@ +# Lesson 1: Simple Semantic Kernel chatbot + +In this lesson we will create a semantic kernel chatbot with a system prompt and keeping track of chat history. + +1. Ensure all [pre-requisites](pre-reqs.md) are met and installed. + +1. Switch to Lesson 1 directory: + + ```bash + cd workshop/dotnet/Lessons/Lesson1 + ``` + +1. Open the project in your favorite IDE or text editor. + +1. Open `Program.cs` and locate the **TODO** for each step and apply the following changes for each: + + 1. TODO: Step 1: add code to initialize kernel with chat completion: + + ```csharp + IKernelBuilder builder = KernelBuilderProvider.CreateKernelWithChatCompletion(); + Kernel kernel = builder.Build(); + ``` + + 1. TODO: Step 2: add the following system prompt: + + ```csharp + OpenAIPromptExecutionSettings promptExecutionSettings = new() + { + ChatSystemPrompt = @"You are a friendly financial advisor that only emits financial advice in a creative and funny tone" + }; + ``` + + 1. TODO: Step 3: initialize kernel arguments + + ```csharp + KernelArguments kernelArgs = new(promptExecutionSettings); + ``` + + 1. TODO: Step 4: add a loop to invoke prompt asynchronously providing user input and kernel arguments: + + ```csharp + await foreach (var response in kernel.InvokePromptStreamingAsync(userInput, kernelArgs)) + { + Console.Write(response); + } + ``` + +1. Run the program with this command: + + ```bash + dotnet run + ``` + +1. When prompted ask for financial advice: + + ```txt + Which stocks do you recommend buying for moderate growth? + ``` + + You will receive a similar response: + + ```txt + Assistant > Ah, the magical world of stock picking! Imagine walking into a buffet, and instead of loading your plate with mystery meat, you're strategically choosing the tastiest, most promising dishes. Here are a few general menus to consider, with a sprinkle of fun: + + 1. **Tech Tango** - Think companies that dance to the tune of innovation! Look for firms diving into AI or cloud computing. They're like the cool kids at the financial disco. + + 2. **Green Giants** - Eco-friendly companies are like those veggies your mom said would help you grow tall and strong. Renewable energy stocks might just add some height to your portfolio. + + 3. **Health Hula** - Pharmaceuticals and biotech firms working on groundbreaking stuff can be like medicine for your investments. Just remember, there's always a bit of a twirl and spin with these. + + 4. **Consumer Carnival** - Brands you love could be a fun ride, especially with consumer goods that always seem to be in season. + + 5. **Financial Fiesta** - Banks or fintech companies can be like salsa on your stock tacos—adding a bit of spice and zing! + + Remember, always research like you're planning the perfect vacation and balance your choices like you balance a pizza with just the right amount of toppings. And of course, consult a real-world financial oracle before making any big moves. Bon appétit in the stock market buffet! + ``` diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson2.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson2.md new file mode 100644 index 0000000..44e1873 --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson2.md @@ -0,0 +1,127 @@ +# Lesson 2: Simple Semantic Kernel chatbot with history + +In this lesson we will add chat history to our chat agent. + +1. Ensure all [pre-requisites](pre-reqs.md) are met and installed. + +1. Switch to Lesson 2 directory: + + ```bash + cd ../Lesson2 + ``` + +1. Start by copying `appsetting.json` from Lesson 1: + + ```bash + cp ../Lesson1/appsettings.json . + ``` + +1. Open the project in your favorite IDE or text editor. + +1. Open `Program.cs` and locate the **TODO** for each step and apply the following changes for each: + + 1. TODO: Step 1: add code to include the chat completion namespace + + ```csharp + using Microsoft.SemanticKernel.ChatCompletion; + ``` + + 1. TODO: Step 2a: Add code to get `chatCompletionService` instance and to initialize `chatHistory` with system prompt + + ```csharp + var chatCompletionService = kernel.GetRequiredService(); + ChatHistory chatHistory = new("You are a friendly financial advisor that only emits financial advice in a creative and funny tone"); + ``` + + TODO: Step 2b: **Remove** the `promptExecutionSettings` and `kernelArgs` initialization code below + + ```csharp + OpenAIPromptExecutionSettings promptExecutionSettings = new() + { + ChatSystemPrompt = @"You are a friendly financial advisor that only emits financial advice in a creative and funny tone" + }; + + // Initialize kernel arguments + KernelArguments kernelArgs = new(promptExecutionSettings); + ``` + + 1. TODO: Step 3: Add code to initialize `fullMessage` variable and add user input to chat history: + + ```csharp + string fullMessage = ""; + chatHistory.AddUserMessage(userInput); + ``` + + 1. TODO: Step 4: **Remove** the `foreach` loop below: + + ```csharp + await foreach (var response in kernel.InvokePromptStreamingAsync(userInput, kernelArgs)) + { + Console.Write(response); + } + ``` + + And replace it with this `foreach` loop including adding assistant message to chat history: + + ```csharp + await foreach (var chatUpdate in chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory)) + { + Console.Write(chatUpdate.Content); + fullMessage += chatUpdate.Content ?? ""; + } + chatHistory.AddAssistantMessage(fullMessage); + ``` + +1. Run the program and start by stating your portfolio preference: + + ```bash + dotnet run + ``` + +1. Introduce yourself and provide your year of birth: + + ```txt + My name is John and I was born in 1980 + ``` + + You will receive a similar response: + + ```txt + Assistant > Ah, John, fresh from the 80s, where big hair and bigger dreams reigned! As you're jamming to your life’s mixtape, let's rewind and fast-forward through some financial wisdom: + + 1. **Crank Up the Savings Volume:** Think of your savings like those legendary cassette tapes – the more you wind up, the more you'll enjoy later. Aim to save 15-20% of your income! + + 2. **Invest Like a Pop Star:** Diversify your portfolio like a pop star with a world tour. Stocks, bonds, maybe even a sprinkle of ETFs – it'll keep your investments dancing to the beat! + + 3. **Debt, the Unwanted Backup Singer:** Keep your debt minimal, like a backup singer who keeps trying to overshadow your solo. Pay off high-interest debt ASAP! + + 4. **Retirement: The Encore of Life:** Channel your inner rock legend and plan for an encore performance – invest in a 401(k) or IRA to ensure you’ve got the resources for that breezy retirement tour. + + 5. **Budget Like a 80’s Hairdo:** Structured and resilient! Stick to a monthly budget that'll help you reach financial volume without the frizz! + + Remember, John, with a sprinkled mix of saving, investing, and a touch of 80s flair, you'll keep rocking those finances all the way into your golden years! + ``` + +1. Next ask which stocks you should have bought if you could go back to the year you were born: + + ```txt + If I could go back in time to the year I was born, which stocks would have made me a millionare? + ``` + + You will receive a similar response: + + ```txt + Assistant > Oh, if only we had a DeLorean stocked with hindsight! Let’s put on our leg warmers and moonwalk back to 1980. Here are some stocks that would've been music to your financial ears: + + 1. **Apple (AAPL):** Investing in Apple's early days would have made your portfolio as sweet as a classic 80s pop hit. The iRevolution was just around the corner! + + 2. **Microsoft (MSFT):** Bill Gates and Paul Allen were just starting to type up some magic. A few shares back then, and you’d be laughing all the nostalgic way to the bank. + + 3. **Berkshire Hathaway (BRK.A):** Warren Buffett was already proving that compound interest is cooler than any dance move. + + 4. **Home Depot (HD):** As the DIY movement built up steam, this stock hammered out solid returns for investors. + + 5. **Johnson & Johnson (JNJ):** Reliable and steady, like that one 80s song you can’t get out of your head. + + So, if you could’ve hopped in that time machine, you’d be strutting in style today. But fear not! Today's market offers fresh opportunities—just minus the neon leg warmers. + ``` diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson3.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson3.md new file mode 100644 index 0000000..2035ecd --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson3.md @@ -0,0 +1,155 @@ +# Lesson 3: Simple Semantic Kernel chat agent with plugins + +In this lesson we will a semantic kernel plugins to be able to retrieve stock pricing. + +1. Ensure all [pre-requisites](pre-reqs.md) are met and installed (including updating the StockService `apiKey` value in the `appSettings.json` file using the key from [polygon.io](https://polygon.io/dashboard)). + +1. Switch to Lesson 3 directory: + + ```bash + cd ../Lesson3 + ``` + +1. Start by copying `appsettings.json` from Lesson 1: + + ```bash + cp ../Lesson1/appsettings.json . + ``` + +1. Run program and ask what the current date is: + + ```bash + dotnet run + ``` + + At the prompt enter + + ```bash + What is the current date? + ``` + + Assistant will give a similar response: + + ```txt + Assistant > I can't access today's date, but imagine it’s an eternal "Fri-yay," ready for financial fun! How can I help you on this hypothetical day? + ``` + +1. Notice it does not provide a specific answer. We can use a Semantic Kernel Plugin to be able to fix that. + +1. In the `Plugins` directory from `Core.Utilities` directory review the file named + `TimeInformationPlugin.cs` which has the following content: + + ```csharp + using System.ComponentModel; + using Microsoft.SemanticKernel; + + namespace Plugins; + + public class TimeInformationPlugin + { + [KernelFunction]  + [Description("Retrieves the current time in UTC.")] + public string GetCurrentUtcTime() => DateTime.UtcNow.ToString("R"); + } + ``` + +1. Next locate TODO: Step 1 in `Program.cs` and add the following import line: + + ```csharp + using Core.Utilities.Plugins; + ``` + +1. Next locate TODO: Step 2 in `Program.cs` and provide the following line to register the `TimeInformationPlugin`: + + ```csharp + kernel.Plugins.AddFromObject(new TimeInformationPlugin()); + ``` + +1. Next locate TODO: Step 3 and add the following line to be able to + auto invoke kernel functions: + + ```csharp + ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions + ``` + +1. Next locate TODO: Step 4 and add the following parameters: + + ```csharp + await foreach (var chatUpdate in chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory, promptExecutionSettings, kernel)) + ``` + +1. Re-run the program and ask what the current date is. The current date should be displayed this time: + + ```bash + dotnet run + What is the current date? + ``` + + Assistant response: + + ```txt + Assistant > Today's date is October 4, 2024. Time flies like an arrow; fruit flies like a banana! + ``` + +1. Congratulations you are now using your first Semantic Kernel plugin! Next, we are going to leverage another plugin + that will provide a `StockService`. This plugin is included within the `Core.Utilities` project. + Review the file named `StockDataPlugin.cs` from `Core.Utilities\Plugins` which includes 2 functions, + one to retrieve the stock price for the current date and another one for a specific date: + + ```csharp + using Core.Utilities.Services; + using Core.Utilities.Models; + using Core.Utilities.Extensions; + + using Microsoft.SemanticKernel; + using System.ComponentModel; + + public class StockDataPlugin(StocksService stockService) + { + private readonly StocksService _stockService = stockService; + + [KernelFunction, Description("Gets stock price")] + public async Task GetStockPrice(string symbol) + { + string tabularData = (await _stockService.GetStockDailyOpenClose(symbol)).FormatStockData(); + return tabularData; + } + + [KernelFunction, Description("Gets stock price for a given date")] + public async Task GetStockPriceForDate(string symbol, DateTime date) + { + string tabularData = (await _stockService.GetStockDailyOpenClose(symbol, date)).FormatStockData(); + return tabularData; + } + + } + ``` + +1. Next, locate TODO: Step 5 in `Program.cs` and add import required for `StockService`: + + ```csharp + using Core.Utilities.Services; + ``` + +1. Next locate TODO: Step 6 and provide the following line to register the new `StockDataPlugin`: + + ```csharp + HttpClient httpClient = new(); + StockDataPlugin stockDataPlugin = new(new StocksService(httpClient)); + kernel.Plugins.AddFromObject(stockDataPlugin); + ``` + +1. Next run program and ask stock pricing information: + + ```bash + dotnet run + What is MSFT price? + + ``` + + Assistant response: + + ```txt + Assistant > Hold onto your calculators! The price of MSFT is currently $417.63. + Looks like it's trying to outshine the stars! + ``` diff --git a/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/pre-reqs.md b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/pre-reqs.md new file mode 100644 index 0000000..a134eda --- /dev/null +++ b/docs/wksp/05-semantic-kernel-workshop/simple-ai-orchestration/pre-reqs.md @@ -0,0 +1,103 @@ +# Prerequisites + +Before attending the Intelligent App Development Workshop, please ensure you have the following prerequisites in place: + +1. **Azure account**: A Microsoft Azure account with an active subscription. If you don't have one, sign up for a [free trial](https://azure.microsoft.com/en-us/free/). +1. **Azure subscription with access enabled for the Azure OpenAI Service** - For more details, see the [Azure OpenAI Service documentation on how to get access](https://learn.microsoft.com/azure/ai-services/openai/overview#how-do-i-get-access-to-azure-openai). +1. **Azure OpenAI resource** - For this workshop, you'll need to deploy at least one model such as GPT 4. See the Azure OpenAI Service documentation for more details on [deploying models](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal) and [model availability](https://learn.microsoft.com/azure/ai-services/openai/concepts/models). + +## Development Environment Setup + +You have the option of using [Github Codespaces](https://docs.github.com/en/codespaces/getting-started/quickstart) or your local development environment. + +### Using Github Codespaces (recommmended) + +If using Github Codespaces all prerequisites will be pre-installed, however you will need to create a fork as follows: + +1. Navigate to this link to create a new [fork](https://github.com/Azure/intelligent-app-workshop/fork) (must be logged into your github account). +1. Accept the default values and click on **"Create fork"** which will take you to the forked repository in the browser. +1. From your forked repository click on the **"<> Code"** button. Then click on the **"Create codespace on main"** button. + +### Using local development environment + +If you prefer using a computer with using a local development environment, the following pre-requisites need to be installed: + +1. **Git**: Ensure you have [Git](https://git-scm.com/downloads) installed on your computer. +1. **Azure CLI**: Install the [Azure Command-Line Interface (CLI)](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) to interact with Azure services and manage resources from the command line. +1. **.NET SDK**: install [.NET SDK](https://dotnet.microsoft.com/en-us/download) to build and run .NET projects. +1. **Docker**: Install [Docker Desktop](https://www.docker.com/products/docker-desktop) to build and run containerized applications. +1. **Node.Js**: Install [Node.Js](https://nodejs.org/en/download/package-manager) to build and run web application. +1. **Azure Development CLI**: Install [azd](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) to be able to provision and deploy application to Azure. +1. **bash/shell terminal**: the lessons assume bash/shell script syntax. If using Windows, either you can either using Git Bash (included when you install Git) or installing [WSL (Windows Subsystem for Linux)](https://learn.microsoft.com/en-us/windows/wsl/install). + +Next you will need to clone this repo using: + +```bash +git clone https://github.com/Azure/intelligent-app-workshop.git +``` + +Change directory into cloned repo: + +```bash +cd intelligent-app-workshop +``` + +## Initial Setup + +1. Copy and rename the file `appsettings.json.example` into the corresponding lesson directory as follows (example command for Lesson1): + + ```bash + cp workshop/dotnet/Lessons/appsettings.json.example workshop/dotnet/Lessons/Lesson1/appsettings.json + ``` + +1. Create Azure OpenAI Service and retrieve the Endpoint URL, API Key and deployed model name then update newly created `appsettings.json` + + 1. Get Azure OpenAI access values (from Azure Portal): + + First we need to create a new Azure OpenAI Service, so let's start there. + 1. Go to the [Azure Portal](https://portal.azure.com). + 1. Click on [Create A Resource](https://ms.portal.azure.com/#create/hub) + 1. On the search bar type **Azure OpenAI** and hit enter + 1. Locate **Azure OpenAI** and click **Create** + 1. On the **Create Azure OpeanAI** page, provide the following information for the fields on the Basics tab: + * Subscription: The Azure subscription to used for your service. + * Resource group: The Azure resource group to contain your Azure OpenAI service resource. You can create a new group or use a pre-existing group. + * Region: The location of your instance. Different locations can introduce latency, but they don't affect the runtime availability of your resource. + * Name: A descriptive and unique name for your Azure AI Service resource, such as `aoai-intelligent-app-workshop-myid`. + * Pricing Tier: The pricing tier for the resource. Currently, only the `Standard S0` tier is available for the Azure AI Service. + * Check the box to acknowledge that you have read and understood all the Responsible AI notices. + 1. Click **Next**. + 1. Review default **Network** values and click **Next** + 1. On the **Tags** tab click **Next** + 1. Click **Create**. + 1. From the deployment page, wait for the deployment to complete and then click **Go to resource** + 1. Expand the **Resource Management** section in the sidebar (menu at left) + 1. Click the **Keys and Endpoint** option - you should see the following: KEY 1, KEY 2 and Endpoint. + 1. Copy the **KEY 1** value and paste it into the **apiKey** value within the `OpenAI` element in the `appsettings.json` file. + 1. Copy the **Endpoint** value and paste it as the **endpoint** value within the `OpenAI` element in the `appsettings.json` file. + + ![Azure Open AI Keys and Endpoint](./images/keys-and-endpoint.jpg) + + Next, we need to create deployments from the Azure OpenAI models. + + 1. Click the **Model deployments** option in the sidebar (left menu) for Azure OpenAI resource. + 1. In the destination page, click **Manage Deployments** + 1. (Optional) You can directly navigate to the [Azure OpenAI Studio website](https://oai.azure.com). + + This will take you to the Azure OpenAI Studio website, where we'll find the other values as described below. + + 1. Create and get Azure OpenAI deployment value (from Azure OpenAI Studio): + + 1. Navigate to [Azure OpenAI Studio](https://oai.azure.com) **from your resource** as described above. + 1. Click the **Deployments** tab (sidebar, left) to view currently deployed models. + 1. If your desired model is not deployed, click on **Deploy Model** then select to **Deploy Base Model**. + 1. You will need a chat completion model. For this workshop we recommend using `gpt-4o`. Select `gpt-4o` from the drop down and click **Confirm**. + 1. Accept the default `gpt-4o` values and click **Deploy** + ![Terminal](./images/deploy-model.jpg) + 1. Update `appsettings.json` deploymentName field with your model deployment name. + 1. Use the **Deployment Name** value (e.g. gpt-4o) as the **deploymentName** value within the `OpenAI` element in the `appsettings.json` file. + +1. Additionally, we need to obtain an API Key to be able to get stock prices from [polygon.io](https://polygon.io/dashboard/login). You can sign up for a free API Key by creating a login. This value will be needed for [Lesson 3](lesson3.md). + 1. Once logged in, from the [polygon.io Dashboard](https://polygon.io/dashboard) locate the **Keys** section. Copy the default key value and paste it as the **apiKey** value within the `StockService` element in the `appsettings.json` file. + +By ensuring you have completed these prerequisites, you'll be well-prepared to dive into the Intelligent App Development Workshop and make the most of the hands-on learning experience. diff --git a/docs/wksp/05-use-cases/generation.md b/docs/wksp/06-use-cases/generation.md similarity index 100% rename from docs/wksp/05-use-cases/generation.md rename to docs/wksp/06-use-cases/generation.md diff --git a/docs/wksp/05-use-cases/index.md b/docs/wksp/06-use-cases/index.md similarity index 100% rename from docs/wksp/05-use-cases/index.md rename to docs/wksp/06-use-cases/index.md diff --git a/docs/wksp/05-use-cases/synthesis.md b/docs/wksp/06-use-cases/synthesis.md similarity index 100% rename from docs/wksp/05-use-cases/synthesis.md rename to docs/wksp/06-use-cases/synthesis.md diff --git a/docs/wksp/07-custom-copilot/extend-copilot-ai-doc-intel.md b/docs/wksp/07-custom-copilot/extend-copilot-ai-doc-intel.md new file mode 100644 index 0000000..6ca3747 --- /dev/null +++ b/docs/wksp/07-custom-copilot/extend-copilot-ai-doc-intel.md @@ -0,0 +1 @@ +# Extend custom copilot with AI Document Intelligence, AI Vision, and multi-modal Foundation model \ No newline at end of file diff --git a/docs/wksp/07-custom-copilot/index.md b/docs/wksp/07-custom-copilot/index.md new file mode 100644 index 0000000..4947e2e --- /dev/null +++ b/docs/wksp/07-custom-copilot/index.md @@ -0,0 +1,14 @@ +# Build Custom Copilots using Azure AI Studio + +We will start with by building copilots using Azure AI Studio + +## Step-by Step guide for building a custom copilot using the chat playground in Azure AI Studio + +Follow this tutorial to build a custom copilot using AI Studio + +[Create a project and use the chat playground in Azure AI Studio](https://learn.microsoft.com/en-us/azure/ai-studio/quickstarts/get-started-playground){ .md-button } + + +## Step-by Step guide for building a custom copilot using the prompt flow SDK in Azure AI Studio + +[Build a custom chat app in Python using the prompt flow SDK](https://learn.microsoft.com/en-us/azure/ai-studio/quickstarts/get-started-code){ .md-button } diff --git a/mkdocs.yml b/mkdocs.yml index b58e9ff..36725d2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,25 +18,41 @@ nav: - Module 2 - Fork and Get Hands-on: wksp/02-fork-and-get-hands-on/index.md - Module 3 - Inner-loop: wksp/03-inner-loop/index.md - Module 4 - Basics of Semantic Kernel: wksp/04-Semantic-Kernel-Basics/index.md - - Module 5 - Use cases deep-dive with Miyagi and Reddog: - - wksp/05-use-cases/index.md - - Synthesis: wksp/05-use-cases/synthesis.md + - Module 5 - Hands-on AI Orchestration using Semantic Kernel Workshop: + - wksp/05-semantic-kernel-workshop/simple-ai-orchestration/index.md + - Pre-requisites: wksp/05-semantic-kernel-workshop/simple-ai-orchestration/pre-reqs.md + - Lesson 1 - Simple Semantic Kernel chatbot: wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson1.md + - Lesson 2 - Simple Semantic Kernel chatbot with history: wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson2.md + - Lesson 3 - Simple Semantic Kernel chatbot with plugins: wksp/05-semantic-kernel-workshop/simple-ai-orchestration/lesson3.md + - Build a deployable web application: + - wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/index.md + - Create Backend API: wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/backend-api.md + - Create Web application: wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/web-app.md + - Deploy Azure resources: wksp/05-semantic-kernel-workshop/simple-ai-orchestration/create-deployable-app/azd-infra.md + - Module 6 - Use cases deep-dive with Miyagi and Reddog: + - wksp/06-use-cases/index.md + - Synthesis: wksp/06-use-cases/synthesis.md - Generation: - - Overview: wksp/05-use-cases/generation.md - - Text: wksp/05-use-cases/generation.md - - Images: wksp/05-use-cases/generation.md - - Code: wksp/05-use-cases/generation.md - - Video: wksp/05-use-cases/generation.md - - Conversation: wksp/05-use-cases/synthesis.md - - Summarization: wksp/05-use-cases/synthesis.md - - Classification: wksp/05-use-cases/synthesis.md - - Translation: wksp/05-use-cases/synthesis.md - - Speech-to-text: wksp/05-use-cases/synthesis.md - - Semantic Search: wksp/05-use-cases/synthesis.md - - Anomaly Detection: wksp/05-use-cases/synthesis.md - - Agency and Planning: wksp/05-use-cases/synthesis.md - - QA on Domain Knowledge: wksp/05-use-cases/synthesis.md - - Plugins: wksp/05-use-cases/synthesis.md + - Overview: wksp/06-use-cases/generation.md + - Text: wksp/06-use-cases/generation.md + - Images: wksp/06-use-cases/generation.md + - Code: wksp/06-use-cases/generation.md + - Video: wksp/06-use-cases/generation.md + - Conversation: wksp/06-use-cases/synthesis.md + - Summarization: wksp/06-use-cases/synthesis.md + - Classification: wksp/06-use-cases/synthesis.md + - Translation: wksp/06-use-cases/synthesis.md + - Speech-to-text: wksp/06-use-cases/synthesis.md + - Semantic Search: wksp/06-use-cases/synthesis.md + - Anomaly Detection: wksp/06-use-cases/synthesis.md + - Agency and Planning: wksp/06-use-cases/synthesis.md + - QA on Domain Knowledge: wksp/06-use-cases/synthesis.md + - Plugins: wksp/06-use-cases/synthesis.md + - Module 7 - Building Custom Copilots: + - Build Custom Copilots using Azure AI Studio: wksp/07-custom-copilot/index.md + - Extend Custom Copilot with AI Document Intelligence, AI Vision, and multi-modal Foundation model: wksp/07-custom-copilot/extend-copilot-ai-doc-intel.md + + - Post-workshop: - Feedback: wksp/other/feedback.md - Appendix: @@ -119,6 +135,7 @@ plugins: - git-revision-date - search - macros + - table-reader - glightbox - open-in-new-tab - mkdocs-video diff --git a/workshop/dotnet/App/Directory.Build.props b/workshop/dotnet/App/Directory.Build.props new file mode 100644 index 0000000..4d74175 --- /dev/null +++ b/workshop/dotnet/App/Directory.Build.props @@ -0,0 +1,14 @@ + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/workshop/dotnet/App/Dockerfile b/workshop/dotnet/App/Dockerfile new file mode 100644 index 0000000..971339e --- /dev/null +++ b/workshop/dotnet/App/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 8080 +EXPOSE 443 + +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY ["../Directory.Build.props", "."] +COPY ["../Directory.Packages.props", "."] +COPY ["App/Directory.Build.props", "App/"] +COPY ["App/backend/", "App/backend/"] +COPY ["../../Core.Utilities/", "Core.Utilities/"] +RUN dotnet restore "App/backend/backend.csproj" + +WORKDIR "/src/App/backend" +FROM build AS publish +RUN dotnet publish "backend.csproj" -o /app + +FROM base AS final +WORKDIR /app +COPY --from=publish /app . +ENTRYPOINT ["dotnet", "backend.dll"] \ No newline at end of file diff --git a/workshop/dotnet/App/backend/Controllers/ChatController.cs b/workshop/dotnet/App/backend/Controllers/ChatController.cs new file mode 100644 index 0000000..8cbb12f --- /dev/null +++ b/workshop/dotnet/App/backend/Controllers/ChatController.cs @@ -0,0 +1,66 @@ +using Core.Utilities.Models; +using Core.Utilities.Extensions; +// Add import required for StockService +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +// Add ChatCompletion import +using Microsoft.SemanticKernel.ChatCompletion; +// Temporarily added to enable Semantic Kernel tracing +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Microsoft.AspNetCore.Mvc; + +namespace Controllers; + +[ApiController] +[Route("sk")] +public class ChatController : ControllerBase { + + private readonly Kernel _kernel; + private readonly OpenAIPromptExecutionSettings _promptExecutionSettings; + + public ChatController(Kernel kernel) + { + _kernel = kernel; + _promptExecutionSettings = new() + { + // Step 3 - Add Auto invoke kernel functions as the tool call behavior + ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions + }; + + } + + [HttpPost("/chat")] + public async Task ReplyAsync([FromBody]ChatRequest request) + { + // Get chatCompletionService and initialize chatHistory wiht system prompt + var chatCompletionService = _kernel.GetRequiredService(); + var chatHistory = new ChatHistory(); + if (request.MessageHistory.Count == 0) { + chatHistory.AddSystemMessage("You are a friendly financial advisor that only emits financial advice in a creative and funny tone"); + } + else { + chatHistory = request.ToChatHistory(); + } + + // Initialize fullMessage variable and add user input to chat history + string fullMessage = ""; + if (request.InputMessage != null) + { + chatHistory.AddUserMessage(request.InputMessage); + + // Step 4 - Provide promptExecutionSettings and kernel arguments + await foreach (var chatUpdate in chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory, _promptExecutionSettings, _kernel)) + { + Console.Write(chatUpdate.Content); + fullMessage += chatUpdate.Content ?? ""; + } + chatHistory.AddAssistantMessage(fullMessage); + } + var chatResponse = new ChatResponse(fullMessage, chatHistory.FromChatHistory()); + return chatResponse; + } + + +} \ No newline at end of file diff --git a/workshop/dotnet/App/backend/Extensions/ServiceExtensions.cs b/workshop/dotnet/App/backend/Extensions/ServiceExtensions.cs new file mode 100644 index 0000000..d92820b --- /dev/null +++ b/workshop/dotnet/App/backend/Extensions/ServiceExtensions.cs @@ -0,0 +1,34 @@ +using Core.Utilities.Config; +using Core.Utilities.Models; +// Add import for Plugins +using Core.Utilities.Plugins; +// Add import required for StockService +using Core.Utilities.Services; +using Microsoft.SemanticKernel; + +namespace Extensions; + +public static class ServiceExtensions +{ + public static void AddSkServices(this IServiceCollection services) + { + services.AddSingleton(_ => + { + IKernelBuilder builder = KernelBuilderProvider.CreateKernelWithChatCompletion(); + // Enable tracing + builder.Services.AddLogging(services => services.AddConsole().SetMinimumLevel(LogLevel.Trace)); + Kernel kernel = builder.Build(); + + // Step 2 - Initialize Time plugin and registration in the kernel + kernel.Plugins.AddFromObject(new TimeInformationPlugin()); + + // Step 6 - Initialize Stock Data Plugin and register it in the kernel + HttpClient httpClient = new(); + StockDataPlugin stockDataPlugin = new(new StocksService(httpClient)); + kernel.Plugins.AddFromObject(stockDataPlugin); + + return kernel; + }); + } + +} \ No newline at end of file diff --git a/workshop/dotnet/App/backend/Program.cs b/workshop/dotnet/App/backend/Program.cs new file mode 100644 index 0000000..a88afca --- /dev/null +++ b/workshop/dotnet/App/backend/Program.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Antiforgery; +using Extensions; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +// See: https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +// Required to generate enumeration values in Swagger doc +builder.Services.AddControllersWithViews().AddJsonOptions(options => + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); +builder.Services.AddOutputCache(); +builder.Services.AddAntiforgery(options => { + options.HeaderName = "X-CSRF-TOKEN-HEADER"; + options.FormFieldName = "X-CSRF-TOKEN-FORM"; }); +builder.Services.AddHttpClient(); +builder.Services.AddDistributedMemoryCache(); +// Add Semantic Kernel services +builder.Services.AddSkServices(); + +// Load user secrets +builder.Configuration.AddUserSecrets(); + +var app = builder.Build(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseOutputCache(); +app.UseRouting(); +app.UseCors(); +app.UseAntiforgery(); +app.MapControllers(); + +app.Use(next => context => +{ + var antiforgery = app.Services.GetRequiredService(); + var tokens = antiforgery.GetAndStoreTokens(context); + context.Response.Cookies.Append("XSRF-TOKEN", tokens?.RequestToken ?? string.Empty, new CookieOptions() { HttpOnly = false }); + return next(context); +}); + +app.Map("/", () => Results.Redirect("/swagger")); + +app.MapControllerRoute( + "default", + "{controller=ChatController}"); + +app.Run(); \ No newline at end of file diff --git a/workshop/dotnet/App/backend/backend.csproj b/workshop/dotnet/App/backend/backend.csproj new file mode 100644 index 0000000..27cdbe1 --- /dev/null +++ b/workshop/dotnet/App/backend/backend.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/workshop/dotnet/Core.Utilities.Tests/Core.Utilities.Tests.csproj b/workshop/dotnet/Core.Utilities.Tests/Core.Utilities.Tests.csproj new file mode 100644 index 0000000..bd3667d --- /dev/null +++ b/workshop/dotnet/Core.Utilities.Tests/Core.Utilities.Tests.csproj @@ -0,0 +1,28 @@ + + + SKEXP0110;SKEXP0001;SKEXP0101 + + + + Always + + + + + + + + + + + + + + + + + + + + + diff --git a/workshop/dotnet/Core.Utilities.Tests/Core.Utilities.Tests.sln b/workshop/dotnet/Core.Utilities.Tests/Core.Utilities.Tests.sln new file mode 100644 index 0000000..74c3064 --- /dev/null +++ b/workshop/dotnet/Core.Utilities.Tests/Core.Utilities.Tests.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.Utilities.Tests", "Core.Utilities.Tests.csproj", "{51B8B6A8-715E-4C2E-B325-80FC8B932942}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {51B8B6A8-715E-4C2E-B325-80FC8B932942}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51B8B6A8-715E-4C2E-B325-80FC8B932942}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51B8B6A8-715E-4C2E-B325-80FC8B932942}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51B8B6A8-715E-4C2E-B325-80FC8B932942}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F51AE0FC-B37B-4D43-98B2-2B6E098F9D01} + EndGlobalSection +EndGlobal diff --git a/workshop/dotnet/Core.Utilities.Tests/Services/StocksServiceTest.cs b/workshop/dotnet/Core.Utilities.Tests/Services/StocksServiceTest.cs new file mode 100644 index 0000000..f714850 --- /dev/null +++ b/workshop/dotnet/Core.Utilities.Tests/Services/StocksServiceTest.cs @@ -0,0 +1,268 @@ +using Core.Utilities.Services; +using Core.Utilities.Models; +using Moq; +using Moq.Protected; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using System.Net.Http.Json; +using Xunit; + +namespace Core.Utilities.Services.Tests +{ + public class StocksServiceTest + { + private readonly Mock _httpMessageHandlerMock; + private readonly HttpClient _httpClient; + private readonly StocksService _stocksService; + + public StocksServiceTest() + { + _httpMessageHandlerMock = new Mock(); + _httpClient = new HttpClient(_httpMessageHandlerMock.Object) + { + BaseAddress = new Uri("https://api.polygon.io/v1/") + }; + _stocksService = new StocksService(_httpClient); + } + + [Fact] + public async Task GetStockDailyOpenClose_ReturnsStock_WhenResponseIsSuccessful() + { + // Arrange + string symbol = "AAPL"; + var date = new DateTime(2023, 10, 1); + var stock = new Stock(symbol, 150.0, 155.0, 160.0, 145.0, DateTime.Now.ToString("yyyy-MM-dd")); + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(stock) + }); + + // Act + var result = await _stocksService.GetStockDailyOpenClose(symbol, date); + + // Assert + Assert.NotNull(result); + Assert.Equal(symbol, result.Symbol); + Assert.Equal(150.0, result.Open); + Assert.Equal(155.0, result.Close); + } + + [Fact] + public async Task GetStockDailyOpenClose_ThrowsHttpRequestException_WhenResponseIsUnsuccessful() + { + // Arrange + var symbol = "AAPL"; + var date = new DateTime(2023, 10, 1); + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadRequest, + Content = new StringContent("Bad Request") + }); + + // Act & Assert + await Assert.ThrowsAsync(() => _stocksService.GetStockDailyOpenClose(symbol, date)); + } + + [Fact] + public async Task GetStockDailyOpenClose_ReturnsStock_WhenResponseIsSuccessfulWitMockJson() + { + // Arrange + string symbol = "MSFT"; + var date = new DateTime(2024, 10, 3); + var jsonResponse = @" + { + ""status"": ""OK"", + ""from"": ""2024-10-03"", + ""symbol"": ""MSFT"", + ""open"": 417.63, + ""high"": 419.55, + ""low"": 414.29, + ""close"": 416.54, + ""volume"": 13031361, + ""afterHours"": 416.33, + ""preMarket"": 416.91 + }"; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(jsonResponse, System.Text.Encoding.UTF8, "application/json") + }); + + // Act + var result = await _stocksService.GetStockDailyOpenClose(symbol, date); + + // Assert + Assert.NotNull(result); + Assert.Equal("MSFT", result.Symbol); + Assert.Equal(417.63, result.Open); + Assert.Equal(416.54, result.Close); + Assert.Equal("2024-10-03", result.From); + Assert.Equal("OK", result.Status); + } + + + [Fact] + public async Task GetStockAggregate_ReturnsStock_WhenResponseIsSuccessfulWitMockJson() + { + // Arrange + string symbol = "MSFT"; + var date = new DateTime(2024, 10, 3); + var jsonResponse = @" + { + ""status"": ""OK"", + ""ticker"": ""MSFT"", + ""queryCount"": 1, + ""results"": [ + { + ""o"": 150.0, + ""h"": 155.50, + ""l"": 145.20, + ""c"": 152.54 + } + ] + }"; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(jsonResponse, System.Text.Encoding.UTF8, "application/json") + }); + + // Act + var result = await _stocksService.GetStockAggregate(symbol, date); + + // Assert + Assert.NotNull(result); + Assert.Equal(symbol, result.Symbol); + Assert.Equal(150.00, result.Open); + Assert.Equal(155.50, result.High); + Assert.Equal(145.20, result.Low); + Assert.Equal(152.54, result.Close); + Assert.Equal("OK", result.Status); + } + + [Fact] + public async Task GetStockAggregate_ReturnsStock_WhenResponseIsSuccessful() + { + // Arrange + string symbol = "AAPL"; + var date = new DateTime(2023, 10, 1); + + var aggregateStockResponse = new AggregateStockResponse( + 1, + "OK", + new List + { + new StockResult(Open: 150.0, High: 155.0, Low: 145.0, Close: 152.0) + } + ); + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(aggregateStockResponse) + }); + + // Act + var result = await _stocksService.GetStockAggregate(symbol, date); + + // Assert + Assert.NotNull(result); + Assert.Equal(symbol, result.Symbol); + Assert.Equal(150.0, result.Open); + Assert.Equal(155.0, result.High); + Assert.Equal(145.0, result.Low); + Assert.Equal(152.0, result.Close); + Assert.Equal("OK", result.Status); + } + + [Fact] + public async Task GetStockAggregate_ThrowsHttpRequestException_WhenResponseIsUnsuccessful() + { + // Arrange + var symbol = "AAPL"; + var date = new DateTime(2023, 10, 1); + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadRequest, + Content = new StringContent("Bad Request") + }); + + // Act & Assert + await Assert.ThrowsAsync(() => _stocksService.GetStockAggregate(symbol, date)); + } + + [Fact] + public async Task GetStockAggregate_ReturnsNull_WhenNoStockResults() + { + // Arrange + string symbol = "AAPL"; + var date = new DateTime(2023, 10, 1); + var aggregateStockResponse = new AggregateStockResponse( + 0, + "OK", + new List() + ); + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(aggregateStockResponse) + }); + + // Act + var result = await _stocksService.GetStockAggregate(symbol, date); + + // Assert + Assert.Null(result); + } + } +} diff --git a/workshop/dotnet/Core.Utilities/Config/AISettingsProvider.cs b/workshop/dotnet/Core.Utilities/Config/AISettingsProvider.cs new file mode 100644 index 0000000..d5b9fd7 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Config/AISettingsProvider.cs @@ -0,0 +1,28 @@ +using Ardalis.GuardClauses; +using Core.Utilities.Models; +using Microsoft.Extensions.Configuration; +using System.Reflection; + +namespace Core.Utilities.Config; + +internal static class AISettingsProvider +{ + internal static AppSettings GetSettings() + { + IConfigurationRoot config = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .Build(); + + var aiSettings = config + .Get(); + Guard.Against.Null(aiSettings); + Guard.Against.Null(aiSettings.OpenAI); + Guard.Against.NullOrEmpty(aiSettings.OpenAI.DeploymentName); + Guard.Against.NullOrEmpty(aiSettings.OpenAI.ApiKey); + Guard.Against.NullOrEmpty(aiSettings.OpenAI.Endpoint); + + return aiSettings; + } +} \ No newline at end of file diff --git a/workshop/dotnet/Core.Utilities/Config/KernelBuilderProvider.cs b/workshop/dotnet/Core.Utilities/Config/KernelBuilderProvider.cs new file mode 100644 index 0000000..199db10 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Config/KernelBuilderProvider.cs @@ -0,0 +1,17 @@ +using Microsoft.SemanticKernel; + +namespace Core.Utilities.Config; + +public static class KernelBuilderProvider +{ + public static IKernelBuilder CreateKernelWithChatCompletion() + { + var applicationSettings = AISettingsProvider.GetSettings(); + return Kernel + .CreateBuilder() + .AddAzureOpenAIChatCompletion( + applicationSettings.OpenAI.DeploymentName, + applicationSettings.OpenAI.Endpoint, + applicationSettings.OpenAI.ApiKey); + } +} \ No newline at end of file diff --git a/workshop/dotnet/Core.Utilities/Core.Utilities.csproj b/workshop/dotnet/Core.Utilities/Core.Utilities.csproj new file mode 100644 index 0000000..71d176b --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Core.Utilities.csproj @@ -0,0 +1,18 @@ + + + SKEXP0110;SKEXP0001;SKEXP0101 + + + + + + + + + + + + + + + diff --git a/workshop/dotnet/Core.Utilities/Extensions/ModelExtensionMethods.cs b/workshop/dotnet/Core.Utilities/Extensions/ModelExtensionMethods.cs new file mode 100644 index 0000000..f6fe3f7 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Extensions/ModelExtensionMethods.cs @@ -0,0 +1,45 @@ +using Core.Utilities.Models; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using System.Text; + +namespace Core.Utilities.Extensions +{ + public static class ModelExtensionMethods + { + public static string FormatStockData(this Stock stockData) + { + StringBuilder stringBuilder = new(); + + stringBuilder.AppendLine("| Symbol | Price | Open | Low | High | Date "); + stringBuilder.AppendLine("| ----- | ----- | ----- | ----- |"); + stringBuilder.AppendLine($"| {stockData.Symbol} | {stockData.Close} | {stockData.Open} | {stockData.Low} | {stockData.High} | {stockData.From} "); + + return stringBuilder.ToString(); + } + + public static ChatHistory ToChatHistory(this ChatRequest chatRequest) + { + var chatHistory = new ChatHistory(); + chatRequest.MessageHistory.ForEach(chatMessage => { + string role = chatMessage.Role.ToString(); + if ("Tool".Equals(role, StringComparison.OrdinalIgnoreCase)) { + role = AuthorRole.Assistant.Label; + role = "assistant"; + } + chatHistory.Add(new ChatMessageContent(new AuthorRole(role), chatMessage.Message)); + }); + return chatHistory; + } + + public static List FromChatHistory(this ChatHistory chatHistory) { + var messageHistory = new List(); + messageHistory.AddRange(chatHistory + .Where(m => m.Content != null) + .Select(m => new ChatMessage(m.Content!, Enum.TryParse(m.Role.Label, out var role) ? role : Role.User))); + + return messageHistory; + } + + } +} diff --git a/workshop/dotnet/Core.Utilities/Models/AggregateStockResponse.cs b/workshop/dotnet/Core.Utilities/Models/AggregateStockResponse.cs new file mode 100644 index 0000000..947e903 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Models/AggregateStockResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Core.Utilities.Models; + +public record AggregateStockResponse( + int ResultsCount, + string Status, + [property: JsonPropertyName("results")] + List StockResults +); + +public record StockResult( + [property: JsonPropertyName("o")] + double Open, + [property: JsonPropertyName("c")] + double Close, + [property: JsonPropertyName("h")] + double High, + [property: JsonPropertyName("l")] + double Low +); \ No newline at end of file diff --git a/workshop/dotnet/Core.Utilities/Models/AppSettings.cs b/workshop/dotnet/Core.Utilities/Models/AppSettings.cs new file mode 100644 index 0000000..b34b782 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Models/AppSettings.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Core.Utilities.Models; + +public record AppSettings ( + OpenAI OpenAI, + StockService StockService +); + +public record OpenAI ( + string Endpoint, + string DeploymentName, + string ApiKey +); + +public record StockService ( + string ApiKey +); \ No newline at end of file diff --git a/workshop/dotnet/Core.Utilities/Models/ChatMessage.cs b/workshop/dotnet/Core.Utilities/Models/ChatMessage.cs new file mode 100644 index 0000000..e4c8999 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Models/ChatMessage.cs @@ -0,0 +1,14 @@ +namespace Core.Utilities.Models; + +public record ChatMessage ( + string Message, + Role Role +); + +public enum Role { + User, + Assistant, + Tool, + System +} + diff --git a/workshop/dotnet/Core.Utilities/Models/ChatRequest.cs b/workshop/dotnet/Core.Utilities/Models/ChatRequest.cs new file mode 100644 index 0000000..ab27fed --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Models/ChatRequest.cs @@ -0,0 +1,7 @@ +namespace Core.Utilities.Models; + +public record ChatRequest( + string InputMessage, + List MessageHistory +); + diff --git a/workshop/dotnet/Core.Utilities/Models/ChatResponse.cs b/workshop/dotnet/Core.Utilities/Models/ChatResponse.cs new file mode 100644 index 0000000..5cff796 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Models/ChatResponse.cs @@ -0,0 +1,7 @@ +namespace Core.Utilities.Models; + +public record ChatResponse( + string Response, + List MessageHistory +); + diff --git a/workshop/dotnet/Core.Utilities/Models/Stocks.cs b/workshop/dotnet/Core.Utilities/Models/Stocks.cs new file mode 100644 index 0000000..4e23965 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Models/Stocks.cs @@ -0,0 +1,14 @@ +namespace Core.Utilities.Models; + +public record Stocks(List Stock); + +public record Stock( + string Symbol, + double Open, + double Close, + double High, + double Low, + string From, + string Status = "OK" +); + diff --git a/workshop/dotnet/Core.Utilities/Plugins/StockDataPlugin.cs b/workshop/dotnet/Core.Utilities/Plugins/StockDataPlugin.cs new file mode 100644 index 0000000..616330c --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Plugins/StockDataPlugin.cs @@ -0,0 +1,28 @@ +using Core.Utilities.Services; +using Core.Utilities.Models; +using Core.Utilities.Extensions; + +using Microsoft.SemanticKernel; +using System.ComponentModel; + +namespace Core.Utilities.Plugins; + +public class StockDataPlugin(StocksService stockService) +{ + private readonly StocksService _stockService = stockService; + + [KernelFunction, Description("Gets stock price")] + public async Task GetStockPrice(string symbol) + { + string tabularData = (await _stockService.GetStockAggregate(symbol)).FormatStockData(); + return tabularData; + } + + [KernelFunction, Description("Gets stock price for a given date")] + public async Task GetStockPriceForDate(string symbol, DateTime date) + { + string tabularData = (await _stockService.GetStockAggregate(symbol, date)).FormatStockData(); + return tabularData; + } + +} \ No newline at end of file diff --git a/workshop/dotnet/Core.Utilities/Plugins/TimeInformationPlugin.cs b/workshop/dotnet/Core.Utilities/Plugins/TimeInformationPlugin.cs new file mode 100644 index 0000000..72f1e96 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Plugins/TimeInformationPlugin.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; +using Microsoft.SemanticKernel; + +namespace Core.Utilities.Plugins; + +public class TimeInformationPlugin +{ + [KernelFunction]  + [Description("Retrieves the current time in UTC.")] + public string GetCurrentUtcTime() => DateTime.UtcNow.ToString("R"); +} \ No newline at end of file diff --git a/workshop/dotnet/Core.Utilities/Services/StocksService.cs b/workshop/dotnet/Core.Utilities/Services/StocksService.cs new file mode 100644 index 0000000..f076783 --- /dev/null +++ b/workshop/dotnet/Core.Utilities/Services/StocksService.cs @@ -0,0 +1,94 @@ +using Ardalis.GuardClauses; +using Core.Utilities.Config; +using Core.Utilities.Models; +using Microsoft.Extensions.Logging; +using System.Net.Http.Json; + +namespace Core.Utilities.Services +{ + /// + /// Service for retrieving stock information from the Polygon API. + /// + public class StocksService + { + private readonly HttpClient _httpClient; + + private string _apiKey; + + private static ILogger logger = LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(); + + public StocksService(HttpClient httpClient) + { + httpClient.BaseAddress = new Uri("https://api.polygon.io/v1/"); + _httpClient = httpClient; + _apiKey = AISettingsProvider.GetSettings().StockService.ApiKey; + } + + /// + /// Retrieves the daily open and close prices for a stock. + /// NOTE: this requires a premium subscription to get data for the current month + /// + public async Task GetStockDailyOpenClose(string symbol, DateTime? date = null) + { + //Default to yesterday's date if no date is provided as current day pricing requires premium subscription + string dateFormatted = date?.ToString("yyyy-MM-dd") ?? DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd"); + var requestUri = $"open-close/{symbol}/{dateFormatted}?adjusted=true&apiKey={_apiKey}"; + Stock stock = await GetHttpResponse(requestUri); + return stock; + } + + + /// + /// Retrieves the aggregate stock information for a given symbol and date. + /// NOTE: using this to get stock last price as it is free to usecd + /// + public async Task GetStockAggregate(string symbol, DateTime? date = null) + { + logger.LogDebug("> Getting stock aggregate"); + //Default to yesterday's date if no date is provided as current day pricing requires premium subscription + DateTime toDate = date ?? DateTime.Now.AddDays(-1); + string toDateFormatted = toDate.ToString("yyyy-MM-dd"); + //Default fromDate to 4 days ago to account for weekends and Monday holidays + string fromDateFormatted = toDate.AddDays(-4).ToString("yyyy-MM-dd"); + var requestUri = $"/v2/aggs/ticker/{symbol}/range/1/day/{fromDateFormatted}/{toDateFormatted}?adjusted=true&sort=desc&apiKey="; + // Log the requestUri without the apiKey + logger.LogDebug($"Request URI: {requestUri}"); + requestUri += $"{_apiKey}"; + + AggregateStockResponse response = await GetHttpResponse(requestUri); + Stock stock = null; + if (response.StockResults.Count > 0) { + StockResult stockResult = response.StockResults[0]; + stock = new Stock( + Symbol: symbol, + Open: stockResult.Open, + High: stockResult.High, + Low: stockResult.Low, + Close: stockResult.Close, + From: toDateFormatted, + Status: response.Status + ); + + } + logger.LogDebug("< Getting stock aggregate count: " + response.ResultsCount); + return stock; + } + + + private async Task GetHttpResponse(string requestUri) + { + HttpResponseMessage response = await _httpClient.GetAsync(requestUri); + + if (!response.IsSuccessStatusCode) + { + string errorMessage = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException($"Request failed with status code: {response.StatusCode}, message: {errorMessage}"); + } + + T? data = await response.Content.ReadFromJsonAsync(); + Guard.Against.Null(data); + + return data; + } + } +} diff --git a/workshop/dotnet/Directory.Build.props b/workshop/dotnet/Directory.Build.props new file mode 100644 index 0000000..de2479e --- /dev/null +++ b/workshop/dotnet/Directory.Build.props @@ -0,0 +1,11 @@ + + + + net8.0 + enable + enable + SKEXP0110;SKEXP0001;SKEXP0101 + true + + + \ No newline at end of file diff --git a/workshop/dotnet/Directory.Packages.props b/workshop/dotnet/Directory.Packages.props new file mode 100644 index 0000000..ca00759 --- /dev/null +++ b/workshop/dotnet/Directory.Packages.props @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/workshop/dotnet/Lessons/Directory.Build.props b/workshop/dotnet/Lessons/Directory.Build.props new file mode 100644 index 0000000..e83f79a --- /dev/null +++ b/workshop/dotnet/Lessons/Directory.Build.props @@ -0,0 +1,18 @@ + + + + + Exe + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/workshop/dotnet/Lessons/Lesson1/Lesson1.csproj b/workshop/dotnet/Lessons/Lesson1/Lesson1.csproj new file mode 100644 index 0000000..e6d1111 --- /dev/null +++ b/workshop/dotnet/Lessons/Lesson1/Lesson1.csproj @@ -0,0 +1,7 @@ + + + + Always + + + diff --git a/workshop/dotnet/Lessons/Lesson1/Program.cs b/workshop/dotnet/Lessons/Lesson1/Program.cs new file mode 100644 index 0000000..201e503 --- /dev/null +++ b/workshop/dotnet/Lessons/Lesson1/Program.cs @@ -0,0 +1,30 @@ +using Core.Utilities.Config; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + + +// TODO: Step 1 - Initialize the kernel with chat completion + + +// TODO: Step 2 - Add system prompt + + +// TODO: Step 3 - Initialize kernel arguments + +// Execute program. +const string terminationPhrase = "quit"; +string? userInput; +do +{ + Console.Write("User > "); + userInput = Console.ReadLine(); + + if (userInput != null && userInput != terminationPhrase) + { + Console.Write("Assistant > "); + // TODO: Step 4 - add a loop to invoke prompt asynchronously providing user input and kernel arguments + + Console.WriteLine(); + } +} +while (userInput != terminationPhrase); \ No newline at end of file diff --git a/workshop/dotnet/Lessons/Lesson2/Lesson2.csproj b/workshop/dotnet/Lessons/Lesson2/Lesson2.csproj new file mode 100644 index 0000000..e6d1111 --- /dev/null +++ b/workshop/dotnet/Lessons/Lesson2/Lesson2.csproj @@ -0,0 +1,7 @@ + + + + Always + + + diff --git a/workshop/dotnet/Lessons/Lesson2/Program.cs b/workshop/dotnet/Lessons/Lesson2/Program.cs new file mode 100644 index 0000000..1bed785 --- /dev/null +++ b/workshop/dotnet/Lessons/Lesson2/Program.cs @@ -0,0 +1,47 @@ +using Core.Utilities.Config; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +// TODO: Step 1 - add ChatCompletion import + +// Initialize the kernel with chat completion +IKernelBuilder builder = KernelBuilderProvider.CreateKernelWithChatCompletion(); +Kernel kernel = builder.Build(); + +// TODO: Step 2a - Get chatCompletionService and initialize chatHistory wiht system prompt + +// TODO: Step 2b - Remove the promptExecutionSettings and kernelArgs initialization code +OpenAIPromptExecutionSettings promptExecutionSettings = new() +{ + // Add Auto invoke kernel functions as the tool call behavior + ChatSystemPrompt = @"You are a friendly financial advisor that only emits financial advice in a creative and funny tone" +}; + +// Initialize kernel arguments +KernelArguments kernelArgs = new(promptExecutionSettings); + +// Execute program. +const string terminationPhrase = "quit"; +string? userInput; +do +{ + Console.Write("User > "); + userInput = Console.ReadLine(); + + if (userInput != null && userInput != terminationPhrase) + { + Console.Write("Assistant > "); + // TODO: Step 3 - Initialize fullMessage variable and add user input to chat history + + + // TODO: Step 4 - Remove the foreach loop and replace it with `chatCompletionService` code + // including adding assistant message to chat history + await foreach (var response in kernel.InvokePromptStreamingAsync(userInput, kernelArgs)) + { + Console.Write(response); + } + + Console.WriteLine(); + } +} +while (userInput != terminationPhrase); diff --git a/workshop/dotnet/Lessons/Lesson3/Lesson3.csproj b/workshop/dotnet/Lessons/Lesson3/Lesson3.csproj new file mode 100644 index 0000000..e6d1111 --- /dev/null +++ b/workshop/dotnet/Lessons/Lesson3/Lesson3.csproj @@ -0,0 +1,7 @@ + + + + Always + + + diff --git a/workshop/dotnet/Lessons/Lesson3/Program.cs b/workshop/dotnet/Lessons/Lesson3/Program.cs new file mode 100644 index 0000000..61de308 --- /dev/null +++ b/workshop/dotnet/Lessons/Lesson3/Program.cs @@ -0,0 +1,62 @@ +using Core.Utilities.Config; +// TODO: Step 1 - Add import for Plugins + +// TODO: Step 5 - Add import required for StockService + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +// Add ChatCompletion import +using Microsoft.SemanticKernel.ChatCompletion; + + +// Initialize the kernel with chat completion +IKernelBuilder builder = KernelBuilderProvider.CreateKernelWithChatCompletion(); +Kernel kernel = builder.Build(); + +// TODO: Step 2 - Initialize Time plugin and registration in the kernel + + +// TODO: Step 6 - Initialize Stock Data Plugin and register it in the kernel + + +// Get chatCompletionService and initialize chatHistory wiht system prompt +var chatCompletionService = kernel.GetRequiredService(); +ChatHistory chatHistory = new("You are a friendly financial advisor that only emits financial advice in a creative and funny tone"); +// Remove the promptExecutionSettings and kernelArgs initialization code +// Add system prompt +OpenAIPromptExecutionSettings promptExecutionSettings = new() +{ + // Step 3 - Add Auto invoke kernel functions as the tool call behavior + +}; + +// Initialize kernel arguments +KernelArguments kernelArgs = new(promptExecutionSettings); + +// Execute program. +const string terminationPhrase = "quit"; +string? userInput; +do +{ + Console.Write("User > "); + userInput = Console.ReadLine(); + + if (userInput != null && userInput != terminationPhrase) + { + Console.Write("Assistant > "); + // Initialize fullMessage variable and add user input to chat history + string fullMessage = ""; + chatHistory.AddUserMessage(userInput); + + // TODO: Step 4 - Provide promptExecutionSettings and kernel arguments + await foreach (var chatUpdate in chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory)) + { + Console.Write(chatUpdate.Content); + fullMessage += chatUpdate.Content ?? ""; + } + chatHistory.AddAssistantMessage(fullMessage); + + Console.WriteLine(); + } +} +while (userInput != terminationPhrase); \ No newline at end of file diff --git a/workshop/dotnet/Lessons/appsettings.json.example b/workshop/dotnet/Lessons/appsettings.json.example new file mode 100644 index 0000000..471f31d --- /dev/null +++ b/workshop/dotnet/Lessons/appsettings.json.example @@ -0,0 +1,10 @@ +{ + "OpenAI": { + "endpoint": "", + "apiKey": "", + "deploymentName": "" + }, + "StockService": { + "apiKey": "" + } +} diff --git a/workshop/dotnet/Solutions/Directory.Build.props b/workshop/dotnet/Solutions/Directory.Build.props new file mode 100644 index 0000000..e83f79a --- /dev/null +++ b/workshop/dotnet/Solutions/Directory.Build.props @@ -0,0 +1,18 @@ + + + + + Exe + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/workshop/dotnet/Solutions/Lesson1/Lesson1.csproj b/workshop/dotnet/Solutions/Lesson1/Lesson1.csproj new file mode 100644 index 0000000..e6d1111 --- /dev/null +++ b/workshop/dotnet/Solutions/Lesson1/Lesson1.csproj @@ -0,0 +1,7 @@ + + + + Always + + + diff --git a/workshop/dotnet/Solutions/Lesson1/Program.cs b/workshop/dotnet/Solutions/Lesson1/Program.cs new file mode 100644 index 0000000..9759507 --- /dev/null +++ b/workshop/dotnet/Solutions/Lesson1/Program.cs @@ -0,0 +1,38 @@ +using Core.Utilities.Config; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + + +// TODO: Step 1 - Initialize the kernel with chat completion +IKernelBuilder builder = KernelBuilderProvider.CreateKernelWithChatCompletion(); +Kernel kernel = builder.Build(); + +// TODO: Step 2 - Add system prompt +OpenAIPromptExecutionSettings promptExecutionSettings = new() +{ + ChatSystemPrompt = @"You are a friendly financial advisor that only emits financial advice in a creative and funny tone" +}; + +// TODO: Step 3 - Initialize kernel arguments +KernelArguments kernelArgs = new(promptExecutionSettings); + +// Execute program. +const string terminationPhrase = "quit"; +string? userInput; +do +{ + Console.Write("User > "); + userInput = Console.ReadLine(); + + if (userInput != null && userInput != terminationPhrase) + { + Console.Write("Assistant > "); + // TODO: Step 4 - add a loop to invoke prompt asynchronously providing user input and kernel arguments + await foreach (var response in kernel.InvokePromptStreamingAsync(userInput, kernelArgs)) + { + Console.Write(response); + } + Console.WriteLine(); + } +} +while (userInput != terminationPhrase); \ No newline at end of file diff --git a/workshop/dotnet/Solutions/Lesson2/Lesson2.csproj b/workshop/dotnet/Solutions/Lesson2/Lesson2.csproj new file mode 100644 index 0000000..e6d1111 --- /dev/null +++ b/workshop/dotnet/Solutions/Lesson2/Lesson2.csproj @@ -0,0 +1,7 @@ + + + + Always + + + diff --git a/workshop/dotnet/Solutions/Lesson2/Program.cs b/workshop/dotnet/Solutions/Lesson2/Program.cs new file mode 100644 index 0000000..e52ea08 --- /dev/null +++ b/workshop/dotnet/Solutions/Lesson2/Program.cs @@ -0,0 +1,43 @@ +using Core.Utilities.Config; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +// Step 1 - Add ChatCompletion import +using Microsoft.SemanticKernel.ChatCompletion; + + +// Initialize the kernel with chat completion +IKernelBuilder builder = KernelBuilderProvider.CreateKernelWithChatCompletion(); +Kernel kernel = builder.Build(); + +// Step 2a - Get chatCompletionService and initialize chatHistory wiht system prompt +var chatCompletionService = kernel.GetRequiredService(); +ChatHistory chatHistory = new("You are a friendly financial advisor that only emits financial advice in a creative and funny tone"); +// Step 2b - Remove the promptExecutionSettings and kernelArgs initialization code - REMOVED + +// Execute program. +const string terminationPhrase = "quit"; +string? userInput; +do +{ + Console.Write("User > "); + userInput = Console.ReadLine(); + + if (userInput != null && userInput != terminationPhrase) + { + Console.Write("Assistant > "); + // Step 3 - Initialize fullMessage variable and add user input to chat history + string fullMessage = ""; + chatHistory.AddUserMessage(userInput); + + // Step 4 - Replace the foreach loop and replace it with this code including adding assistant message to chat history + await foreach (var chatUpdate in chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory)) + { + Console.Write(chatUpdate.Content); + fullMessage += chatUpdate.Content ?? ""; + } + chatHistory.AddAssistantMessage(fullMessage); + + Console.WriteLine(); + } +} +while (userInput != terminationPhrase); \ No newline at end of file diff --git a/workshop/dotnet/Solutions/Lesson3/Lesson3.csproj b/workshop/dotnet/Solutions/Lesson3/Lesson3.csproj new file mode 100644 index 0000000..ac0838b --- /dev/null +++ b/workshop/dotnet/Solutions/Lesson3/Lesson3.csproj @@ -0,0 +1,8 @@ + + + + + Always + + + diff --git a/workshop/dotnet/Solutions/Lesson3/Lesson3.sln b/workshop/dotnet/Solutions/Lesson3/Lesson3.sln new file mode 100644 index 0000000..9a7529a --- /dev/null +++ b/workshop/dotnet/Solutions/Lesson3/Lesson3.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lesson3", "Lesson3.csproj", "{BF89ED0F-8671-429B-91A4-CEFB432DAD21}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BF89ED0F-8671-429B-91A4-CEFB432DAD21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF89ED0F-8671-429B-91A4-CEFB432DAD21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF89ED0F-8671-429B-91A4-CEFB432DAD21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF89ED0F-8671-429B-91A4-CEFB432DAD21}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C064C523-9186-413D-8213-7EDE44A472F8} + EndGlobalSection +EndGlobal diff --git a/workshop/dotnet/Solutions/Lesson3/Program.cs b/workshop/dotnet/Solutions/Lesson3/Program.cs new file mode 100644 index 0000000..dc171b6 --- /dev/null +++ b/workshop/dotnet/Solutions/Lesson3/Program.cs @@ -0,0 +1,69 @@ +using Core.Utilities.Config; +// Step 1 - Add import for Plugins +using Core.Utilities.Plugins; +// Step 5 - Add import required for StockService +using Core.Utilities.Services; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +// Add ChatCompletion import +using Microsoft.SemanticKernel.ChatCompletion; +// Temporarily added to enable Semantic Kernel tracing +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + + +// Initialize the kernel with chat completion +IKernelBuilder builder = KernelBuilderProvider.CreateKernelWithChatCompletion(); +// Enable tracing +//builder.Services.AddLogging(services => services.AddConsole().SetMinimumLevel(LogLevel.Trace)); +Kernel kernel = builder.Build(); + +// Step 2 - Initialize Time plugin and registration in the kernel +kernel.Plugins.AddFromObject(new TimeInformationPlugin()); + +// Step 6 - Initialize Stock Data Plugin and register it in the kernel +HttpClient httpClient = new(); +StockDataPlugin stockDataPlugin = new(new StocksService(httpClient)); +kernel.Plugins.AddFromObject(stockDataPlugin); + +// Get chatCompletionService and initialize chatHistory wiht system prompt +var chatCompletionService = kernel.GetRequiredService(); +ChatHistory chatHistory = new("You are a friendly financial advisor that only emits financial advice in a creative and funny tone"); +// Remove the promptExecutionSettings and kernelArgs initialization code +// Add system prompt +OpenAIPromptExecutionSettings promptExecutionSettings = new() +{ + // Step 3 - Add Auto invoke kernel functions as the tool call behavior + ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions +}; + +// Initialize kernel arguments +KernelArguments kernelArgs = new(promptExecutionSettings); + +// Execute program. +const string terminationPhrase = "quit"; +string? userInput; +do +{ + Console.Write("User > "); + userInput = Console.ReadLine(); + + if (userInput != null && userInput != terminationPhrase) + { + Console.Write("Assistant > "); + // Initialize fullMessage variable and add user input to chat history + string fullMessage = ""; + chatHistory.AddUserMessage(userInput); + + // Step 4 - Provide promptExecutionSettings and kernel arguments + await foreach (var chatUpdate in chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory, promptExecutionSettings, kernel)) + { + Console.Write(chatUpdate.Content); + fullMessage += chatUpdate.Content ?? ""; + } + chatHistory.AddAssistantMessage(fullMessage); + + Console.WriteLine(); + } +} +while (userInput != terminationPhrase); \ No newline at end of file diff --git a/workshop/dotnet/Solutions/Solutions.sln b/workshop/dotnet/Solutions/Solutions.sln new file mode 100644 index 0000000..88f0283 --- /dev/null +++ b/workshop/dotnet/Solutions/Solutions.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lesson1", "Lesson1\Lesson1.csproj", "{B230A526-2B91-4CC7-BF4E-B29568AB9670}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lesson2", "Lesson2\Lesson2.csproj", "{A5E9D722-3085-4780-954C-A132A67EB63B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B230A526-2B91-4CC7-BF4E-B29568AB9670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B230A526-2B91-4CC7-BF4E-B29568AB9670}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B230A526-2B91-4CC7-BF4E-B29568AB9670}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B230A526-2B91-4CC7-BF4E-B29568AB9670}.Release|Any CPU.Build.0 = Release|Any CPU + {A5E9D722-3085-4780-954C-A132A67EB63B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5E9D722-3085-4780-954C-A132A67EB63B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5E9D722-3085-4780-954C-A132A67EB63B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5E9D722-3085-4780-954C-A132A67EB63B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A0BEA744-7023-4A7A-9475-8E2A2FF9E13A} + EndGlobalSection +EndGlobal diff --git a/workshop/dotnet/azure.yaml b/workshop/dotnet/azure.yaml new file mode 100644 index 0000000..ba4446e --- /dev/null +++ b/workshop/dotnet/azure.yaml @@ -0,0 +1,18 @@ +name: semantic-kernel-workshop-csharp +metadata: + template: semantic-kernel-workshop-csharp@0.0.1-beta +services: + api: + project: ./App/backend/ + host: containerapp + language: dotnet + docker: + path: ../Dockerfile + context: ../../ + web: + project: ../frontend/ + host: containerapp + language: ts + docker: + path: ./Dockerfile + context: ./ \ No newline at end of file diff --git a/workshop/dotnet/dotnet.sln b/workshop/dotnet/dotnet.sln new file mode 100644 index 0000000..b2a7bf0 --- /dev/null +++ b/workshop/dotnet/dotnet.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.Utilities", "Core.Utilities\Core.Utilities.csproj", "{50BF7C2E-633E-46AA-945B-62180162EED2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lessons", "Lessons", "{7A43C88B-1868-4EB0-B337-E1FF94C2340D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lesson1", "Lessons\Lesson1\Lesson1.csproj", "{0F686E81-D73A-419A-8E2C-44BA950DB0E3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {50BF7C2E-633E-46AA-945B-62180162EED2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50BF7C2E-633E-46AA-945B-62180162EED2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50BF7C2E-633E-46AA-945B-62180162EED2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50BF7C2E-633E-46AA-945B-62180162EED2}.Release|Any CPU.Build.0 = Release|Any CPU + {0F686E81-D73A-419A-8E2C-44BA950DB0E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F686E81-D73A-419A-8E2C-44BA950DB0E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F686E81-D73A-419A-8E2C-44BA950DB0E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F686E81-D73A-419A-8E2C-44BA950DB0E3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0F686E81-D73A-419A-8E2C-44BA950DB0E3} = {7A43C88B-1868-4EB0-B337-E1FF94C2340D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {9A9D85F9-2CE3-44A2-B93E-209605E30E98} + EndGlobalSection +EndGlobal diff --git a/workshop/dotnet/infra/abbreviations.json b/workshop/dotnet/infra/abbreviations.json new file mode 100644 index 0000000..4841479 --- /dev/null +++ b/workshop/dotnet/infra/abbreviations.json @@ -0,0 +1,136 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "cognitiveServicesComputerVision": "cog-cv-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/workshop/dotnet/infra/app/api.bicep b/workshop/dotnet/infra/app/api.bicep new file mode 100644 index 0000000..efe8d96 --- /dev/null +++ b/workshop/dotnet/infra/app/api.bicep @@ -0,0 +1,103 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('The name of the identity') +param identityName string + +@description('The name of the Application Insights') +param applicationInsightsName string + +@description('The name of the container apps environment') +param containerAppsEnvironmentName string + +@description('The name of the container registry') +param containerRegistryName string + +@description('The name of the service') +param serviceName string = 'api' + +@description('The name of the image') +param imageName string = '' + +@description('Specifies if the resource exists') +param exists bool + +@description('The OpenAI endpoint') +param openAiEndpoint string + +@description('The OpenAI ChatGPT deployment name') +param openAiChatGptDeployment string + +@description('The OpenAI API key') +param openAiApiKey string + +@description('The Stock Service API key') +param stockServiceApiKey string + +@description('An array of service binds') +param serviceBinds array + +type managedIdentity = { + resourceId: string + clientId: string +} + +@description('Unique identifier for user-assigned managed identity.') +param userAssignedManagedIdentity managedIdentity + +module app '../core/host/container-app-upsert.bicep' = { + name: '${serviceName}-container-app' + params: { + name: name + location: location + tags: union(tags, { 'azd-service-name': serviceName }) + identityName: identityName + imageName: imageName + exists: exists + serviceBinds: serviceBinds + containerAppsEnvironmentName: containerAppsEnvironmentName + containerRegistryName: containerRegistryName + secrets: { + 'open-ai-api-key': openAiApiKey + 'stock-service-api-key': stockServiceApiKey + 'azure-managed-identity-client-id': userAssignedManagedIdentity.clientId + } + env: [ + { + name: 'AZURE_MANAGED_IDENTITY_CLIENT_ID' + value: 'azure-managed-identity-client-id' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: !empty(applicationInsightsName) ? applicationInsights.properties.ConnectionString : '' + } + { + name: 'OpenAI__Endpoint' + value: openAiEndpoint + } + { + name: 'OpenAI__DeploymentName' + value: openAiChatGptDeployment + } + { + name: 'OpenAI__ApiKey' + secretRef: 'open-ai-api-key' + } + { + name: 'StockService__ApiKey' + secretRef: 'stock-service-api-key' + } + ] + targetPort: 8080 + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { + name: applicationInsightsName +} + +output SERVICE_API_IDENTITY_NAME string = identityName +output SERVICE_API_IMAGE_NAME string = app.outputs.imageName +output SERVICE_API_NAME string = app.outputs.name +output SERVICE_API_URI string = app.outputs.uri diff --git a/workshop/dotnet/infra/app/user-assigned-identity.bicep b/workshop/dotnet/infra/app/user-assigned-identity.bicep new file mode 100644 index 0000000..0583ab8 --- /dev/null +++ b/workshop/dotnet/infra/app/user-assigned-identity.bicep @@ -0,0 +1,17 @@ +metadata description = 'Creates a Microsoft Entra user-assigned identity.' + +param name string +param location string = resourceGroup().location +param tags object = {} + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: name + location: location + tags: tags +} + +output name string = identity.name +output resourceId string = identity.id +output principalId string = identity.properties.principalId +output clientId string = identity.properties.clientId +output tenantId string = identity.properties.tenantId diff --git a/workshop/dotnet/infra/app/web.bicep b/workshop/dotnet/infra/app/web.bicep new file mode 100644 index 0000000..9ded691 --- /dev/null +++ b/workshop/dotnet/infra/app/web.bicep @@ -0,0 +1,91 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('The name of the identity') +param identityName string + +@description('The name of the Application Insights') +param applicationInsightsName string + +@description('Port for Web UI') +param webPort string = '80' + +@description('The name of the container apps environment') +param containerAppsEnvironmentName string + +@description('The name of the container registry') +param containerRegistryName string + +@description('The name of the service') +param serviceName string = 'web' + +@description('The name of the image') +param imageName string = '' + +@description('Specifies if the resource exists') +param exists bool + +@description('The URI for the backend API') +param apiEndpoint string + +@description('An array of service binds') +param serviceBinds array + +type managedIdentity = { + resourceId: string + clientId: string +} + +@description('Unique identifier for user-assigned managed identity.') +param userAssignedManagedIdentity managedIdentity + +module app '../core/host/container-app-upsert.bicep' = { + name: '${serviceName}-container-app' + params: { + name: name + location: location + tags: union(tags, { 'azd-service-name': serviceName }) + identityName: identityName + imageName: imageName + exists: exists + serviceBinds: serviceBinds + containerAppsEnvironmentName: containerAppsEnvironmentName + containerRegistryName: containerRegistryName + secrets: { + 'azure-managed-identity-client-id': userAssignedManagedIdentity.clientId + } + env: [ + { + name: 'AZURE_MANAGED_IDENTITY_CLIENT_ID' + value: 'azure-managed-identity-client-id' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: !empty(applicationInsightsName) ? applicationInsights.properties.ConnectionString : '' + } + { + name: 'API_URL' + value: apiEndpoint + } + { + name: 'PORT' + value: webPort + } + { + name: 'REACT_APP_PROXY_URL' + value: '/api/chat' + } + ] + targetPort: 80 + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { + name: applicationInsightsName +} + +output SERVICE_WEB_IDENTITY_NAME string = identityName +output SERVICE_WEB_IMAGE_NAME string = app.outputs.imageName +output SERVICE_WEB_NAME string = app.outputs.name +output SERVICE_WEB_URI string = app.outputs.uri diff --git a/workshop/dotnet/infra/core/ai/cognitiveservices.bicep b/workshop/dotnet/infra/core/ai/cognitiveservices.bicep new file mode 100644 index 0000000..3588072 --- /dev/null +++ b/workshop/dotnet/infra/core/ai/cognitiveservices.bicep @@ -0,0 +1,58 @@ +metadata description = 'Creates an Azure Cognitive Services instance.' +param name string +param location string = resourceGroup().location +param tags object = {} +@description('The custom subdomain name used to access the API. Defaults to the value of the name parameter.') +param customSubDomainName string = name +param deployments array = [] +param kind string = 'OpenAI' + +@allowed([ 'Enabled', 'Disabled' ]) +param publicNetworkAccess string = 'Enabled' +param sku object = { + name: 'S0' +} + +param allowedIpRules array = [] +param networkAcls object = empty(allowedIpRules) ? { + defaultAction: 'Allow' +} : { + ipRules: allowedIpRules + defaultAction: 'Deny' +} + +resource account 'Microsoft.CognitiveServices/accounts@2023-05-01' = { + name: name + location: location + tags: tags + kind: kind + identity: { + type: 'SystemAssigned' + } + properties: { + customSubDomainName: customSubDomainName + publicNetworkAccess: publicNetworkAccess + networkAcls: networkAcls + disableLocalAuth: true + } + sku: sku +} + +@batchSize(1) +resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in deployments: { + parent: account + name: deployment.name + properties: { + model: deployment.model + raiPolicyName: contains(deployment, 'raiPolicyName') ? deployment.raiPolicyName : null + } + sku: contains(deployment, 'sku') ? deployment.sku : { + name: 'Standard' + capacity: 20 + } +}] + +output endpoint string = account.properties.endpoint +output endpoints object = account.properties.endpoints +output id string = account.id +output name string = account.name diff --git a/workshop/dotnet/infra/core/host/container-app-upsert.bicep b/workshop/dotnet/infra/core/host/container-app-upsert.bicep new file mode 100644 index 0000000..5e05f89 --- /dev/null +++ b/workshop/dotnet/infra/core/host/container-app-upsert.bicep @@ -0,0 +1,110 @@ +metadata description = 'Creates or updates an existing Azure Container App.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('The environment name for the container apps') +param containerAppsEnvironmentName string + +@description('The number of CPU cores allocated to a single container instance, e.g., 0.5') +param containerCpuCoreCount string = '0.5' + +@description('The maximum number of replicas to run. Must be at least 1.') +@minValue(1) +param containerMaxReplicas int = 10 + +@description('The amount of memory allocated to a single container instance, e.g., 1Gi') +param containerMemory string = '1.0Gi' + +@description('The minimum number of replicas to run. Must be at least 1.') +@minValue(1) +param containerMinReplicas int = 1 + +@description('The name of the container') +param containerName string = 'main' + +@description('The name of the container registry') +param containerRegistryName string = '' + +@description('Hostname suffix for container registry. Set when deploying to sovereign clouds') +param containerRegistryHostSuffix string = 'azurecr.io' + +@allowed([ 'http', 'grpc' ]) +@description('The protocol used by Dapr to connect to the app, e.g., HTTP or gRPC') +param daprAppProtocol string = 'http' + +@description('Enable or disable Dapr for the container app') +param daprEnabled bool = false + +@description('The Dapr app ID') +param daprAppId string = containerName + +@description('Specifies if the resource already exists') +param exists bool = false + +@description('Specifies if Ingress is enabled for the container app') +param ingressEnabled bool = true + +@description('The type of identity for the resource') +@allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) +param identityType string = 'None' + +@description('The name of the user-assigned identity') +param identityName string = '' + +@description('The name of the container image') +param imageName string = '' + +@description('The secrets required for the container') +@secure() +param secrets object = {} + +@description('The environment variables for the container') +param env array = [] + +@description('Specifies if the resource ingress is exposed externally') +param external bool = true + +@description('The service binds associated with the container') +param serviceBinds array = [] + +@description('The target port for the container') +param targetPort int = 80 + +resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) { + name: name +} + +module app 'container-app.bicep' = { + name: '${deployment().name}-update' + params: { + name: name + location: location + tags: tags + identityType: identityType + identityName: identityName + ingressEnabled: ingressEnabled + containerName: containerName + containerAppsEnvironmentName: containerAppsEnvironmentName + containerRegistryName: containerRegistryName + containerRegistryHostSuffix: containerRegistryHostSuffix + containerCpuCoreCount: containerCpuCoreCount + containerMemory: containerMemory + containerMinReplicas: containerMinReplicas + containerMaxReplicas: containerMaxReplicas + daprEnabled: daprEnabled + daprAppId: daprAppId + daprAppProtocol: daprAppProtocol + secrets: secrets + external: external + env: env + imageName: !empty(imageName) ? imageName : exists ? existingApp.properties.template.containers[0].image : '' + targetPort: targetPort + serviceBinds: serviceBinds + } +} + +output defaultDomain string = app.outputs.defaultDomain +output imageName string = app.outputs.imageName +output name string = app.outputs.name +output uri string = app.outputs.uri diff --git a/workshop/dotnet/infra/core/host/container-app.bicep b/workshop/dotnet/infra/core/host/container-app.bicep new file mode 100644 index 0000000..c64fc82 --- /dev/null +++ b/workshop/dotnet/infra/core/host/container-app.bicep @@ -0,0 +1,169 @@ +metadata description = 'Creates a container app in an Azure Container App environment.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Allowed origins') +param allowedOrigins array = [] + +@description('Name of the environment for container apps') +param containerAppsEnvironmentName string + +@description('CPU cores allocated to a single container instance, e.g., 0.5') +param containerCpuCoreCount string = '0.5' + +@description('The maximum number of replicas to run. Must be at least 1.') +@minValue(1) +param containerMaxReplicas int = 10 + +@description('Memory allocated to a single container instance, e.g., 1Gi') +param containerMemory string = '1.0Gi' + +@description('The minimum number of replicas to run. Must be at least 1.') +param containerMinReplicas int = 1 + +@description('The name of the container') +param containerName string = 'main' + +@description('The name of the container registry') +param containerRegistryName string = '' + +@description('Hostname suffix for container registry. Set when deploying to sovereign clouds') +param containerRegistryHostSuffix string = 'azurecr.io' + +@description('The protocol used by Dapr to connect to the app, e.g., http or grpc') +@allowed([ 'http', 'grpc' ]) +param daprAppProtocol string = 'http' + +@description('The Dapr app ID') +param daprAppId string = containerName + +@description('Enable Dapr') +param daprEnabled bool = false + +@description('The environment variables for the container') +param env array = [] + +@description('Specifies if the resource ingress is exposed externally') +param external bool = true + +@description('The name of the user-assigned identity') +param identityName string = '' + +@description('The type of identity for the resource') +@allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) +param identityType string = 'None' + +@description('The name of the container image') +param imageName string = '' + +@description('Specifies if Ingress is enabled for the container app') +param ingressEnabled bool = true + +param revisionMode string = 'Single' + +@description('The secrets required for the container') +@secure() +param secrets object = {} + +@description('The service binds associated with the container') +param serviceBinds array = [] + +@description('The name of the container apps add-on to use. e.g. redis') +param serviceType string = '' + +@description('The target port for the container') +param targetPort int = 80 + +resource userIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = if (!empty(identityName)) { + name: identityName +} + +// Private registry support requires both an ACR name and a User Assigned managed identity +var usePrivateRegistry = !empty(identityName) && !empty(containerRegistryName) + +// Automatically set to `UserAssigned` when an `identityName` has been set +var normalizedIdentityType = !empty(identityName) ? 'UserAssigned' : identityType + +module containerRegistryAccess '../security/registry-access.bicep' = if (usePrivateRegistry) { + name: '${deployment().name}-registry-access' + params: { + containerRegistryName: containerRegistryName + principalId: usePrivateRegistry ? userIdentity.properties.principalId : '' + } +} + +resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { + name: name + location: location + tags: tags + // It is critical that the identity is granted ACR pull access before the app is created + // otherwise the container app will throw a provision error + // This also forces us to use an user assigned managed identity since there would no way to + // provide the system assigned identity with the ACR pull access before the app is created + dependsOn: usePrivateRegistry ? [ containerRegistryAccess ] : [] + identity: { + type: normalizedIdentityType + userAssignedIdentities: !empty(identityName) && normalizedIdentityType == 'UserAssigned' ? { '${userIdentity.id}': {} } : null + } + properties: { + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + activeRevisionsMode: revisionMode + ingress: ingressEnabled ? { + external: external + targetPort: targetPort + transport: 'auto' + corsPolicy: { + allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins) + } + } : null + dapr: daprEnabled ? { + enabled: true + appId: daprAppId + appProtocol: daprAppProtocol + appPort: ingressEnabled ? targetPort : 0 + } : { enabled: false } + secrets: [for secret in items(secrets): { + name: secret.key + value: secret.value + }] + service: !empty(serviceType) ? { type: serviceType } : null + registries: usePrivateRegistry ? [ + { + server: '${containerRegistryName}.${containerRegistryHostSuffix}' + identity: userIdentity.id + } + ] : [] + } + template: { + serviceBinds: !empty(serviceBinds) ? serviceBinds : null + containers: [ + { + image: !empty(imageName) ? imageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + name: containerName + env: env + resources: { + cpu: json(containerCpuCoreCount) + memory: containerMemory + } + } + ] + scale: { + minReplicas: containerMinReplicas + maxReplicas: containerMaxReplicas + } + } + } +} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' existing = { + name: containerAppsEnvironmentName +} + +output defaultDomain string = containerAppsEnvironment.properties.defaultDomain +output identityPrincipalId string = normalizedIdentityType == 'None' ? '' : (empty(identityName) ? app.identity.principalId : userIdentity.properties.principalId) +output imageName string = imageName +output name string = app.name +output serviceBind object = !empty(serviceType) ? { serviceId: app.id, name: name } : {} +output uri string = ingressEnabled ? 'https://${app.properties.configuration.ingress.fqdn}' : '' diff --git a/workshop/dotnet/infra/core/host/container-apps-environment.bicep b/workshop/dotnet/infra/core/host/container-apps-environment.bicep new file mode 100644 index 0000000..20f4632 --- /dev/null +++ b/workshop/dotnet/infra/core/host/container-apps-environment.bicep @@ -0,0 +1,41 @@ +metadata description = 'Creates an Azure Container Apps environment.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Name of the Application Insights resource') +param applicationInsightsName string = '' + +@description('Specifies if Dapr is enabled') +param daprEnabled bool = false + +@description('Name of the Log Analytics workspace') +param logAnalyticsWorkspaceName string + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' = { + name: name + location: location + tags: tags + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.properties.customerId + sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey + } + } + daprAIInstrumentationKey: daprEnabled && !empty(applicationInsightsName) ? applicationInsights.properties.InstrumentationKey : '' + } +} + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { + name: logAnalyticsWorkspaceName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (daprEnabled && !empty(applicationInsightsName)) { + name: applicationInsightsName +} + +output defaultDomain string = containerAppsEnvironment.properties.defaultDomain +output id string = containerAppsEnvironment.id +output name string = containerAppsEnvironment.name diff --git a/workshop/dotnet/infra/core/host/container-apps.bicep b/workshop/dotnet/infra/core/host/container-apps.bicep new file mode 100644 index 0000000..b382ccd --- /dev/null +++ b/workshop/dotnet/infra/core/host/container-apps.bicep @@ -0,0 +1,46 @@ +metadata description = 'Creates an Azure Container Registry and an Azure Container Apps environment.' +param name string +param location string = resourceGroup().location +param tags object = {} + +param containerAppsEnvironmentName string +param containerRegistryName string +param containerRegistryResourceGroupName string = '' +param containerRegistryAdminUserEnabled bool = false +param logAnalyticsWorkspaceName string = '' +param applicationInsightsName string = '' +param daprEnabled bool = false + +module containerAppsEnvironment 'container-apps-environment.bicep' = { + name: '${name}-container-apps-environment' + params: { + name: containerAppsEnvironmentName + location: location + tags: tags + logAnalyticsWorkspaceName: logAnalyticsWorkspaceName + applicationInsightsName: applicationInsightsName + daprEnabled: daprEnabled + } +} + +module containerRegistry 'container-registry.bicep' = { + name: '${name}-container-registry' + scope: !empty(containerRegistryResourceGroupName) ? resourceGroup(containerRegistryResourceGroupName) : resourceGroup() + params: { + name: containerRegistryName + location: location + adminUserEnabled: containerRegistryAdminUserEnabled + tags: tags + sku: { + name: 'Standard' + } + anonymousPullEnabled: false + } +} + +output defaultDomain string = containerAppsEnvironment.outputs.defaultDomain +output environmentName string = containerAppsEnvironment.outputs.name +output environmentId string = containerAppsEnvironment.outputs.id + +output registryLoginServer string = containerRegistry.outputs.loginServer +output registryName string = containerRegistry.outputs.name diff --git a/workshop/dotnet/infra/core/host/container-registry.bicep b/workshop/dotnet/infra/core/host/container-registry.bicep new file mode 100644 index 0000000..d14731c --- /dev/null +++ b/workshop/dotnet/infra/core/host/container-registry.bicep @@ -0,0 +1,137 @@ +metadata description = 'Creates an Azure Container Registry.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Indicates whether admin user is enabled') +param adminUserEnabled bool = false + +@description('Indicates whether anonymous pull is enabled') +param anonymousPullEnabled bool = false + +@description('Azure ad authentication as arm policy settings') +param azureADAuthenticationAsArmPolicy object = { + status: 'enabled' +} + +@description('Indicates whether data endpoint is enabled') +param dataEndpointEnabled bool = false + +@description('Encryption settings') +param encryption object = { + status: 'disabled' +} + +@description('Export policy settings') +param exportPolicy object = { + status: 'enabled' +} + +@description('Metadata search settings') +param metadataSearch string = 'Disabled' + +@description('Options for bypassing network rules') +param networkRuleBypassOptions string = 'AzureServices' + +@description('Public network access setting') +param publicNetworkAccess string = 'Enabled' + +@description('Quarantine policy settings') +param quarantinePolicy object = { + status: 'disabled' +} + +@description('Retention policy settings') +param retentionPolicy object = { + days: 7 + status: 'disabled' +} + +@description('Scope maps setting') +param scopeMaps array = [] + +@description('SKU settings') +param sku object = { + name: 'Basic' +} + +@description('Soft delete policy settings') +param softDeletePolicy object = { + retentionDays: 7 + status: 'disabled' +} + +@description('Trust policy settings') +param trustPolicy object = { + type: 'Notary' + status: 'disabled' +} + +@description('Zone redundancy setting') +param zoneRedundancy string = 'Disabled' + +@description('The log analytics workspace ID used for logging and monitoring') +param workspaceId string = '' + +// 2023-11-01-preview needed for metadataSearch +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { + name: name + location: location + tags: tags + sku: sku + properties: { + adminUserEnabled: adminUserEnabled + anonymousPullEnabled: anonymousPullEnabled + dataEndpointEnabled: dataEndpointEnabled + encryption: encryption + metadataSearch: metadataSearch + networkRuleBypassOptions: networkRuleBypassOptions + policies:{ + quarantinePolicy: quarantinePolicy + trustPolicy: trustPolicy + retentionPolicy: retentionPolicy + exportPolicy: exportPolicy + azureADAuthenticationAsArmPolicy: azureADAuthenticationAsArmPolicy + softDeletePolicy: softDeletePolicy + } + publicNetworkAccess: publicNetworkAccess + zoneRedundancy: zoneRedundancy + } + + resource scopeMap 'scopeMaps' = [for scopeMap in scopeMaps: { + name: scopeMap.name + properties: scopeMap.properties + }] +} + +// TODO: Update diagnostics to be its own module +// Blocking issue: https://github.com/Azure/bicep/issues/622 +// Unable to pass in a `resource` scope or unable to use string interpolation in resource types +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: 'registry-diagnostics' + scope: containerRegistry + properties: { + workspaceId: workspaceId + logs: [ + { + category: 'ContainerRegistryRepositoryEvents' + enabled: true + } + { + category: 'ContainerRegistryLoginEvents' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + timeGrain: 'PT1M' + } + ] + } +} + +output id string = containerRegistry.id +output loginServer string = containerRegistry.properties.loginServer +output name string = containerRegistry.name diff --git a/workshop/dotnet/infra/core/monitor/applicationinsights-dashboard.bicep b/workshop/dotnet/infra/core/monitor/applicationinsights-dashboard.bicep new file mode 100644 index 0000000..fcd37ac --- /dev/null +++ b/workshop/dotnet/infra/core/monitor/applicationinsights-dashboard.bicep @@ -0,0 +1,1252 @@ +metadata description = 'Creates a dashboard for an Application Insights instance.' +param name string +param applicationInsightsName string +param location string = resourceGroup().location +param tags object = {} + +// 2020-09-01-preview because that is the latest valid version +resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { + name: name + location: location + tags: tags + properties: { + lenses: [ + { + order: 0 + parts: [ + { + position: { + x: 0 + y: 0 + colSpan: 2 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'id' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' + #disable-next-line BCP037 + asset: { + idInputName: 'id' + type: 'ApplicationInsights' + } + #disable-next-line BCP037 + defaultMenuItemId: 'overview' + } + } + { + position: { + x: 2 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' + #disable-next-line BCP037 + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + #disable-next-line BCP037 + defaultMenuItemId: 'ProactiveDetection' + } + } + { + position: { + x: 3 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' + #disable-next-line BCP037 + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:20:33.345Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' + #disable-next-line BCP037 + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 5 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-08T18:47:35.237Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'ConfigurationId' + value: '78ce933e-e864-4b05-a27b-71fd55a6afad' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' + #disable-next-line BCP037 + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 0 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Usage' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 3 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:22:35.782Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' + #disable-next-line BCP037 + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Reliability' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 7 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:42:40.072Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' + #disable-next-line BCP037 + isAdapter: true + #disable-next-line BCP037 + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + #disable-next-line BCP037 + defaultMenuItemId: 'failures' + } + } + { + position: { + x: 8 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Responsiveness\r\n' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 11 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:43:37.804Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' + #disable-next-line BCP037 + isAdapter: true + #disable-next-line BCP037 + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + #disable-next-line BCP037 + defaultMenuItemId: 'performance' + } + } + { + position: { + x: 12 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Browser' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 15 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'MetricsExplorerJsonDefinitionId' + value: 'BrowserPerformanceTimelineMetrics' + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + createdTime: '2018-05-08T12:16:27.534Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'CurrentFilter' + value: { + eventTypes: [ + 4 + 1 + 3 + 5 + 2 + 6 + 13 + ] + typeFacets: {} + isPermissive: false + } + } + { + name: 'id' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' + #disable-next-line BCP037 + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + #disable-next-line BCP037 + defaultMenuItemId: 'browser' + } + } + { + position: { + x: 0 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'sessions/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Sessions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'users/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Users' + color: '#7E58FF' + } + } + ] + title: 'Unique sessions and users' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'segmentationUsers' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Failed requests' + color: '#EC008C' + } + } + ] + title: 'Failed requests' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'failures' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/duration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server response time' + color: '#00BCF2' + } + } + ] + title: 'Server response time' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'performance' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/networkDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Page load network connect time' + color: '#7E58FF' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/processingDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Client processing time' + color: '#44F1C8' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/sendDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Send request time' + color: '#EB9371' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/receiveDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Receiving response time' + color: '#0672F1' + } + } + ] + title: 'Average page load time breakdown' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/availabilityPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability' + color: '#47BDF5' + } + } + ] + title: 'Average availability' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'availability' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/server' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server exceptions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'dependencies/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Dependency failures' + color: '#7E58FF' + } + } + ] + title: 'Server exceptions and Dependency failures' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processorCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Processor time' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process CPU' + color: '#7E58FF' + } + } + ] + title: 'Average processor and process CPU utilization' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/browser' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Browser exceptions' + color: '#47BDF5' + } + } + ] + title: 'Browser exceptions' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/count' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability test results count' + color: '#47BDF5' + } + } + ] + title: 'Availability test results count' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processIOBytesPerSecond' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process IO rate' + color: '#47BDF5' + } + } + ] + title: 'Average process I/O rate' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/memoryAvailableBytes' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Available memory' + color: '#47BDF5' + } + } + ] + title: 'Average available memory' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + ] + } + ] + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} diff --git a/workshop/dotnet/infra/core/monitor/applicationinsights.bicep b/workshop/dotnet/infra/core/monitor/applicationinsights.bicep new file mode 100644 index 0000000..850e9fe --- /dev/null +++ b/workshop/dotnet/infra/core/monitor/applicationinsights.bicep @@ -0,0 +1,31 @@ +metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' +param name string +param dashboardName string = '' +param location string = resourceGroup().location +param tags object = {} +param logAnalyticsWorkspaceId string + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspaceId + } +} + +module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { + name: 'application-insights-dashboard' + params: { + name: dashboardName + location: location + applicationInsightsName: applicationInsights.name + } +} + +output connectionString string = applicationInsights.properties.ConnectionString +output id string = applicationInsights.id +output instrumentationKey string = applicationInsights.properties.InstrumentationKey +output name string = applicationInsights.name diff --git a/workshop/dotnet/infra/core/monitor/loganalytics.bicep b/workshop/dotnet/infra/core/monitor/loganalytics.bicep new file mode 100644 index 0000000..33f9dc2 --- /dev/null +++ b/workshop/dotnet/infra/core/monitor/loganalytics.bicep @@ -0,0 +1,22 @@ +metadata description = 'Creates a Log Analytics workspace.' +param name string +param location string = resourceGroup().location +param tags object = {} + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { + name: name + location: location + tags: tags + properties: any({ + retentionInDays: 30 + features: { + searchVersion: 1 + } + sku: { + name: 'PerGB2018' + } + }) +} + +output id string = logAnalytics.id +output name string = logAnalytics.name diff --git a/workshop/dotnet/infra/core/monitor/monitoring.bicep b/workshop/dotnet/infra/core/monitor/monitoring.bicep new file mode 100644 index 0000000..a95a50d --- /dev/null +++ b/workshop/dotnet/infra/core/monitor/monitoring.bicep @@ -0,0 +1,35 @@ +metadata description = 'Creates an Application Insights instance and a Log Analytics workspace.' +param logAnalyticsName string +param includeApplicationInsights bool = false +param applicationInsightsName string +param applicationInsightsDashboardName string = '' +param location string = resourceGroup().location +param tags object = {} + + +module logAnalytics 'loganalytics.bicep' = { + name: 'loganalytics' + params: { + name: logAnalyticsName + location: location + tags: tags + } +} + +module applicationInsights 'applicationinsights.bicep' = if(includeApplicationInsights) { + name: 'applicationinsights' + params: { + name: applicationInsightsName + location: location + tags: tags + dashboardName: applicationInsightsDashboardName + logAnalyticsWorkspaceId: logAnalytics.outputs.id + } +} + +output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString +output applicationInsightsId string = applicationInsights.outputs.id +output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey +output applicationInsightsName string = applicationInsights.outputs.name +output logAnalyticsWorkspaceId string = logAnalytics.outputs.id +output logAnalyticsWorkspaceName string = logAnalytics.outputs.name diff --git a/workshop/dotnet/infra/core/security/keyvault-access.bicep b/workshop/dotnet/infra/core/security/keyvault-access.bicep new file mode 100644 index 0000000..316775f --- /dev/null +++ b/workshop/dotnet/infra/core/security/keyvault-access.bicep @@ -0,0 +1,22 @@ +metadata description = 'Assigns an Azure Key Vault access policy.' +param name string = 'add' + +param keyVaultName string +param permissions object = { secrets: [ 'get', 'list' ] } +param principalId string + +resource keyVaultAccessPolicies 'Microsoft.KeyVault/vaults/accessPolicies@2022-07-01' = { + parent: keyVault + name: name + properties: { + accessPolicies: [ { + objectId: principalId + tenantId: subscription().tenantId + permissions: permissions + } ] + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} diff --git a/workshop/dotnet/infra/core/security/keyvault-secret.bicep b/workshop/dotnet/infra/core/security/keyvault-secret.bicep new file mode 100644 index 0000000..7441b29 --- /dev/null +++ b/workshop/dotnet/infra/core/security/keyvault-secret.bicep @@ -0,0 +1,31 @@ +metadata description = 'Creates or updates a secret in an Azure Key Vault.' +param name string +param tags object = {} +param keyVaultName string +param contentType string = 'string' +@description('The value of the secret. Provide only derived values like blob storage access, but do not hard code any secrets in your templates') +@secure() +param secretValue string + +param enabled bool = true +param exp int = 0 +param nbf int = 0 + +resource keyVaultSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + name: name + tags: tags + parent: keyVault + properties: { + attributes: { + enabled: enabled + exp: exp + nbf: nbf + } + contentType: contentType + value: secretValue + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} diff --git a/workshop/dotnet/infra/core/security/keyvault-secrets.bicep b/workshop/dotnet/infra/core/security/keyvault-secrets.bicep new file mode 100644 index 0000000..7116bf8 --- /dev/null +++ b/workshop/dotnet/infra/core/security/keyvault-secrets.bicep @@ -0,0 +1,23 @@ +param tags object = {} +param keyVaultName string +param secrets array = [] + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +@batchSize(1) +resource keyVaultSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = [for secret in secrets: { + parent: keyVault + name: secret.name + tags: tags + properties: { + attributes: { + enabled: contains(secret, 'enabled') ? secret.enabled : true + exp: contains(secret, 'exp') ? secret.exp : 0 + nbf: contains(secret, 'nbf') ? secret.nbf : 0 + } + contentType: contains(secret, 'contentType') ? secret.contentType : 'string' + value: secret.value + } +}] diff --git a/workshop/dotnet/infra/core/security/keyvault.bicep b/workshop/dotnet/infra/core/security/keyvault.bicep new file mode 100644 index 0000000..663ec00 --- /dev/null +++ b/workshop/dotnet/infra/core/security/keyvault.bicep @@ -0,0 +1,27 @@ +metadata description = 'Creates an Azure Key Vault.' +param name string +param location string = resourceGroup().location +param tags object = {} + +param principalId string = '' + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { + name: name + location: location + tags: tags + properties: { + tenantId: subscription().tenantId + sku: { family: 'A', name: 'standard' } + accessPolicies: !empty(principalId) ? [ + { + objectId: principalId + permissions: { secrets: [ 'get', 'list' ] } + tenantId: subscription().tenantId + } + ] : [] + } +} + +output endpoint string = keyVault.properties.vaultUri +output id string = keyVault.id +output name string = keyVault.name diff --git a/workshop/dotnet/infra/core/security/registry-access.bicep b/workshop/dotnet/infra/core/security/registry-access.bicep new file mode 100644 index 0000000..fc66837 --- /dev/null +++ b/workshop/dotnet/infra/core/security/registry-access.bicep @@ -0,0 +1,19 @@ +metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.' +param containerRegistryName string +param principalId string + +var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + +resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: containerRegistry // Use when specifying a scope that is different than the deployment scope + name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) + properties: { + roleDefinitionId: acrPullRole + principalType: 'ServicePrincipal' + principalId: principalId + } +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { + name: containerRegistryName +} diff --git a/workshop/dotnet/infra/core/security/role.bicep b/workshop/dotnet/infra/core/security/role.bicep new file mode 100644 index 0000000..0b30cfd --- /dev/null +++ b/workshop/dotnet/infra/core/security/role.bicep @@ -0,0 +1,21 @@ +metadata description = 'Creates a role assignment for a service principal.' +param principalId string + +@allowed([ + 'Device' + 'ForeignGroup' + 'Group' + 'ServicePrincipal' + 'User' +]) +param principalType string = 'ServicePrincipal' +param roleDefinitionId string + +resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} diff --git a/workshop/dotnet/infra/core/storage/storage-account.bicep b/workshop/dotnet/infra/core/storage/storage-account.bicep new file mode 100644 index 0000000..f33133a --- /dev/null +++ b/workshop/dotnet/infra/core/storage/storage-account.bicep @@ -0,0 +1,101 @@ +metadata description = 'Creates an Azure storage account.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@allowed([ + 'Cool' + 'Hot' + 'Premium' ]) +param accessTier string = 'Hot' +param allowBlobPublicAccess bool = false +param allowCrossTenantReplication bool = true +param allowSharedKeyAccess bool = true +param containers array = [] +param corsRules array = [] +param defaultToOAuthAuthentication bool = false +param deleteRetentionPolicy object = {} +@allowed([ 'AzureDnsZone', 'Standard' ]) +param dnsEndpointType string = 'Standard' +param files array = [] +param kind string = 'StorageV2' +param minimumTlsVersion string = 'TLS1_2' +param queues array = [] +param shareDeleteRetentionPolicy object = {} +param supportsHttpsTrafficOnly bool = true +param tables array = [] +param networkAcls object = { + bypass: 'AzureServices' + defaultAction: 'Allow' +} +@allowed([ 'Enabled', 'Disabled' ]) +param publicNetworkAccess string = 'Enabled' +param sku object = { name: 'Standard_LRS' } + +resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = { + name: name + location: location + tags: tags + kind: kind + sku: sku + properties: { + accessTier: accessTier + allowBlobPublicAccess: allowBlobPublicAccess + allowCrossTenantReplication: allowCrossTenantReplication + allowSharedKeyAccess: allowSharedKeyAccess + defaultToOAuthAuthentication: defaultToOAuthAuthentication + dnsEndpointType: dnsEndpointType + minimumTlsVersion: minimumTlsVersion + networkAcls: networkAcls + publicNetworkAccess: publicNetworkAccess + supportsHttpsTrafficOnly: supportsHttpsTrafficOnly + } + + resource blobServices 'blobServices' = if (!empty(containers)) { + name: 'default' + properties: { + cors: { + corsRules: corsRules + } + deleteRetentionPolicy: deleteRetentionPolicy + } + resource container 'containers' = [for container in containers: { + name: container.name + properties: { + publicAccess: contains(container, 'publicAccess') ? container.publicAccess : 'None' + } + }] + } + + resource fileServices 'fileServices' = if (!empty(files)) { + name: 'default' + properties: { + cors: { + corsRules: corsRules + } + shareDeleteRetentionPolicy: shareDeleteRetentionPolicy + } + } + + resource queueServices 'queueServices' = if (!empty(queues)) { + name: 'default' + properties: { + + } + resource queue 'queues' = [for queue in queues: { + name: queue.name + properties: { + metadata: {} + } + }] + } + + resource tableServices 'tableServices' = if (!empty(tables)) { + name: 'default' + properties: {} + } +} + +output id string = storage.id +output name string = storage.name +output primaryEndpoints object = storage.properties.primaryEndpoints diff --git a/workshop/dotnet/infra/main.bicep b/workshop/dotnet/infra/main.bicep new file mode 100644 index 0000000..b09ccba --- /dev/null +++ b/workshop/dotnet/infra/main.bicep @@ -0,0 +1,374 @@ +targetScope = 'subscription' + +@description('Name of the environment used to generate a short unique hash for resources.') +@minLength(1) +@maxLength(64) +param environmentName string + +@description('Primary location for all resources') +@allowed([ 'centralus', 'eastus2', 'eastasia', 'westus', 'westeurope', 'westus2', 'australiaeast', 'eastus', 'francecentral', 'japaneast', 'nortcentralus', 'swedencentral', 'switzerlandnorth', 'uksouth' ]) +param location string +param tags string = '' + +@description('Location for the OpenAI resource group') +@allowed([ 'canadaeast', 'westus', 'eastus', 'eastus2', 'francecentral', 'swedencentral', 'switzerlandnorth', 'uksouth', 'japaneast', 'northcentralus', 'australiaeast' ]) +@metadata({ + azd: { + type: 'location' + } +}) +param openAiResourceGroupLocation string + +@description('Name of the chat GPT model. Default: gpt-35-turbo') +@allowed([ 'gpt-35-turbo', 'gpt-4', 'gpt-4o', 'gpt-4o-mini', 'gpt-35-turbo-16k', 'gpt-4-16k' ]) +param azureOpenAIChatGptModelName string = 'gpt-4o-mini' + +@description('Name of the chat GPT model. Default: 0613 for gpt-35-turbo, or choose 2024-07-18 for gpt-4o-mini') +@allowed([ '0613', '2024-07-18' ]) +param azureOpenAIChatGptModelVersion string ='2024-07-18' + +@description('Defines if the process will deploy an Azure Application Insights resource') +param useApplicationInsights bool = true + +// @description('Name of the Azure Application Insights dashboard') +param applicationInsightsDashboardName string = '' + +@description('Name of the Azure Application Insights resource') +param applicationInsightsName string = '' + +@description('Name of the Azure App Service Plan') +param appServicePlanName string = '' + +@description('Capacity of the chat GPT deployment. Default: 10') +param chatGptDeploymentCapacity int = 8 + +@description('Name of the chat GPT deployment') +param azureChatGptDeploymentName string = 'chat' + +@description('Name of the container apps environment') +param containerAppsEnvironmentName string = '' + +@description('Name of the Azure container registry') +param containerRegistryName string = '' + +@description('Name of the resource group for the Azure container registry') +param containerRegistryResourceGroupName string = '' + +@description('Name of the Azure Log Analytics workspace') +param logAnalyticsName string = '' + +@description('Name of the resource group for the OpenAI resources') +param openAiResourceGroupName string = '' + +@description('Name of the OpenAI service') +param openAiServiceName string = '' + +@description('SKU name for the OpenAI service. Default: S0') +param openAiSkuName string = 'S0' + +@description('ID of the principal') +param principalId string = '' + +@description('Type of the principal. Valid values: User,ServicePrincipal') +param principalType string = 'User' + +@description('Name of the resource group') +param resourceGroupName string = '' + +@description('Name of the storage account') +param storageAccountName string = '' + +@description('Name of the storage container. Default: content') +param storageContainerName string = 'content' + +@description('Location of the resource group for the storage account') +param storageResourceGroupLocation string = location + +@description('Name of the resource group for the storage account') +param storageResourceGroupName string = '' + +@description('Specifies if the web app exists') +param webAppExists bool = false + +@description('Specifies if the api app exists') +param apiAppExists bool = false + +@description('Name of the api app container') +param apiContainerAppName string = '' + +@description('Name of the web app container') +param webContainerAppName string = '' + +@description('Name of the web app image') +param webImageName string = '' + +@description('Name of the api app image') +param apiImageName string = '' + +@description('Use Azure OpenAI service') +param useAOAI bool + +@description('OpenAI API Key, leave empty to provision a new Azure OpenAI instance') +param openAIApiKey string + +@description('OpenAI Deployment name') +param openAiChatGptDeployment string + +@description('OpenAI Endoint') +param openAiEndpoint string + +@description('Stock Service API Key from Polygon.io') +param stockServiceApiKey string + +var abbrs = loadJsonContent('./abbreviations.json') +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +var baseTags = { 'azd-env-name': environmentName } +var updatedTags = union(empty(tags) ? {} : base64ToJson(tags), baseTags) + +// Organize resources in a resource group +resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' + location: location + tags: updatedTags +} + +resource azureOpenAiResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = if (!empty(openAiResourceGroupName) && useAOAI) { + name: !empty(openAiResourceGroupName) ? openAiResourceGroupName : resourceGroup.name +} + + +resource storageResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = if (!empty(storageResourceGroupName)) { + name: !empty(storageResourceGroupName) ? storageResourceGroupName : resourceGroup.name +} + + +// Create a user assigned identity +module identity './app/user-assigned-identity.bicep' = { + name: 'identity' + scope: resourceGroup + params: { + name: 'sk-app-identity' + } +} + + +// Container apps host (including container registry) +module containerApps 'core/host/container-apps.bicep' = { + name: 'container-apps' + scope: resourceGroup + params: { + name: 'app' + containerAppsEnvironmentName: !empty(containerAppsEnvironmentName) ? containerAppsEnvironmentName : '${abbrs.appManagedEnvironments}${resourceToken}' + containerRegistryName: !empty(containerRegistryName) ? containerRegistryName : '${abbrs.containerRegistryRegistries}${resourceToken}' + containerRegistryResourceGroupName: !empty(containerRegistryResourceGroupName) ? containerRegistryResourceGroupName : resourceGroup.name + location: location + logAnalyticsWorkspaceName: monitoring.outputs.logAnalyticsWorkspaceName + } +} + + +// App api +module api './app/api.bicep' = { + name: 'api' + scope: resourceGroup + params: { + name: !empty(apiContainerAppName) ? apiContainerAppName : '${abbrs.appContainerApps}api-${resourceToken}' + location: location + tags: updatedTags + imageName: apiImageName + identityName: identity.outputs.name + applicationInsightsName: monitoring.outputs.applicationInsightsName + containerAppsEnvironmentName: containerApps.outputs.environmentName + containerRegistryName: containerApps.outputs.registryName + userAssignedManagedIdentity: { + resourceId: identity.outputs.resourceId + clientId: identity.outputs.clientId + } + exists: apiAppExists + openAiApiKey: useAOAI ? '' : openAIApiKey + openAiEndpoint: useAOAI ? azureOpenAi.outputs.endpoint : openAiEndpoint + stockServiceApiKey: stockServiceApiKey + openAiChatGptDeployment: useAOAI ? azureChatGptDeploymentName : openAiChatGptDeployment + serviceBinds: [] + } +} + +// App web +module web './app/web.bicep' = { + name: 'web' + scope: resourceGroup + params: { + name: !empty(webContainerAppName) ? webContainerAppName : '${abbrs.appContainerApps}web-${resourceToken}' + location: location + tags: updatedTags + imageName: webImageName + identityName: identity.outputs.name + applicationInsightsName: monitoring.outputs.applicationInsightsName + containerAppsEnvironmentName: containerApps.outputs.environmentName + containerRegistryName: containerApps.outputs.registryName + userAssignedManagedIdentity: { + resourceId: identity.outputs.resourceId + clientId: identity.outputs.clientId + } + exists: webAppExists + apiEndpoint: '${api.outputs.SERVICE_API_URI}/chat' + serviceBinds: [] + } +} + + +// Monitor application with Azure Monitor +module monitoring 'core/monitor/monitoring.bicep' = { + name: 'monitoring' + scope: resourceGroup + params: { + location: location + tags: updatedTags + includeApplicationInsights: true + logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' + applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' + applicationInsightsDashboardName: !empty(applicationInsightsDashboardName) ? applicationInsightsDashboardName : '${abbrs.portalDashboards}${resourceToken}' + } +} + +module azureOpenAi 'core/ai/cognitiveservices.bicep' = if (useAOAI) { + name: 'openai' + scope: azureOpenAiResourceGroup + params: { + name: !empty(openAiServiceName) ? openAiServiceName : '${abbrs.cognitiveServicesAccounts}${resourceToken}' + location: openAiResourceGroupLocation + tags: updatedTags + sku: { + name: openAiSkuName + } + deployments: [ + { + name: azureChatGptDeploymentName + model: { + format: 'OpenAI' + name: azureOpenAIChatGptModelName + version: azureOpenAIChatGptModelVersion + } + sku: { + name: 'Standard' + capacity: chatGptDeploymentCapacity + } + } + ] + } +} + +module storage 'core/storage/storage-account.bicep' = { + name: 'storage' + scope: storageResourceGroup + params: { + name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts}${resourceToken}' + location: storageResourceGroupLocation + tags: updatedTags + sku: { + name: 'Standard_LRS' + } + deleteRetentionPolicy: { + enabled: true + days: 2 + } + containers: [ + { + name: storageContainerName + } + ] + } +} + +// USER ROLES +module azureOpenAiRoleUser 'core/security/role.bicep' = if (useAOAI) { + scope: azureOpenAiResourceGroup + name: 'openai-role-user' + params: { + principalId: principalId + roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + principalType: principalType + } +} + +// Assign storage blob data contributor to the user for local runs +module storageRoleUser 'core/security/role.bicep' = { + scope: storageResourceGroup + name: 'storage-role-user' + params: { + principalId: principalId + roleDefinitionId: '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1' // built-in role definition id for storage blob data reader + principalType: principalType + } +} + +// Assign storage blob data contributor to the identity +module storageContribRoleUser 'core/security/role.bicep' = { + scope: storageResourceGroup + name: 'storage-contribrole-user' + params: { + principalId: principalId + roleDefinitionId: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + principalType: principalType + } +} + + +// SYSTEM IDENTITIES +module azureOpenAiRoleApi 'core/security/role.bicep' = if (useAOAI) { + scope: azureOpenAiResourceGroup + name: 'openai-role-api' + params: { + principalId: identity.outputs.principalId + roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + principalType: 'ServicePrincipal' + } +} + +module storageRoleApi 'core/security/role.bicep' = { + scope: storageResourceGroup + name: 'storage-role-api' + params: { + principalId: identity.outputs.principalId + roleDefinitionId: '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1' + principalType: 'ServicePrincipal' + } +} + +module storageContribRoleApi 'core/security/role.bicep' = { + scope: storageResourceGroup + name: 'storage-contribrole-api' + params: { + principalId: identity.outputs.principalId + roleDefinitionId: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + principalType: 'ServicePrincipal' + } +} + +output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString +output APPLICATIONINSIGHTS_NAME string = monitoring.outputs.applicationInsightsName +output AZURE_USE_APPLICATION_INSIGHTS bool = useApplicationInsights +output AZURE_CONTAINER_ENVIRONMENT_NAME string = containerApps.outputs.environmentName +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerApps.outputs.registryLoginServer +output AZURE_CONTAINER_REGISTRY_NAME string = containerApps.outputs.registryName +output AZURE_CONTAINER_REGISTRY_RESOURCE_GROUP string = containerApps.outputs.registryName +output AZURE_LOCATION string = location +output AZURE_OPENAI_RESOURCE_LOCATION string = openAiResourceGroupLocation +output AZURE_OPENAI_CHATGPT_DEPLOYMENT string = azureChatGptDeploymentName +output AZURE_OPENAI_ENDPOINT string = useAOAI? azureOpenAi.outputs.endpoint : '' +output AZURE_OPENAI_RESOURCE_GROUP string = useAOAI ? azureOpenAiResourceGroup.name : '' +output AZURE_OPENAI_SERVICE string = useAOAI ? azureOpenAi.outputs.name : '' +output AZURE_RESOURCE_GROUP string = resourceGroup.name +output AZURE_STORAGE_ACCOUNT string = storage.outputs.name +output AZURE_STORAGE_BLOB_ENDPOINT string = storage.outputs.primaryEndpoints.blob +output AZURE_STORAGE_CONTAINER string = storageContainerName +output AZURE_STORAGE_RESOURCE_GROUP string = storageResourceGroup.name +output AZURE_TENANT_ID string = tenant().tenantId +output SERVICE_WEB_IDENTITY_NAME string = web.outputs.SERVICE_WEB_IDENTITY_NAME +output SERVICE_WEB_NAME string = web.outputs.SERVICE_WEB_NAME +output SERVICE_API_IDENTITY_NAME string = api.outputs.SERVICE_API_IDENTITY_NAME +output SERVICE_API_NAME string = api.outputs.SERVICE_API_NAME +output USE_AOAI bool = useAOAI +output AZURE_OPENAI_CHATGPT_MODEL_VERSION string = azureOpenAIChatGptModelVersion +output AZURE_OPENAI_CHATGPT_MODEL_NAME string = azureOpenAIChatGptModelName diff --git a/workshop/dotnet/infra/main.parameters.json b/workshop/dotnet/infra/main.parameters.json new file mode 100644 index 0000000..2d2b9aa --- /dev/null +++ b/workshop/dotnet/infra/main.parameters.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "tags": { + "value": "${AZURE_TAGS}" + }, + "keyVaultName": { + "value": "${AZURE_KEY_VAULT_NAME}" + }, + "keyVaultResourceGroupName": { + "value": "${AZURE_KEY_VAULT_RESOURCE_GROUP}" + }, + "openAiResourceGroupName": { + "value": "${AZURE_OPENAI_RESOURCE_GROUP}" + }, + "openAiResourceGroupLocation": { + "value": "${AZURE_OPENAI_RESOURCE_LOCATION=${AZURE_LOCATION}}" + }, + "chatGptDeploymentName": { + "value": "${AZURE_OPENAI_CHATGPT_DEPLOYMENT=chat}" + }, + "openAiServiceName": { + "value": "${AZURE_OPENAI_SERVICE}" + }, + "openAiSkuName": { + "value": "S0" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "principalType": { + "value": "${AZURE_PRINCIPAL_TYPE=User}" + }, + "resourceGroupName": { + "value": "${AZURE_RESOURCE_GROUP}" + }, + "storageAccountName": { + "value": "${AZURE_STORAGE_ACCOUNT}" + }, + "storageResourceGroupName": { + "value": "${AZURE_STORAGE_RESOURCE_GROUP}" + }, + "webAppExists": { + "value": "${SERVICE_WEB_RESOURCE_EXISTS=false}" + }, + "webIdentityName": { + "value": "${SERVICE_WEB_IDENTITY_NAME}" + }, + "apiAppExists": { + "value": "${SERVICE_API_RESOURCE_EXISTS=false}" + }, + "useApplicationInsights": { + "value": "${AZURE_USE_APPLICATION_INSIGHTS=true}" + }, + "publicNetworkAccess": { + "value": "${AZURE_PUBLIC_NETWORK_ACCESS=Enabled}" + }, + "openAIApiKey": { + "value": "${OPENAI_API_KEY}" + }, + "useAOAI": { + "value": "${USE_AOAI=false}" + }, + "openAiChatGptDeployment": { + "value": "${OPENAI_CHATGPT_DEPLOYMENT}" + }, + "openAiEndpoint": { + "value": "${OPENAI_ENDPOINT}" + }, + "azureOpenAIChatGptModelVersion": { + "value": "${AZURE_OPENAI_CHATGPT_MODEL_VERSION=2024-07-18}" + }, + "azureOpenAIChatGptModelName": { + "value": "${AZURE_OPENAI_CHATGPT_MODEL_NAME=gpt-4o-mini}" + }, + "azureOpenAIChatGptDeploymentCapacity": { + "value": "${AZURE_OPENAI_CHATGPT_DEPLOYMENT_CAPACITY=30}" + } + } +} diff --git a/workshop/frontend/.dockerignore b/workshop/frontend/.dockerignore new file mode 100644 index 0000000..a8a20f8 --- /dev/null +++ b/workshop/frontend/.dockerignore @@ -0,0 +1,7 @@ +# Exclude node_modules and logs +node_modules +npm-debug.log + +# Exclude Git-related files +.git +.gitignore diff --git a/workshop/frontend/.github/workflows/azure-container-apps-deploy.yml b/workshop/frontend/.github/workflows/azure-container-apps-deploy.yml new file mode 100644 index 0000000..cb8194a --- /dev/null +++ b/workshop/frontend/.github/workflows/azure-container-apps-deploy.yml @@ -0,0 +1,49 @@ +name: Deploy to Azure Container Apps + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +env: + AZURE_CONTAINER_REGISTRY: gksamples24 + CONTAINER_APP_NAME: sample-frontend + RESOURCE_GROUP: copilotwksp + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Azure + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Log in to Azure Container Registry + uses: azure/docker-login@v1 + with: + login-server: ${{ env.AZURE_CONTAINER_REGISTRY }}.azurecr.io + username: ${{ secrets.AZURE_CLIENT_ID }} + password: ${{ secrets.AZURE_CLIENT_SECRET }} + + - name: Build and push image to ACR + uses: docker/build-push-action@v4 + with: + push: true + tags: ${{ env.AZURE_CONTAINER_REGISTRY }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ github.sha }} + file: ./Dockerfile + + - name: Deploy to Azure Container Apps + uses: azure/container-apps-deploy-action@v1 + with: + appName: ${{ env.CONTAINER_APP_NAME }} + resourceGroup: ${{ env.RESOURCE_GROUP }} + imageToDeploy: ${{ env.AZURE_CONTAINER_REGISTRY }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ github.sha }} + registryUrl: ${{ env.AZURE_CONTAINER_REGISTRY }}.azurecr.io + registryUsername: ${{ secrets.AZURE_CLIENT_ID }} + registryPassword: ${{ secrets.AZURE_CLIENT_SECRET }} diff --git a/workshop/frontend/Dockerfile b/workshop/frontend/Dockerfile new file mode 100644 index 0000000..9fc2eda --- /dev/null +++ b/workshop/frontend/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +FROM node:16 AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +RUN npm ci + +COPY . . +RUN npm run build + +# Production stage +FROM node:16-alpine + +WORKDIR /app + +COPY --from=build /app/build ./build +COPY --from=build /app/package*.json ./ +COPY --from=build /app/server.js ./ + +# Install production dependencies +RUN npm ci --only=production + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=80 + +# Expose the correct port +EXPOSE 80 + +# Create a shell script to run the server +RUN echo 'node server.js' > /app/run.sh +RUN chmod +x /app/run.sh + +CMD ["/app/run.sh"] diff --git a/workshop/frontend/README.md b/workshop/frontend/README.md new file mode 100644 index 0000000..0678a6a --- /dev/null +++ b/workshop/frontend/README.md @@ -0,0 +1,51 @@ +# Simple Copilot Chat Frontend + +A React-based chat application for interacting with an AI assistant (backend), designed for deployment on Azure Container Apps. + +## Getting Started + +### Prerequisites + +- **Node.js** (v16 or higher) +- **npm** (v7 or higher) +- **Git** + +### Installation + +1. **Install Dependencies:** + + ```bash + npm install + ``` + +1. **Set Environment Variables:** + + Create a `.env` file in the root directory of frontend and add the following environment variables: + + ```bash + API_URL=https://your-backend-api-url/chat + REACT_APP_PROXY_URL=http://localhost/api/chat + PORT=80 + ``` + +1. **Run the Application:** + + ```bash + npm start + ``` + + The application will be available at `http://localhost:80`. + +### Docker Usage (Optional) + +1. **Build the Docker Image:** + + ```bash + docker build -t simple-copilot-frontend . + ``` + +1. **Run the Docker Container:** + + ```bash + docker run -p 80:80 -d simple-copilot-frontend + ``` \ No newline at end of file diff --git a/workshop/frontend/package.json b/workshop/frontend/package.json new file mode 100644 index 0000000..248c014 --- /dev/null +++ b/workshop/frontend/package.json @@ -0,0 +1,52 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "dependencies": { + "@chakra-ui/icons": "^2.2.4", + "@chakra-ui/react": "^2.10.2", + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.113", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "axios": "^1.7.7", + "cors": "^2.8.5", + "express": "^4.21.1", + "framer-motion": "^11.11.9", + "frontend": "file:", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-scripts": "5.0.1", + "typescript": "^4.9.5", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/workshop/frontend/public/index.html b/workshop/frontend/public/index.html new file mode 100644 index 0000000..a34fe12 --- /dev/null +++ b/workshop/frontend/public/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Simple Copilot Chat Frontend + + + +
+ + diff --git a/workshop/frontend/server.js b/workshop/frontend/server.js new file mode 100644 index 0000000..50eeac0 --- /dev/null +++ b/workshop/frontend/server.js @@ -0,0 +1,41 @@ +const express = require('express'); +const cors = require('cors'); +const axios = require('axios'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT; + +app.use(cors()); +app.use(express.json()); + +// Serve static files from the React app +app.use(express.static(path.join(__dirname, 'build'))); + +const API_URL = process.env.API_URL; + +app.post('/api/chat', async (req, res) => { + try { + const response = await axios.post(API_URL, req.body, { + headers: { + 'Content-Type': 'application/json', + 'accept': 'text/plain' + } + }); + res.json(response.data); + } catch (error) { + console.error('Error:', error.message); + res.status(500).json({ error: 'An error occurred while processing your request' }); + } +}); + +// The "catchall" handler: for any request that doesn't +// match one above, send back React's index.html file. +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'build', 'index.html')); +}); + +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + console.log(`API URL: ${API_URL}`); +}); diff --git a/workshop/frontend/src/App.tsx b/workshop/frontend/src/App.tsx new file mode 100644 index 0000000..4b1bb91 --- /dev/null +++ b/workshop/frontend/src/App.tsx @@ -0,0 +1,146 @@ +import React, { useState, useEffect } from 'react'; +import { ChakraProvider, Box, VStack, Input, Text, Container, useColorMode, useColorModeValue, IconButton, Flex, InputGroup, InputRightElement } from '@chakra-ui/react'; +import { SunIcon, MoonIcon, ArrowForwardIcon } from '@chakra-ui/icons'; +import axios from 'axios'; +import { extendTheme, ThemeConfig } from '@chakra-ui/react'; + +interface Message { + message: string; + role: string; +} + +// Azure color scheme +const colors = { + azure: { + 50: '#e5f1fb', + 100: '#cce4f6', + 200: '#99c9ed', + 300: '#66ade3', + 400: '#3392da', + 500: '#0078d4', // Primary Azure color + 600: '#006abe', + 700: '#005ba7', + 800: '#004d8f', + 900: '#003e77', + }, +}; + +// Custom theme configuration +const config: ThemeConfig = { + initialColorMode: "light", + useSystemColorMode: false, +}; + +// Create a custom theme with Azure colors +const theme = extendTheme({ colors, config }); + +// Color mode toggle button component +const ColorModeToggle = () => { + const { toggleColorMode } = useColorMode(); + const text = useColorModeValue("dark", "light"); + const SwitchIcon = useColorModeValue(MoonIcon, SunIcon); + + return ( + } + size="md" + /> + ); +}; + +function App() { + const [inputMessage, setInputMessage] = useState(''); + const [messageHistory, setMessageHistory] = useState([]); + const [welcomeMessage, setWelcomeMessage] = useState({ message: 'You are a friendly financial advisor that only emits financial advice in a creative and funny tone', role: 'system' }); + const proxy_url = process.env.REACT_APP_PROXY_URL || '/api/chat'; + useEffect(() => { + setMessageHistory([welcomeMessage]); + }, []); + + const handleSendMessage = async () => { + if (!inputMessage.trim()) return; + if (!proxy_url) { + console.error('Proxy URL is not set'); + return; + } + + try { + const result = await axios.post(proxy_url, { + inputMessage, + messageHistory + }, { + headers: { + 'Content-Type': 'application/json' + } + }); + + if (result.data && result.data.messageHistory) { + setMessageHistory(result.data.messageHistory); + } else { + const newUserMessage: Message = { message: inputMessage, role: 'user' }; + const newAssistantMessage: Message = { + message: result.data.response || 'No response received', + role: 'assistant' + }; + setMessageHistory(prevHistory => [...prevHistory, newUserMessage, newAssistantMessage]); + } + setInputMessage(''); + } catch (error) { + console.error('Error sending message:', error); + const errorMessage: Message = { message: 'Error occurred while sending message', role: 'system' }; + setMessageHistory(prevHistory => [...prevHistory, errorMessage]); + } + }; + + const bgColor = useColorModeValue("azure.50", "azure.900"); + const textColor = useColorModeValue("azure.900", "azure.50"); + + return ( + + + + + Simple Copilot chat + + + + + {messageHistory.map((msg, index) => ( + + {msg.role}: {msg.message} + + ))} + + + setInputMessage(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSendMessage(); + } + }} + bg={useColorModeValue("white", "gray.800")} + /> + + } + onClick={handleSendMessage} + colorScheme="azure" + /> + + + + + + + ); +} + +export default App; diff --git a/workshop/frontend/src/index.tsx b/workshop/frontend/src/index.tsx new file mode 100644 index 0000000..c1f31c5 --- /dev/null +++ b/workshop/frontend/src/index.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render( + + + , + document.getElementById('root') +); diff --git a/workshop/frontend/tsconfig.json b/workshop/frontend/tsconfig.json new file mode 100644 index 0000000..7ae7426 --- /dev/null +++ b/workshop/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] + } + \ No newline at end of file