From b91c1ba4e2e7c7f93eca2d5b6b32102af70a44ed Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 05:30:36 -0700 Subject: [PATCH 01/10] Remove GitHub Models from aichatweb template Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../.template.config/template.json | 10 -- .../AIChatWeb-CSharp.AppHost/AppHost.cs | 10 +- .../AIChatWeb-CSharp.Web.csproj-in | 4 +- .../AIChatWeb-CSharp.Web/Program.Aspire.cs | 4 +- .../AIChatWeb-CSharp.Web/Program.cs | 20 +-- .../AIChatWeb-CSharp.Web/README.md | 26 ---- .../AIChatWeb-CSharp/README.Aspire.md | 26 ---- .../AIChatWebExecutionTests.cs | 2 +- .../AIChatWebSnapshotTests.cs | 4 +- .../aichatweb.A.verified/aichatweb/README.md | 57 -------- .../aichatweb/aichatweb.AppHost/AppHost.cs | 18 --- .../Properties/launchSettings.json | 29 ---- .../aichatweb.AppHost.csproj | 21 --- .../appsettings.Development.json | 8 -- .../aichatweb.AppHost/appsettings.json | 9 -- .../aichatweb.ServiceDefaults/Extensions.cs | 134 ------------------ .../aichatweb.ServiceDefaults.csproj | 22 --- .../aichatweb.Web/Components/App.razor | 24 ---- .../Components/Layout/LoadingSpinner.razor | 1 - .../Layout/LoadingSpinner.razor.css | 89 ------------ .../Components/Layout/MainLayout.razor | 9 -- .../Components/Layout/MainLayout.razor.css | 20 --- .../Components/Layout/SurveyPrompt.razor | 13 -- .../Components/Layout/SurveyPrompt.razor.css | 20 --- .../Components/Pages/Chat/Chat.razor | 134 ------------------ .../Components/Pages/Chat/Chat.razor.css | 11 -- .../Components/Pages/Chat/ChatCitation.razor | 39 ----- .../Pages/Chat/ChatCitation.razor.css | 37 ----- .../Components/Pages/Chat/ChatHeader.razor | 17 --- .../Pages/Chat/ChatHeader.razor.css | 25 ---- .../Components/Pages/Chat/ChatInput.razor | 51 ------- .../Components/Pages/Chat/ChatInput.razor.css | 57 -------- .../Components/Pages/Chat/ChatInput.razor.js | 43 ------ .../Pages/Chat/ChatMessageItem.razor | 107 -------------- .../Pages/Chat/ChatMessageItem.razor.css | 120 ---------------- .../Pages/Chat/ChatMessageList.razor | 42 ------ .../Pages/Chat/ChatMessageList.razor.css | 22 --- .../Pages/Chat/ChatMessageList.razor.js | 34 ----- .../Pages/Chat/ChatSuggestions.razor | 78 ---------- .../Pages/Chat/ChatSuggestions.razor.css | 9 -- .../Components/Pages/Error.razor | 36 ----- .../aichatweb.Web/Components/Routes.razor | 6 - .../aichatweb.Web/Components/_Imports.razor | 13 -- .../aichatweb/aichatweb.Web/Program.cs | 45 ------ .../Properties/launchSettings.json | 23 --- .../aichatweb.Web/Services/IngestedChunk.cs | 31 ---- .../Services/Ingestion/DataIngestor.cs | 35 ----- .../Services/Ingestion/DocumentReader.cs | 42 ------ .../aichatweb.Web/Services/SemanticSearch.cs | 27 ---- .../aichatweb.Web/aichatweb.Web.csproj | 26 ---- .../appsettings.Development.json | 9 -- .../aichatweb/aichatweb.Web/appsettings.json | 10 -- .../aichatweb/aichatweb.sln | 34 ----- .../aichatweb/Components/App.razor | 24 ---- .../Components/Layout/LoadingSpinner.razor | 1 - .../Layout/LoadingSpinner.razor.css | 89 ------------ .../Components/Layout/MainLayout.razor | 9 -- .../Components/Layout/MainLayout.razor.css | 20 --- .../Components/Layout/SurveyPrompt.razor | 13 -- .../Components/Layout/SurveyPrompt.razor.css | 20 --- .../Components/Pages/Chat/Chat.razor | 134 ------------------ .../Components/Pages/Chat/Chat.razor.css | 11 -- .../Components/Pages/Chat/ChatCitation.razor | 39 ----- .../Pages/Chat/ChatCitation.razor.css | 37 ----- .../Components/Pages/Chat/ChatHeader.razor | 17 --- .../Pages/Chat/ChatHeader.razor.css | 25 ---- .../Components/Pages/Chat/ChatInput.razor | 51 ------- .../Components/Pages/Chat/ChatInput.razor.css | 57 -------- .../Components/Pages/Chat/ChatInput.razor.js | 43 ------ .../Pages/Chat/ChatMessageItem.razor | 107 -------------- .../Pages/Chat/ChatMessageItem.razor.css | 120 ---------------- .../Pages/Chat/ChatMessageList.razor | 42 ------ .../Pages/Chat/ChatMessageList.razor.css | 22 --- .../Pages/Chat/ChatMessageList.razor.js | 34 ----- .../Pages/Chat/ChatSuggestions.razor | 78 ---------- .../Pages/Chat/ChatSuggestions.razor.css | 9 -- .../aichatweb/Components/Pages/Error.razor | 36 ----- .../aichatweb/Components/Routes.razor | 6 - .../aichatweb/Components/_Imports.razor | 13 -- .../aichatweb/Program.cs | 53 ------- .../aichatweb/Properties/launchSettings.json | 23 --- .../aichatweb/README.md | 19 --- .../aichatweb/Services/IngestedChunk.cs | 31 ---- .../Services/Ingestion/DataIngestor.cs | 35 ----- .../Services/Ingestion/DocumentReader.cs | 36 ----- .../Services/Ingestion/PdfPigReader.cs | 42 ------ .../aichatweb/Services/SemanticSearch.cs | 27 ---- .../aichatweb/aichatweb.csproj | 21 --- .../aichatweb/appsettings.Development.json | 9 -- .../aichatweb/appsettings.json | 10 -- 90 files changed, 12 insertions(+), 3094 deletions(-) delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/App.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/_Imports.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.Development.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.sln delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/App.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/_Imports.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Properties/launchSettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/README.md delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/aichatweb.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.Development.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.json diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json index 4058affe004..c769d2e9aca 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json @@ -128,18 +128,12 @@ "type": "parameter", "displayName": "_AI service provider", "datatype": "choice", - "defaultValue": "githubmodels", "choices": [ { "choice": "azureopenai", "displayName": "Azure OpenAI", "description": "Uses Azure OpenAI service" }, - { - "choice": "githubmodels", - "displayName": "GitHub Models", - "description": "Uses GitHub Models" - }, { "choice": "ollama", "displayName": "Ollama (for local development)", @@ -215,10 +209,6 @@ "type": "computed", "value": "(AiServiceProvider == \"openai\")" }, - "IsGHModels": { - "type": "computed", - "value": "(AiServiceProvider == \"githubmodels\")" - }, "IsOllama": { "type": "computed", "value": "(AiServiceProvider == \"ollama\")" diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs index 3304884fa78..fd577fedc2c 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs @@ -1,17 +1,11 @@ var builder = DistributedApplication.CreateBuilder(args); #if (IsOllama) // ASPIRE PARAMETERS -#elif (IsOpenAI || IsGHModels) +#elif (IsOpenAI) // You will need to set the connection string to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory -#if (IsOpenAI) // dotnet user-secrets set ConnectionStrings:openai "Key=YOUR-API-KEY" -#elif (IsGHModels) -// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -#else // IsAzureOpenAI -// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" -#endif var openai = builder.AddConnectionString("openai"); #else // IsAzureOpenAI @@ -62,7 +56,7 @@ .WithReference(embeddings) .WaitFor(chat) .WaitFor(embeddings); -#elif (IsOpenAI || IsGHModels) +#elif (IsOpenAI) webApp.WithReference(openai); #else // IsAzureOpenAI webApp diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in index 293b315d3bd..9b4f08998f4 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in @@ -13,13 +13,13 @@ #endif --> - + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs index 31442718f1f..66b017af999 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.AI; -#if (IsOpenAI || IsGHModels) +#if (IsOpenAI) using OpenAI; #endif using AIChatWeb_CSharp.Web.Components; @@ -19,7 +19,7 @@ builder.AddOllamaApiClient("embeddings") .AddEmbeddingGenerator(); #elif (IsAzureAIFoundry) -#else // (IsOpenAI || IsAzureOpenAI || IsGHModels) +#else // (IsOpenAI || IsAzureOpenAI) #if (IsOpenAI) var openai = builder.AddOpenAIClient("openai"); #else diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs index 89a231105e9..ae7ccc9d0ae 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs @@ -1,4 +1,4 @@ -#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) +#if (IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) using System.ClientModel; #elif (IsAzureOpenAI && IsManagedIdentity) using System.ClientModel.Primitives; @@ -11,7 +11,7 @@ using Microsoft.Extensions.AI; #if (IsOllama) using OllamaSharp; -#elif (IsGHModels || IsOpenAI || IsAzureOpenAI) +#elif (IsOpenAI || IsAzureOpenAI) using OpenAI; #endif using AIChatWeb_CSharp.Web.Components; @@ -21,21 +21,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); -#if (IsGHModels) -// You will need to set the endpoint and key to your own values -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set GitHubModels:Token YOUR-GITHUB-TOKEN -var credential = new ApiKeyCredential(builder.Configuration["GitHubModels:Token"] ?? throw new InvalidOperationException("Missing configuration: GitHubModels:Token. See the README for details.")); -var openAIOptions = new OpenAIClientOptions() -{ - Endpoint = new Uri("https://models.inference.ai.azure.com") -}; - -var ghModelsClient = new OpenAIClient(credential, openAIOptions); -var chatClient = ghModelsClient.GetChatClient("gpt-4o-mini").AsIChatClient(); -var embeddingGenerator = ghModelsClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); -#elif (IsOllama) +#if (IsOllama) IChatClient chatClient = new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2"); IEmbeddingGenerator> embeddingGenerator = new OllamaApiClient(new Uri("http://localhost:11434"), diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md index 14677ca5372..29552b95212 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md @@ -21,32 +21,6 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See #### ---#endif # Configure the AI Model Provider -#### ---#if (IsGHModels) -To use models hosted by GitHub Models, you will need to create a GitHub personal access token with `models:read` permissions, but no other scopes or permissions. See [Prototyping with AI models](https://docs.github.com/github-models/prototyping-with-ai-models) and [Managing your personal access tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) in the GitHub Docs for more information. - -#### ---#if (hostIdentifier == "vs") -Configure your token for this project using .NET User Secrets: - -1. In Visual Studio, right-click on your project in the Solution Explorer and select "Manage User Secrets". -2. This opens a `secrets.json` file where you can store your API keys without them being tracked in source control. Add the following key and value: - - ```json - { - "GitHubModels:Token": "YOUR-TOKEN" - } - ``` -#### ---#else -From the command line, configure your token for this project using .NET User Secrets by running the following commands: - -```sh -cd <> -dotnet user-secrets set GitHubModels:Token YOUR-TOKEN -``` -#### ---#endif - -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). - -#### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md index 0fff810489c..fc3071a5e80 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md @@ -20,32 +20,6 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See # Configure the AI Model Provider -#### ---#if (IsGHModels) -## Using GitHub Models -To use models hosted by GitHub Models, you will need to create a GitHub personal access token. The token should not have any scopes or permissions. See [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). - -#### ---#if (hostIdentifier == "vs") -Configure your token for this project using .NET User Secrets: - -1. In Visual Studio, right-click on the AIChatWeb-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". -2. This opens a `secrets.json` file where you can store your API keys without them being tracked in source control. Add the following key and value: - - ```json - { - "ConnectionStrings:openai": "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" - } - ``` -#### ---#else -From the command line, configure your token for this project using .NET User Secrets by running the following commands: - -```sh -cd AIChatWeb-CSharp.AppHost -dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -``` -#### ---#endif - -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). -#### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index 74bfd3ffdd6..4d4ceb4073c 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs @@ -36,7 +36,7 @@ public AIChatWebExecutionTests(TemplateExecutionTestFixture fixture, ITestOutput public static IEnumerable GetSupportedProjectConfigurations() { (string name, string[] values)[] allOptionValues = [ - ("--provider", ["azureopenai", "githubmodels", "ollama", "openai" /*, "azureaifoundry" */]), + ("--provider", ["azureopenai", "ollama", "openai" /*, "azureaifoundry" */]), ("--vector-store", ["azureaisearch", "local", "qdrant"]), ("--aspire", ["true", "false"]), ("--managed-identity", ["true", "false"]), diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs index ac02c466ea8..c94354aa0cd 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs @@ -27,10 +27,10 @@ public AIChatWebSnapshotTests(ITestOutputHelper log) } [Theory] - [InlineData /* Defaults: --provider=githubmodels --vector-store=local */] + [InlineData("--provider=openai")] [InlineData("--provider=ollama", "--vector-store=qdrant")] [InlineData("--provider=openai", "--vector-store=azureaisearch")] - [InlineData("--aspire")] + [InlineData("--aspire", "--provider=openai")] [InlineData("--aspire", "--provider=azureopenai", "--vector-store=azureaisearch")] public async Task RunSnapshotTests(params string[] templateArgs) { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md deleted file mode 100644 index 94c542fda7f..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# AI Chat with Custom Data - -This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). - ->[!NOTE] -> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. - -### Known Issues - -#### Errors running Ollama or Docker - -A recent incompatibility was found between Ollama and Docker Desktop. This issue results in runtime errors when connecting to Ollama, and the workaround for that can lead to Docker not working for Aspire projects. - -This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See [ollama/ollama#9509](https://github.com/ollama/ollama/issues/9509#issuecomment-2842461831) for more information and a link to install the version of Docker Desktop with the fix. - -# Configure the AI Model Provider - -## Using GitHub Models -To use models hosted by GitHub Models, you will need to create a GitHub personal access token. The token should not have any scopes or permissions. See [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). - -From the command line, configure your token for this project using .NET User Secrets by running the following commands: - -```sh -cd aichatweb.AppHost -dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -``` - -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). - -# Running the application - -## Using Visual Studio - -1. Open the `.sln` file in Visual Studio. -2. Press `Ctrl+F5` or click the "Start" button in the toolbar to run the project. - -## Using Visual Studio Code - -1. Open the project folder in Visual Studio Code. -2. Install the [C# Dev Kit extension](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) for Visual Studio Code. -3. Once installed, Open the `Program.cs` file in the aichatweb.AppHost project. -4. Run the project by clicking the "Run" button in the Debug view. - -## Trust the localhost certificate - -Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. - -See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. - -# Updating JavaScript dependencies - -This template leverages JavaScript libraries to provide essential functionality. These libraries are located in the wwwroot/lib folder of the aichatweb.Web project. For instructions on updating each dependency, please refer to the README.md file in each respective folder. - -# Learn More -To learn more about development with .NET and AI, check out the following links: - -* [AI for .NET Developers](https://learn.microsoft.com/dotnet/ai/) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs deleted file mode 100644 index bf116a0c47e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs +++ /dev/null @@ -1,18 +0,0 @@ -var builder = DistributedApplication.CreateBuilder(args); - -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -var openai = builder.AddConnectionString("openai"); - -var markitdown = builder.AddContainer("markitdown", "mcp/markitdown") - .WithArgs("--http", "--host", "0.0.0.0", "--port", "3001") - .WithHttpEndpoint(targetPort: 3001, name: "http"); - -var webApp = builder.AddProject("aichatweb-app"); -webApp.WithReference(openai); -webApp - .WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); - -builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json deleted file mode 100644 index f08691f26f1..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:9995;http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9995", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9995" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9996", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9996" - } - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj deleted file mode 100644 index c9340b988a1..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - Exe - net10.0 - enable - enable - {00000000-0000-0000-0000-000000000000} - - - - - - - - - - - diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json deleted file mode 100644 index b0bacf42851..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.json deleted file mode 100644 index bfad98588cd..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Aspire.Hosting.Dcp": "Warning" - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs deleted file mode 100644 index 8d0b0cd5d67..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -namespace Microsoft.Extensions.Hosting; - -// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. -// This project should be referenced by each service project in your solution. -// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults -public static class Extensions -{ - private const string HealthEndpointPath = "/health"; - private const string AlivenessEndpointPath = "/alive"; - - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { -#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental - http.RemoveAllResilienceHandlers(); -#pragma warning restore EXTEXP0001 - - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); - - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddMeter("Experimental.Microsoft.Extensions.AI"); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation(tracing => - // Exclude health check requests from tracing - tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) - ) - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation() - .AddSource("Experimental.Microsoft.Extensions.AI") - .AddSource("Experimental.Microsoft.Extensions.DataIngestion"); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks(HealthEndpointPath); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj deleted file mode 100644 index b3853baa2d2..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net10.0 - enable - enable - true - - - - - - - - - - - - - - - diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/App.razor deleted file mode 100644 index 262359d5f5a..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/App.razor +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -@code { - private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor deleted file mode 100644 index 116455ce45b..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css deleted file mode 100644 index d85b851a679..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Used under CC0 license */ - -.lds-ellipsis { - color: #666; - animation: fade-in 1s; -} - -@keyframes fade-in { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - - .lds-ellipsis, - .lds-ellipsis div { - box-sizing: border-box; - } - -.lds-ellipsis { - margin: auto; - display: block; - position: relative; - width: 80px; - height: 80px; -} - - .lds-ellipsis div { - position: absolute; - top: 33.33333px; - width: 10px; - height: 10px; - border-radius: 50%; - background: currentColor; - animation-timing-function: cubic-bezier(0, 1, 1, 0); - } - - .lds-ellipsis div:nth-child(1) { - left: 8px; - animation: lds-ellipsis1 0.6s infinite; - } - - .lds-ellipsis div:nth-child(2) { - left: 8px; - animation: lds-ellipsis2 0.6s infinite; - } - - .lds-ellipsis div:nth-child(3) { - left: 32px; - animation: lds-ellipsis2 0.6s infinite; - } - - .lds-ellipsis div:nth-child(4) { - left: 56px; - animation: lds-ellipsis3 0.6s infinite; - } - -@keyframes lds-ellipsis1 { - 0% { - transform: scale(0); - } - - 100% { - transform: scale(1); - } -} - -@keyframes lds-ellipsis3 { - 0% { - transform: scale(1); - } - - 100% { - transform: scale(0); - } -} - -@keyframes lds-ellipsis2 { - 0% { - transform: translate(0, 0); - } - - 100% { - transform: translate(24px, 0); - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor deleted file mode 100644 index 96fbbe6cc42..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor +++ /dev/null @@ -1,9 +0,0 @@ -@inherits LayoutComponentBase - -@Body - -
- An unhandled error has occurred. - Reload - 🗙 -
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css deleted file mode 100644 index cda2020dcb0..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -#blazor-error-ui { - color-scheme: light only; - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - box-sizing: border-box; - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; -} - - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor deleted file mode 100644 index 6fc5881c18f..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ /dev/null @@ -1,134 +0,0 @@ -@page "/" -@using System.ComponentModel -@inject IChatClient ChatClient -@inject NavigationManager Nav -@inject SemanticSearch Search -@implements IDisposable - -Chat - - - - - -
To get started, try asking about these example documents. You can replace these with your own data and replace this message.
- - -
-
- -
- - - @* Remove this line to eliminate the template survey message *@ -
- -@code { - private const string SystemPrompt = @" - You are an assistant who answers questions about information you retrieve. - Do not answer questions about anything else. - Use only simple markdown to format your responses. - - Use the LoadDocuments tool to prepare for searches before answering any questions. - - Use the Search tool to find relevant information. When you do this, end your - reply with citations in the special XML format: - - exact quote here - - Always include the citation in your response if there are results. - - The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant. - Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. - "; - - private int statefulMessageCount; - private readonly ChatOptions chatOptions = new(); - private readonly List messages = new(); - private CancellationTokenSource? currentResponseCancellation; - private ChatMessage? currentResponseMessage; - private ChatInput? chatInput; - private ChatSuggestions? chatSuggestions; - - protected override void OnInitialized() - { - statefulMessageCount = 0; - messages.Add(new(ChatRole.System, SystemPrompt)); - chatOptions.Tools = [ - AIFunctionFactory.Create(LoadDocumentsAsync), - AIFunctionFactory.Create(SearchAsync) - ]; - } - - private async Task AddUserMessageAsync(ChatMessage userMessage) - { - CancelAnyCurrentResponse(); - - // Add the user message to the conversation - messages.Add(userMessage); - chatSuggestions?.Clear(); - await chatInput!.FocusAsync(); - - // Stream and display a new response from the IChatClient - var responseText = new TextContent(""); - currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); - currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) - { - messages.AddMessages(update, filter: c => c is not TextContent); - responseText.Text += update.Text; - chatOptions.ConversationId = update.ConversationId; - ChatMessageItem.NotifyChanged(currentResponseMessage); - } - - // Store the final response in the conversation, and begin getting suggestions - messages.Add(currentResponseMessage!); - statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; - currentResponseMessage = null; - chatSuggestions?.Update(messages); - } - - private void CancelAnyCurrentResponse() - { - // If a response was cancelled while streaming, include it in the conversation so it's not lost - if (currentResponseMessage is not null) - { - messages.Add(currentResponseMessage); - } - - currentResponseCancellation?.Cancel(); - currentResponseMessage = null; - } - - private async Task ResetConversationAsync() - { - CancelAnyCurrentResponse(); - messages.Clear(); - messages.Add(new(ChatRole.System, SystemPrompt)); - chatOptions.ConversationId = null; - statefulMessageCount = 0; - chatSuggestions?.Clear(); - await chatInput!.FocusAsync(); - } - - [Description("Loads the documents needed for performing searches. Must be completed before a search can be executed, but only needs to be completed once.")] - private async Task LoadDocumentsAsync() - { - await InvokeAsync(StateHasChanged); - await Search.LoadDocumentsAsync(); - } - - [Description("Searches for information using a phrase or keyword. Relies on documents already being loaded.")] - private async Task> SearchAsync( - [Description("The phrase to search for.")] string searchPhrase, - [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) - { - await InvokeAsync(StateHasChanged); - var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); - return results.Select(result => - $"{result.Text}"); - } - - public void Dispose() - => currentResponseCancellation?.Cancel(); -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css deleted file mode 100644 index 98ed1ba7d1e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css +++ /dev/null @@ -1,11 +0,0 @@ -.chat-container { - position: sticky; - bottom: 0; - padding-left: 1.5rem; - padding-right: 1.5rem; - padding-top: 0.75rem; - padding-bottom: 1.5rem; - border-top-width: 1px; - background-color: #F3F4F6; - border-color: #E5E7EB; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor deleted file mode 100644 index 667189beabd..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor +++ /dev/null @@ -1,39 +0,0 @@ -@using System.Web -@if (!string.IsNullOrWhiteSpace(viewerUrl)) -{ - - - - -
-
@File
-
@Quote
-
-
-} - -@code { - [Parameter] - public required string File { get; set; } - - [Parameter] - public string? Quote { get; set; } - - private string? viewerUrl; - - protected override void OnParametersSet() - { - viewerUrl = null; - - // If you ingest other types of content besides Markdown or PDF files, construct a URL to an appropriate viewer here - if (File.EndsWith(".md")) - { - viewerUrl = $"lib/markdown_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#:~:text={Uri.EscapeDataString(Quote ?? "")}"; - } - else if (File.EndsWith(".pdf")) - { - var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); - viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#search={HttpUtility.UrlEncode(search)}&phrase=true"; - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css deleted file mode 100644 index 0ca029b7e64..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css +++ /dev/null @@ -1,37 +0,0 @@ -.citation { - display: inline-flex; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - margin-top: 1rem; - margin-right: 1rem; - border-bottom: 2px solid #a770de; - gap: 0.5rem; - border-radius: 0.25rem; - font-size: 0.875rem; - line-height: 1.25rem; - background-color: #ffffff; -} - - .citation[href]:hover { - outline: 1px solid #865cb1; - } - - .citation svg { - width: 1.5rem; - height: 1.5rem; - } - - .citation:active { - background-color: rgba(0,0,0,0.05); - } - -.citation-content { - display: flex; - flex-direction: column; -} - -.citation-file { - font-weight: 600; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor deleted file mode 100644 index 12b1d524e23..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
- -

aichatweb.Web

-
- -@code { - [Parameter] - public EventCallback OnNewChat { get; set; } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css deleted file mode 100644 index 6adcc414540..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css +++ /dev/null @@ -1,25 +0,0 @@ -.chat-header-container { - top: 0; - padding: 1.5rem; -} - -.chat-header-controls { - margin-bottom: 1.5rem; -} - -h1 { - overflow: hidden; - text-overflow: ellipsis; -} - -.new-chat-icon { - width: 1.25rem; - height: 1.25rem; - color: rgb(55, 65, 81); -} - -@media (min-width: 768px) { - .chat-header-container { - position: sticky; - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor deleted file mode 100644 index e87ac6ccf47..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor +++ /dev/null @@ -1,51 +0,0 @@ -@inject IJSRuntime JS - - - - - -@code { - private ElementReference textArea; - private string? messageText; - - [Parameter] - public EventCallback OnSend { get; set; } - - public ValueTask FocusAsync() - => textArea.FocusAsync(); - - private async Task SendMessageAsync() - { - if (messageText is { Length: > 0 } text) - { - messageText = null; - await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); - } - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - try - { - var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); - await module.InvokeVoidAsync("init", textArea); - await module.DisposeAsync(); - } - catch (JSDisconnectedException) - { - } - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css deleted file mode 100644 index 3b26c9af316..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css +++ /dev/null @@ -1,57 +0,0 @@ -.input-box { - display: flex; - flex-direction: column; - background: white; - border: 1px solid rgb(229, 231, 235); - border-radius: 8px; - padding: 0.5rem 0.75rem; - margin-top: 0.75rem; -} - - .input-box:focus-within { - outline: 2px solid #4152d5; - } - -textarea { - resize: none; - border: none; - outline: none; - flex-grow: 1; -} - - textarea:placeholder-shown + .tools { - --send-button-color: #aaa; - } - -.tools { - display: flex; - margin-top: 1rem; - align-items: center; -} - -.tool-icon { - width: 1.25rem; - height: 1.25rem; -} - -.send-button { - color: var(--send-button-color); - margin-left: auto; -} - - .send-button:hover { - color: black; - } - -.attach { - background-color: white; - border-style: dashed; - color: #888; - border-color: #888; - padding: 3px 8px; -} - - .attach:hover { - background-color: #f0f0f0; - color: black; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js deleted file mode 100644 index 39e18ac7b74..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js +++ /dev/null @@ -1,43 +0,0 @@ -export function init(elem) { - elem.focus(); - - // Auto-resize whenever the user types or if the value is set programmatically - elem.addEventListener('input', () => resizeToFit(elem)); - afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); - - // Auto-submit the form on 'enter' keypress - elem.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); - elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); - } - }); -} - -function resizeToFit(elem) { - const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); - - elem.rows = 1; - const numLines = Math.ceil(elem.scrollHeight / lineHeight); - elem.rows = Math.min(5, Math.max(1, numLines)); -} - -function afterPropertyWritten(target, propName, callback) { - const descriptor = getPropertyDescriptor(target, propName); - Object.defineProperty(target, propName, { - get: function () { - return descriptor.get.apply(this, arguments); - }, - set: function () { - const result = descriptor.set.apply(this, arguments); - callback(); - return result; - } - }); -} - -function getPropertyDescriptor(target, propertyName) { - return Object.getOwnPropertyDescriptor(target, propertyName) - || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor deleted file mode 100644 index e45d92ab5f9..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor +++ /dev/null @@ -1,107 +0,0 @@ -@using System.Runtime.CompilerServices -@using System.Text.RegularExpressions -@using System.Linq - -@if (Message.Role == ChatRole.User) -{ -
- @Message.Text -
-} -else if (Message.Role == ChatRole.Assistant) -{ - foreach (var content in Message.Contents) - { - if (content is TextContent { Text: { Length: > 0 } text }) - { -
-
-
- - - -
-
-
Assistant
-
- - - @foreach (var citation in citations ?? []) - { - - } -
-
- } - else if (content is FunctionCallContent { Name: "LoadDocuments" }) - { - - } - else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) - { - - } - } -} - -@code { - private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); - - private List<(string File, string Quote)>? citations; - - [Parameter, EditorRequired] - public required ChatMessage Message { get; set; } - - [Parameter] - public bool InProgress { get; set;} - - protected override void OnInitialized() - { - SubscribersLookup.AddOrUpdate(Message, this); - - if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) - { - ParseCitations(text); - } - } - - public static void NotifyChanged(ChatMessage source) - { - if (SubscribersLookup.TryGetValue(source, out var subscriber)) - { - subscriber.StateHasChanged(); - } - } - - private void ParseCitations(string text) - { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css deleted file mode 100644 index 10453454be8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css +++ /dev/null @@ -1,120 +0,0 @@ -.user-message { - background: rgb(182 215 232); - align-self: flex-end; - min-width: 25%; - max-width: calc(100% - 5rem); - padding: 0.5rem 1.25rem; - border-radius: 0.25rem; - color: #1F2937; - white-space: pre-wrap; -} - -.assistant-message, .assistant-search { - display: grid; - grid-template-rows: min-content; - grid-template-columns: 2rem minmax(0, 1fr); - gap: 0.25rem; -} - -.assistant-message-header { - font-weight: 600; -} - -.assistant-message-text { - grid-column-start: 2; -} - -.assistant-message-icon { - display: flex; - justify-content: center; - align-items: center; - border-radius: 9999px; - width: 1.5rem; - height: 1.5rem; - color: #ffffff; - background: #9b72ce; -} - - .assistant-message-icon svg { - width: 1rem; - height: 1rem; - } - -.assistant-search { - font-size: 0.875rem; - line-height: 1.25rem; -} - -.assistant-search-icon { - display: flex; - justify-content: center; - align-items: center; - width: 1.5rem; - height: 1.5rem; -} - - .assistant-search-icon svg { - width: 1rem; - height: 1rem; - } - -.assistant-search-content { - align-content: center; -} - -.assistant-search-phrase { - font-weight: 600; -} - -/* Default styling for markdown-formatted assistant messages */ -::deep ul { - list-style-type: disc; - margin-left: 1.5rem; -} - -::deep ol { - list-style-type: decimal; - margin-left: 1.5rem; -} - -::deep li { - margin: 0.5rem 0; -} - -::deep strong { - font-weight: 600; -} - -::deep h3 { - margin: 1rem 0; - font-weight: 600; -} - -::deep p + p { - margin-top: 1rem; -} - -::deep table { - margin: 1rem 0; -} - -::deep th { - text-align: left; - border-bottom: 1px solid silver; -} - -::deep th, ::deep td { - padding: 0.1rem 0.5rem; -} - -::deep th, ::deep tr:nth-child(even) { - background-color: rgba(0, 0, 0, 0.05); -} - -::deep pre > code { - background-color: white; - display: block; - padding: 0.5rem 1rem; - margin: 1rem 0; - overflow-x: auto; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor deleted file mode 100644 index d245f455f11..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor +++ /dev/null @@ -1,42 +0,0 @@ -@inject IJSRuntime JS - -
- - @foreach (var message in Messages) - { - - } - - @if (InProgressMessage is not null) - { - - - } - else if (IsEmpty) - { -
@NoMessagesContent
- } -
-
- -@code { - [Parameter] - public required IEnumerable Messages { get; set; } - - [Parameter] - public ChatMessage? InProgressMessage { get; set; } - - [Parameter] - public RenderFragment? NoMessagesContent { get; set; } - - private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - // Activates the auto-scrolling behavior - await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css deleted file mode 100644 index 6fbf083c7fa..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css +++ /dev/null @@ -1,22 +0,0 @@ -.message-list-container { - margin: 2rem 1.5rem; - flex-grow: 1; -} - -.message-list { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.no-messages { - text-align: center; - font-size: 1.25rem; - color: #999; - margin-top: calc(40vh - 18rem); -} - -chat-messages > ::deep div:last-of-type { - /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ - margin-bottom: 2rem; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js deleted file mode 100644 index 3de8de273b8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js +++ /dev/null @@ -1,34 +0,0 @@ -// The following logic provides auto-scroll behavior for the chat messages list. -// If you don't want that behavior, you can simply not load this module. - -window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { - static _isFirstAutoScroll = true; - - connectedCallback() { - this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); - this._observer.observe(this, { childList: true, attributes: true }); - } - - disconnectedCallback() { - this._observer.disconnect(); - } - - _scheduleAutoScroll(mutations) { - // Debounce the calls in case multiple DOM updates occur together - cancelAnimationFrame(this._nextAutoScroll); - this._nextAutoScroll = requestAnimationFrame(() => { - const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); - const elem = this.lastElementChild; - if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { - elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); - ChatMessages._isFirstAutoScroll = false; - } - }); - } - - _elemIsNearScrollBoundary(elem, threshold) { - const maxScrollPos = document.body.scrollHeight - window.innerHeight; - const remainingScrollDistance = maxScrollPos - window.scrollY; - return remainingScrollDistance < elem.offsetHeight + threshold; - } -}); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor deleted file mode 100644 index 69ca922a8ce..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor +++ /dev/null @@ -1,78 +0,0 @@ -@inject IChatClient ChatClient - -@if (suggestions is not null) -{ -
- @foreach (var suggestion in suggestions) - { - - } -
-} - -@code { - private static string Prompt = @" - Suggest up to 3 follow-up questions that I could ask you to help me complete my task. - Each suggestion must be a complete sentence, maximum 6 words. - Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, - for example 'How do I do that?' or 'Explain ...'. - If there are no suggestions, reply with an empty list. - "; - - private string[]? suggestions; - private CancellationTokenSource? cancellation; - - [Parameter] - public EventCallback OnSelected { get; set; } - - public void Clear() - { - suggestions = null; - cancellation?.Cancel(); - } - - public void Update(IReadOnlyList messages) - { - // Runs in the background and handles its own cancellation/errors - _ = UpdateSuggestionsAsync(messages); - } - - private async Task UpdateSuggestionsAsync(IReadOnlyList messages) - { - cancellation?.Cancel(); - cancellation = new CancellationTokenSource(); - - try - { - var response = await ChatClient.GetResponseAsync( - [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], - cancellationToken: cancellation.Token); - if (!response.TryGetResult(out suggestions)) - { - suggestions = null; - } - - StateHasChanged(); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - await DispatchExceptionAsync(ex); - } - } - - private async Task AddSuggestionAsync(string text) - { - await OnSelected.InvokeAsync(new(ChatRole.User, text)); - } - - private IEnumerable ReduceMessages(IReadOnlyList messages) - { - // Get any leading system messages, plus up to 5 user/assistant messages - // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long - var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); - var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); - return systemMessages.Concat(otherMessages); - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css deleted file mode 100644 index b291042c6d4..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css +++ /dev/null @@ -1,9 +0,0 @@ -.suggestions { - text-align: right; - white-space: nowrap; - gap: 0.5rem; - justify-content: flex-end; - flex-wrap: wrap; - display: flex; - margin-bottom: 0.75rem; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor deleted file mode 100644 index 576cc2d2f4d..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor +++ /dev/null @@ -1,36 +0,0 @@ -@page "/Error" -@using System.Diagnostics - -Error - -

Error.

-

An error occurred while processing your request.

- -@if (ShowRequestId) -{ -

- Request ID: @RequestId -

-} - -

Development Mode

-

- Swapping to Development environment will display more detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

- -@code{ - [CascadingParameter] - private HttpContext? HttpContext { get; set; } - - private string? RequestId { get; set; } - private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - - protected override void OnInitialized() => - RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor deleted file mode 100644 index f756e19dfbc..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/_Imports.razor deleted file mode 100644 index fa7cadef6ea..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/_Imports.razor +++ /dev/null @@ -1,13 +0,0 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using static Microsoft.AspNetCore.Components.Web.RenderMode -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.Extensions.AI -@using Microsoft.JSInterop -@using aichatweb.Web -@using aichatweb.Web.Components -@using aichatweb.Web.Components.Layout -@using aichatweb.Web.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs deleted file mode 100644 index e47bad71cc8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.Extensions.AI; -using OpenAI; -using aichatweb.Web.Components; -using aichatweb.Web.Services; -using aichatweb.Web.Services.Ingestion; - -var builder = WebApplication.CreateBuilder(args); -builder.AddServiceDefaults(); -builder.Services.AddRazorComponents().AddInteractiveServerComponents(); - -var openai = builder.AddAzureOpenAIClient("openai"); -openai.AddChatClient("gpt-4o-mini") - .UseFunctionInvocation() - .UseOpenTelemetry(configure: c => - c.EnableSensitiveData = builder.Environment.IsDevelopment()); -openai.AddEmbeddingGenerator("text-embedding-3-small"); - -var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); -var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; -builder.Services.AddSqliteVectorStore(_ => vectorStoreConnectionString); -builder.Services.AddSqliteCollection(IngestedChunk.CollectionName, vectorStoreConnectionString); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddKeyedSingleton("ingestion_directory", new DirectoryInfo(Path.Combine(builder.Environment.WebRootPath, "Data"))); - -var app = builder.Build(); - -app.MapDefaultEndpoints(); - -// Configure the HTTP request pipeline. -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); -} - -app.UseHttpsRedirection(); -app.UseAntiforgery(); - -app.UseStaticFiles(); -app.MapRazorComponents() - .AddInteractiveServerRenderMode(); - -app.Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json deleted file mode 100644 index 6e300d8a802..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:9995;http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs deleted file mode 100644 index af609ea239e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json.Serialization; -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Web.Services; - -public class IngestedChunk -{ - public const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model - public const string VectorDistanceFunction = DistanceFunction.CosineDistance; - public const string CollectionName = "data-aichatweb-chunks"; - - [VectorStoreKey(StorageName = "key")] - [JsonPropertyName("key")] - public required Guid Key { get; set; } - - [VectorStoreData(StorageName = "documentid")] - [JsonPropertyName("documentid")] - public required string DocumentId { get; set; } - - [VectorStoreData(StorageName = "content")] - [JsonPropertyName("content")] - public required string Text { get; set; } - - [VectorStoreData(StorageName = "context")] - [JsonPropertyName("context")] - public string? Context { get; set; } - - [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction, StorageName = "embedding")] - [JsonPropertyName("embedding")] - public string? Vector => Text; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs deleted file mode 100644 index 9dd366a03a5..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DataIngestion; -using Microsoft.Extensions.DataIngestion.Chunkers; -using Microsoft.Extensions.VectorData; -using Microsoft.ML.Tokenizers; - -namespace aichatweb.Web.Services.Ingestion; - -public class DataIngestor( - ILogger logger, - ILoggerFactory loggerFactory, - VectorStore vectorStore, - IEmbeddingGenerator> embeddingGenerator) -{ - public async Task IngestDataAsync(DirectoryInfo directory, string searchPattern) - { - using var writer = new VectorStoreWriter(vectorStore, dimensionCount: IngestedChunk.VectorDimensions, new() - { - CollectionName = IngestedChunk.CollectionName, - DistanceFunction = IngestedChunk.VectorDistanceFunction, - IncrementalIngestion = false, - }); - - using var pipeline = new IngestionPipeline( - reader: new DocumentReader(directory), - chunker: new SemanticSimilarityChunker(embeddingGenerator, new(TiktokenTokenizer.CreateForModel("gpt-4o"))), - writer: writer, - loggerFactory: loggerFactory); - - await foreach (var result in pipeline.ProcessAsync(directory, searchPattern)) - { - logger.LogInformation("Completed processing '{id}'. Succeeded: '{succeeded}'.", result.DocumentId, result.Succeeded); - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs deleted file mode 100644 index 60fcdbdc128..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.Extensions.DataIngestion; - -namespace aichatweb.Web.Services.Ingestion; - -internal sealed class DocumentReader(DirectoryInfo rootDirectory) : IngestionDocumentReader -{ - private readonly MarkdownReader _markdownReader = new(); - private readonly MarkItDownMcpReader _pdfReader = new(mcpServerUri: GetMarkItDownMcpServerUrl()); - - public override Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) - { - if (Path.IsPathFullyQualified(identifier)) - { - // Normalize the identifier to its relative path - identifier = Path.GetRelativePath(rootDirectory.FullName, identifier); - } - - mediaType = GetCustomMediaType(source) ?? mediaType; - return base.ReadAsync(source, identifier, mediaType, cancellationToken); - } - - public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) - => mediaType switch - { - "application/pdf" => _pdfReader.ReadAsync(source, identifier, mediaType, cancellationToken), - "text/markdown" => _markdownReader.ReadAsync(source, identifier, mediaType, cancellationToken), - _ => throw new InvalidOperationException($"Unsupported media type '{mediaType}'"), - }; - - private static string? GetCustomMediaType(FileInfo source) - => source.Extension switch - { - ".md" => "text/markdown", - _ => null - }; - - private static Uri GetMarkItDownMcpServerUrl() - { - var markItDownMcpUrl = $"{Environment.GetEnvironmentVariable("MARKITDOWN_MCP_URL")}/mcp"; - return new Uri(markItDownMcpUrl); - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs deleted file mode 100644 index d043c8efb84..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs +++ /dev/null @@ -1,27 +0,0 @@ -using aichatweb.Web.Services.Ingestion; -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Web.Services; - -public class SemanticSearch( - VectorStoreCollection vectorCollection, - [FromKeyedServices("ingestion_directory")] DirectoryInfo ingestionDirectory, - DataIngestor dataIngestor) -{ - private Task? _ingestionTask; - - public async Task LoadDocumentsAsync() => await ( _ingestionTask ??= dataIngestor.IngestDataAsync(ingestionDirectory, searchPattern: "*.*")); - - public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) - { - // Ensure documents have been loaded before searching - await LoadDocumentsAsync(); - - var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions - { - Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, - }); - - return await nearest.Select(result => result.Record).ToListAsync(); - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj deleted file mode 100644 index 3325bff88d9..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - enable - {00000000-0000-0000-0000-000000000000} - - - - - - - - - - - - - - - - - - - diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.Development.json deleted file mode 100644 index e22bd83cf3a..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.json deleted file mode 100644 index d286041f99d..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.sln b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.sln deleted file mode 100644 index 67d2a3cad3c..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.sln +++ /dev/null @@ -1,34 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.AppHost", "aichatweb.AppHost\aichatweb.AppHost.csproj", "{00000000-0000-0000-0000-000000000000}" -EndProject -Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.ServiceDefaults", "aichatweb.ServiceDefaults\aichatweb.ServiceDefaults.csproj", "{00000000-0000-0000-0000-000000000000}" -EndProject -Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.Web", "aichatweb.Web\aichatweb.Web.csproj", "{00000000-0000-0000-0000-000000000000}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/App.razor deleted file mode 100644 index 40862eea8e7..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/App.razor +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -@code { - private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor deleted file mode 100644 index 116455ce45b..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css deleted file mode 100644 index d85b851a679..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Used under CC0 license */ - -.lds-ellipsis { - color: #666; - animation: fade-in 1s; -} - -@keyframes fade-in { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - - .lds-ellipsis, - .lds-ellipsis div { - box-sizing: border-box; - } - -.lds-ellipsis { - margin: auto; - display: block; - position: relative; - width: 80px; - height: 80px; -} - - .lds-ellipsis div { - position: absolute; - top: 33.33333px; - width: 10px; - height: 10px; - border-radius: 50%; - background: currentColor; - animation-timing-function: cubic-bezier(0, 1, 1, 0); - } - - .lds-ellipsis div:nth-child(1) { - left: 8px; - animation: lds-ellipsis1 0.6s infinite; - } - - .lds-ellipsis div:nth-child(2) { - left: 8px; - animation: lds-ellipsis2 0.6s infinite; - } - - .lds-ellipsis div:nth-child(3) { - left: 32px; - animation: lds-ellipsis2 0.6s infinite; - } - - .lds-ellipsis div:nth-child(4) { - left: 56px; - animation: lds-ellipsis3 0.6s infinite; - } - -@keyframes lds-ellipsis1 { - 0% { - transform: scale(0); - } - - 100% { - transform: scale(1); - } -} - -@keyframes lds-ellipsis3 { - 0% { - transform: scale(1); - } - - 100% { - transform: scale(0); - } -} - -@keyframes lds-ellipsis2 { - 0% { - transform: translate(0, 0); - } - - 100% { - transform: translate(24px, 0); - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor deleted file mode 100644 index 96fbbe6cc42..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor +++ /dev/null @@ -1,9 +0,0 @@ -@inherits LayoutComponentBase - -@Body - -
- An unhandled error has occurred. - Reload - 🗙 -
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css deleted file mode 100644 index cda2020dcb0..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -#blazor-error-ui { - color-scheme: light only; - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - box-sizing: border-box; - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; -} - - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor deleted file mode 100644 index 6fc5881c18f..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ /dev/null @@ -1,134 +0,0 @@ -@page "/" -@using System.ComponentModel -@inject IChatClient ChatClient -@inject NavigationManager Nav -@inject SemanticSearch Search -@implements IDisposable - -Chat - - - - - -
To get started, try asking about these example documents. You can replace these with your own data and replace this message.
- - -
-
- -
- - - @* Remove this line to eliminate the template survey message *@ -
- -@code { - private const string SystemPrompt = @" - You are an assistant who answers questions about information you retrieve. - Do not answer questions about anything else. - Use only simple markdown to format your responses. - - Use the LoadDocuments tool to prepare for searches before answering any questions. - - Use the Search tool to find relevant information. When you do this, end your - reply with citations in the special XML format: - - exact quote here - - Always include the citation in your response if there are results. - - The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant. - Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. - "; - - private int statefulMessageCount; - private readonly ChatOptions chatOptions = new(); - private readonly List messages = new(); - private CancellationTokenSource? currentResponseCancellation; - private ChatMessage? currentResponseMessage; - private ChatInput? chatInput; - private ChatSuggestions? chatSuggestions; - - protected override void OnInitialized() - { - statefulMessageCount = 0; - messages.Add(new(ChatRole.System, SystemPrompt)); - chatOptions.Tools = [ - AIFunctionFactory.Create(LoadDocumentsAsync), - AIFunctionFactory.Create(SearchAsync) - ]; - } - - private async Task AddUserMessageAsync(ChatMessage userMessage) - { - CancelAnyCurrentResponse(); - - // Add the user message to the conversation - messages.Add(userMessage); - chatSuggestions?.Clear(); - await chatInput!.FocusAsync(); - - // Stream and display a new response from the IChatClient - var responseText = new TextContent(""); - currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); - currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) - { - messages.AddMessages(update, filter: c => c is not TextContent); - responseText.Text += update.Text; - chatOptions.ConversationId = update.ConversationId; - ChatMessageItem.NotifyChanged(currentResponseMessage); - } - - // Store the final response in the conversation, and begin getting suggestions - messages.Add(currentResponseMessage!); - statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; - currentResponseMessage = null; - chatSuggestions?.Update(messages); - } - - private void CancelAnyCurrentResponse() - { - // If a response was cancelled while streaming, include it in the conversation so it's not lost - if (currentResponseMessage is not null) - { - messages.Add(currentResponseMessage); - } - - currentResponseCancellation?.Cancel(); - currentResponseMessage = null; - } - - private async Task ResetConversationAsync() - { - CancelAnyCurrentResponse(); - messages.Clear(); - messages.Add(new(ChatRole.System, SystemPrompt)); - chatOptions.ConversationId = null; - statefulMessageCount = 0; - chatSuggestions?.Clear(); - await chatInput!.FocusAsync(); - } - - [Description("Loads the documents needed for performing searches. Must be completed before a search can be executed, but only needs to be completed once.")] - private async Task LoadDocumentsAsync() - { - await InvokeAsync(StateHasChanged); - await Search.LoadDocumentsAsync(); - } - - [Description("Searches for information using a phrase or keyword. Relies on documents already being loaded.")] - private async Task> SearchAsync( - [Description("The phrase to search for.")] string searchPhrase, - [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) - { - await InvokeAsync(StateHasChanged); - var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); - return results.Select(result => - $"{result.Text}"); - } - - public void Dispose() - => currentResponseCancellation?.Cancel(); -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css deleted file mode 100644 index 98ed1ba7d1e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css +++ /dev/null @@ -1,11 +0,0 @@ -.chat-container { - position: sticky; - bottom: 0; - padding-left: 1.5rem; - padding-right: 1.5rem; - padding-top: 0.75rem; - padding-bottom: 1.5rem; - border-top-width: 1px; - background-color: #F3F4F6; - border-color: #E5E7EB; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor deleted file mode 100644 index 667189beabd..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor +++ /dev/null @@ -1,39 +0,0 @@ -@using System.Web -@if (!string.IsNullOrWhiteSpace(viewerUrl)) -{ - - - - -
-
@File
-
@Quote
-
-
-} - -@code { - [Parameter] - public required string File { get; set; } - - [Parameter] - public string? Quote { get; set; } - - private string? viewerUrl; - - protected override void OnParametersSet() - { - viewerUrl = null; - - // If you ingest other types of content besides Markdown or PDF files, construct a URL to an appropriate viewer here - if (File.EndsWith(".md")) - { - viewerUrl = $"lib/markdown_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#:~:text={Uri.EscapeDataString(Quote ?? "")}"; - } - else if (File.EndsWith(".pdf")) - { - var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); - viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#search={HttpUtility.UrlEncode(search)}&phrase=true"; - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css deleted file mode 100644 index 0ca029b7e64..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css +++ /dev/null @@ -1,37 +0,0 @@ -.citation { - display: inline-flex; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - margin-top: 1rem; - margin-right: 1rem; - border-bottom: 2px solid #a770de; - gap: 0.5rem; - border-radius: 0.25rem; - font-size: 0.875rem; - line-height: 1.25rem; - background-color: #ffffff; -} - - .citation[href]:hover { - outline: 1px solid #865cb1; - } - - .citation svg { - width: 1.5rem; - height: 1.5rem; - } - - .citation:active { - background-color: rgba(0,0,0,0.05); - } - -.citation-content { - display: flex; - flex-direction: column; -} - -.citation-file { - font-weight: 600; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor deleted file mode 100644 index 0e9d9c8894a..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
- -

aichatweb

-
- -@code { - [Parameter] - public EventCallback OnNewChat { get; set; } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css deleted file mode 100644 index 6adcc414540..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css +++ /dev/null @@ -1,25 +0,0 @@ -.chat-header-container { - top: 0; - padding: 1.5rem; -} - -.chat-header-controls { - margin-bottom: 1.5rem; -} - -h1 { - overflow: hidden; - text-overflow: ellipsis; -} - -.new-chat-icon { - width: 1.25rem; - height: 1.25rem; - color: rgb(55, 65, 81); -} - -@media (min-width: 768px) { - .chat-header-container { - position: sticky; - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor deleted file mode 100644 index e87ac6ccf47..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor +++ /dev/null @@ -1,51 +0,0 @@ -@inject IJSRuntime JS - - - - - -@code { - private ElementReference textArea; - private string? messageText; - - [Parameter] - public EventCallback OnSend { get; set; } - - public ValueTask FocusAsync() - => textArea.FocusAsync(); - - private async Task SendMessageAsync() - { - if (messageText is { Length: > 0 } text) - { - messageText = null; - await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); - } - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - try - { - var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); - await module.InvokeVoidAsync("init", textArea); - await module.DisposeAsync(); - } - catch (JSDisconnectedException) - { - } - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css deleted file mode 100644 index 3b26c9af316..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css +++ /dev/null @@ -1,57 +0,0 @@ -.input-box { - display: flex; - flex-direction: column; - background: white; - border: 1px solid rgb(229, 231, 235); - border-radius: 8px; - padding: 0.5rem 0.75rem; - margin-top: 0.75rem; -} - - .input-box:focus-within { - outline: 2px solid #4152d5; - } - -textarea { - resize: none; - border: none; - outline: none; - flex-grow: 1; -} - - textarea:placeholder-shown + .tools { - --send-button-color: #aaa; - } - -.tools { - display: flex; - margin-top: 1rem; - align-items: center; -} - -.tool-icon { - width: 1.25rem; - height: 1.25rem; -} - -.send-button { - color: var(--send-button-color); - margin-left: auto; -} - - .send-button:hover { - color: black; - } - -.attach { - background-color: white; - border-style: dashed; - color: #888; - border-color: #888; - padding: 3px 8px; -} - - .attach:hover { - background-color: #f0f0f0; - color: black; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js deleted file mode 100644 index 39e18ac7b74..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js +++ /dev/null @@ -1,43 +0,0 @@ -export function init(elem) { - elem.focus(); - - // Auto-resize whenever the user types or if the value is set programmatically - elem.addEventListener('input', () => resizeToFit(elem)); - afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); - - // Auto-submit the form on 'enter' keypress - elem.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); - elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); - } - }); -} - -function resizeToFit(elem) { - const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); - - elem.rows = 1; - const numLines = Math.ceil(elem.scrollHeight / lineHeight); - elem.rows = Math.min(5, Math.max(1, numLines)); -} - -function afterPropertyWritten(target, propName, callback) { - const descriptor = getPropertyDescriptor(target, propName); - Object.defineProperty(target, propName, { - get: function () { - return descriptor.get.apply(this, arguments); - }, - set: function () { - const result = descriptor.set.apply(this, arguments); - callback(); - return result; - } - }); -} - -function getPropertyDescriptor(target, propertyName) { - return Object.getOwnPropertyDescriptor(target, propertyName) - || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor deleted file mode 100644 index e45d92ab5f9..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor +++ /dev/null @@ -1,107 +0,0 @@ -@using System.Runtime.CompilerServices -@using System.Text.RegularExpressions -@using System.Linq - -@if (Message.Role == ChatRole.User) -{ -
- @Message.Text -
-} -else if (Message.Role == ChatRole.Assistant) -{ - foreach (var content in Message.Contents) - { - if (content is TextContent { Text: { Length: > 0 } text }) - { -
-
-
- - - -
-
-
Assistant
-
- - - @foreach (var citation in citations ?? []) - { - - } -
-
- } - else if (content is FunctionCallContent { Name: "LoadDocuments" }) - { - - } - else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) - { - - } - } -} - -@code { - private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); - - private List<(string File, string Quote)>? citations; - - [Parameter, EditorRequired] - public required ChatMessage Message { get; set; } - - [Parameter] - public bool InProgress { get; set;} - - protected override void OnInitialized() - { - SubscribersLookup.AddOrUpdate(Message, this); - - if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) - { - ParseCitations(text); - } - } - - public static void NotifyChanged(ChatMessage source) - { - if (SubscribersLookup.TryGetValue(source, out var subscriber)) - { - subscriber.StateHasChanged(); - } - } - - private void ParseCitations(string text) - { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css deleted file mode 100644 index 10453454be8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css +++ /dev/null @@ -1,120 +0,0 @@ -.user-message { - background: rgb(182 215 232); - align-self: flex-end; - min-width: 25%; - max-width: calc(100% - 5rem); - padding: 0.5rem 1.25rem; - border-radius: 0.25rem; - color: #1F2937; - white-space: pre-wrap; -} - -.assistant-message, .assistant-search { - display: grid; - grid-template-rows: min-content; - grid-template-columns: 2rem minmax(0, 1fr); - gap: 0.25rem; -} - -.assistant-message-header { - font-weight: 600; -} - -.assistant-message-text { - grid-column-start: 2; -} - -.assistant-message-icon { - display: flex; - justify-content: center; - align-items: center; - border-radius: 9999px; - width: 1.5rem; - height: 1.5rem; - color: #ffffff; - background: #9b72ce; -} - - .assistant-message-icon svg { - width: 1rem; - height: 1rem; - } - -.assistant-search { - font-size: 0.875rem; - line-height: 1.25rem; -} - -.assistant-search-icon { - display: flex; - justify-content: center; - align-items: center; - width: 1.5rem; - height: 1.5rem; -} - - .assistant-search-icon svg { - width: 1rem; - height: 1rem; - } - -.assistant-search-content { - align-content: center; -} - -.assistant-search-phrase { - font-weight: 600; -} - -/* Default styling for markdown-formatted assistant messages */ -::deep ul { - list-style-type: disc; - margin-left: 1.5rem; -} - -::deep ol { - list-style-type: decimal; - margin-left: 1.5rem; -} - -::deep li { - margin: 0.5rem 0; -} - -::deep strong { - font-weight: 600; -} - -::deep h3 { - margin: 1rem 0; - font-weight: 600; -} - -::deep p + p { - margin-top: 1rem; -} - -::deep table { - margin: 1rem 0; -} - -::deep th { - text-align: left; - border-bottom: 1px solid silver; -} - -::deep th, ::deep td { - padding: 0.1rem 0.5rem; -} - -::deep th, ::deep tr:nth-child(even) { - background-color: rgba(0, 0, 0, 0.05); -} - -::deep pre > code { - background-color: white; - display: block; - padding: 0.5rem 1rem; - margin: 1rem 0; - overflow-x: auto; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor deleted file mode 100644 index d245f455f11..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor +++ /dev/null @@ -1,42 +0,0 @@ -@inject IJSRuntime JS - -
- - @foreach (var message in Messages) - { - - } - - @if (InProgressMessage is not null) - { - - - } - else if (IsEmpty) - { -
@NoMessagesContent
- } -
-
- -@code { - [Parameter] - public required IEnumerable Messages { get; set; } - - [Parameter] - public ChatMessage? InProgressMessage { get; set; } - - [Parameter] - public RenderFragment? NoMessagesContent { get; set; } - - private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - // Activates the auto-scrolling behavior - await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css deleted file mode 100644 index 6fbf083c7fa..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css +++ /dev/null @@ -1,22 +0,0 @@ -.message-list-container { - margin: 2rem 1.5rem; - flex-grow: 1; -} - -.message-list { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.no-messages { - text-align: center; - font-size: 1.25rem; - color: #999; - margin-top: calc(40vh - 18rem); -} - -chat-messages > ::deep div:last-of-type { - /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ - margin-bottom: 2rem; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js deleted file mode 100644 index 3de8de273b8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js +++ /dev/null @@ -1,34 +0,0 @@ -// The following logic provides auto-scroll behavior for the chat messages list. -// If you don't want that behavior, you can simply not load this module. - -window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { - static _isFirstAutoScroll = true; - - connectedCallback() { - this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); - this._observer.observe(this, { childList: true, attributes: true }); - } - - disconnectedCallback() { - this._observer.disconnect(); - } - - _scheduleAutoScroll(mutations) { - // Debounce the calls in case multiple DOM updates occur together - cancelAnimationFrame(this._nextAutoScroll); - this._nextAutoScroll = requestAnimationFrame(() => { - const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); - const elem = this.lastElementChild; - if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { - elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); - ChatMessages._isFirstAutoScroll = false; - } - }); - } - - _elemIsNearScrollBoundary(elem, threshold) { - const maxScrollPos = document.body.scrollHeight - window.innerHeight; - const remainingScrollDistance = maxScrollPos - window.scrollY; - return remainingScrollDistance < elem.offsetHeight + threshold; - } -}); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor deleted file mode 100644 index 69ca922a8ce..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor +++ /dev/null @@ -1,78 +0,0 @@ -@inject IChatClient ChatClient - -@if (suggestions is not null) -{ -
- @foreach (var suggestion in suggestions) - { - - } -
-} - -@code { - private static string Prompt = @" - Suggest up to 3 follow-up questions that I could ask you to help me complete my task. - Each suggestion must be a complete sentence, maximum 6 words. - Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, - for example 'How do I do that?' or 'Explain ...'. - If there are no suggestions, reply with an empty list. - "; - - private string[]? suggestions; - private CancellationTokenSource? cancellation; - - [Parameter] - public EventCallback OnSelected { get; set; } - - public void Clear() - { - suggestions = null; - cancellation?.Cancel(); - } - - public void Update(IReadOnlyList messages) - { - // Runs in the background and handles its own cancellation/errors - _ = UpdateSuggestionsAsync(messages); - } - - private async Task UpdateSuggestionsAsync(IReadOnlyList messages) - { - cancellation?.Cancel(); - cancellation = new CancellationTokenSource(); - - try - { - var response = await ChatClient.GetResponseAsync( - [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], - cancellationToken: cancellation.Token); - if (!response.TryGetResult(out suggestions)) - { - suggestions = null; - } - - StateHasChanged(); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - await DispatchExceptionAsync(ex); - } - } - - private async Task AddSuggestionAsync(string text) - { - await OnSelected.InvokeAsync(new(ChatRole.User, text)); - } - - private IEnumerable ReduceMessages(IReadOnlyList messages) - { - // Get any leading system messages, plus up to 5 user/assistant messages - // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long - var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); - var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); - return systemMessages.Concat(otherMessages); - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css deleted file mode 100644 index b291042c6d4..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css +++ /dev/null @@ -1,9 +0,0 @@ -.suggestions { - text-align: right; - white-space: nowrap; - gap: 0.5rem; - justify-content: flex-end; - flex-wrap: wrap; - display: flex; - margin-bottom: 0.75rem; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor deleted file mode 100644 index 576cc2d2f4d..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor +++ /dev/null @@ -1,36 +0,0 @@ -@page "/Error" -@using System.Diagnostics - -Error - -

Error.

-

An error occurred while processing your request.

- -@if (ShowRequestId) -{ -

- Request ID: @RequestId -

-} - -

Development Mode

-

- Swapping to Development environment will display more detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

- -@code{ - [CascadingParameter] - private HttpContext? HttpContext { get; set; } - - private string? RequestId { get; set; } - private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - - protected override void OnInitialized() => - RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor deleted file mode 100644 index f756e19dfbc..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/_Imports.razor deleted file mode 100644 index 9a70e8453ef..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/_Imports.razor +++ /dev/null @@ -1,13 +0,0 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using static Microsoft.AspNetCore.Components.Web.RenderMode -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.Extensions.AI -@using Microsoft.JSInterop -@using aichatweb -@using aichatweb.Components -@using aichatweb.Components.Layout -@using aichatweb.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs deleted file mode 100644 index 27e50372647..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.ClientModel; -using Microsoft.Extensions.AI; -using OpenAI; -using aichatweb.Components; -using aichatweb.Services; -using aichatweb.Services.Ingestion; - -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddRazorComponents().AddInteractiveServerComponents(); - -// You will need to set the endpoint and key to your own values -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set GitHubModels:Token YOUR-GITHUB-TOKEN -var credential = new ApiKeyCredential(builder.Configuration["GitHubModels:Token"] ?? throw new InvalidOperationException("Missing configuration: GitHubModels:Token. See the README for details.")); -var openAIOptions = new OpenAIClientOptions() -{ - Endpoint = new Uri("https://models.inference.ai.azure.com") -}; - -var ghModelsClient = new OpenAIClient(credential, openAIOptions); -var chatClient = ghModelsClient.GetChatClient("gpt-4o-mini").AsIChatClient(); -var embeddingGenerator = ghModelsClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); - -var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); -var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; -builder.Services.AddSqliteVectorStore(_ => vectorStoreConnectionString); -builder.Services.AddSqliteCollection(IngestedChunk.CollectionName, vectorStoreConnectionString); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddKeyedSingleton("ingestion_directory", new DirectoryInfo(Path.Combine(builder.Environment.WebRootPath, "Data"))); -builder.Services.AddChatClient(chatClient).UseFunctionInvocation().UseLogging(); -builder.Services.AddEmbeddingGenerator(embeddingGenerator); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); -} - -app.UseHttpsRedirection(); -app.UseAntiforgery(); - -app.UseStaticFiles(); -app.MapRazorComponents() - .AddInteractiveServerRenderMode(); - -app.Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Properties/launchSettings.json deleted file mode 100644 index 6e300d8a802..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:9995;http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/README.md deleted file mode 100644 index cf7deff5878..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# AI Chat with Custom Data - -This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). - ->[!NOTE] -> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. - -# Configure the AI Model Provider -To use models hosted by GitHub Models, you will need to create a GitHub personal access token with `models:read` permissions, but no other scopes or permissions. See [Prototyping with AI models](https://docs.github.com/github-models/prototyping-with-ai-models) and [Managing your personal access tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) in the GitHub Docs for more information. - -From the command line, configure your token for this project using .NET User Secrets by running the following commands: - -```sh -cd <> -dotnet user-secrets set GitHubModels:Token YOUR-TOKEN -``` - -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). - diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs deleted file mode 100644 index 68af3ef20fb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json.Serialization; -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Services; - -public class IngestedChunk -{ - public const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model - public const string VectorDistanceFunction = DistanceFunction.CosineDistance; - public const string CollectionName = "data-aichatweb-chunks"; - - [VectorStoreKey(StorageName = "key")] - [JsonPropertyName("key")] - public required Guid Key { get; set; } - - [VectorStoreData(StorageName = "documentid")] - [JsonPropertyName("documentid")] - public required string DocumentId { get; set; } - - [VectorStoreData(StorageName = "content")] - [JsonPropertyName("content")] - public required string Text { get; set; } - - [VectorStoreData(StorageName = "context")] - [JsonPropertyName("context")] - public string? Context { get; set; } - - [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction, StorageName = "embedding")] - [JsonPropertyName("embedding")] - public string? Vector => Text; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs deleted file mode 100644 index d97b986b694..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DataIngestion; -using Microsoft.Extensions.DataIngestion.Chunkers; -using Microsoft.Extensions.VectorData; -using Microsoft.ML.Tokenizers; - -namespace aichatweb.Services.Ingestion; - -public class DataIngestor( - ILogger logger, - ILoggerFactory loggerFactory, - VectorStore vectorStore, - IEmbeddingGenerator> embeddingGenerator) -{ - public async Task IngestDataAsync(DirectoryInfo directory, string searchPattern) - { - using var writer = new VectorStoreWriter(vectorStore, dimensionCount: IngestedChunk.VectorDimensions, new() - { - CollectionName = IngestedChunk.CollectionName, - DistanceFunction = IngestedChunk.VectorDistanceFunction, - IncrementalIngestion = false, - }); - - using var pipeline = new IngestionPipeline( - reader: new DocumentReader(directory), - chunker: new SemanticSimilarityChunker(embeddingGenerator, new(TiktokenTokenizer.CreateForModel("gpt-4o"))), - writer: writer, - loggerFactory: loggerFactory); - - await foreach (var result in pipeline.ProcessAsync(directory, searchPattern)) - { - logger.LogInformation("Completed processing '{id}'. Succeeded: '{succeeded}'.", result.DocumentId, result.Succeeded); - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs deleted file mode 100644 index 315a6ad3d53..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.Extensions.DataIngestion; - -namespace aichatweb.Services.Ingestion; - -internal sealed class DocumentReader(DirectoryInfo rootDirectory) : IngestionDocumentReader -{ - private readonly MarkdownReader _markdownReader = new(); - private readonly PdfPigReader _pdfReader = new(); - - public override Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) - { - if (Path.IsPathFullyQualified(identifier)) - { - // Normalize the identifier to its relative path - identifier = Path.GetRelativePath(rootDirectory.FullName, identifier); - } - - mediaType = GetCustomMediaType(source) ?? mediaType; - return base.ReadAsync(source, identifier, mediaType, cancellationToken); - } - - public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) - => mediaType switch - { - "application/pdf" => _pdfReader.ReadAsync(source, identifier, mediaType, cancellationToken), - "text/markdown" => _markdownReader.ReadAsync(source, identifier, mediaType, cancellationToken), - _ => throw new InvalidOperationException($"Unsupported media type '{mediaType}'"), - }; - - private static string? GetCustomMediaType(FileInfo source) - => source.Extension switch - { - ".md" => "text/markdown", - _ => null - }; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs deleted file mode 100644 index f6de539eb22..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.Extensions.DataIngestion; -using UglyToad.PdfPig; -using UglyToad.PdfPig.Content; -using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; -using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; - -namespace aichatweb.Services.Ingestion; - -internal sealed class PdfPigReader : IngestionDocumentReader -{ - public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) - { - using var pdf = PdfDocument.Open(source); - var document = new IngestionDocument(identifier); - foreach (var page in pdf.GetPages()) - { - document.Sections.Add(GetPageSection(page)); - } - return Task.FromResult(document); - } - - private static IngestionDocumentSection GetPageSection(Page pdfPage) - { - var section = new IngestionDocumentSection - { - PageNumber = pdfPage.Number, - }; - - var letters = pdfPage.Letters; - var words = NearestNeighbourWordExtractor.Instance.GetWords(letters); - - foreach (var textBlock in DocstrumBoundingBoxes.Instance.GetBlocks(words)) - { - section.Elements.Add(new IngestionDocumentParagraph(textBlock.Text) - { - Text = textBlock.Text - }); - } - - return section; - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs deleted file mode 100644 index 8072f8bcddb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs +++ /dev/null @@ -1,27 +0,0 @@ -using aichatweb.Services.Ingestion; -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Services; - -public class SemanticSearch( - VectorStoreCollection vectorCollection, - [FromKeyedServices("ingestion_directory")] DirectoryInfo ingestionDirectory, - DataIngestor dataIngestor) -{ - private Task? _ingestionTask; - - public async Task LoadDocumentsAsync() => await ( _ingestionTask ??= dataIngestor.IngestDataAsync(ingestionDirectory, searchPattern: "*.*")); - - public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) - { - // Ensure documents have been loaded before searching - await LoadDocumentsAsync(); - - var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions - { - Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, - }); - - return await nearest.Select(result => result.Record).ToListAsync(); - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/aichatweb.csproj deleted file mode 100644 index a4536e86ee6..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/aichatweb.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - {00000000-0000-0000-0000-000000000000} - - - - - - - - - - - - - - diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.Development.json deleted file mode 100644 index e22bd83cf3a..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.json deleted file mode 100644 index d286041f99d..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "AllowedHosts": "*" -} From df7eb8d3945fd5485f8c0874ae33643065fe4dcb Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 05:30:59 -0700 Subject: [PATCH 02/10] Remove GitHub Models from aiagent-webapi template Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../.template.config/template.json | 10 -- .../AIAgentWebApi-CSharp.csproj-in | 2 +- .../templates/AIAgentWebApi-CSharp/Program.cs | 18 +-- .../templates/AIAgentWebApi-CSharp/README.md | 57 +------- src/ProjectTemplates/README.md | 4 +- .../AIAgentWebAPIExecutionTests.cs | 2 +- .../AIAgentWebAPISnapshotTests.cs | 3 +- .../aiagent-webapi/Program.cs | 67 --------- .../Properties/launchSettings.json | 25 ---- .../aiagent-webapi/README.md | 129 ------------------ .../aiagent-webapi/aiagent-webapi.csproj | 20 --- .../aiagent-webapi/appsettings.json | 9 -- 12 files changed, 14 insertions(+), 332 deletions(-) delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Properties/launchSettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/appsettings.json diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/.template.config/template.json index b534bdc52be..3db05881b08 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/.template.config/template.json @@ -61,18 +61,12 @@ "type": "parameter", "displayName": "_AI service provider", "datatype": "choice", - "defaultValue": "githubmodels", "choices": [ { "choice": "azureopenai", "displayName": "Azure OpenAI", "description": "Uses Azure OpenAI service" }, - { - "choice": "githubmodels", - "displayName": "GitHub Models", - "description": "Uses GitHub Models" - }, { "choice": "ollama", "displayName": "Ollama (for local development)", @@ -110,10 +104,6 @@ "type": "computed", "value": "(AiServiceProvider == \"openai\")" }, - "IsGHModels": { - "type": "computed", - "value": "(AiServiceProvider == \"githubmodels\")" - }, "IsOllama": { "type": "computed", "value": "(AiServiceProvider == \"ollama\")" diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/AIAgentWebApi-CSharp.csproj-in b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/AIAgentWebApi-CSharp.csproj-in index b808f360513..6c1fff69daa 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/AIAgentWebApi-CSharp.csproj-in +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/AIAgentWebApi-CSharp.csproj-in @@ -15,7 +15,7 @@ - + diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs index 3702afef4cd..7bc6241dcd9 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs @@ -1,4 +1,4 @@ -#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) +#if (IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) using System.ClientModel; #elif (IsAzureOpenAI && IsManagedIdentity) using System.ClientModel.Primitives; @@ -15,26 +15,16 @@ #if (IsOllama) using OllamaSharp; #endif -#if (IsGHModels || IsAzureOpenAI) +#if (IsAzureOpenAI) using OpenAI; #endif -#if (IsGHModels || IsOpenAI || IsAzureOpenAI) +#if (IsOpenAI || IsAzureOpenAI) using OpenAI.Chat; #endif var builder = WebApplication.CreateBuilder(args); -#if (IsGHModels) -// You will need to set the token to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" -var chatClient = new ChatClient( - "gpt-4o-mini", - new ApiKeyCredential(builder.Configuration["GITHUB_TOKEN"] ?? throw new InvalidOperationException("Missing configuration: GITHUB_TOKEN")), - new OpenAIClientOptions { Endpoint = new Uri("https://models.inference.ai.azure.com") }) - .AsIChatClient(); -#elif (IsOpenAI) +#if (IsOpenAI) // You will need to set the API key to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md index eb0a525f12a..8dd93d1ecca 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md @@ -4,9 +4,7 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ## Prerequisites - -- A GitHub Models API token (free to get started) - + - An OpenAI API key - An Azure OpenAI service deployment @@ -18,40 +16,7 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ### 1. Configure Your AI Service - -#### GitHub Models Configuration - -This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: - -**Option A: Using User Secrets (Recommended for Development)** - -```bash -dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" -``` - -**Option B: Using Environment Variables** - -Set the `GITHUB_TOKEN` environment variable: - -- **Windows (PowerShell)**: - ```powershell - $env:GITHUB_TOKEN = "your-github-models-token-here" - ``` - -- **Linux/macOS**: - ```bash - export GITHUB_TOKEN="your-github-models-token-here" - ``` - -#### Get a GitHub Models Token - -1. Visit [GitHub Models](https://github.com/marketplace/models) -2. Sign in with your GitHub account -3. Select a model (e.g., gpt-4o-mini) -4. Click "Get API Key" or follow the authentication instructions -5. Copy your personal access token - - + #### OpenAI Configuration This application uses the OpenAI Platform (model: gpt-4o-mini). You'll need to configure your OpenAI API key: @@ -198,13 +163,12 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) @@ -223,9 +187,7 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 - [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) - [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) - -- [GitHub Models](https://github.com/marketplace/models) - + - [OpenAI Platform](https://platform.openai.com) - [Azure OpenAI Service](https://azure.microsoft.com/products/ai-services/openai-service) @@ -235,16 +197,7 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ## Troubleshooting - -**Problem**: Application fails with "Missing configuration: GITHUB_TOKEN" - -**Solution**: Make sure you've configured your GitHub Models API token using one of the methods described above. - -**Problem**: API requests fail with authentication errors - -**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. - - + **Problem**: Application fails with "Missing configuration: OPENAI_KEY" **Solution**: Make sure you've configured your OpenAI API key using one of the methods described above. diff --git a/src/ProjectTemplates/README.md b/src/ProjectTemplates/README.md index 69c052282ad..d4dad3ab88a 100644 --- a/src/ProjectTemplates/README.md +++ b/src/ProjectTemplates/README.md @@ -140,13 +140,13 @@ Finally, create a project from the template and run it: ```pwsh dotnet new aiagent-webapi ` - [--provider ] ` + [--provider ] ` [--managed-identity] # or dotnet new aichatweb ` - [--provider ] ` + [--provider ] ` [--vector-store ] ` [--aspire] ` [--managed-identity] diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs index 68674e9016e..dca4e7769f8 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs @@ -36,7 +36,7 @@ public AIAgentWebAPIExecutionTests(TemplateExecutionTestFixture fixture, ITestOu public static IEnumerable GetSupportedProjectConfigurations() { (string name, string[] values)[] allOptionValues = [ - ("--provider", ["azureopenai", "githubmodels", "ollama", "openai"]), + ("--provider", ["azureopenai", "ollama", "openai"]), ("--managed-identity", ["true", "false"]), ("--framework", ["net8.0", "net9.0", "net10.0"]) ]; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs index 8285ab383d7..1cb35bf7167 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs @@ -22,9 +22,8 @@ public AIAgentWebAPISnapshotTests(ITestOutputHelper log) } [Theory] - [InlineData /* Defaults: --provider=githubmodels */] - [InlineData("--provider=ollama")] [InlineData("--provider=openai")] + [InlineData("--provider=ollama")] [InlineData("--provider=azureopenai", "--managed-identity=true")] [InlineData("--provider=azureopenai", "--managed-identity=false")] public async Task RunSnapshotTests(params string[] templateArgs) diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs deleted file mode 100644 index 2744b8bb8af..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.ClientModel; -using System.ComponentModel; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.DevUI; -using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; -using OpenAI; -using OpenAI.Chat; - -var builder = WebApplication.CreateBuilder(args); - -// You will need to set the token to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" -var chatClient = new ChatClient( - "gpt-4o-mini", - new ApiKeyCredential(builder.Configuration["GITHUB_TOKEN"] ?? throw new InvalidOperationException("Missing configuration: GITHUB_TOKEN")), - new OpenAIClientOptions { Endpoint = new Uri("https://models.inference.ai.azure.com") }) - .AsIChatClient(); - -builder.Services.AddChatClient(chatClient); - -builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); - -builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( - chatClient, - name: key, - instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", - tools: [AIFunctionFactory.Create(FormatStory)] -)); - -builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( - workflowName: key, - agents: - [ - sp.GetRequiredKeyedService("writer"), - sp.GetRequiredKeyedService("editor") - ] -)).AddAsAIAgent("publisher-agent"); - -// Register services for OpenAI responses and conversations (also required for DevUI) -builder.Services.AddOpenAIResponses(); -builder.Services.AddOpenAIConversations(); - -var app = builder.Build(); -app.UseHttpsRedirection(); - -// Map endpoints for OpenAI responses and conversations (also required for DevUI) -app.MapOpenAIResponses(); -app.MapOpenAIConversations(); - -if (builder.Environment.IsDevelopment()) -{ - // Map DevUI endpoint to /devui - app.MapDevUI(); -} - -app.Run(); - -[Description("Formats the story for publication, revealing its title.")] -string FormatStory(string title, string story) => $""" - **Title**: {title} - - {story} - """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Properties/launchSettings.json deleted file mode 100644 index d71b1876d35..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Properties/launchSettings.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "devui/", - "applicationUrl": "http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "devui/", - "applicationUrl": "https://localhost:9995;http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md deleted file mode 100644 index 8ea2640ad89..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# AI Agent Web API - -This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). - -## Prerequisites - -- A GitHub Models API token (free to get started) - -## Getting Started - -### 1. Configure Your AI Service - -#### GitHub Models Configuration - -This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: - -**Option A: Using User Secrets (Recommended for Development)** - -```bash -dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" -``` - -**Option B: Using Environment Variables** - -Set the `GITHUB_TOKEN` environment variable: - -- **Windows (PowerShell)**: - ```powershell - $env:GITHUB_TOKEN = "your-github-models-token-here" - ``` - -- **Linux/macOS**: - ```bash - export GITHUB_TOKEN="your-github-models-token-here" - ``` - -#### Get a GitHub Models Token - -1. Visit [GitHub Models](https://github.com/marketplace/models) -2. Sign in with your GitHub account -3. Select a model (e.g., gpt-4o-mini) -4. Click "Get API Key" or follow the authentication instructions -5. Copy your personal access token - - -### 2. Run the Application - -```bash -dotnet run -lp https -``` - -The application will start and listen on: -- HTTP: `http://localhost:9996` -- HTTPS: `https://localhost:9995` - -### 3. Test the Application - -The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. - -In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. - -## How It Works - -This application demonstrates Agent Framework with: - -1. **Writer Agent**: Writes short stories (300 words or less) about specified topics -2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words -3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents - -The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. - -## Template Parameters - -When creating a new project, you can customize it using template parameters: - -```bash -# Specify AI service provider -dotnet new aiagent-webapi --provider azureopenai - -# Specify a custom chat model -dotnet new aiagent-webapi --chat-model gpt-4o - -# Use API key authentication for Azure OpenAI -dotnet new aiagent-webapi --provider azureopenai --managed-identity false - -# Use Ollama with a different model -dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 -``` - -### Available Parameters - -- **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models - - `azureopenai` - Azure OpenAI - - `openai` - OpenAI Platform - - `ollama` - Ollama (local development) - -- **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` - - Default for Ollama: `llama3.2` - -- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) - - Only applicable when `--provider azureopenai` - -- **`--framework`**: Target framework (default: `net10.0`) - - Options: `net10.0`, `net9.0`, `net8.0` - -## Project Structure - -- `Program.cs` - Application entry point and configuration -- `appsettings.json` - Application configuration -- `Properties/launchSettings.json` - Launch profiles for development - -## Learn More - -- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) -- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) -- [GitHub Models](https://github.com/marketplace/models) - -## Troubleshooting - -**Problem**: Application fails with "Missing configuration: GITHUB_TOKEN" - -**Solution**: Make sure you've configured your GitHub Models API token using one of the methods described above. - -**Problem**: API requests fail with authentication errors - -**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. - diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj deleted file mode 100644 index ba6fd29474f..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net10.0 - enable - enable - {00000000-0000-0000-0000-000000000000} - - - - - - - - - - - - - diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/appsettings.json deleted file mode 100644 index 223027717b4..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} From 56e07a5d50f15f6bca1b1a59b616ceaef1e485cb Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 24 Jul 2026 17:13:55 -0700 Subject: [PATCH 03/10] Introduce MockChatClient in a new Microsoft.Extensions.AI.Testing library Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../references/package-areas.md | 1 + .../Microsoft.Extensions.AI.Testing.csproj | 37 ++ .../MockChatClient.cs | 436 ++++++++++++++++++ .../MockChatClientRequest.cs | 59 +++ .../Microsoft.Extensions.AI.Testing/README.md | 174 +++++++ ...crosoft.Extensions.AI.Testing.Tests.csproj | 11 + .../MockChatClientTests.cs | 384 +++++++++++++++ 7 files changed, 1102 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/README.md create mode 100644 test/Libraries/Microsoft.Extensions.AI.Testing.Tests/Microsoft.Extensions.AI.Testing.Tests.csproj create mode 100644 test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs diff --git a/.github/agents/release-manager/write-release-notes/references/package-areas.md b/.github/agents/release-manager/write-release-notes/references/package-areas.md index 9d8203951da..bdbce611e8f 100644 --- a/.github/agents/release-manager/write-release-notes/references/package-areas.md +++ b/.github/agents/release-manager/write-release-notes/references/package-areas.md @@ -10,6 +10,7 @@ Packages: - `Microsoft.Extensions.AI` - `Microsoft.Extensions.AI.Abstractions` - `Microsoft.Extensions.AI.OpenAI` +- `Microsoft.Extensions.AI.Testing` ### AI Evaluation diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj new file mode 100644 index 00000000000..c13459e7461 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj @@ -0,0 +1,37 @@ + + + + Microsoft.Extensions.AI + Deterministic mock implementations for testing applications built on Microsoft.Extensions.AI. + AI + Testing + $(PackageTags);Testing;AI;Chat + true + + + + dev + EXTEXP0019 + false + n/a + n/a + + + + $(TargetFrameworks);netstandard2.0 + $(NoWarn);MEAI001 + true + true + + + + true + true + true + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs new file mode 100644 index 00000000000..e5f05422f8b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs @@ -0,0 +1,436 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A deterministic implementation for tests and local mock scenarios, +/// using seeded responses based on request predicates. +/// +/// +/// +/// This client starts with no seeded responses. Each request is matched against seeded responses +/// in reverse insertion order, and the most recently added match is used. +/// +/// +/// Seeds are reusable by default. Set singleUse: true to remove a seed after its first match. +/// +/// +/// If no seed matches a request, an is thrown. Response factories +/// can return fully populated and payloads to model +/// citations, reasoning, tool calls, usage, metadata, errors, and other chat-client behavior. +/// +/// +public class MockChatClient : IChatClient +{ + private static ChatResponse CloneResponse(ChatResponse response) + { + var clone = new ChatResponse(response.Messages.Select(message => message.Clone()).ToList()) + { + AdditionalProperties = response.AdditionalProperties?.Clone(), + ContinuationToken = response.ContinuationToken, + ConversationId = response.ConversationId, + CreatedAt = response.CreatedAt, + FinishReason = response.FinishReason, + ModelId = response.ModelId, + RawRepresentation = response.RawRepresentation, + ResponseId = response.ResponseId, + Usage = response.Usage, + }; + + return clone; + } + + private static async IAsyncEnumerable EnumerateUpdatesAsync( + IEnumerable updates, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (ChatResponseUpdate update in updates) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return Throw.IfNull(update).Clone(); + } + } + + private static async IAsyncEnumerable EnumerateUpdatesAsync( + IAsyncEnumerable updates, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (ChatResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + yield return Throw.IfNull(update).Clone(); + } + } + + private static async IAsyncEnumerable GetResponseUpdatesAsync( + Func> getResponse, + MockChatClientRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ChatResponse response = Throw.IfNull(await getResponse(request, cancellationToken).ConfigureAwait(false)); + await foreach (ChatResponseUpdate update in EnumerateUpdatesAsync(response.ToChatResponseUpdates(), cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + private static Task GetResponseFromUpdatesAsync( + Func> getResponse, + MockChatClientRequest request, + CancellationToken cancellationToken) => + EnumerateUpdatesAsync(Throw.IfNull(getResponse(request, cancellationToken)), cancellationToken) + .ToChatResponseAsync(cancellationToken); + + private static async IAsyncEnumerable ThrowExceptionAsync( + Func exceptionFactory, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.FromException(Throw.IfNull(exceptionFactory())).ConfigureAwait(false); + yield break; + } + + private readonly object _sync = new(); + private readonly List _seededResponses = []; + private readonly List _requests = []; + private bool _disposed; + + /// + /// Gets or sets an optional service provider surfaced through . + /// + /// + /// This provider is queried only for non-keyed lookups when the requested service type is not + /// itself. + /// + public IServiceProvider? Services { get; set; } + + /// + /// Gets a snapshot of requests previously sent to this instance. + /// + /// + /// Requests are recorded in call order across both streaming and non-streaming APIs. + /// + public IReadOnlyList Requests + { + get + { + lock (_sync) + { + return _requests.ToArray(); + } + } + } + + /// Seeds a response factory for matching requests. + /// Predicate used to select whether this seed applies to a request. + /// + /// Asynchronously creates the response for a matching request. The supplied cancellation token is the one passed + /// to the matching chat-client call. + /// + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + /// + /// If is called + /// for a matching request, the response is converted to updates by using the chat response update conversion helpers. + /// + /// + /// or is . + /// + /// This instance has been disposed. + public virtual MockChatClient AddResponse( + Func requestPredicate, + Func> getResponse, + bool singleUse = false) => + AddSeed( + requestPredicate, + getResponse, + (request, cancellationToken) => GetResponseUpdatesAsync(getResponse, request, cancellationToken), + singleUse); + + /// Seeds non-streaming and streaming response factories for matching requests. + /// Predicate used to select whether this seed applies to a request. + /// + /// Asynchronously creates the response for a matching non-streaming request. The supplied cancellation token is + /// the one passed to the matching chat-client call. + /// + /// + /// Asynchronously creates the updates for a matching streaming request. The supplied cancellation token is the + /// one passed to the matching chat-client call. + /// + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + /// + /// , , or + /// is . + /// + /// This instance has been disposed. + public virtual MockChatClient AddResponse( + Func requestPredicate, + Func> getResponse, + Func> getStreamingResponse, + bool singleUse = false) => + AddSeed(requestPredicate, getResponse, getStreamingResponse, singleUse); + + /// Seeds a streaming response factory for matching requests. + /// Predicate used to select whether this seed applies to a request. + /// + /// Asynchronously creates the updates for a matching streaming request. The supplied cancellation token is the + /// one passed to the matching chat-client call. + /// + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + /// + /// If is called for a + /// matching request, these updates are converted to a single response by using the chat response conversion helpers. + /// + /// + /// or is . + /// + /// This instance has been disposed. + public virtual MockChatClient AddStreamingResponse( + Func requestPredicate, + Func> getResponse, + bool singleUse = false) => + AddSeed( + requestPredicate, + (request, cancellationToken) => GetResponseFromUpdatesAsync(getResponse, request, cancellationToken), + getResponse, + singleUse); + + /// Seeds an exception for matching requests. + /// Predicate used to select whether this seed applies to a request. + /// Creates the exception to throw for each matching request. + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + /// + /// or is . + /// + /// This instance has been disposed. + public virtual MockChatClient AddException( + Func requestPredicate, + Func exceptionFactory, + bool singleUse = false) + { + _ = Throw.IfNull(exceptionFactory); + + return AddSeed( + requestPredicate, + (_, _) => Task.FromException(Throw.IfNull(exceptionFactory())), + (_, cancellationToken) => ThrowExceptionAsync(exceptionFactory, cancellationToken), + singleUse); + } + + /// Removes all seeded responses and exceptions. + /// + /// This method does not clear . + /// + /// The same instance for chaining. + /// This instance has been disposed. + public virtual MockChatClient ClearResponses() + { + ThrowIfDisposed(); + + lock (_sync) + { + _seededResponses.Clear(); + } + + return this; + } + + /// + /// Returns a response for the most recently seeded match and records the request. + /// + /// The request messages. + /// Request options, if any. + /// The cancellation token. + /// A deterministic response for the most recently seeded match. + /// is . + /// No seeded response matched the request. + /// This instance has been disposed. + public virtual async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + ThrowIfDisposed(); + + MockChatClientRequest request = RecordRequest(messages, options, isStreaming: false); + SeededResponse seeded = MatchSeededResponse(request); + return CloneResponse(Throw.IfNull(await seeded.ResponseFactory(request, cancellationToken).ConfigureAwait(false))); + } + + /// + /// Returns streaming updates for the most recently seeded match and records the request. + /// + /// The request messages. + /// Request options, if any. + /// The cancellation token. + /// A deterministic update stream for the most recently seeded match. + /// is . + /// No seeded response matched the request. + /// This instance has been disposed. + public virtual IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + ThrowIfDisposed(); + + MockChatClientRequest request = RecordRequest(messages, options, isStreaming: true); + SeededResponse seeded = MatchSeededResponse(request); + return EnumerateUpdatesAsync(Throw.IfNull(seeded.StreamingFactory(request, cancellationToken)), cancellationToken); + } + + /// + /// Gets a service object from this client or the optional provider. + /// + /// The type of service object to get. + /// An optional service key. Keyed lookup is not supported. + /// + /// This instance when is assignable from ; + /// otherwise a non-keyed service from when available; otherwise . + /// + /// is . + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + if (serviceKey is null) + { + if (serviceType.IsInstanceOfType(this)) + { + return this; + } + + return Services?.GetService(serviceType); + } + + return null; + } + + /// Disposes this instance and clears all seeds and recorded requests. + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Releases resources used by this mock client. + /// when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + lock (_sync) + { + _disposed = true; + _seededResponses.Clear(); + _requests.Clear(); + } + } + + private MockChatClient AddSeed( + Func requestPredicate, + Func> getResponse, + Func> getStreamingResponse, + bool singleUse) + { + _ = Throw.IfNull(requestPredicate); + _ = Throw.IfNull(getResponse); + _ = Throw.IfNull(getStreamingResponse); + + lock (_sync) + { + ThrowIfDisposed(); + + _seededResponses.Add(new SeededResponse + { + RequestPredicate = requestPredicate, + ResponseFactory = getResponse, + StreamingFactory = getStreamingResponse, + SingleUse = singleUse, + }); + } + + return this; + } + + private MockChatClientRequest RecordRequest(IEnumerable messages, ChatOptions? options, bool isStreaming) + { + ChatMessage[] messageArray = messages.Select(m => Throw.IfNull(m).Clone()).ToArray(); + var request = new MockChatClientRequest(messageArray, options, isStreaming); + + lock (_sync) + { + _requests.Add(request); + } + + return request; + } + + private SeededResponse MatchSeededResponse(MockChatClientRequest request) + { + lock (_sync) + { + for (int i = _seededResponses.Count - 1; i >= 0; i--) + { + SeededResponse seeded = _seededResponses[i]; + if (!seeded.RequestPredicate(request)) + { + continue; + } + + if (seeded.SingleUse) + { + _seededResponses.RemoveAt(i); + } + + return seeded; + } + } + + throw new InvalidOperationException( + $"No seeded response matched. Last user text: '{request.LastUserText ?? ""}'."); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MockChatClient)); + } + } + + private sealed class SeededResponse + { + public Func RequestPredicate { get; init; } = default!; + + public Func> ResponseFactory { get; init; } = default!; + + public Func> StreamingFactory { get; init; } = default!; + + public bool SingleUse { get; init; } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs new file mode 100644 index 00000000000..b7d4c51e228 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents one request invocation captured by . +/// +/// +/// Instances are created by when +/// or +/// +/// is called. +/// +public sealed class MockChatClientRequest +{ + /// Initializes a new instance of the class. + /// The request messages in call order. + /// The request options, if any. + /// + /// when the request came from + /// . + /// + /// is . + public MockChatClientRequest(IReadOnlyList messages, ChatOptions? options, bool isStreaming) + { + Messages = Throw.IfNull(messages); + Options = options; + IsStreaming = isStreaming; + } + + /// Gets the request messages. + /// + /// For requests recorded by , each message is cloned when captured so later changes + /// to original message instances do not affect stored request history. + /// + public IReadOnlyList Messages { get; } + + /// Gets the request options. + public ChatOptions? Options { get; } + + /// + /// Gets a value indicating whether this request originated from the streaming chat API. + /// + public bool IsStreaming { get; } + + /// + /// Gets the text of the last user message in , if available. + /// + /// + /// Returns when there is no user message, or when the last user message has no text. + /// + public string? LastUserText => + Messages.LastOrDefault(m => m.Role == ChatRole.User)?.Text; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md new file mode 100644 index 00000000000..ee816ca67d9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md @@ -0,0 +1,174 @@ +# Microsoft.Extensions.AI.Testing + +Deterministic test doubles for applications built on `Microsoft.Extensions.AI`. + +> [!IMPORTANT] +> This package is for **mocking and deterministic testing** of chat and embedding behavior. + +## Install + +```console +dotnet add package Microsoft.Extensions.AI.Testing +``` + +Or in a project file: + +```xml + + + +``` + +## What you get + +- `MockChatClient` (`IChatClient`) for deterministic, request-aware chat responses. +- `MockChatClientRequest` for request matching and request-history inspection. + +## MockChatClient behavior + +| Behavior | Details | +| --- | --- | +| Starts empty | No responses are predefined. | +| Match order | Responses are checked in reverse insertion order; the most recently added matching response wins. | +| Consumption | Seeds are reusable by default. | +| One-time seed | Set `singleUse: true` to remove a seed after its first match. | +| Unmatched request | Throws `InvalidOperationException` with the last user message text. | +| History | Every request is captured in `client.Requests` (`Messages`, `Options`, `IsStreaming`). | +| Streaming/non-streaming | Supports both `GetResponseAsync` and `GetStreamingResponseAsync`. | + +## Quick start + +`MockChatClient` accepts a required predicate plus a cancellable asynchronous response factory. The factory can inspect the captured request and receives the cancellation token from the matching chat call. + +```csharp +using Microsoft.Extensions.AI; + +var client = new MockChatClient() + .AddResponse( + static _ => true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Fallback response."))), + singleUse: true) + .AddResponse( + static request => request.LastUserText?.Contains("hello", StringComparison.OrdinalIgnoreCase) is true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello from a deterministic mock.")))); + +ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "hello")]); +Console.WriteLine(response.Text); +``` + +## Seeding patterns + +### Non-streaming response + +```csharp +client.AddResponse( + static request => request.LastUserText == "explain", + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer")))); +``` + +### Streaming response + +```csharp +using System.Runtime.CompilerServices; + +client.AddStreamingResponse( + static request => request.LastUserText == "stream", + static (_, cancellationToken) => GetUpdatesAsync(cancellationToken)); + +static async IAsyncEnumerable GetUpdatesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) +{ + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 1 "); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 2"); +} +``` + +### Distinct streaming and non-streaming responses + +```csharp +client.AddResponse( + static request => request.LastUserText == "both", + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer"))), + static (_, cancellationToken) => GetUpdatesAsync(cancellationToken)); +``` + +## Exercise rich chat features + +`MockChatClient` does not synthesize model output; factories return the payloads you choose. Seed fully populated `ChatResponse` and `ChatResponseUpdate` payloads to exercise: + +- citations and other annotations +- reasoning content +- tool calls and tool results +- usage fields +- additional metadata + +For example, a response can include citations, reasoning, and usage: + +```csharp +client.AddResponse( + static request => request.LastUserText == "sources", + static (_, _) => Task.FromResult( + new ChatResponse( + [ + new ChatMessage( + ChatRole.Assistant, + [ + new TextContent("TrailMaster tracks your progress.") + { + Annotations = + [ + new CitationAnnotation + { + Title = "Example_GPS_Watch.md", + Snippet = "track your progress", + } + ] + }, + new TextReasoningContent("The document describes tracking features.") + ]) + ]) + { + Usage = new UsageDetails { InputTokenCount = 3, OutputTokenCount = 7 } + })); +``` + +## Assert what your app sent + +```csharp +MockChatClientRequest first = client.Requests[0]; +string? lastUserText = first.LastUserText; +bool wasStreaming = first.IsStreaming; +``` + +This is useful for validating prompts, routing logic, and chat options. + +## Clear and reseed + +`ClearResponses()` removes all response and exception seeds while preserving captured request history: + +```csharp +client.ClearResponses() + .AddResponse( + static _ => true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "New test scenario.")))); +``` + +## Mock errors + +Use `AddException` to model a failing provider. The factory creates a new exception for each matching request: + +```csharp +client.AddException( + static request => request.LastUserText == "UNAVAILABLE", + static () => new HttpRequestException("The provider is temporarily unavailable.")); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/Microsoft.Extensions.AI.Testing.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/Microsoft.Extensions.AI.Testing.Tests.csproj new file mode 100644 index 00000000000..c9701755473 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/Microsoft.Extensions.AI.Testing.Tests.csproj @@ -0,0 +1,11 @@ + + + Microsoft.Extensions.AI + Unit tests for Microsoft.Extensions.AI.Testing. + $(NoWarn);MEAI001;CA1063;VSTHRD003 + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs new file mode 100644 index 00000000000..3e1b7005035 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs @@ -0,0 +1,384 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class MockChatClientTests +{ + [Fact] + public async Task AddResponse_MatchesMostRecentlyAddedSeed_AndFallsBackAfterSingleUseSeed() + { + using var client = new MockChatClient(); + client + .AddResponse(static _ => true, static (_, _) => CreateResponseAsync("first"), singleUse: true) + .AddResponse(static _ => true, static (_, _) => CreateResponseAsync("second"), singleUse: true); + + Assert.Equal("second", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("first", (await client.GetResponseAsync([new(ChatRole.User, "hello again")])).Text); + + var ex = await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "third request")])); + + Assert.Contains("third request", ex.Message); + } + + [Fact] + public async Task AddResponse_IsReusableByDefault() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => CreateResponseAsync("stable")); + + Assert.Equal("stable", (await client.GetResponseAsync([new(ChatRole.User, "first")])).Text); + Assert.Equal("stable", (await client.GetResponseAsync([new(ChatRole.User, "second")])).Text); + } + + [Fact] + public async Task AddResponse_FactoryCanInspectRequestAndCancellationToken() + { + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken observedToken = default; + using var client = new MockChatClient(); + client.AddResponse( + static request => request.LastUserText?.Contains("weather", StringComparison.OrdinalIgnoreCase) is true, + (request, cancellationToken) => + { + observedToken = cancellationToken; + return CreateResponseAsync($"{request.LastUserText} branch"); + }); + + ChatResponse response = await client.GetResponseAsync( + [new(ChatRole.User, "What's the weather today?")], + cancellationToken: cancellationTokenSource.Token); + + Assert.Equal("What's the weather today? branch", response.Text); + Assert.Equal(cancellationTokenSource.Token, observedToken); + } + + [Fact] + public async Task AddResponse_FactoryCanStageFunctionCallsUsingFunctionResults() + { + const string LoadDocumentsCallId = "load-documents"; + const string SearchCallId = "search"; + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (request, _) => Task.FromResult(CreateToolResponse(request))); + + ChatMessage userMessage = new(ChatRole.User, "What does TrailMaster track?"); + + ChatResponse loadDocuments = await client.GetResponseAsync([userMessage]); + FunctionCallContent loadDocumentsCall = Assert.IsType( + Assert.Single(Assert.Single(loadDocuments.Messages).Contents)); + Assert.Equal("LoadDocuments", loadDocumentsCall.Name); + + var loadDocumentsResult = new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(LoadDocumentsCallId, "Success: Function completed.")]); + ChatResponse search = await client.GetResponseAsync([userMessage, loadDocuments.Messages[0], loadDocumentsResult]); + FunctionCallContent searchCall = Assert.IsType( + Assert.Single(Assert.Single(search.Messages).Contents)); + Assert.Equal("Search", searchCall.Name); + Assert.Equal(userMessage.Text, searchCall.Arguments!["searchPhrase"]); + + var searchResult = new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(SearchCallId, new[] { "distance traveled speed elevation gain heart rate" })]); + ChatResponse final = await client.GetResponseAsync( + [userMessage, loadDocuments.Messages[0], loadDocumentsResult, search.Messages[0], searchResult]); + + Assert.Equal("Final answer", final.Text); + + static ChatResponse CreateToolResponse(MockChatClientRequest request) + { + if (HasFunctionResult(request, SearchCallId)) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "Final answer")); + } + + if (HasFunctionResult(request, LoadDocumentsCallId)) + { + return new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent( + SearchCallId, + "Search", + new Dictionary + { + ["searchPhrase"] = request.LastUserText, + })])); + } + + return new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(LoadDocumentsCallId, "LoadDocuments")])); + } + + static bool HasFunctionResult(MockChatClientRequest request, string callId) => + request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(result => result.CallId == callId); + } + + [Fact] + public async Task AddResponse_PropagatesFactoryCancellation() + { + using var client = new MockChatClient(); + client.AddResponse( + static _ => true, + static async (_, cancellationToken) => + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "unreachable")); + }); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => + client.GetResponseAsync([new(ChatRole.User, "cancel")], cancellationToken: cancellationTokenSource.Token)); + } + + [Fact] + public async Task AddStreamingResponse_SupportsNonStreamingConversion_WithCitationsReasoningAndUsage() + { + var text = new TextContent("Final answer") + { + Annotations = + [ + new CitationAnnotation + { + Title = "Example_GPS_Watch.md", + Snippet = "track your progress", + } + ] + }; + + ChatResponseUpdate[] updates = + [ + new(ChatRole.Assistant, [text, new TextReasoningContent("Reasoning step")]) + { + ConversationId = "conversation-1", + ResponseId = "response-1", + ModelId = "mock-model", + FinishReason = ChatFinishReason.Stop, + }, + new() + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 3, OutputTokenCount = 7 })] + } + ]; + + using var client = new MockChatClient(); + client.AddStreamingResponse( + static _ => true, + (_, _) => EnumerateUpdatesAsync(updates)); + + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "question")]); + ChatMessage message = Assert.Single(response.Messages); + + var textContent = Assert.IsType(message.Contents[0]); + Assert.Equal("Final answer", textContent.Text); + var annotation = Assert.IsType(Assert.Single(textContent.Annotations!)); + Assert.Equal("Example_GPS_Watch.md", annotation.Title); + Assert.Equal("track your progress", annotation.Snippet); + + Assert.Equal("Reasoning step", Assert.IsType(message.Contents[1]).Text); + Assert.Equal("conversation-1", response.ConversationId); + Assert.Equal("response-1", response.ResponseId); + Assert.Equal("mock-model", response.ModelId); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + Assert.Equal(3, response.Usage!.InputTokenCount); + Assert.Equal(7, response.Usage.OutputTokenCount); + } + + [Fact] + public async Task AddResponse_SupportsStreamingConversion() + { + var text = new TextContent("Streaming text") + { + Annotations = + [ + new CitationAnnotation + { + Title = "Example_Emergency_Survival_Kit.pdf", + Snippet = "water purification tablets", + } + ] + }; + + var response = new ChatResponse( + [ + new ChatMessage(ChatRole.Assistant, [text, new TextReasoningContent("Think")]) + ]) + { + Usage = new UsageDetails { OutputTokenCount = 5 }, + ConversationId = "conversation-2", + }; + + using var client = new MockChatClient(); + client.AddResponse( + static _ => true, + (_, _) => Task.FromResult(response)); + + List updates = await ToListAsync( + client.GetStreamingResponseAsync([new(ChatRole.User, "stream this")])); + + Assert.Equal(2, updates.Count); // message + usage + Assert.Equal("conversation-2", updates[0].ConversationId); + Assert.Equal("Streaming text", updates[0].Text); + + var streamedText = Assert.IsType(updates[0].Contents[0]); + Assert.Equal("Streaming text", streamedText.Text); + Assert.Equal("Example_Emergency_Survival_Kit.pdf", Assert.IsType(Assert.Single(streamedText.Annotations!)).Title); + Assert.Equal("Think", Assert.IsType(updates[0].Contents[1]).Text); + + Assert.IsType(Assert.Single(updates[1].Contents)); + } + + [Fact] + public async Task AddResponse_UsesDistinctStreamingFactory() + { + using var client = new MockChatClient(); + client.AddResponse( + static _ => true, + static (_, _) => CreateResponseAsync("non-streaming"), + static (_, _) => EnumerateUpdatesAsync([new(ChatRole.Assistant, "streaming")])); + + Assert.Equal("non-streaming", (await client.GetResponseAsync([new(ChatRole.User, "one")])).Text); + + List updates = await ToListAsync( + client.GetStreamingResponseAsync([new(ChatRole.User, "two")])); + Assert.Equal("streaming", Assert.Single(updates).Text); + } + + [Fact] + public async Task Requests_HistoryCapturesStreamingFlag() + { + using var client = new MockChatClient(); + client + .AddResponse(static _ => true, static (_, _) => CreateResponseAsync("non-streaming"), singleUse: true) + .AddResponse(static _ => true, static (_, _) => CreateResponseAsync("streaming"), singleUse: true); + + _ = await client.GetResponseAsync([new(ChatRole.User, "one")]); + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync([new(ChatRole.User, "two")])) + { + Assert.NotNull(update); + } + + Assert.Equal(2, client.Requests.Count); + Assert.False(client.Requests[0].IsStreaming); + Assert.True(client.Requests[1].IsStreaming); + Assert.Equal("one", client.Requests[0].LastUserText); + Assert.Equal("two", client.Requests[1].LastUserText); + } + + [Fact] + public void GetService_ReturnsSelfWhenRequested() + { + using var client = new MockChatClient(); + + Assert.Same(client, client.GetService(typeof(MockChatClient))); + Assert.Same(client, client.GetService(typeof(IChatClient))); + Assert.Null(client.GetService(typeof(string))); + } + + [Fact] + public async Task Dispose_PreventsFurtherUse() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => CreateResponseAsync("before dispose")); + + client.Dispose(); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "after dispose")])); + } + + [Fact] + public async Task ClearResponses_RemovesSeedsAndPreservesRequests() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => CreateResponseAsync("first")); + + _ = await client.GetResponseAsync([new(ChatRole.User, "first request")]); + Assert.Same(client, client.ClearResponses()); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "second request")])); + + Assert.Equal(2, client.Requests.Count); + } + + [Fact] + public async Task AddException_UsesFreshExceptionForEachRequest() + { + using var client = new MockChatClient(); + client.AddException( + static request => request.LastUserText == "fail", + static () => new InvalidOperationException("expected")); + + InvalidOperationException first = await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "fail")])); + InvalidOperationException second = await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "fail")])); + + Assert.NotSame(first, second); + } + + [Fact] + public async Task AddException_StreamingRequestFaultsDuringEnumeration() + { + using var client = new MockChatClient(); + client.AddException(static _ => true, static () => new InvalidOperationException("expected")); + + await Assert.ThrowsAsync(async () => + { + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync([new(ChatRole.User, "fail")])) + { + Assert.NotNull(update); + } + }); + } + + [Fact] + public void Registration_RequiresRequestPredicate() + { + using var client = new MockChatClient(); + + Assert.Throws(() => + client.AddResponse(null!, static (_, _) => CreateResponseAsync("response"))); + Assert.Throws(() => + client.AddStreamingResponse(null!, static (_, _) => EnumerateUpdatesAsync([]))); + Assert.Throws(() => + client.AddException(null!, static () => new InvalidOperationException())); + } + + private static Task CreateResponseAsync(string text) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); + + private static async IAsyncEnumerable EnumerateUpdatesAsync(IEnumerable updates) + { + foreach (ChatResponseUpdate update in updates) + { + yield return update; + await Task.Yield(); + } + } + + private static async Task> ToListAsync(IAsyncEnumerable updates) + { + var results = new List(); + await foreach (ChatResponseUpdate update in updates) + { + results.Add(update); + } + + return results; + } +} From 5dbfe4067db5e7547aefc2af4e4bdf5d74f058e6 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 24 Jul 2026 15:19:38 -0700 Subject: [PATCH 04/10] Introduce MockEmbeddingGenerator within the MEAI.Testing library Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../references/package-areas.md | 1 - .../MockEmbeddingGenerator.cs | 100 ++++++++++++++++++ .../Microsoft.Extensions.AI.Testing/README.md | 19 ++++ .../DelegatingEmbeddingGeneratorTests.cs | 12 +-- .../EmbeddingGeneratorExtensionsTests.cs | 14 +-- .../Embeddings/GeneratedEmbeddingsTests.cs | 2 +- ...ft.Extensions.AI.Abstractions.Tests.csproj | 1 + .../TestEmbeddingGenerator.cs | 41 ------- .../MockChatClientTests.cs | 74 +++++++++++++ .../Microsoft.Extensions.AI.Tests.csproj | 2 +- .../MockEmbeddingGeneratorAlias.cs | 4 + .../SemanticSimilarityChunkerTests.cs | 18 +++- .../IngestionPipelineTests.cs | 41 ++++--- ...soft.Extensions.DataIngestion.Tests.csproj | 2 +- .../Utils/TestEmbeddingGenerator.cs | 32 ------ .../Writers/InMemoryVectorStoreWriterTests.cs | 3 +- .../Writers/SqliteVectorStoreWriterTests.cs | 3 +- .../Writers/VectorStoreWriterTests.cs | 28 +++-- 18 files changed, 279 insertions(+), 118 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/MockEmbeddingGeneratorAlias.cs delete mode 100644 test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs diff --git a/.github/agents/release-manager/write-release-notes/references/package-areas.md b/.github/agents/release-manager/write-release-notes/references/package-areas.md index bdbce611e8f..9d8203951da 100644 --- a/.github/agents/release-manager/write-release-notes/references/package-areas.md +++ b/.github/agents/release-manager/write-release-notes/references/package-areas.md @@ -10,7 +10,6 @@ Packages: - `Microsoft.Extensions.AI` - `Microsoft.Extensions.AI.Abstractions` - `Microsoft.Extensions.AI.OpenAI` -- `Microsoft.Extensions.AI.Testing` ### AI Evaluation diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs new file mode 100644 index 00000000000..ea05467f0c0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A configurable for tests and local mock scenarios. +/// +/// The type of input accepted by the embedding generator. +/// +/// Configure to produce the embeddings required by a test. The callback receives +/// the original input enumerable and cancellation token. records every generation request. +/// +public class MockEmbeddingGenerator : IEmbeddingGenerator> +{ + private int _callCount; + + /// Initializes a new instance of the class. + public MockEmbeddingGenerator() + { + GetServiceCallback = DefaultGetServiceCallback; + } + + /// Gets or sets the callback that generates embeddings. + /// + /// The callback must be set before calling . + /// + public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>>? GenerateAsyncCallback { get; set; } + + /// Gets or sets the callback that resolves services. + /// + /// By default, this callback returns this generator for compatible, non-keyed requests and + /// for all other requests. + /// + public Func GetServiceCallback { get; set; } + + /// Gets the number of calls to . + public int CallCount => Volatile.Read(ref _callCount); + + /// + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(values); + RecordCall(); + + return GenerateCoreAsync(values, options, cancellationToken); + } + + /// Generates embeddings after the request has been recorded. + /// The values to embed. + /// The generation options. + /// The cancellation token. + /// The generated embeddings. + protected virtual Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + if (GenerateAsyncCallback is not { } callback) + { + throw new InvalidOperationException("No embedding generation callback has been configured."); + } + + return Throw.IfNull(callback(values, options, cancellationToken)); + } + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + return Throw.IfNull(GetServiceCallback)(serviceType, serviceKey); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Releases resources used by this mock generator. + /// when called from . + protected virtual void Dispose(bool disposing) + { + } + + private void RecordCall() => Interlocked.Increment(ref _callCount); + + private MockEmbeddingGenerator? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md index ee816ca67d9..07333b5976c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md @@ -23,6 +23,7 @@ Or in a project file: - `MockChatClient` (`IChatClient`) for deterministic, request-aware chat responses. - `MockChatClientRequest` for request matching and request-history inspection. +- `MockEmbeddingGenerator` (`IEmbeddingGenerator>`) for configurable, deterministic embeddings. ## MockChatClient behavior @@ -169,6 +170,24 @@ client.AddException( static () => new HttpRequestException("The provider is temporarily unavailable.")); ``` +## Mock embeddings + +Configure `MockEmbeddingGenerator` with the embeddings a test needs. Its callback receives the original input enumerable and cancellation token, and `CallCount` records generation requests. + +```csharp +using var embeddings = new MockEmbeddingGenerator +{ + GenerateAsyncCallback = static (_, _, _) => + Task.FromResult>>( + [new(new float[] { 0.1f, 0.2f, 0.3f })]), +}; + +GeneratedEmbeddings> result = await embeddings.GenerateAsync(["trail"]); +Console.WriteLine(embeddings.CallCount); +``` + +Use the input type required by the test, such as `MockEmbeddingGenerator` or `MockEmbeddingGenerator`. + ## Feedback & Contributing We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs index ededd588383..899d80f7420 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs @@ -26,7 +26,7 @@ public async Task GenerateEmbeddingsDefaultsToInnerServiceAsync() var expectedCancellationToken = cts.Token; var expectedResult = new TaskCompletionSource>>(); var expectedEmbedding = new GeneratedEmbeddings>([new(new float[] { 1.0f, 2.0f, 3.0f })]); - using var inner = new TestEmbeddingGenerator + using var inner = new MockEmbeddingGenerator { GenerateAsyncCallback = (input, options, cancellationToken) => { @@ -51,7 +51,7 @@ public async Task GenerateEmbeddingsDefaultsToInnerServiceAsync() [Fact] public void GetServiceThrowsForNullType() { - using var inner = new TestEmbeddingGenerator(); + using var inner = new MockEmbeddingGenerator(); using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); Assert.Throws("serviceType", () => delegating.GetService(null!)); } @@ -60,7 +60,7 @@ public void GetServiceThrowsForNullType() public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() { // Arrange - using var inner = new TestEmbeddingGenerator(); + using var inner = new MockEmbeddingGenerator(); using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); // Act @@ -76,8 +76,8 @@ public void GetServiceDelegatesToInnerIfKeyIsNotNull() // Arrange var expectedParam = new object(); var expectedKey = new object(); - using var expectedResult = new TestEmbeddingGenerator(); - using var inner = new TestEmbeddingGenerator + using var expectedResult = new MockEmbeddingGenerator(); + using var inner = new MockEmbeddingGenerator { GetServiceCallback = (_, _) => expectedResult }; @@ -97,7 +97,7 @@ public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() var expectedParam = new object(); var expectedResult = TimeZoneInfo.Local; var expectedKey = new object(); - using var inner = new TestEmbeddingGenerator + using var inner = new MockEmbeddingGenerator { GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey ? expectedResult diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs index 60e15848e0f..e7c77371a07 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs @@ -21,14 +21,14 @@ public void GetRequiredService_InvalidArgs_Throws() { Assert.Throws("generator", () => EmbeddingGeneratorExtensions.GetRequiredService(null!)); - using var generator = new TestEmbeddingGenerator(); + using var generator = new MockEmbeddingGenerator(); Assert.Throws("serviceType", () => generator.GetRequiredService(null!)); } [Fact] public void GetService_ValidService_Returned() { - using IEmbeddingGenerator> generator = new TestEmbeddingGenerator + using IEmbeddingGenerator> generator = new MockEmbeddingGenerator { GetServiceCallback = (serviceType, serviceKey) => { @@ -78,9 +78,9 @@ public void GetService_ValidService_Returned() [Fact] public async Task GenerateAsync_InvalidArgs_ThrowsAsync() { - await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateAsync("hello")); - await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateVectorAsync("hello")); - await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateAndZipAsync(["hello"])); + await Assert.ThrowsAsync("generator", () => ((MockEmbeddingGenerator)null!).GenerateAsync("hello")); + await Assert.ThrowsAsync("generator", () => ((MockEmbeddingGenerator)null!).GenerateVectorAsync("hello")); + await Assert.ThrowsAsync("generator", () => ((MockEmbeddingGenerator)null!).GenerateAndZipAsync(["hello"])); } [Fact] @@ -88,7 +88,7 @@ public async Task GenerateAsync_ReturnsSingleEmbeddingAsync() { Embedding result = new(new float[] { 1f, 2f, 3f }); - using TestEmbeddingGenerator service = new() + using MockEmbeddingGenerator service = new() { GenerateAsyncCallback = (values, options, cancellationToken) => Task.FromResult>>([result]) @@ -110,7 +110,7 @@ public async Task GenerateAndZipEmbeddingsAsync_ReturnsExpectedList(int count) .Select(i => new Embedding(Enumerable.Range(i, 4).Select(i => (float)i).ToArray())) .ToArray(); - using TestEmbeddingGenerator service = new() + using MockEmbeddingGenerator service = new() { GenerateAsyncCallback = (values, options, cancellationToken) => Task.FromResult>>(new(embeddings)) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs index a345af7b508..393fdd18634 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs @@ -250,7 +250,7 @@ public async Task Generator_SupportsCovariantInput() { var expectedGeneratedEmbeddings = new GeneratedEmbeddings>([new Embedding(new float[] { 1, 2, 3 })]); - using IEmbeddingGenerator> acceptsObject = new TestEmbeddingGenerator> + using IEmbeddingGenerator> acceptsObject = new MockEmbeddingGenerator { GenerateAsyncCallback = (values, options, cancellationToken) => Task.FromResult(expectedGeneratedEmbeddings), }; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index f2b8c40018a..59b8c7f6dbb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -37,5 +37,6 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs deleted file mode 100644 index dbb69304007..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -#pragma warning disable SA1402 // File may only contain a single type -#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize - -namespace Microsoft.Extensions.AI; - -public class TestEmbeddingGenerator : IEmbeddingGenerator - where TEmbedding : Embedding -{ - public TestEmbeddingGenerator() - { - GetServiceCallback = DefaultGetServiceCallback; - } - - public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>? GenerateAsyncCallback { get; set; } - - public Func GetServiceCallback { get; set; } - - private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => - serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - - public Task> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) - => GenerateAsyncCallback!.Invoke(values, options, cancellationToken); - - public object? GetService(Type serviceType, object? serviceKey = null) - => GetServiceCallback(serviceType, serviceKey); - - void IDisposable.Dispose() - { - // No resources to dispose - } -} - -public sealed class TestEmbeddingGenerator : TestEmbeddingGenerator>; diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs index 3e1b7005035..a157ea04bcd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -359,6 +360,79 @@ public void Registration_RequiresRequestPredicate() client.AddException(null!, static () => new InvalidOperationException())); } + [Fact] + public async Task MockEmbeddingGenerator_InvokesConfiguredCallbackAndRecordsCall() + { + var values = new List { "trail", "camp" }; + var options = new EmbeddingGenerationOptions(); + using var cancellationTokenSource = new CancellationTokenSource(); + var expected = new GeneratedEmbeddings>([new(new float[] { 1, 2, 3, 4 })]); + + using var generator = new MockEmbeddingGenerator + { + GenerateAsyncCallback = (actualValues, actualOptions, cancellationToken) => + { + Assert.Same(values, actualValues); + Assert.Same(options, actualOptions); + Assert.Equal(cancellationTokenSource.Token, cancellationToken); + return Task.FromResult(expected); + }, + }; + + Assert.Same(expected, await generator.GenerateAsync(values, options, cancellationTokenSource.Token)); + Assert.Equal(1, generator.CallCount); + } + + [Fact] + public async Task MockEmbeddingGenerator_SupportsGenericInputs() + { + var value = new object(); + var expected = new GeneratedEmbeddings>([new(new float[] { 1, 2, 3, 4 })]); + using var generator = new MockEmbeddingGenerator + { + GenerateAsyncCallback = (values, _, _) => + { + Assert.Same(value, Assert.Single(values)); + return Task.FromResult(expected); + }, + }; + + Assert.Same(expected, await generator.GenerateAsync([value])); + } + + [Fact] + public async Task MockEmbeddingGenerator_RecordsCallsForDerivedGenerators() + { + var expected = new GeneratedEmbeddings>([new(new float[] { 1, 2, 3, 4 })]); + using var generator = new DerivedMockEmbeddingGenerator(expected); + + Assert.Same(expected, await generator.GenerateAsync(["trail"])); + Assert.Equal(1, generator.CallCount); + } + + [Fact] + public void MockEmbeddingGenerator_ConfiguresServiceResolution() + { + using var generator = new MockEmbeddingGenerator(); + Assert.Same(generator, generator.GetService(typeof(MockEmbeddingGenerator))); + + var expected = new object(); + generator.GetServiceCallback = static (_, _) => null; + Assert.Null(generator.GetService(typeof(object))); + + generator.GetServiceCallback = (_, _) => expected; + Assert.Same(expected, generator.GetService(typeof(object))); + } + + private sealed class DerivedMockEmbeddingGenerator(GeneratedEmbeddings> expected) : MockEmbeddingGenerator + { + protected override Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) => + Task.FromResult(expected); + } + private static Task CreateResponseAsync(string text) => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj index 6c805e270c8..41bf468fcaf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj @@ -25,7 +25,6 @@ - @@ -44,5 +43,6 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/MockEmbeddingGeneratorAlias.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/MockEmbeddingGeneratorAlias.cs new file mode 100644 index 00000000000..3ae0cecf65f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/MockEmbeddingGeneratorAlias.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using TestEmbeddingGenerator = Microsoft.Extensions.AI.MockEmbeddingGenerator; diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs index 354cebf1565..16c3bd40f62 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs @@ -16,12 +16,12 @@ public class SemanticSimilarityChunkerTests : DocumentChunkerTests protected override IngestionChunker CreateDocumentChunker(int maxTokensPerChunk = 2_000, int overlapTokens = 500) { #pragma warning disable CA2000 // Dispose objects before losing scope - TestEmbeddingGenerator embeddingClient = new(); + MockEmbeddingGenerator embeddingClient = CreateMockEmbeddingGenerator(); #pragma warning restore CA2000 // Dispose objects before losing scope return CreateSemanticSimilarityChunker(embeddingClient, maxTokensPerChunk, overlapTokens); } - private static IngestionChunker CreateSemanticSimilarityChunker(TestEmbeddingGenerator embeddingClient, int maxTokensPerChunk = 2_000, int overlapTokens = 500) + private static IngestionChunker CreateSemanticSimilarityChunker(IEmbeddingGenerator> embeddingClient, int maxTokensPerChunk = 2_000, int overlapTokens = 500) { Tokenizer tokenizer = TiktokenTokenizer.CreateForModel("gpt-4o"); return new SemanticSimilarityChunker(embeddingClient, @@ -42,7 +42,7 @@ public async Task SingleParagraph() new IngestionDocumentParagraph(text) } }); - using TestEmbeddingGenerator customGenerator = new() + using MockEmbeddingGenerator customGenerator = new() { GenerateAsyncCallback = static async (values, options, ct) => { @@ -77,7 +77,7 @@ public async Task TwoTopicsParagraphs() } }); - using var customGenerator = new TestEmbeddingGenerator + using var customGenerator = new MockEmbeddingGenerator { GenerateAsyncCallback = async (values, options, ct) => { @@ -154,7 +154,7 @@ public async Task TwoSeparateTopicsWithAllKindsOfElements() } }); - using var customGenerator = new TestEmbeddingGenerator + using var customGenerator = new MockEmbeddingGenerator { GenerateAsyncCallback = async (values, options, ct) => { @@ -236,6 +236,14 @@ These gods resided on Mount Olympus and ruled over different aspects of mortal a }; } + private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => + new() + { + GenerateAsyncCallback = static (values, _, _) => + Task.FromResult>>( + new(values.Select(_ => new Embedding(new float[] { 1, 2, 3, 4 })))), + }; + private static IngestionDocumentParagraph?[,] ToParagraphCells(string[,] cells) { int rows = cells.GetLength(0); diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs index 7159cdc8718..e1e87363a13 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs @@ -22,6 +22,8 @@ namespace Microsoft.Extensions.DataIngestion.Tests; public sealed class IngestionPipelineTests : IDisposable { + private const int EmbeddingDimensionCount = 4; + private readonly FileInfo _withTable; private readonly FileInfo _withImage; private readonly IReadOnlyList _sampleFiles; @@ -82,9 +84,9 @@ public async Task CanProcessDocuments() List activities = []; using TracerProvider tracerProvider = CreateTraceProvider(activities); - TestEmbeddingGenerator embeddingGenerator = new(); + MockEmbeddingGenerator embeddingGenerator = CreateMockEmbeddingGenerator(); using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); - using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: EmbeddingDimensionCount); using IngestionPipeline pipeline = new(CreateReader(), CreateChunker(), vectorStoreWriter); List ingestionResults = await pipeline.ProcessAsync(_sampleFiles).ToListAsync(); @@ -92,7 +94,7 @@ public async Task CanProcessDocuments() Assert.Equal(_sampleFiles.Count, ingestionResults.Count); AssertAllIngestionsSucceeded(ingestionResults); - Assert.True(embeddingGenerator.WasCalled, "Embedding generator should have been called."); + Assert.True(embeddingGenerator.CallCount > 0, "Embedding generator should have been called."); var retrieved = await vectorStoreWriter.VectorStoreCollection .GetAsync(record => _sampleFiles.Any(info => info.FullName == (string)record["documentid"]!), top: 1000) @@ -115,9 +117,9 @@ public async Task CanProcessDocumentsInDirectory() List activities = []; using TracerProvider tracerProvider = CreateTraceProvider(activities); - TestEmbeddingGenerator embeddingGenerator = new(); + MockEmbeddingGenerator embeddingGenerator = CreateMockEmbeddingGenerator(); using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); - using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: EmbeddingDimensionCount); using IngestionPipeline pipeline = new(CreateReader(), CreateChunker(), vectorStoreWriter); @@ -126,7 +128,7 @@ public async Task CanProcessDocumentsInDirectory() Assert.Equal(directory.EnumerateFiles("*.md").Count(), ingestionResults.Count); AssertAllIngestionsSucceeded(ingestionResults); - Assert.True(embeddingGenerator.WasCalled, "Embedding generator should have been called."); + Assert.True(embeddingGenerator.CallCount > 0, "Embedding generator should have been called."); var retrieved = await vectorStoreWriter.VectorStoreCollection .GetAsync(record => ((string)record["documentid"]!).StartsWith(directory.FullName), top: 1000) @@ -149,12 +151,12 @@ public async Task ChunksCanBeMoreThanJustText() List activities = []; using TracerProvider tracerProvider = CreateTraceProvider(activities); - TestEmbeddingGenerator embeddingGenerator = new(); + MockEmbeddingGenerator embeddingGenerator = CreateMockEmbeddingGenerator(); using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); - using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: EmbeddingDimensionCount); using IngestionPipeline pipeline = new(CreateReader(), new ImageChunker(), vectorStoreWriter); - Assert.False(embeddingGenerator.WasCalled); + Assert.Equal(0, embeddingGenerator.CallCount); var ingestionResults = await pipeline.ProcessAsync(_sampleFiles).ToListAsync(); AssertAllIngestionsSucceeded(ingestionResults); @@ -162,7 +164,7 @@ public async Task ChunksCanBeMoreThanJustText() .GetAsync(record => ((string)record["documentid"]!).EndsWith(_withImage.Name), top: 100) .ToListAsync(); - Assert.True(embeddingGenerator.WasCalled); + Assert.True(embeddingGenerator.CallCount > 0); Assert.NotEmpty(retrieved); for (int i = 0; i < retrieved.Count; i++) { @@ -197,9 +199,9 @@ public async Task SingleFailureDoesNotTearDownEntirePipeline() List activities = []; using TracerProvider tracerProvider = CreateTraceProvider(activities); - TestEmbeddingGenerator embeddingGenerator = new(); + MockEmbeddingGenerator embeddingGenerator = CreateMockEmbeddingGenerator(); using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); - using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: EmbeddingDimensionCount); using IngestionPipeline pipeline = new(failingForFirstReader, CreateChunker(), vectorStoreWriter); @@ -225,6 +227,21 @@ async Task Verify(IAsyncEnumerable results) private static IngestionChunker CreateChunker() => new HeaderChunker(new(TiktokenTokenizer.CreateForModel("gpt-4"))); + private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => + new() + { + GenerateAsyncCallback = static (_, _, _) => CreateFixedEmbeddings(), + }; + + private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => + new() + { + GenerateAsyncCallback = static (_, _, _) => CreateFixedEmbeddings(), + }; + + private static Task>> CreateFixedEmbeddings() => + Task.FromResult>>([new(new float[] { 0, 1, 2, 3 })]); + private static TracerProvider CreateTraceProvider(List activities) => Sdk.CreateTracerProviderBuilder() .AddSource("Experimental.Microsoft.Extensions.DataIngestion") diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj index 312ed567bd0..85f1fe81a31 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj @@ -15,6 +15,7 @@ + @@ -32,7 +33,6 @@ - diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs deleted file mode 100644 index 611243684cd..00000000000 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Extensions.DataIngestion; - -public sealed class TestEmbeddingGenerator : IEmbeddingGenerator> -{ - public const int DimensionCount = 4; - - public bool WasCalled { get; private set; } - - public void Dispose() - { - // No resources to dispose - } - - public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) - { - WasCalled = true; - - return Task.FromResult(new GeneratedEmbeddings>( - [new(new float[] { 0, 1, 2, 3 })])); - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; -} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs index f9767414976..7969a74acef 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs @@ -2,12 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using CommunityToolkit.VectorData.InMemory; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; namespace Microsoft.Extensions.DataIngestion.Writers.Tests; public class InMemoryVectorStoreWriterTests : VectorStoreWriterTests { - protected override VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator) + protected override VectorStore CreateVectorStore(MockEmbeddingGenerator testEmbeddingGenerator) => new InMemoryVectorStore(new() { EmbeddingGenerator = testEmbeddingGenerator }); } diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs index 7f29da9358d..5751c99c3ec 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs @@ -4,6 +4,7 @@ using System; using System.IO; using CommunityToolkit.VectorData.SqliteVec; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; namespace Microsoft.Extensions.DataIngestion.Writers.Tests; @@ -14,6 +15,6 @@ public sealed class SqliteVectorStoreWriterTests : VectorStoreWriterTests, IDisp public void Dispose() => File.Delete(_tempFile); - protected override VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator) + protected override VectorStore CreateVectorStore(MockEmbeddingGenerator testEmbeddingGenerator) => new SqliteVectorStore($"Data Source={_tempFile};Pooling=false", new() { EmbeddingGenerator = testEmbeddingGenerator }); } diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs index 0395c470ce0..ac31f9b437a 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; using Xunit; @@ -12,16 +13,18 @@ namespace Microsoft.Extensions.DataIngestion.Writers.Tests; public abstract class VectorStoreWriterTests { + private const int EmbeddingDimensionCount = 4; + [Fact] public async Task CanGenerateDynamicSchema() { string documentId = Guid.NewGuid().ToString(); - using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using MockEmbeddingGenerator testEmbeddingGenerator = CreateMockEmbeddingGenerator(); using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); using VectorStoreWriter writer = new( vectorStore, - dimensionCount: TestEmbeddingGenerator.DimensionCount); + dimensionCount: EmbeddingDimensionCount); IngestionDocument document = new(documentId); List> chunks = @@ -38,7 +41,7 @@ public async Task CanGenerateDynamicSchema() } ]; - Assert.False(testEmbeddingGenerator.WasCalled); + Assert.Equal(0, testEmbeddingGenerator.CallCount); await writer.WriteAsync(chunks.ToAsyncEnumerable()); Dictionary record = await writer.VectorStoreCollection @@ -49,7 +52,7 @@ public async Task CanGenerateDynamicSchema() Assert.NotNull(record["key"]); Assert.Equal(documentId, record["documentid"]); Assert.Equal(chunks[0].Content, record["content"]); - Assert.True(testEmbeddingGenerator.WasCalled); + Assert.True(testEmbeddingGenerator.CallCount > 0); foreach (var kvp in chunks[0].Metadata) { Assert.True(record.ContainsKey(kvp.Key), $"Record does not contain key '{kvp.Key}'"); @@ -62,11 +65,11 @@ public async Task DoesSupportIncrementalIngestion() { string documentId = Guid.NewGuid().ToString(); - using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using MockEmbeddingGenerator testEmbeddingGenerator = CreateMockEmbeddingGenerator(); using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); using VectorStoreWriter writer = new( vectorStore, - dimensionCount: TestEmbeddingGenerator.DimensionCount, + dimensionCount: EmbeddingDimensionCount, options: new() { IncrementalIngestion = true, @@ -122,11 +125,11 @@ public async Task IncrementalIngestion_WithManyRecords_DeletesAllPreExistingChun { string documentId = Guid.NewGuid().ToString(); - using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using MockEmbeddingGenerator testEmbeddingGenerator = CreateMockEmbeddingGenerator(); using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); using VectorStoreWriter writer = new( vectorStore, - dimensionCount: TestEmbeddingGenerator.DimensionCount, + dimensionCount: EmbeddingDimensionCount, options: new() { IncrementalIngestion = true, @@ -167,5 +170,12 @@ public async Task IncrementalIngestion_WithManyRecords_DeletesAllPreExistingChun Assert.Contains(records, r => (string)r["content"]! == "updated chunk 2"); } - protected abstract VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator); + protected abstract VectorStore CreateVectorStore(MockEmbeddingGenerator testEmbeddingGenerator); + + private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => + new() + { + GenerateAsyncCallback = static (_, _, _) => + Task.FromResult>>([new(new float[] { 0, 1, 2, 3 })]), + }; } From ea96e76a3140c93886b837c10345e135bced97b2 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 12:23:49 -0700 Subject: [PATCH 05/10] Add mock provider to aichatweb template with canned responses Introduces the 'mock' provider option (--provider mock) to the aichatweb project template. The mock provider uses Microsoft.Extensions.AI.Testing's MockChatClient seeded with ~20 canned responses about the TrailMaster X4 GPS Watch, allowing the template to be used without any external AI provider credentials while still demonstrating the full chat experience with citations, suggestions, and vector-based semantic search. Also updates the Aspire test scenario to use --provider=mock instead of --provider=openai, and adds a test for the plain --provider=openai scenario. Updates the TemplateSnapshotTestBase to resolve template package paths using file timestamps rather than wildcard paths, improving cross-platform reliability. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../Microsoft.Extensions.AI.Templates.csproj | 1 + .../.template.config/template.json | 18 ++ .../AIChatWeb-CSharp.AppHost/AppHost.cs | 2 + .../AIChatWeb-CSharp.Web.csproj-in | 3 + .../Components/Layout/SurveyPrompt.razor | 13 - .../Components/Layout/SurveyPrompt.razor.css | 20 -- .../Components/Pages/Chat/Chat.razor | 7 +- .../Pages/Chat/ChatMessageItem.razor | 28 +- .../AIChatWeb-CSharp.Web/Program.Aspire.cs | 6 + .../AIChatWeb-CSharp.Web/Program.cs | 4 + .../AIChatWeb-CSharp.Web/README.md | 16 + .../Services/IngestedChunk.cs | 4 + .../Services/LexicalMockEmbeddingGenerator.cs | 104 +++++++ .../Services/MockChatClientExtensions.cs | 294 ++++++++++++++++++ .../Services/MockServices.cs | 157 ++++++++++ .../AIChatWeb-CSharp/README.Aspire.md | 16 + src/ProjectTemplates/README.md | 4 +- .../TemplateSnapshotTestBase.cs | 24 +- .../AIChatWebExecutionTests.cs | 2 +- .../AIChatWebSnapshotTests.cs | 3 +- .../Components/Layout/SurveyPrompt.razor | 13 - .../Components/Layout/SurveyPrompt.razor.css | 20 -- .../Components/Pages/Chat/Chat.razor | 7 +- .../Pages/Chat/ChatMessageItem.razor | 28 +- .../aichatweb/README.md | 56 ++++ .../aichatweb/aichatweb.AppHost/AppHost.cs | 11 + .../Properties/launchSettings.json | 29 ++ .../aichatweb.AppHost.csproj | 21 ++ .../appsettings.Development.json | 8 + .../aichatweb.AppHost/appsettings.json | 9 + .../aichatweb.ServiceDefaults/Extensions.cs | 134 ++++++++ .../aichatweb.ServiceDefaults.csproj | 22 ++ .../aichatweb.Web/Components/App.razor | 24 ++ .../Components/Layout/LoadingSpinner.razor | 1 + .../Layout/LoadingSpinner.razor.css | 89 ++++++ .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Components/Pages/Chat/Chat.razor | 137 ++++++++ .../Components/Pages/Chat/Chat.razor.css | 11 + .../Components/Pages/Chat/ChatCitation.razor | 39 +++ .../Pages/Chat/ChatCitation.razor.css | 37 +++ .../Components/Pages/Chat/ChatHeader.razor | 17 + .../Pages/Chat/ChatHeader.razor.css | 25 ++ .../Components/Pages/Chat/ChatInput.razor | 51 +++ .../Components/Pages/Chat/ChatInput.razor.css | 57 ++++ .../Components/Pages/Chat/ChatInput.razor.js | 43 +++ .../Pages/Chat/ChatMessageItem.razor | 123 ++++++++ .../Pages/Chat/ChatMessageItem.razor.css | 120 +++++++ .../Pages/Chat/ChatMessageList.razor | 42 +++ .../Pages/Chat/ChatMessageList.razor.css | 22 ++ .../Pages/Chat/ChatMessageList.razor.js | 34 ++ .../Pages/Chat/ChatSuggestions.razor | 78 +++++ .../Pages/Chat/ChatSuggestions.razor.css | 9 + .../Components/Pages/Error.razor | 36 +++ .../aichatweb.Web/Components/Routes.razor | 6 + .../aichatweb.Web/Components/_Imports.razor | 13 + .../aichatweb/aichatweb.Web/Program.cs | 43 +++ .../Properties/launchSettings.json | 23 ++ .../aichatweb.Web/Services/IngestedChunk.cs | 31 ++ .../Services/Ingestion/DataIngestor.cs | 35 +++ .../Services/Ingestion/DocumentReader.cs | 42 +++ .../Services/LexicalMockEmbeddingGenerator.cs | 104 +++++++ .../Services/MockChatClientExtensions.cs | 294 ++++++++++++++++++ .../aichatweb.Web/Services/MockServices.cs | 157 ++++++++++ .../aichatweb.Web/Services/SemanticSearch.cs | 27 ++ .../aichatweb.Web/aichatweb.Web.csproj | 25 ++ .../appsettings.Development.json | 9 + .../aichatweb/aichatweb.Web/appsettings.json | 10 + .../aichatweb/aichatweb.sln | 34 ++ .../aichatweb/Components/App.razor | 24 ++ .../Components/Layout/LoadingSpinner.razor | 1 + .../Layout/LoadingSpinner.razor.css | 89 ++++++ .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Components/Pages/Chat/Chat.razor | 137 ++++++++ .../Components/Pages/Chat/Chat.razor.css | 11 + .../Components/Pages/Chat/ChatCitation.razor | 39 +++ .../Pages/Chat/ChatCitation.razor.css | 37 +++ .../Components/Pages/Chat/ChatHeader.razor | 17 + .../Pages/Chat/ChatHeader.razor.css | 25 ++ .../Components/Pages/Chat/ChatInput.razor | 51 +++ .../Components/Pages/Chat/ChatInput.razor.css | 57 ++++ .../Components/Pages/Chat/ChatInput.razor.js | 43 +++ .../Pages/Chat/ChatMessageItem.razor | 123 ++++++++ .../Pages/Chat/ChatMessageItem.razor.css | 120 +++++++ .../Pages/Chat/ChatMessageList.razor | 42 +++ .../Pages/Chat/ChatMessageList.razor.css | 22 ++ .../Pages/Chat/ChatMessageList.razor.js | 34 ++ .../Pages/Chat/ChatSuggestions.razor | 78 +++++ .../Pages/Chat/ChatSuggestions.razor.css | 9 + .../aichatweb/Components/Pages/Error.razor | 36 +++ .../aichatweb/Components/Routes.razor | 6 + .../aichatweb/Components/_Imports.razor | 13 + .../aichatweb/Program.cs | 41 +++ .../aichatweb/Properties/launchSettings.json | 23 ++ .../aichatweb/README.md | 18 ++ .../aichatweb/Services/IngestedChunk.cs | 31 ++ .../Services/Ingestion/DataIngestor.cs | 35 +++ .../Services/Ingestion/DocumentReader.cs | 36 +++ .../Services/Ingestion/PdfPigReader.cs | 42 +++ .../Services/LexicalMockEmbeddingGenerator.cs | 104 +++++++ .../Services/MockChatClientExtensions.cs | 294 ++++++++++++++++++ .../aichatweb/Services/MockServices.cs | 157 ++++++++++ .../aichatweb/Services/SemanticSearch.cs | 27 ++ .../aichatweb/aichatweb.csproj | 21 ++ .../aichatweb/appsettings.Development.json | 9 + .../aichatweb/appsettings.json | 10 + .../Components/Layout/SurveyPrompt.razor | 13 - .../Components/Layout/SurveyPrompt.razor.css | 20 -- .../Components/Pages/Chat/Chat.razor | 7 +- .../Pages/Chat/ChatMessageItem.razor | 28 +- .../aichatweb/Components/App.razor | 24 ++ .../Components/Layout/LoadingSpinner.razor | 1 + .../Layout/LoadingSpinner.razor.css | 89 ++++++ .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Components/Pages/Chat/Chat.razor | 137 ++++++++ .../Components/Pages/Chat/Chat.razor.css | 11 + .../Components/Pages/Chat/ChatCitation.razor | 39 +++ .../Pages/Chat/ChatCitation.razor.css | 37 +++ .../Components/Pages/Chat/ChatHeader.razor | 17 + .../Pages/Chat/ChatHeader.razor.css | 25 ++ .../Components/Pages/Chat/ChatInput.razor | 51 +++ .../Components/Pages/Chat/ChatInput.razor.css | 57 ++++ .../Components/Pages/Chat/ChatInput.razor.js | 43 +++ .../Pages/Chat/ChatMessageItem.razor | 123 ++++++++ .../Pages/Chat/ChatMessageItem.razor.css | 120 +++++++ .../Pages/Chat/ChatMessageList.razor | 42 +++ .../Pages/Chat/ChatMessageList.razor.css | 22 ++ .../Pages/Chat/ChatMessageList.razor.js | 34 ++ .../Pages/Chat/ChatSuggestions.razor | 78 +++++ .../Pages/Chat/ChatSuggestions.razor.css | 9 + .../aichatweb/Components/Pages/Error.razor | 36 +++ .../aichatweb/Components/Routes.razor | 6 + .../aichatweb/Components/_Imports.razor | 13 + .../aichatweb/Program.cs | 53 ++++ .../aichatweb/Properties/launchSettings.json | 23 ++ .../aichatweb/README.md | 19 ++ .../aichatweb/Services/IngestedChunk.cs | 31 ++ .../Services/Ingestion/DataIngestor.cs | 35 +++ .../Services/Ingestion/DocumentReader.cs | 36 +++ .../Services/Ingestion/PdfPigReader.cs | 42 +++ .../aichatweb/Services/SemanticSearch.cs | 27 ++ .../aichatweb/aichatweb.csproj | 21 ++ .../aichatweb/appsettings.Development.json | 9 + .../aichatweb/appsettings.json | 10 + .../Components/Layout/SurveyPrompt.razor | 13 - .../Components/Layout/SurveyPrompt.razor.css | 20 -- .../Components/Pages/Chat/Chat.razor | 7 +- .../Pages/Chat/ChatMessageItem.razor | 28 +- 150 files changed, 6167 insertions(+), 170 deletions(-) delete mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor delete mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/App.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Routes.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/_Imports.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/App.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Error.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Routes.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/_Imports.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/README.md create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/IngestedChunk.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DataIngestor.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DocumentReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/PdfPigReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/SemanticSearch.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/aichatweb.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/App.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Error.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Routes.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/_Imports.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/README.md create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DataIngestor.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DocumentReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/PdfPigReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/SemanticSearch.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/aichatweb.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index f83fda4db7e..a39a6b1492f 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -26,6 +26,7 @@ + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json index c769d2e9aca..5c7ccd3f487 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json @@ -98,6 +98,14 @@ "exclude": [ "AIChatWeb-CSharp.Web/Services/JsonVectorStore.cs" ] + }, + { + "condition": "(!IsMock)", + "exclude": [ + "AIChatWeb-CSharp.Web/Services/MockServices.cs", + "AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs", + "AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs" + ] } ] }], @@ -128,12 +136,18 @@ "type": "parameter", "displayName": "_AI service provider", "datatype": "choice", + "isRequired": true, "choices": [ { "choice": "azureopenai", "displayName": "Azure OpenAI", "description": "Uses Azure OpenAI service" }, + { + "choice": "mock", + "displayName": "Mock (for local development and testing)", + "description": "Uses deterministic, pre-seeded responses" + }, { "choice": "ollama", "displayName": "Ollama (for local development)", @@ -213,6 +227,10 @@ "type": "computed", "value": "(AiServiceProvider == \"ollama\")" }, + "IsMock": { + "type": "computed", + "value": "(AiServiceProvider == \"mock\")" + }, "IsAzureAIFoundry": { "type": "computed", "value": "(AiServiceProvider == \"azureaifoundry\")" diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs index fd577fedc2c..fa1d10780ac 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs @@ -7,6 +7,7 @@ // cd this-project-directory // dotnet user-secrets set ConnectionStrings:openai "Key=YOUR-API-KEY" var openai = builder.AddConnectionString("openai"); +#elif (IsMock) #else // IsAzureOpenAI // See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration @@ -58,6 +59,7 @@ .WaitFor(embeddings); #elif (IsOpenAI) webApp.WithReference(openai); +#elif (IsMock) #else // IsAzureOpenAI webApp .WithReference(openai) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in index 9b4f08998f4..84035f7bacd 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in @@ -28,6 +28,9 @@ + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/Chat.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/Chat.razor +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs index 66b017af999..e19c44ffe45 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs @@ -19,6 +19,12 @@ builder.AddOllamaApiClient("embeddings") .AddEmbeddingGenerator(); #elif (IsAzureAIFoundry) +#elif (IsMock) +builder.Services.AddChatClient(MockServices.CreateChatClient()) + .UseFunctionInvocation() + .UseOpenTelemetry(configure: c => + c.EnableSensitiveData = builder.Environment.IsDevelopment()); +builder.Services.AddEmbeddingGenerator(MockServices.CreateEmbeddingGenerator()); #else // (IsOpenAI || IsAzureOpenAI) #if (IsOpenAI) var openai = builder.AddOpenAIClient("openai"); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs index ae7ccc9d0ae..8fb4d9f8887 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs @@ -42,6 +42,10 @@ var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); #elif (IsAzureAIFoundry) +#elif (IsMock) +var chatClient = MockServices.CreateChatClient(); +IEmbeddingGenerator> embeddingGenerator = MockServices.CreateEmbeddingGenerator(); + #elif (IsAzureOpenAI) // You will need to set the endpoint and key to your own values // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md index 29552b95212..bbaaacd54a1 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md @@ -2,8 +2,10 @@ This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). +#### ---#if (!IsMock) >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. +#### ---#endif #### ---#if (IsAzure) ### Prerequisites @@ -21,6 +23,20 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See #### ---#endif # Configure the AI Model Provider +#### ---#if (IsMock) +## Using Mock provider + +The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads. + +It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`. + +Good starter prompts: + +- What does TrailMaster track? +- How rugged is TrailMaster? +- How does location sharing work? +- Why should I buy this watch? +#### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs index 60e6b5684e4..55202558bb4 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs @@ -19,7 +19,11 @@ public class IngestedChunk [VectorStoreKey(StorageName = "key")] [JsonPropertyName("key")] +#if (IsMock) + public required string Key { get; set; } +#else public required Guid Key { get; set; } +#endif [VectorStoreData(StorageName = "documentid")] [JsonPropertyName("documentid")] diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs new file mode 100644 index 00000000000..6c43f4e5d43 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace AIChatWeb_CSharp.Web.Services; + +internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator +{ + private readonly int _dimensions; + + internal LexicalMockEmbeddingGenerator(int dimensions) + { + ArgumentOutOfRangeException.ThrowIfLessThan(dimensions, 1); + _dimensions = dimensions; + } + + protected override Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + if (GenerateAsyncCallback is not null) + { + return base.GenerateCoreAsync(values, options, cancellationToken); + } + + GeneratedEmbeddings> embeddings = []; + foreach (string value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + embeddings.Add(new Embedding(CreateVector(value, _dimensions))); + } + + return Task.FromResult(embeddings); + } + + private static float[] CreateVector(string? value, int dimensions) + { + var vector = new float[dimensions]; + if (string.IsNullOrEmpty(value)) + { + return vector; + } + + // Hash tokens and three-character shingles so shared words and word fragments have matching dimensions. + int tokenStart = -1; + for (int i = 0; i <= value.Length; i++) + { + if (i < value.Length && char.IsLetterOrDigit(value[i])) + { + tokenStart = tokenStart < 0 ? i : tokenStart; + } + else if (tokenStart >= 0) + { + AddTokenFeatures(value, tokenStart, i - tokenStart, vector); + tokenStart = -1; + } + } + + // Normalize to unit length so cosine similarity favors lexical overlap rather than document length. + float squaredMagnitude = 0; + foreach (float component in vector) + { + squaredMagnitude += component * component; + } + + if (squaredMagnitude > 0) + { + float scale = 1 / (float)Math.Sqrt(squaredMagnitude); + for (int i = 0; i < vector.Length; i++) + { + vector[i] *= scale; + } + } + + return vector; + } + + private static void AddTokenFeatures(string value, int tokenStart, int tokenLength, float[] vector) + { + AddFeature(value, tokenStart, tokenLength, vector); + + for (int i = tokenStart; i <= tokenStart + tokenLength - 3; i++) + { + AddFeature(value, i, 3, vector); + } + } + + private static void AddFeature(string value, int start, int length, float[] vector) + { + uint hash = 2_166_136_261; + unchecked + { + for (int i = start; i < start + length; i++) + { + hash = (hash ^ char.ToLowerInvariant(value[i])) * 16_777_619; + } + } + + vector[(int)(hash % (uint)vector.Length)] += 1; + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs new file mode 100644 index 00000000000..b71e15bbc49 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs @@ -0,0 +1,294 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace AIChatWeb_CSharp.Web.Services; + +internal static class MockChatClientExtensions +{ + private const string LoadDocumentsCallId = "mock-load-documents"; + private const string SearchCallId = "mock-search"; + private const string MockConversationId = "mock-conversation"; + + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// Optional aichatweb follow-up suggestions returned through its structured-output request. Supply an empty + /// collection when the response is a conversation dead end. + /// + /// + /// to invoke the document-search tools and append a citation from the search result. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + IEnumerable? suggestions = null, + bool includeCitation = false, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + if (suggestions is null) + { + return AddResponse(client, requestPredicate, response, minDelay, maxDelay, includeCitation, singleUse); + } + + string structuredResponse = JsonSerializer.Serialize(new { data = suggestions.ToArray() }); + + MockChatClient configuredClient = AddResponse( + client, + request => request.Options?.ResponseFormat is not ChatResponseFormatJson && requestPredicate(request), + response, + minDelay, + maxDelay, + includeCitation, + singleUse); + + return AddResponse( + configuredClient, + request => request.Options?.ResponseFormat is ChatResponseFormatJson && requestPredicate(request), + structuredResponse, + minDelay, + maxDelay, + includeCitation, + singleUse); + } + + internal static string Citation(string filename, string quote) + { + ArgumentNullException.ThrowIfNull(filename); + ArgumentNullException.ThrowIfNull(quote); + + return new XElement( + "citation", + new XAttribute("filename", filename), + quote).ToString(SaveOptions.DisableFormatting); + } + + private static MockChatClient AddResponse( + MockChatClient client, + Func requestPredicate, + string response, + int? minDelay, + int? maxDelay, + bool includeCitation, + bool singleUse) => + client.AddResponse( + requestPredicate, + async (request, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return CreateResponse(request, response, includeCitation); + }, + singleUse); + + private static ChatResponse CreateResponse(MockChatClientRequest request, string response, bool includeCitation) + { + if (request.Options?.ResponseFormat is ChatResponseFormatJson) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response)); + } + + if (TryGetFunctionResult(request, SearchCallId, out FunctionResultContent? searchResult) && searchResult is not null) + { + return CreateConversationResponse($"{response}{GetCitation(searchResult, request.LastUserText)}"); + } + + if (TryGetFunctionResult(request, LoadDocumentsCallId, out _)) + { + return CreateSearchFunctionCallResponse(request); + } + + if (includeCitation) + { + return request.Options?.ConversationId is null + ? CreateFunctionCallResponse(LoadDocumentsCallId, "LoadDocuments") + : CreateSearchFunctionCallResponse(request); + } + + return CreateConversationResponse(response); + } + + private static ChatResponse CreateConversationResponse(string response) => + new(new ChatMessage(ChatRole.Assistant, response)) + { + ConversationId = MockConversationId, + }; + + private static ChatResponse CreateFunctionCallResponse( + string callId, + string name, + IDictionary? arguments = null) => + new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(callId, name, arguments)])); + + private static ChatResponse CreateSearchFunctionCallResponse(MockChatClientRequest request) => + CreateFunctionCallResponse( + SearchCallId, + "Search", + new Dictionary + { + ["searchPhrase"] = request.LastUserText ?? string.Empty, + }); + + private static string GetCitation(FunctionResultContent searchResult, string? searchPhrase) + { + IEnumerable results = searchResult.Result switch + { + IEnumerable result => result, + JsonElement { ValueKind: JsonValueKind.String } result when result.GetString() is { } resultText => [resultText], + JsonElement { ValueKind: JsonValueKind.Array } result => result + .EnumerateArray() + .Where(static element => element.ValueKind is JsonValueKind.String) + .Select(static element => element.GetString()!), + _ => [], + }; + + foreach (string result in results) + { + try + { + XElement element = XElement.Parse(result); + if (element.Name.LocalName != "result" || + element.Attribute("filename")?.Value is not { Length: > 0 } filename) + { + continue; + } + + string quote = GetQuote(element.Value, searchPhrase); + if (quote.Length > 0) + { + return Citation(filename, quote); + } + } + catch (XmlException) + { + // Ignore malformed search results. + } + } + + return string.Empty; + } + + private static string GetQuote(string text, string? searchPhrase) + { + string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + return string.Empty; + } + + int start = 0; + if (!string.IsNullOrWhiteSpace(searchPhrase)) + { + int fewestOccurrences = int.MaxValue; + foreach (string queryWord in searchPhrase.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + { + string term = NormalizeWord(queryWord); + if (term.Length < 4) + { + continue; + } + + int occurrences = 0; + int firstMatch = -1; + for (int i = 0; i < words.Length; i++) + { + if (IsTermMatch(NormalizeWord(words[i]), term)) + { + occurrences++; + firstMatch = firstMatch < 0 ? i : firstMatch; + } + } + + if (occurrences > 0 && occurrences < fewestOccurrences) + { + fewestOccurrences = occurrences; + start = firstMatch; + } + } + } + + return string.Join(" ", words, start, Math.Min(5, words.Length - start)); + } + + private static bool IsTermMatch(string word, string term) => + word.Length > 0 && (word.Contains(term, StringComparison.Ordinal) || term.Contains(word, StringComparison.Ordinal)); + + private static string NormalizeWord(string value) => + string.Concat(value.Where(static character => char.IsLetterOrDigit(character)).Select(static character => char.ToLowerInvariant(character))); + + private static bool TryGetFunctionResult( + MockChatClientRequest request, + string callId, + out FunctionResultContent? functionResult) + { + for (int i = request.Messages.Count - 1; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User) + { + break; + } + + foreach (AIContent content in message.Contents) + { + if (content is FunctionResultContent result && result.CallId == callId) + { + functionResult = result; + return true; + } + } + } + + functionResult = null; + return false; + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelayMilliseconds, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, 0, nameof(maxDelay)); + + if (minDelay is { } minimumDelayMillisecondsForMaximum) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, minimumDelayMillisecondsForMaximum, nameof(maxDelay)); + } + } + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs new file mode 100644 index 00000000000..64bd0dc0c8d --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.AI; +namespace AIChatWeb_CSharp.Web.Services; + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static _ => true, + "Hi! I can help you plan an outdoor trip or find the right gear for it. What would you like to know?", + minDelay: 500, + maxDelay: 500, + suggestions: ["What kind of adventures?", "What kinds of products?"]) + .AddResponse( + static request => LastQuestionContains(request, "what kind of adventures", "outdoor activities"), + "Mostly off-grid trips: hiking, backpacking, trail running, and mountaineering are the classics, and plenty of people use the same gear for camping and long day hikes. Anywhere you lose cell service, the right gear makes the trip safer and easier.", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about hiking adventures.", "Tell me about trail safety."]) + .AddResponse( + static request => LastQuestionContains(request, "what kinds of products"), + "Two main categories: adventure GPS watches for navigation and tracking, and emergency survival kits for backcountry safety. The GPS watch is the flagship, though a lot of people carry both. Want to dig into either one?", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about GPS watches.", "Tell me about survival kits."]) + .AddResponse( + static request => LastQuestionContains(request, "hiking adventures", "hiking"), + "Hiking is right in the TrailMaster GPS Watch's wheelhouse. It's made for hikers, backpackers, and trail runners heading into remote terrain, with offline topographic maps and real-time location sharing to keep you on route and reachable.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "How rugged is TrailMaster?", "How does location sharing work?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps watches", "gps watch"), + "The TrailMaster GPS Watch pairs precise positioning with a rugged build. It runs a multi-constellation receiver for GPS, GLONASS, and Galileo, plus an altimeter, barometer, and compass, so navigation holds up even where satellite coverage is thin.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "What navigation tools are built in?", "How rugged is TrailMaster?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "trail safety", "safety"), + "Trail safety really comes down to preparation. Before heading out, make sure the watch is charged and calibrated and that you know its controls, then use real-time location sharing so someone always knows where you are. For longer trips, an emergency survival kit is a smart backup.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["How does location sharing work?", "How do I fix GPS signal loss?", "Tell me about survival kits."], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "survival kits", "survival kit", "emergency kit"), + "The Life Guard X Emergency Survival Kit is built for when a trip goes sideways. It packs first aid supplies, high-calorie emergency food, a water purification system, an emergency shelter, and signaling tools so you can stay safe and reach help.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What's in the first aid supplies?", "How does water purification work?", "How do I signal for help?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "what does trailmaster track", "what does it track", "performance metrics", "heart rate", "elevation gain"), + "It tracks the numbers that matter for training and pacing: distance traveled, speed, elevation gain, and heart rate. You can watch them live in tracking mode or review them after the activity.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "how rugged", "rugged", "durable", "shock-resistant", "harsh conditions"), + "It's built for abuse. The casing is durable and shock-resistant, and the reinforced strap and rugged design shrug off extreme temperatures, water, and impact out in the field.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "location sharing", "share location", "share my location"), + "Press the share button and pick the contacts you want to reach, and the watch sends your position to them in real time. It needs a stable GPS signal and your phone connected through the TrailMaster app.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "navigation tools", "route planning", "waypoint", "trail tracking"), + "Plenty to work with: preloaded topographic maps, trail tracking, waypoint management, and route planning, backed by an onboard compass, altimeter, and barometer for orientation and weather.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps signal loss", "no gps signal", "lost gps", "gps troubleshooting"), + "If it shows \"No GPS Signal,\" start with your surroundings: dense foliage, buildings, or terrain can block satellites. Check for RF interference, run a signal diagnostics test, and if it keeps happening, reach out to ExpeditionTech support.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "bluetooth", "cannot connect", "connection problem", "connectivity issues"), + "For pairing trouble, keep the watch within Bluetooth range, clear out nearby sources of interference, and update to the latest firmware. If it still won't connect, a Bluetooth signal analyzer can help track down the cause.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "first aid"), + "The first aid supplies cover the basics for minor injuries: adhesive bandages, sterile gauze and tape, antiseptic wipes and antibiotic ointment, over-the-counter medications, and tools like tweezers, scissors, and a thermometer. A first aid guide is included too.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "water purification", "water filter", "purify water", "clean water"), + "It comes with a portable water filter that removes 99.9999% of waterborne bacteria and 99.9% of protozoan parasites, so you can drink from rivers and lakes. Purification tablets are included too for an extra layer of protection.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "signal for help", "signal mirror", "two-way radio", "signaling"), + "For signaling, the kit includes a whistle for short range, a signal mirror that reflects sunlight to catch a rescuer's eye, and a weather-resistant two-way radio with up to 20 miles of range to reach your group or emergency services.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true); + + internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); + + private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) + { + string? question = GetLastQuestion(request); + if (string.IsNullOrWhiteSpace(question)) + { + return false; + } + + foreach (string term in terms) + { + if (question.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? GetLastQuestion(MockChatClientRequest request) + { + int startIndex = request.Options?.ResponseFormat is ChatResponseFormatJson + ? request.Messages.Count - 2 + : request.Messages.Count - 1; + + for (int i = startIndex; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User && !string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + } + + return null; + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md index fc3071a5e80..b4c9e3deb49 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md @@ -2,8 +2,10 @@ This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). +#### ---#if (!IsMock) >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. +#### ---#endif #### ---#if (IsAzure) ### Prerequisites @@ -20,6 +22,20 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See # Configure the AI Model Provider +#### ---#if (IsMock) +## Using Mock provider + +The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads. + +It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`. + +Good starter prompts: + +- What does TrailMaster track? +- How rugged is TrailMaster? +- How does location sharing work? +- Why should I buy this watch? +#### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI diff --git a/src/ProjectTemplates/README.md b/src/ProjectTemplates/README.md index d4dad3ab88a..6e5da5dabcf 100644 --- a/src/ProjectTemplates/README.md +++ b/src/ProjectTemplates/README.md @@ -140,13 +140,13 @@ Finally, create a project from the template and run it: ```pwsh dotnet new aiagent-webapi ` - [--provider ] ` + --provider ` [--managed-identity] # or dotnet new aichatweb ` - [--provider ] ` + --provider ` [--vector-store ] ` [--aspire] ` [--managed-identity] diff --git a/test/ProjectTemplates/Infrastructure/TemplateSnapshotTestBase.cs b/test/ProjectTemplates/Infrastructure/TemplateSnapshotTestBase.cs index d716d0d5d8b..2c23b31b871 100644 --- a/test/ProjectTemplates/Infrastructure/TemplateSnapshotTestBase.cs +++ b/test/ProjectTemplates/Infrastructure/TemplateSnapshotTestBase.cs @@ -31,8 +31,8 @@ protected virtual TemplateVerifierOptions PrepareSnapshotVerifier( Directory.Delete(workingDir, recursive: true); } - // Get the template location from the template package. Use a wildcard for the version number in the file name. - string templateLocation = Path.Combine(WellKnownPaths.LocalShippingPackagesPath, $"{templatePackageName}.*.nupkg"); + // Resolve a concrete template package path so dotnet new install works cross-platform. + string templateLocation = ResolveTemplatePackagePath(templatePackageName); string[]? excludePatterns = Path.DirectorySeparatorChar is '/' ? verificationExcludePatterns @@ -50,4 +50,24 @@ protected virtual TemplateVerifierOptions PrepareSnapshotVerifier( VerificationExcludePatterns = excludePatterns }; } + + private static string ResolveTemplatePackagePath(string templatePackageName) + { + string packageSearchPattern = $"{templatePackageName}.*.nupkg"; + + FileInfo? latestPackage = new DirectoryInfo(WellKnownPaths.LocalShippingPackagesPath) + .EnumerateFiles(packageSearchPattern, SearchOption.TopDirectoryOnly) + .Where(file => !file.Name.EndsWith(".symbols.nupkg", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ThenByDescending(file => file.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + if (latestPackage is null) + { + throw new FileNotFoundException( + $"Unable to find template package '{packageSearchPattern}' under '{WellKnownPaths.LocalShippingPackagesPath}'."); + } + + return latestPackage.FullName; + } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index 4d4ceb4073c..bdfa206e137 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs @@ -36,7 +36,7 @@ public AIChatWebExecutionTests(TemplateExecutionTestFixture fixture, ITestOutput public static IEnumerable GetSupportedProjectConfigurations() { (string name, string[] values)[] allOptionValues = [ - ("--provider", ["azureopenai", "ollama", "openai" /*, "azureaifoundry" */]), + ("--provider", ["mock", "azureopenai", "ollama", "openai" /*, "azureaifoundry" */]), ("--vector-store", ["azureaisearch", "local", "qdrant"]), ("--aspire", ["true", "false"]), ("--managed-identity", ["true", "false"]), diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs index c94354aa0cd..d1b7277507f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs @@ -27,10 +27,11 @@ public AIChatWebSnapshotTests(ITestOutputHelper log) } [Theory] + [InlineData("--provider=mock")] [InlineData("--provider=openai")] [InlineData("--provider=ollama", "--vector-store=qdrant")] [InlineData("--provider=openai", "--vector-store=azureaisearch")] - [InlineData("--aspire", "--provider=openai")] + [InlineData("--aspire", "--provider=mock")] [InlineData("--aspire", "--provider=azureopenai", "--vector-store=azureaisearch")] public async Task RunSnapshotTests(params string[] templateArgs) { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md new file mode 100644 index 00000000000..5d3b5afb2ca --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md @@ -0,0 +1,56 @@ +# AI Chat with Custom Data + +This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). + + +### Known Issues + +#### Errors running Ollama or Docker + +A recent incompatibility was found between Ollama and Docker Desktop. This issue results in runtime errors when connecting to Ollama, and the workaround for that can lead to Docker not working for Aspire projects. + +This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See [ollama/ollama#9509](https://github.com/ollama/ollama/issues/9509#issuecomment-2842461831) for more information and a link to install the version of Docker Desktop with the fix. + +# Configure the AI Model Provider + +## Using Mock provider + +The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads. + +It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`. + +Good starter prompts: + +- What does TrailMaster track? +- How rugged is TrailMaster? +- How does location sharing work? +- Why should I buy this watch? + +# Running the application + +## Using Visual Studio + +1. Open the `.sln` file in Visual Studio. +2. Press `Ctrl+F5` or click the "Start" button in the toolbar to run the project. + +## Using Visual Studio Code + +1. Open the project folder in Visual Studio Code. +2. Install the [C# Dev Kit extension](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) for Visual Studio Code. +3. Once installed, Open the `Program.cs` file in the aichatweb.AppHost project. +4. Run the project by clicking the "Run" button in the Debug view. + +## Trust the localhost certificate + +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. + +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. + +# Updating JavaScript dependencies + +This template leverages JavaScript libraries to provide essential functionality. These libraries are located in the wwwroot/lib folder of the aichatweb.Web project. For instructions on updating each dependency, please refer to the README.md file in each respective folder. + +# Learn More +To learn more about development with .NET and AI, check out the following links: + +* [AI for .NET Developers](https://learn.microsoft.com/dotnet/ai/) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs new file mode 100644 index 00000000000..7b8358c847a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var markitdown = builder.AddContainer("markitdown", "mcp/markitdown") + .WithArgs("--http", "--host", "0.0.0.0", "--port", "3001") + .WithHttpEndpoint(targetPort: 3001, name: "http"); + +var webApp = builder.AddProject("aichatweb-app"); +webApp + .WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); + +builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..f08691f26f1 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9995;http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9995", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9995" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9996", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9996" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj new file mode 100644 index 00000000000..c9340b988a1 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -0,0 +1,21 @@ + + + + + + Exe + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..b0bacf42851 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.json new file mode 100644 index 00000000000..bfad98588cd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000000..8d0b0cd5d67 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -0,0 +1,134 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("Experimental.Microsoft.Extensions.AI"); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation() + .AddSource("Experimental.Microsoft.Extensions.AI") + .AddSource("Experimental.Microsoft.Extensions.DataIngestion"); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj new file mode 100644 index 00000000000..b3853baa2d2 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/App.razor new file mode 100644 index 00000000000..262359d5f5a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor new file mode 100644 index 00000000000..116455ce45b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..96fbbe6cc42 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..cda2020dcb0 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor new file mode 100644 index 00000000000..9d33e3ff5ed --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -0,0 +1,137 @@ +@page "/" +@using System.ComponentModel +@using System.Xml.Linq +@inject IChatClient ChatClient +@inject NavigationManager Nav +@inject SemanticSearch Search +@implements IDisposable + +Chat + + + + + +
To get started, try asking about these example documents. You can replace these with your own data and replace this message.
+ + +
+
+ +
+ + +
+ +@code { + private const string SystemPrompt = @" + You are an assistant who answers questions about information you retrieve. + Do not answer questions about anything else. + Use only simple markdown to format your responses. + + Use the LoadDocuments tool to prepare for searches before answering any questions. + + Use the Search tool to find relevant information. When you do this, end your + reply with citations in the special XML format: + + exact quote here + + Always include the citation in your response if there are results. + + The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant. + Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. + "; + + private int statefulMessageCount; + private readonly ChatOptions chatOptions = new(); + private readonly List messages = new(); + private CancellationTokenSource? currentResponseCancellation; + private ChatMessage? currentResponseMessage; + private ChatInput? chatInput; + private ChatSuggestions? chatSuggestions; + + protected override void OnInitialized() + { + statefulMessageCount = 0; + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.Tools = [ + AIFunctionFactory.Create(LoadDocumentsAsync), + AIFunctionFactory.Create(SearchAsync) + ]; + } + + private async Task AddUserMessageAsync(ChatMessage userMessage) + { + CancelAnyCurrentResponse(); + + // Add the user message to the conversation + messages.Add(userMessage); + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); + currentResponseCancellation = new(); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } + + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; + chatSuggestions?.Update(messages); + } + + private void CancelAnyCurrentResponse() + { + // If a response was cancelled while streaming, include it in the conversation so it's not lost + if (currentResponseMessage is not null) + { + messages.Add(currentResponseMessage); + } + + currentResponseCancellation?.Cancel(); + currentResponseMessage = null; + } + + private async Task ResetConversationAsync() + { + CancelAnyCurrentResponse(); + messages.Clear(); + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + } + + [Description("Loads the documents needed for performing searches. Must be completed before a search can be executed, but only needs to be completed once.")] + private async Task LoadDocumentsAsync() + { + await InvokeAsync(StateHasChanged); + await Search.LoadDocumentsAsync(); + } + + [Description("Searches for information using a phrase or keyword. Relies on documents already being loaded.")] + private async Task> SearchAsync( + [Description("The phrase to search for.")] string searchPhrase, + [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) + { + await InvokeAsync(StateHasChanged); + var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); + return results.Select(result => + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); + } + + public void Dispose() + => currentResponseCancellation?.Cancel(); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css new file mode 100644 index 00000000000..98ed1ba7d1e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css @@ -0,0 +1,11 @@ +.chat-container { + position: sticky; + bottom: 0; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 1.5rem; + border-top-width: 1px; + background-color: #F3F4F6; + border-color: #E5E7EB; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor new file mode 100644 index 00000000000..667189beabd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor @@ -0,0 +1,39 @@ +@using System.Web +@if (!string.IsNullOrWhiteSpace(viewerUrl)) +{ + + + + +
+
@File
+
@Quote
+
+
+} + +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public string? Quote { get; set; } + + private string? viewerUrl; + + protected override void OnParametersSet() + { + viewerUrl = null; + + // If you ingest other types of content besides Markdown or PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".md")) + { + viewerUrl = $"lib/markdown_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#:~:text={Uri.EscapeDataString(Quote ?? "")}"; + } + else if (File.EndsWith(".pdf")) + { + var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#search={HttpUtility.UrlEncode(search)}&phrase=true"; + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css new file mode 100644 index 00000000000..0ca029b7e64 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css @@ -0,0 +1,37 @@ +.citation { + display: inline-flex; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 1rem; + margin-right: 1rem; + border-bottom: 2px solid #a770de; + gap: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: #ffffff; +} + + .citation[href]:hover { + outline: 1px solid #865cb1; + } + + .citation svg { + width: 1.5rem; + height: 1.5rem; + } + + .citation:active { + background-color: rgba(0,0,0,0.05); + } + +.citation-content { + display: flex; + flex-direction: column; +} + +.citation-file { + font-weight: 600; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor new file mode 100644 index 00000000000..12b1d524e23 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
+
+ +
+ +

aichatweb.Web

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css new file mode 100644 index 00000000000..6adcc414540 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css @@ -0,0 +1,25 @@ +.chat-header-container { + top: 0; + padding: 1.5rem; +} + +.chat-header-controls { + margin-bottom: 1.5rem; +} + +h1 { + overflow: hidden; + text-overflow: ellipsis; +} + +.new-chat-icon { + width: 1.25rem; + height: 1.25rem; + color: rgb(55, 65, 81); +} + +@media (min-width: 768px) { + .chat-header-container { + position: sticky; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor new file mode 100644 index 00000000000..e87ac6ccf47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css new file mode 100644 index 00000000000..3b26c9af316 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css @@ -0,0 +1,57 @@ +.input-box { + display: flex; + flex-direction: column; + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-top: 0.75rem; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.tools { + display: flex; + margin-top: 1rem; + align-items: center; +} + +.tool-icon { + width: 1.25rem; + height: 1.25rem; +} + +.send-button { + color: var(--send-button-color); + margin-left: auto; +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js new file mode 100644 index 00000000000..39e18ac7b74 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 00000000000..33367fa3974 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,123 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Xml +@using System.Xml.Linq + +@if (Message.Role == ChatRole.User) +{ +
+ @Message.Text +
+} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
+
+
+ + + +
+
+
Assistant
+
+ + + @foreach (var citation in citations ?? []) + { + + } +
+
+ } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } + else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) + { + + } + } +} + +@code { + private static readonly ConditionalWeakTable SubscribersLookup = new(); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); + + private List<(string File, string Quote)>? citations; + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + + if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) + { + ParseCitations(text); + } + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } + + private void ParseCitations(string text) + { + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css new file mode 100644 index 00000000000..10453454be8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css @@ -0,0 +1,120 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + color: #1F2937; + white-space: pre-wrap; +} + +.assistant-message, .assistant-search { + display: grid; + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.25rem; +} + +.assistant-message-header { + font-weight: 600; +} + +.assistant-message-text { + grid-column-start: 2; +} + +.assistant-message-icon { + display: flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + width: 1.5rem; + height: 1.5rem; + color: #ffffff; + background: #9b72ce; +} + + .assistant-message-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.assistant-search-icon { + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; +} + + .assistant-search-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search-content { + align-content: center; +} + +.assistant-search-phrase { + font-weight: 600; +} + +/* Default styling for markdown-formatted assistant messages */ +::deep ul { + list-style-type: disc; + margin-left: 1.5rem; +} + +::deep ol { + list-style-type: decimal; + margin-left: 1.5rem; +} + +::deep li { + margin: 0.5rem 0; +} + +::deep strong { + font-weight: 600; +} + +::deep h3 { + margin: 1rem 0; + font-weight: 600; +} + +::deep p + p { + margin-top: 1rem; +} + +::deep table { + margin: 1rem 0; +} + +::deep th { + text-align: left; + border-bottom: 1px solid silver; +} + +::deep th, ::deep td { + padding: 0.1rem 0.5rem; +} + +::deep th, ::deep tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} + +::deep pre > code { + background-color: white; + display: block; + padding: 0.5rem 1rem; + margin: 1rem 0; + overflow-x: auto; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor new file mode 100644 index 00000000000..d245f455f11 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS + +
+ + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
@NoMessagesContent
+ } +
+
+ +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Activates the auto-scrolling behavior + await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css new file mode 100644 index 00000000000..6fbf083c7fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css @@ -0,0 +1,22 @@ +.message-list-container { + margin: 2rem 1.5rem; + flex-grow: 1; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: calc(40vh - 18rem); +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor new file mode 100644 index 00000000000..69ca922a8ce --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
+ @foreach (var suggestion in suggestions) + { + + } +
+} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.GetResponseAsync( + [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], + cancellationToken: cancellation.Token); + if (!response.TryGetResult(out suggestions)) + { + suggestions = null; + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await DispatchExceptionAsync(ex); + } + } + + private async Task AddSuggestionAsync(string text) + { + await OnSelected.InvokeAsync(new(ChatRole.User, text)); + } + + private IEnumerable ReduceMessages(IReadOnlyList messages) + { + // Get any leading system messages, plus up to 5 user/assistant messages + // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long + var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); + var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); + return systemMessages.Concat(otherMessages); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css new file mode 100644 index 00000000000..b291042c6d4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css @@ -0,0 +1,9 @@ +.suggestions { + text-align: right; + white-space: nowrap; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; + display: flex; + margin-bottom: 0.75rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Routes.razor new file mode 100644 index 00000000000..f756e19dfbc --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/_Imports.razor new file mode 100644 index 00000000000..fa7cadef6ea --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Extensions.AI +@using Microsoft.JSInterop +@using aichatweb.Web +@using aichatweb.Web.Components +@using aichatweb.Web.Components.Layout +@using aichatweb.Web.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs new file mode 100644 index 00000000000..f31aa5b1e8a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.AI; +using aichatweb.Web.Components; +using aichatweb.Web.Services; +using aichatweb.Web.Services.Ingestion; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +builder.Services.AddChatClient(MockServices.CreateChatClient()) + .UseFunctionInvocation() + .UseOpenTelemetry(configure: c => + c.EnableSensitiveData = builder.Environment.IsDevelopment()); +builder.Services.AddEmbeddingGenerator(MockServices.CreateEmbeddingGenerator()); + +var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); +var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; +builder.Services.AddSqliteVectorStore(_ => vectorStoreConnectionString); +builder.Services.AddSqliteCollection(IngestedChunk.CollectionName, vectorStoreConnectionString); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddKeyedSingleton("ingestion_directory", new DirectoryInfo(Path.Combine(builder.Environment.WebRootPath, "Data"))); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); + +app.UseStaticFiles(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json new file mode 100644 index 00000000000..6e300d8a802 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9995;http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs new file mode 100644 index 00000000000..2f1ca15090a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class IngestedChunk +{ + public const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model + public const string VectorDistanceFunction = DistanceFunction.CosineDistance; + public const string CollectionName = "data-aichatweb-chunks"; + + [VectorStoreKey(StorageName = "key")] + [JsonPropertyName("key")] + public required string Key { get; set; } + + [VectorStoreData(StorageName = "documentid")] + [JsonPropertyName("documentid")] + public required string DocumentId { get; set; } + + [VectorStoreData(StorageName = "content")] + [JsonPropertyName("content")] + public required string Text { get; set; } + + [VectorStoreData(StorageName = "context")] + [JsonPropertyName("context")] + public string? Context { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction, StorageName = "embedding")] + [JsonPropertyName("embedding")] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs new file mode 100644 index 00000000000..9dd366a03a5 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DataIngestion; +using Microsoft.Extensions.DataIngestion.Chunkers; +using Microsoft.Extensions.VectorData; +using Microsoft.ML.Tokenizers; + +namespace aichatweb.Web.Services.Ingestion; + +public class DataIngestor( + ILogger logger, + ILoggerFactory loggerFactory, + VectorStore vectorStore, + IEmbeddingGenerator> embeddingGenerator) +{ + public async Task IngestDataAsync(DirectoryInfo directory, string searchPattern) + { + using var writer = new VectorStoreWriter(vectorStore, dimensionCount: IngestedChunk.VectorDimensions, new() + { + CollectionName = IngestedChunk.CollectionName, + DistanceFunction = IngestedChunk.VectorDistanceFunction, + IncrementalIngestion = false, + }); + + using var pipeline = new IngestionPipeline( + reader: new DocumentReader(directory), + chunker: new SemanticSimilarityChunker(embeddingGenerator, new(TiktokenTokenizer.CreateForModel("gpt-4o"))), + writer: writer, + loggerFactory: loggerFactory); + + await foreach (var result in pipeline.ProcessAsync(directory, searchPattern)) + { + logger.LogInformation("Completed processing '{id}'. Succeeded: '{succeeded}'.", result.DocumentId, result.Succeeded); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs new file mode 100644 index 00000000000..60fcdbdc128 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DataIngestion; + +namespace aichatweb.Web.Services.Ingestion; + +internal sealed class DocumentReader(DirectoryInfo rootDirectory) : IngestionDocumentReader +{ + private readonly MarkdownReader _markdownReader = new(); + private readonly MarkItDownMcpReader _pdfReader = new(mcpServerUri: GetMarkItDownMcpServerUrl()); + + public override Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + if (Path.IsPathFullyQualified(identifier)) + { + // Normalize the identifier to its relative path + identifier = Path.GetRelativePath(rootDirectory.FullName, identifier); + } + + mediaType = GetCustomMediaType(source) ?? mediaType; + return base.ReadAsync(source, identifier, mediaType, cancellationToken); + } + + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + => mediaType switch + { + "application/pdf" => _pdfReader.ReadAsync(source, identifier, mediaType, cancellationToken), + "text/markdown" => _markdownReader.ReadAsync(source, identifier, mediaType, cancellationToken), + _ => throw new InvalidOperationException($"Unsupported media type '{mediaType}'"), + }; + + private static string? GetCustomMediaType(FileInfo source) + => source.Extension switch + { + ".md" => "text/markdown", + _ => null + }; + + private static Uri GetMarkItDownMcpServerUrl() + { + var markItDownMcpUrl = $"{Environment.GetEnvironmentVariable("MARKITDOWN_MCP_URL")}/mcp"; + return new Uri(markItDownMcpUrl); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs new file mode 100644 index 00000000000..cd7af1fecc7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace aichatweb.Web.Services; + +internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator +{ + private readonly int _dimensions; + + internal LexicalMockEmbeddingGenerator(int dimensions) + { + ArgumentOutOfRangeException.ThrowIfLessThan(dimensions, 1); + _dimensions = dimensions; + } + + protected override Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + if (GenerateAsyncCallback is not null) + { + return base.GenerateCoreAsync(values, options, cancellationToken); + } + + GeneratedEmbeddings> embeddings = []; + foreach (string value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + embeddings.Add(new Embedding(CreateVector(value, _dimensions))); + } + + return Task.FromResult(embeddings); + } + + private static float[] CreateVector(string? value, int dimensions) + { + var vector = new float[dimensions]; + if (string.IsNullOrEmpty(value)) + { + return vector; + } + + // Hash tokens and three-character shingles so shared words and word fragments have matching dimensions. + int tokenStart = -1; + for (int i = 0; i <= value.Length; i++) + { + if (i < value.Length && char.IsLetterOrDigit(value[i])) + { + tokenStart = tokenStart < 0 ? i : tokenStart; + } + else if (tokenStart >= 0) + { + AddTokenFeatures(value, tokenStart, i - tokenStart, vector); + tokenStart = -1; + } + } + + // Normalize to unit length so cosine similarity favors lexical overlap rather than document length. + float squaredMagnitude = 0; + foreach (float component in vector) + { + squaredMagnitude += component * component; + } + + if (squaredMagnitude > 0) + { + float scale = 1 / (float)Math.Sqrt(squaredMagnitude); + for (int i = 0; i < vector.Length; i++) + { + vector[i] *= scale; + } + } + + return vector; + } + + private static void AddTokenFeatures(string value, int tokenStart, int tokenLength, float[] vector) + { + AddFeature(value, tokenStart, tokenLength, vector); + + for (int i = tokenStart; i <= tokenStart + tokenLength - 3; i++) + { + AddFeature(value, i, 3, vector); + } + } + + private static void AddFeature(string value, int start, int length, float[] vector) + { + uint hash = 2_166_136_261; + unchecked + { + for (int i = start; i < start + length; i++) + { + hash = (hash ^ char.ToLowerInvariant(value[i])) * 16_777_619; + } + } + + vector[(int)(hash % (uint)vector.Length)] += 1; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs new file mode 100644 index 00000000000..1444bf2fb91 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs @@ -0,0 +1,294 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace aichatweb.Web.Services; + +internal static class MockChatClientExtensions +{ + private const string LoadDocumentsCallId = "mock-load-documents"; + private const string SearchCallId = "mock-search"; + private const string MockConversationId = "mock-conversation"; + + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// Optional aichatweb follow-up suggestions returned through its structured-output request. Supply an empty + /// collection when the response is a conversation dead end. + /// + /// + /// to invoke the document-search tools and append a citation from the search result. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + IEnumerable? suggestions = null, + bool includeCitation = false, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + if (suggestions is null) + { + return AddResponse(client, requestPredicate, response, minDelay, maxDelay, includeCitation, singleUse); + } + + string structuredResponse = JsonSerializer.Serialize(new { data = suggestions.ToArray() }); + + MockChatClient configuredClient = AddResponse( + client, + request => request.Options?.ResponseFormat is not ChatResponseFormatJson && requestPredicate(request), + response, + minDelay, + maxDelay, + includeCitation, + singleUse); + + return AddResponse( + configuredClient, + request => request.Options?.ResponseFormat is ChatResponseFormatJson && requestPredicate(request), + structuredResponse, + minDelay, + maxDelay, + includeCitation, + singleUse); + } + + internal static string Citation(string filename, string quote) + { + ArgumentNullException.ThrowIfNull(filename); + ArgumentNullException.ThrowIfNull(quote); + + return new XElement( + "citation", + new XAttribute("filename", filename), + quote).ToString(SaveOptions.DisableFormatting); + } + + private static MockChatClient AddResponse( + MockChatClient client, + Func requestPredicate, + string response, + int? minDelay, + int? maxDelay, + bool includeCitation, + bool singleUse) => + client.AddResponse( + requestPredicate, + async (request, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return CreateResponse(request, response, includeCitation); + }, + singleUse); + + private static ChatResponse CreateResponse(MockChatClientRequest request, string response, bool includeCitation) + { + if (request.Options?.ResponseFormat is ChatResponseFormatJson) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response)); + } + + if (TryGetFunctionResult(request, SearchCallId, out FunctionResultContent? searchResult) && searchResult is not null) + { + return CreateConversationResponse($"{response}{GetCitation(searchResult, request.LastUserText)}"); + } + + if (TryGetFunctionResult(request, LoadDocumentsCallId, out _)) + { + return CreateSearchFunctionCallResponse(request); + } + + if (includeCitation) + { + return request.Options?.ConversationId is null + ? CreateFunctionCallResponse(LoadDocumentsCallId, "LoadDocuments") + : CreateSearchFunctionCallResponse(request); + } + + return CreateConversationResponse(response); + } + + private static ChatResponse CreateConversationResponse(string response) => + new(new ChatMessage(ChatRole.Assistant, response)) + { + ConversationId = MockConversationId, + }; + + private static ChatResponse CreateFunctionCallResponse( + string callId, + string name, + IDictionary? arguments = null) => + new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(callId, name, arguments)])); + + private static ChatResponse CreateSearchFunctionCallResponse(MockChatClientRequest request) => + CreateFunctionCallResponse( + SearchCallId, + "Search", + new Dictionary + { + ["searchPhrase"] = request.LastUserText ?? string.Empty, + }); + + private static string GetCitation(FunctionResultContent searchResult, string? searchPhrase) + { + IEnumerable results = searchResult.Result switch + { + IEnumerable result => result, + JsonElement { ValueKind: JsonValueKind.String } result when result.GetString() is { } resultText => [resultText], + JsonElement { ValueKind: JsonValueKind.Array } result => result + .EnumerateArray() + .Where(static element => element.ValueKind is JsonValueKind.String) + .Select(static element => element.GetString()!), + _ => [], + }; + + foreach (string result in results) + { + try + { + XElement element = XElement.Parse(result); + if (element.Name.LocalName != "result" || + element.Attribute("filename")?.Value is not { Length: > 0 } filename) + { + continue; + } + + string quote = GetQuote(element.Value, searchPhrase); + if (quote.Length > 0) + { + return Citation(filename, quote); + } + } + catch (XmlException) + { + // Ignore malformed search results. + } + } + + return string.Empty; + } + + private static string GetQuote(string text, string? searchPhrase) + { + string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + return string.Empty; + } + + int start = 0; + if (!string.IsNullOrWhiteSpace(searchPhrase)) + { + int fewestOccurrences = int.MaxValue; + foreach (string queryWord in searchPhrase.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + { + string term = NormalizeWord(queryWord); + if (term.Length < 4) + { + continue; + } + + int occurrences = 0; + int firstMatch = -1; + for (int i = 0; i < words.Length; i++) + { + if (IsTermMatch(NormalizeWord(words[i]), term)) + { + occurrences++; + firstMatch = firstMatch < 0 ? i : firstMatch; + } + } + + if (occurrences > 0 && occurrences < fewestOccurrences) + { + fewestOccurrences = occurrences; + start = firstMatch; + } + } + } + + return string.Join(" ", words, start, Math.Min(5, words.Length - start)); + } + + private static bool IsTermMatch(string word, string term) => + word.Length > 0 && (word.Contains(term, StringComparison.Ordinal) || term.Contains(word, StringComparison.Ordinal)); + + private static string NormalizeWord(string value) => + string.Concat(value.Where(static character => char.IsLetterOrDigit(character)).Select(static character => char.ToLowerInvariant(character))); + + private static bool TryGetFunctionResult( + MockChatClientRequest request, + string callId, + out FunctionResultContent? functionResult) + { + for (int i = request.Messages.Count - 1; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User) + { + break; + } + + foreach (AIContent content in message.Contents) + { + if (content is FunctionResultContent result && result.CallId == callId) + { + functionResult = result; + return true; + } + } + } + + functionResult = null; + return false; + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelayMilliseconds, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, 0, nameof(maxDelay)); + + if (minDelay is { } minimumDelayMillisecondsForMaximum) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, minimumDelayMillisecondsForMaximum, nameof(maxDelay)); + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs new file mode 100644 index 00000000000..73991ba2513 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.AI; +namespace aichatweb.Web.Services; + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static _ => true, + "Hi! I can help you plan an outdoor trip or find the right gear for it. What would you like to know?", + minDelay: 500, + maxDelay: 500, + suggestions: ["What kind of adventures?", "What kinds of products?"]) + .AddResponse( + static request => LastQuestionContains(request, "what kind of adventures", "outdoor activities"), + "Mostly off-grid trips: hiking, backpacking, trail running, and mountaineering are the classics, and plenty of people use the same gear for camping and long day hikes. Anywhere you lose cell service, the right gear makes the trip safer and easier.", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about hiking adventures.", "Tell me about trail safety."]) + .AddResponse( + static request => LastQuestionContains(request, "what kinds of products"), + "Two main categories: adventure GPS watches for navigation and tracking, and emergency survival kits for backcountry safety. The GPS watch is the flagship, though a lot of people carry both. Want to dig into either one?", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about GPS watches.", "Tell me about survival kits."]) + .AddResponse( + static request => LastQuestionContains(request, "hiking adventures", "hiking"), + "Hiking is right in the TrailMaster GPS Watch's wheelhouse. It's made for hikers, backpackers, and trail runners heading into remote terrain, with offline topographic maps and real-time location sharing to keep you on route and reachable.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "How rugged is TrailMaster?", "How does location sharing work?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps watches", "gps watch"), + "The TrailMaster GPS Watch pairs precise positioning with a rugged build. It runs a multi-constellation receiver for GPS, GLONASS, and Galileo, plus an altimeter, barometer, and compass, so navigation holds up even where satellite coverage is thin.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "What navigation tools are built in?", "How rugged is TrailMaster?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "trail safety", "safety"), + "Trail safety really comes down to preparation. Before heading out, make sure the watch is charged and calibrated and that you know its controls, then use real-time location sharing so someone always knows where you are. For longer trips, an emergency survival kit is a smart backup.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["How does location sharing work?", "How do I fix GPS signal loss?", "Tell me about survival kits."], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "survival kits", "survival kit", "emergency kit"), + "The Life Guard X Emergency Survival Kit is built for when a trip goes sideways. It packs first aid supplies, high-calorie emergency food, a water purification system, an emergency shelter, and signaling tools so you can stay safe and reach help.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What's in the first aid supplies?", "How does water purification work?", "How do I signal for help?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "what does trailmaster track", "what does it track", "performance metrics", "heart rate", "elevation gain"), + "It tracks the numbers that matter for training and pacing: distance traveled, speed, elevation gain, and heart rate. You can watch them live in tracking mode or review them after the activity.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "how rugged", "rugged", "durable", "shock-resistant", "harsh conditions"), + "It's built for abuse. The casing is durable and shock-resistant, and the reinforced strap and rugged design shrug off extreme temperatures, water, and impact out in the field.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "location sharing", "share location", "share my location"), + "Press the share button and pick the contacts you want to reach, and the watch sends your position to them in real time. It needs a stable GPS signal and your phone connected through the TrailMaster app.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "navigation tools", "route planning", "waypoint", "trail tracking"), + "Plenty to work with: preloaded topographic maps, trail tracking, waypoint management, and route planning, backed by an onboard compass, altimeter, and barometer for orientation and weather.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps signal loss", "no gps signal", "lost gps", "gps troubleshooting"), + "If it shows \"No GPS Signal,\" start with your surroundings: dense foliage, buildings, or terrain can block satellites. Check for RF interference, run a signal diagnostics test, and if it keeps happening, reach out to ExpeditionTech support.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "bluetooth", "cannot connect", "connection problem", "connectivity issues"), + "For pairing trouble, keep the watch within Bluetooth range, clear out nearby sources of interference, and update to the latest firmware. If it still won't connect, a Bluetooth signal analyzer can help track down the cause.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "first aid"), + "The first aid supplies cover the basics for minor injuries: adhesive bandages, sterile gauze and tape, antiseptic wipes and antibiotic ointment, over-the-counter medications, and tools like tweezers, scissors, and a thermometer. A first aid guide is included too.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "water purification", "water filter", "purify water", "clean water"), + "It comes with a portable water filter that removes 99.9999% of waterborne bacteria and 99.9% of protozoan parasites, so you can drink from rivers and lakes. Purification tablets are included too for an extra layer of protection.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "signal for help", "signal mirror", "two-way radio", "signaling"), + "For signaling, the kit includes a whistle for short range, a signal mirror that reflects sunlight to catch a rescuer's eye, and a weather-resistant two-way radio with up to 20 miles of range to reach your group or emergency services.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true); + + internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); + + private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) + { + string? question = GetLastQuestion(request); + if (string.IsNullOrWhiteSpace(question)) + { + return false; + } + + foreach (string term in terms) + { + if (question.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? GetLastQuestion(MockChatClientRequest request) + { + int startIndex = request.Options?.ResponseFormat is ChatResponseFormatJson + ? request.Messages.Count - 2 + : request.Messages.Count - 1; + + for (int i = startIndex; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User && !string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + } + + return null; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs new file mode 100644 index 00000000000..d043c8efb84 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs @@ -0,0 +1,27 @@ +using aichatweb.Web.Services.Ingestion; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class SemanticSearch( + VectorStoreCollection vectorCollection, + [FromKeyedServices("ingestion_directory")] DirectoryInfo ingestionDirectory, + DataIngestor dataIngestor) +{ + private Task? _ingestionTask; + + public async Task LoadDocumentsAsync() => await ( _ingestionTask ??= dataIngestor.IngestDataAsync(ingestionDirectory, searchPattern: "*.*")); + + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) + { + // Ensure documents have been loaded before searching + await LoadDocumentsAsync(); + + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions + { + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, + }); + + return await nearest.Select(result => result.Record).ToListAsync(); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj new file mode 100644 index 00000000000..1d3b3e6cf38 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.Development.json new file mode 100644 index 00000000000..e22bd83cf3a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.json new file mode 100644 index 00000000000..d286041f99d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln new file mode 100644 index 00000000000..67d2a3cad3c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.AppHost", "aichatweb.AppHost\aichatweb.AppHost.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.ServiceDefaults", "aichatweb.ServiceDefaults\aichatweb.ServiceDefaults.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.Web", "aichatweb.Web\aichatweb.Web.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/App.razor new file mode 100644 index 00000000000..40862eea8e7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor new file mode 100644 index 00000000000..116455ce45b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..96fbbe6cc42 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..cda2020dcb0 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor new file mode 100644 index 00000000000..9d33e3ff5ed --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -0,0 +1,137 @@ +@page "/" +@using System.ComponentModel +@using System.Xml.Linq +@inject IChatClient ChatClient +@inject NavigationManager Nav +@inject SemanticSearch Search +@implements IDisposable + +Chat + + + + + +
To get started, try asking about these example documents. You can replace these with your own data and replace this message.
+ + +
+
+ +
+ + +
+ +@code { + private const string SystemPrompt = @" + You are an assistant who answers questions about information you retrieve. + Do not answer questions about anything else. + Use only simple markdown to format your responses. + + Use the LoadDocuments tool to prepare for searches before answering any questions. + + Use the Search tool to find relevant information. When you do this, end your + reply with citations in the special XML format: + + exact quote here + + Always include the citation in your response if there are results. + + The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant. + Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. + "; + + private int statefulMessageCount; + private readonly ChatOptions chatOptions = new(); + private readonly List messages = new(); + private CancellationTokenSource? currentResponseCancellation; + private ChatMessage? currentResponseMessage; + private ChatInput? chatInput; + private ChatSuggestions? chatSuggestions; + + protected override void OnInitialized() + { + statefulMessageCount = 0; + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.Tools = [ + AIFunctionFactory.Create(LoadDocumentsAsync), + AIFunctionFactory.Create(SearchAsync) + ]; + } + + private async Task AddUserMessageAsync(ChatMessage userMessage) + { + CancelAnyCurrentResponse(); + + // Add the user message to the conversation + messages.Add(userMessage); + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); + currentResponseCancellation = new(); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } + + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; + chatSuggestions?.Update(messages); + } + + private void CancelAnyCurrentResponse() + { + // If a response was cancelled while streaming, include it in the conversation so it's not lost + if (currentResponseMessage is not null) + { + messages.Add(currentResponseMessage); + } + + currentResponseCancellation?.Cancel(); + currentResponseMessage = null; + } + + private async Task ResetConversationAsync() + { + CancelAnyCurrentResponse(); + messages.Clear(); + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + } + + [Description("Loads the documents needed for performing searches. Must be completed before a search can be executed, but only needs to be completed once.")] + private async Task LoadDocumentsAsync() + { + await InvokeAsync(StateHasChanged); + await Search.LoadDocumentsAsync(); + } + + [Description("Searches for information using a phrase or keyword. Relies on documents already being loaded.")] + private async Task> SearchAsync( + [Description("The phrase to search for.")] string searchPhrase, + [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) + { + await InvokeAsync(StateHasChanged); + var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); + return results.Select(result => + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); + } + + public void Dispose() + => currentResponseCancellation?.Cancel(); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor.css new file mode 100644 index 00000000000..98ed1ba7d1e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor.css @@ -0,0 +1,11 @@ +.chat-container { + position: sticky; + bottom: 0; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 1.5rem; + border-top-width: 1px; + background-color: #F3F4F6; + border-color: #E5E7EB; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor new file mode 100644 index 00000000000..667189beabd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor @@ -0,0 +1,39 @@ +@using System.Web +@if (!string.IsNullOrWhiteSpace(viewerUrl)) +{ + + + + +
+
@File
+
@Quote
+
+
+} + +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public string? Quote { get; set; } + + private string? viewerUrl; + + protected override void OnParametersSet() + { + viewerUrl = null; + + // If you ingest other types of content besides Markdown or PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".md")) + { + viewerUrl = $"lib/markdown_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#:~:text={Uri.EscapeDataString(Quote ?? "")}"; + } + else if (File.EndsWith(".pdf")) + { + var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#search={HttpUtility.UrlEncode(search)}&phrase=true"; + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css new file mode 100644 index 00000000000..0ca029b7e64 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css @@ -0,0 +1,37 @@ +.citation { + display: inline-flex; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 1rem; + margin-right: 1rem; + border-bottom: 2px solid #a770de; + gap: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: #ffffff; +} + + .citation[href]:hover { + outline: 1px solid #865cb1; + } + + .citation svg { + width: 1.5rem; + height: 1.5rem; + } + + .citation:active { + background-color: rgba(0,0,0,0.05); + } + +.citation-content { + display: flex; + flex-direction: column; +} + +.citation-file { + font-weight: 600; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor new file mode 100644 index 00000000000..0e9d9c8894a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
+
+ +
+ +

aichatweb

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css new file mode 100644 index 00000000000..6adcc414540 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css @@ -0,0 +1,25 @@ +.chat-header-container { + top: 0; + padding: 1.5rem; +} + +.chat-header-controls { + margin-bottom: 1.5rem; +} + +h1 { + overflow: hidden; + text-overflow: ellipsis; +} + +.new-chat-icon { + width: 1.25rem; + height: 1.25rem; + color: rgb(55, 65, 81); +} + +@media (min-width: 768px) { + .chat-header-container { + position: sticky; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor new file mode 100644 index 00000000000..e87ac6ccf47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css new file mode 100644 index 00000000000..3b26c9af316 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css @@ -0,0 +1,57 @@ +.input-box { + display: flex; + flex-direction: column; + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-top: 0.75rem; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.tools { + display: flex; + margin-top: 1rem; + align-items: center; +} + +.tool-icon { + width: 1.25rem; + height: 1.25rem; +} + +.send-button { + color: var(--send-button-color); + margin-left: auto; +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js new file mode 100644 index 00000000000..39e18ac7b74 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 00000000000..33367fa3974 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,123 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Xml +@using System.Xml.Linq + +@if (Message.Role == ChatRole.User) +{ +
+ @Message.Text +
+} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
+
+
+ + + +
+
+
Assistant
+
+ + + @foreach (var citation in citations ?? []) + { + + } +
+
+ } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } + else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) + { + + } + } +} + +@code { + private static readonly ConditionalWeakTable SubscribersLookup = new(); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); + + private List<(string File, string Quote)>? citations; + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + + if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) + { + ParseCitations(text); + } + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } + + private void ParseCitations(string text) + { + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css new file mode 100644 index 00000000000..10453454be8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css @@ -0,0 +1,120 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + color: #1F2937; + white-space: pre-wrap; +} + +.assistant-message, .assistant-search { + display: grid; + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.25rem; +} + +.assistant-message-header { + font-weight: 600; +} + +.assistant-message-text { + grid-column-start: 2; +} + +.assistant-message-icon { + display: flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + width: 1.5rem; + height: 1.5rem; + color: #ffffff; + background: #9b72ce; +} + + .assistant-message-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.assistant-search-icon { + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; +} + + .assistant-search-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search-content { + align-content: center; +} + +.assistant-search-phrase { + font-weight: 600; +} + +/* Default styling for markdown-formatted assistant messages */ +::deep ul { + list-style-type: disc; + margin-left: 1.5rem; +} + +::deep ol { + list-style-type: decimal; + margin-left: 1.5rem; +} + +::deep li { + margin: 0.5rem 0; +} + +::deep strong { + font-weight: 600; +} + +::deep h3 { + margin: 1rem 0; + font-weight: 600; +} + +::deep p + p { + margin-top: 1rem; +} + +::deep table { + margin: 1rem 0; +} + +::deep th { + text-align: left; + border-bottom: 1px solid silver; +} + +::deep th, ::deep td { + padding: 0.1rem 0.5rem; +} + +::deep th, ::deep tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} + +::deep pre > code { + background-color: white; + display: block; + padding: 0.5rem 1rem; + margin: 1rem 0; + overflow-x: auto; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor new file mode 100644 index 00000000000..d245f455f11 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS + +
+ + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
@NoMessagesContent
+ } +
+
+ +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Activates the auto-scrolling behavior + await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css new file mode 100644 index 00000000000..6fbf083c7fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css @@ -0,0 +1,22 @@ +.message-list-container { + margin: 2rem 1.5rem; + flex-grow: 1; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: calc(40vh - 18rem); +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor new file mode 100644 index 00000000000..69ca922a8ce --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
+ @foreach (var suggestion in suggestions) + { + + } +
+} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.GetResponseAsync( + [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], + cancellationToken: cancellation.Token); + if (!response.TryGetResult(out suggestions)) + { + suggestions = null; + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await DispatchExceptionAsync(ex); + } + } + + private async Task AddSuggestionAsync(string text) + { + await OnSelected.InvokeAsync(new(ChatRole.User, text)); + } + + private IEnumerable ReduceMessages(IReadOnlyList messages) + { + // Get any leading system messages, plus up to 5 user/assistant messages + // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long + var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); + var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); + return systemMessages.Concat(otherMessages); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css new file mode 100644 index 00000000000..b291042c6d4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css @@ -0,0 +1,9 @@ +.suggestions { + text-align: right; + white-space: nowrap; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; + display: flex; + margin-bottom: 0.75rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Error.razor new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Routes.razor new file mode 100644 index 00000000000..f756e19dfbc --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/_Imports.razor new file mode 100644 index 00000000000..9a70e8453ef --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Extensions.AI +@using Microsoft.JSInterop +@using aichatweb +@using aichatweb.Components +@using aichatweb.Components.Layout +@using aichatweb.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Program.cs new file mode 100644 index 00000000000..91bc130378b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Program.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.AI; +using aichatweb.Components; +using aichatweb.Services; +using aichatweb.Services.Ingestion; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +var chatClient = MockServices.CreateChatClient(); +IEmbeddingGenerator> embeddingGenerator = MockServices.CreateEmbeddingGenerator(); + + +var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); +var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; +builder.Services.AddSqliteVectorStore(_ => vectorStoreConnectionString); +builder.Services.AddSqliteCollection(IngestedChunk.CollectionName, vectorStoreConnectionString); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddKeyedSingleton("ingestion_directory", new DirectoryInfo(Path.Combine(builder.Environment.WebRootPath, "Data"))); +builder.Services.AddChatClient(chatClient).UseFunctionInvocation().UseLogging(); +builder.Services.AddEmbeddingGenerator(embeddingGenerator); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); + +app.UseStaticFiles(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Properties/launchSettings.json new file mode 100644 index 00000000000..6e300d8a802 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9995;http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/README.md new file mode 100644 index 00000000000..cc993cc5d58 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/README.md @@ -0,0 +1,18 @@ +# AI Chat with Custom Data + +This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). + + +# Configure the AI Model Provider +## Using Mock provider + +The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads. + +It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`. + +Good starter prompts: + +- What does TrailMaster track? +- How rugged is TrailMaster? +- How does location sharing work? +- Why should I buy this watch? diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/IngestedChunk.cs new file mode 100644 index 00000000000..a7136d1988d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/IngestedChunk.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class IngestedChunk +{ + public const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model + public const string VectorDistanceFunction = DistanceFunction.CosineDistance; + public const string CollectionName = "data-aichatweb-chunks"; + + [VectorStoreKey(StorageName = "key")] + [JsonPropertyName("key")] + public required string Key { get; set; } + + [VectorStoreData(StorageName = "documentid")] + [JsonPropertyName("documentid")] + public required string DocumentId { get; set; } + + [VectorStoreData(StorageName = "content")] + [JsonPropertyName("content")] + public required string Text { get; set; } + + [VectorStoreData(StorageName = "context")] + [JsonPropertyName("context")] + public string? Context { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction, StorageName = "embedding")] + [JsonPropertyName("embedding")] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DataIngestor.cs new file mode 100644 index 00000000000..d97b986b694 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DataIngestor.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DataIngestion; +using Microsoft.Extensions.DataIngestion.Chunkers; +using Microsoft.Extensions.VectorData; +using Microsoft.ML.Tokenizers; + +namespace aichatweb.Services.Ingestion; + +public class DataIngestor( + ILogger logger, + ILoggerFactory loggerFactory, + VectorStore vectorStore, + IEmbeddingGenerator> embeddingGenerator) +{ + public async Task IngestDataAsync(DirectoryInfo directory, string searchPattern) + { + using var writer = new VectorStoreWriter(vectorStore, dimensionCount: IngestedChunk.VectorDimensions, new() + { + CollectionName = IngestedChunk.CollectionName, + DistanceFunction = IngestedChunk.VectorDistanceFunction, + IncrementalIngestion = false, + }); + + using var pipeline = new IngestionPipeline( + reader: new DocumentReader(directory), + chunker: new SemanticSimilarityChunker(embeddingGenerator, new(TiktokenTokenizer.CreateForModel("gpt-4o"))), + writer: writer, + loggerFactory: loggerFactory); + + await foreach (var result in pipeline.ProcessAsync(directory, searchPattern)) + { + logger.LogInformation("Completed processing '{id}'. Succeeded: '{succeeded}'.", result.DocumentId, result.Succeeded); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DocumentReader.cs new file mode 100644 index 00000000000..315a6ad3d53 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DocumentReader.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DataIngestion; + +namespace aichatweb.Services.Ingestion; + +internal sealed class DocumentReader(DirectoryInfo rootDirectory) : IngestionDocumentReader +{ + private readonly MarkdownReader _markdownReader = new(); + private readonly PdfPigReader _pdfReader = new(); + + public override Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + if (Path.IsPathFullyQualified(identifier)) + { + // Normalize the identifier to its relative path + identifier = Path.GetRelativePath(rootDirectory.FullName, identifier); + } + + mediaType = GetCustomMediaType(source) ?? mediaType; + return base.ReadAsync(source, identifier, mediaType, cancellationToken); + } + + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + => mediaType switch + { + "application/pdf" => _pdfReader.ReadAsync(source, identifier, mediaType, cancellationToken), + "text/markdown" => _markdownReader.ReadAsync(source, identifier, mediaType, cancellationToken), + _ => throw new InvalidOperationException($"Unsupported media type '{mediaType}'"), + }; + + private static string? GetCustomMediaType(FileInfo source) + => source.Extension switch + { + ".md" => "text/markdown", + _ => null + }; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/PdfPigReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/PdfPigReader.cs new file mode 100644 index 00000000000..f6de539eb22 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/PdfPigReader.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DataIngestion; +using UglyToad.PdfPig; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; + +namespace aichatweb.Services.Ingestion; + +internal sealed class PdfPigReader : IngestionDocumentReader +{ + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + using var pdf = PdfDocument.Open(source); + var document = new IngestionDocument(identifier); + foreach (var page in pdf.GetPages()) + { + document.Sections.Add(GetPageSection(page)); + } + return Task.FromResult(document); + } + + private static IngestionDocumentSection GetPageSection(Page pdfPage) + { + var section = new IngestionDocumentSection + { + PageNumber = pdfPage.Number, + }; + + var letters = pdfPage.Letters; + var words = NearestNeighbourWordExtractor.Instance.GetWords(letters); + + foreach (var textBlock in DocstrumBoundingBoxes.Instance.GetBlocks(words)) + { + section.Elements.Add(new IngestionDocumentParagraph(textBlock.Text) + { + Text = textBlock.Text + }); + } + + return section; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs new file mode 100644 index 00000000000..742bc6d93d5 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace aichatweb.Services; + +internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator +{ + private readonly int _dimensions; + + internal LexicalMockEmbeddingGenerator(int dimensions) + { + ArgumentOutOfRangeException.ThrowIfLessThan(dimensions, 1); + _dimensions = dimensions; + } + + protected override Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + if (GenerateAsyncCallback is not null) + { + return base.GenerateCoreAsync(values, options, cancellationToken); + } + + GeneratedEmbeddings> embeddings = []; + foreach (string value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + embeddings.Add(new Embedding(CreateVector(value, _dimensions))); + } + + return Task.FromResult(embeddings); + } + + private static float[] CreateVector(string? value, int dimensions) + { + var vector = new float[dimensions]; + if (string.IsNullOrEmpty(value)) + { + return vector; + } + + // Hash tokens and three-character shingles so shared words and word fragments have matching dimensions. + int tokenStart = -1; + for (int i = 0; i <= value.Length; i++) + { + if (i < value.Length && char.IsLetterOrDigit(value[i])) + { + tokenStart = tokenStart < 0 ? i : tokenStart; + } + else if (tokenStart >= 0) + { + AddTokenFeatures(value, tokenStart, i - tokenStart, vector); + tokenStart = -1; + } + } + + // Normalize to unit length so cosine similarity favors lexical overlap rather than document length. + float squaredMagnitude = 0; + foreach (float component in vector) + { + squaredMagnitude += component * component; + } + + if (squaredMagnitude > 0) + { + float scale = 1 / (float)Math.Sqrt(squaredMagnitude); + for (int i = 0; i < vector.Length; i++) + { + vector[i] *= scale; + } + } + + return vector; + } + + private static void AddTokenFeatures(string value, int tokenStart, int tokenLength, float[] vector) + { + AddFeature(value, tokenStart, tokenLength, vector); + + for (int i = tokenStart; i <= tokenStart + tokenLength - 3; i++) + { + AddFeature(value, i, 3, vector); + } + } + + private static void AddFeature(string value, int start, int length, float[] vector) + { + uint hash = 2_166_136_261; + unchecked + { + for (int i = start; i < start + length; i++) + { + hash = (hash ^ char.ToLowerInvariant(value[i])) * 16_777_619; + } + } + + vector[(int)(hash % (uint)vector.Length)] += 1; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs new file mode 100644 index 00000000000..e0ea691e8d7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs @@ -0,0 +1,294 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace aichatweb.Services; + +internal static class MockChatClientExtensions +{ + private const string LoadDocumentsCallId = "mock-load-documents"; + private const string SearchCallId = "mock-search"; + private const string MockConversationId = "mock-conversation"; + + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// Optional aichatweb follow-up suggestions returned through its structured-output request. Supply an empty + /// collection when the response is a conversation dead end. + /// + /// + /// to invoke the document-search tools and append a citation from the search result. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + IEnumerable? suggestions = null, + bool includeCitation = false, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + if (suggestions is null) + { + return AddResponse(client, requestPredicate, response, minDelay, maxDelay, includeCitation, singleUse); + } + + string structuredResponse = JsonSerializer.Serialize(new { data = suggestions.ToArray() }); + + MockChatClient configuredClient = AddResponse( + client, + request => request.Options?.ResponseFormat is not ChatResponseFormatJson && requestPredicate(request), + response, + minDelay, + maxDelay, + includeCitation, + singleUse); + + return AddResponse( + configuredClient, + request => request.Options?.ResponseFormat is ChatResponseFormatJson && requestPredicate(request), + structuredResponse, + minDelay, + maxDelay, + includeCitation, + singleUse); + } + + internal static string Citation(string filename, string quote) + { + ArgumentNullException.ThrowIfNull(filename); + ArgumentNullException.ThrowIfNull(quote); + + return new XElement( + "citation", + new XAttribute("filename", filename), + quote).ToString(SaveOptions.DisableFormatting); + } + + private static MockChatClient AddResponse( + MockChatClient client, + Func requestPredicate, + string response, + int? minDelay, + int? maxDelay, + bool includeCitation, + bool singleUse) => + client.AddResponse( + requestPredicate, + async (request, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return CreateResponse(request, response, includeCitation); + }, + singleUse); + + private static ChatResponse CreateResponse(MockChatClientRequest request, string response, bool includeCitation) + { + if (request.Options?.ResponseFormat is ChatResponseFormatJson) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response)); + } + + if (TryGetFunctionResult(request, SearchCallId, out FunctionResultContent? searchResult) && searchResult is not null) + { + return CreateConversationResponse($"{response}{GetCitation(searchResult, request.LastUserText)}"); + } + + if (TryGetFunctionResult(request, LoadDocumentsCallId, out _)) + { + return CreateSearchFunctionCallResponse(request); + } + + if (includeCitation) + { + return request.Options?.ConversationId is null + ? CreateFunctionCallResponse(LoadDocumentsCallId, "LoadDocuments") + : CreateSearchFunctionCallResponse(request); + } + + return CreateConversationResponse(response); + } + + private static ChatResponse CreateConversationResponse(string response) => + new(new ChatMessage(ChatRole.Assistant, response)) + { + ConversationId = MockConversationId, + }; + + private static ChatResponse CreateFunctionCallResponse( + string callId, + string name, + IDictionary? arguments = null) => + new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(callId, name, arguments)])); + + private static ChatResponse CreateSearchFunctionCallResponse(MockChatClientRequest request) => + CreateFunctionCallResponse( + SearchCallId, + "Search", + new Dictionary + { + ["searchPhrase"] = request.LastUserText ?? string.Empty, + }); + + private static string GetCitation(FunctionResultContent searchResult, string? searchPhrase) + { + IEnumerable results = searchResult.Result switch + { + IEnumerable result => result, + JsonElement { ValueKind: JsonValueKind.String } result when result.GetString() is { } resultText => [resultText], + JsonElement { ValueKind: JsonValueKind.Array } result => result + .EnumerateArray() + .Where(static element => element.ValueKind is JsonValueKind.String) + .Select(static element => element.GetString()!), + _ => [], + }; + + foreach (string result in results) + { + try + { + XElement element = XElement.Parse(result); + if (element.Name.LocalName != "result" || + element.Attribute("filename")?.Value is not { Length: > 0 } filename) + { + continue; + } + + string quote = GetQuote(element.Value, searchPhrase); + if (quote.Length > 0) + { + return Citation(filename, quote); + } + } + catch (XmlException) + { + // Ignore malformed search results. + } + } + + return string.Empty; + } + + private static string GetQuote(string text, string? searchPhrase) + { + string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + return string.Empty; + } + + int start = 0; + if (!string.IsNullOrWhiteSpace(searchPhrase)) + { + int fewestOccurrences = int.MaxValue; + foreach (string queryWord in searchPhrase.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + { + string term = NormalizeWord(queryWord); + if (term.Length < 4) + { + continue; + } + + int occurrences = 0; + int firstMatch = -1; + for (int i = 0; i < words.Length; i++) + { + if (IsTermMatch(NormalizeWord(words[i]), term)) + { + occurrences++; + firstMatch = firstMatch < 0 ? i : firstMatch; + } + } + + if (occurrences > 0 && occurrences < fewestOccurrences) + { + fewestOccurrences = occurrences; + start = firstMatch; + } + } + } + + return string.Join(" ", words, start, Math.Min(5, words.Length - start)); + } + + private static bool IsTermMatch(string word, string term) => + word.Length > 0 && (word.Contains(term, StringComparison.Ordinal) || term.Contains(word, StringComparison.Ordinal)); + + private static string NormalizeWord(string value) => + string.Concat(value.Where(static character => char.IsLetterOrDigit(character)).Select(static character => char.ToLowerInvariant(character))); + + private static bool TryGetFunctionResult( + MockChatClientRequest request, + string callId, + out FunctionResultContent? functionResult) + { + for (int i = request.Messages.Count - 1; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User) + { + break; + } + + foreach (AIContent content in message.Contents) + { + if (content is FunctionResultContent result && result.CallId == callId) + { + functionResult = result; + return true; + } + } + } + + functionResult = null; + return false; + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelayMilliseconds, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, 0, nameof(maxDelay)); + + if (minDelay is { } minimumDelayMillisecondsForMaximum) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, minimumDelayMillisecondsForMaximum, nameof(maxDelay)); + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs new file mode 100644 index 00000000000..35eab095ce1 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.AI; +namespace aichatweb.Services; + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static _ => true, + "Hi! I can help you plan an outdoor trip or find the right gear for it. What would you like to know?", + minDelay: 500, + maxDelay: 500, + suggestions: ["What kind of adventures?", "What kinds of products?"]) + .AddResponse( + static request => LastQuestionContains(request, "what kind of adventures", "outdoor activities"), + "Mostly off-grid trips: hiking, backpacking, trail running, and mountaineering are the classics, and plenty of people use the same gear for camping and long day hikes. Anywhere you lose cell service, the right gear makes the trip safer and easier.", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about hiking adventures.", "Tell me about trail safety."]) + .AddResponse( + static request => LastQuestionContains(request, "what kinds of products"), + "Two main categories: adventure GPS watches for navigation and tracking, and emergency survival kits for backcountry safety. The GPS watch is the flagship, though a lot of people carry both. Want to dig into either one?", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about GPS watches.", "Tell me about survival kits."]) + .AddResponse( + static request => LastQuestionContains(request, "hiking adventures", "hiking"), + "Hiking is right in the TrailMaster GPS Watch's wheelhouse. It's made for hikers, backpackers, and trail runners heading into remote terrain, with offline topographic maps and real-time location sharing to keep you on route and reachable.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "How rugged is TrailMaster?", "How does location sharing work?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps watches", "gps watch"), + "The TrailMaster GPS Watch pairs precise positioning with a rugged build. It runs a multi-constellation receiver for GPS, GLONASS, and Galileo, plus an altimeter, barometer, and compass, so navigation holds up even where satellite coverage is thin.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "What navigation tools are built in?", "How rugged is TrailMaster?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "trail safety", "safety"), + "Trail safety really comes down to preparation. Before heading out, make sure the watch is charged and calibrated and that you know its controls, then use real-time location sharing so someone always knows where you are. For longer trips, an emergency survival kit is a smart backup.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["How does location sharing work?", "How do I fix GPS signal loss?", "Tell me about survival kits."], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "survival kits", "survival kit", "emergency kit"), + "The Life Guard X Emergency Survival Kit is built for when a trip goes sideways. It packs first aid supplies, high-calorie emergency food, a water purification system, an emergency shelter, and signaling tools so you can stay safe and reach help.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What's in the first aid supplies?", "How does water purification work?", "How do I signal for help?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "what does trailmaster track", "what does it track", "performance metrics", "heart rate", "elevation gain"), + "It tracks the numbers that matter for training and pacing: distance traveled, speed, elevation gain, and heart rate. You can watch them live in tracking mode or review them after the activity.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "how rugged", "rugged", "durable", "shock-resistant", "harsh conditions"), + "It's built for abuse. The casing is durable and shock-resistant, and the reinforced strap and rugged design shrug off extreme temperatures, water, and impact out in the field.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "location sharing", "share location", "share my location"), + "Press the share button and pick the contacts you want to reach, and the watch sends your position to them in real time. It needs a stable GPS signal and your phone connected through the TrailMaster app.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "navigation tools", "route planning", "waypoint", "trail tracking"), + "Plenty to work with: preloaded topographic maps, trail tracking, waypoint management, and route planning, backed by an onboard compass, altimeter, and barometer for orientation and weather.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps signal loss", "no gps signal", "lost gps", "gps troubleshooting"), + "If it shows \"No GPS Signal,\" start with your surroundings: dense foliage, buildings, or terrain can block satellites. Check for RF interference, run a signal diagnostics test, and if it keeps happening, reach out to ExpeditionTech support.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "bluetooth", "cannot connect", "connection problem", "connectivity issues"), + "For pairing trouble, keep the watch within Bluetooth range, clear out nearby sources of interference, and update to the latest firmware. If it still won't connect, a Bluetooth signal analyzer can help track down the cause.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "first aid"), + "The first aid supplies cover the basics for minor injuries: adhesive bandages, sterile gauze and tape, antiseptic wipes and antibiotic ointment, over-the-counter medications, and tools like tweezers, scissors, and a thermometer. A first aid guide is included too.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "water purification", "water filter", "purify water", "clean water"), + "It comes with a portable water filter that removes 99.9999% of waterborne bacteria and 99.9% of protozoan parasites, so you can drink from rivers and lakes. Purification tablets are included too for an extra layer of protection.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "signal for help", "signal mirror", "two-way radio", "signaling"), + "For signaling, the kit includes a whistle for short range, a signal mirror that reflects sunlight to catch a rescuer's eye, and a weather-resistant two-way radio with up to 20 miles of range to reach your group or emergency services.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true); + + internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); + + private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) + { + string? question = GetLastQuestion(request); + if (string.IsNullOrWhiteSpace(question)) + { + return false; + } + + foreach (string term in terms) + { + if (question.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? GetLastQuestion(MockChatClientRequest request) + { + int startIndex = request.Options?.ResponseFormat is ChatResponseFormatJson + ? request.Messages.Count - 2 + : request.Messages.Count - 1; + + for (int i = startIndex; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User && !string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + } + + return null; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/SemanticSearch.cs new file mode 100644 index 00000000000..8072f8bcddb --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/SemanticSearch.cs @@ -0,0 +1,27 @@ +using aichatweb.Services.Ingestion; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class SemanticSearch( + VectorStoreCollection vectorCollection, + [FromKeyedServices("ingestion_directory")] DirectoryInfo ingestionDirectory, + DataIngestor dataIngestor) +{ + private Task? _ingestionTask; + + public async Task LoadDocumentsAsync() => await ( _ingestionTask ??= dataIngestor.IngestDataAsync(ingestionDirectory, searchPattern: "*.*")); + + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) + { + // Ensure documents have been loaded before searching + await LoadDocumentsAsync(); + + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions + { + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, + }); + + return await nearest.Select(result => result.Record).ToListAsync(); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/aichatweb.csproj new file mode 100644 index 00000000000..6b47e9d5c43 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/aichatweb.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.Development.json new file mode 100644 index 00000000000..e22bd83cf3a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.json new file mode 100644 index 00000000000..d286041f99d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/App.razor new file mode 100644 index 00000000000..40862eea8e7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor new file mode 100644 index 00000000000..116455ce45b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..96fbbe6cc42 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..cda2020dcb0 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor new file mode 100644 index 00000000000..9d33e3ff5ed --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -0,0 +1,137 @@ +@page "/" +@using System.ComponentModel +@using System.Xml.Linq +@inject IChatClient ChatClient +@inject NavigationManager Nav +@inject SemanticSearch Search +@implements IDisposable + +Chat + + + + + +
To get started, try asking about these example documents. You can replace these with your own data and replace this message.
+ + +
+
+ +
+ + +
+ +@code { + private const string SystemPrompt = @" + You are an assistant who answers questions about information you retrieve. + Do not answer questions about anything else. + Use only simple markdown to format your responses. + + Use the LoadDocuments tool to prepare for searches before answering any questions. + + Use the Search tool to find relevant information. When you do this, end your + reply with citations in the special XML format: + + exact quote here + + Always include the citation in your response if there are results. + + The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant. + Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. + "; + + private int statefulMessageCount; + private readonly ChatOptions chatOptions = new(); + private readonly List messages = new(); + private CancellationTokenSource? currentResponseCancellation; + private ChatMessage? currentResponseMessage; + private ChatInput? chatInput; + private ChatSuggestions? chatSuggestions; + + protected override void OnInitialized() + { + statefulMessageCount = 0; + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.Tools = [ + AIFunctionFactory.Create(LoadDocumentsAsync), + AIFunctionFactory.Create(SearchAsync) + ]; + } + + private async Task AddUserMessageAsync(ChatMessage userMessage) + { + CancelAnyCurrentResponse(); + + // Add the user message to the conversation + messages.Add(userMessage); + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); + currentResponseCancellation = new(); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } + + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; + chatSuggestions?.Update(messages); + } + + private void CancelAnyCurrentResponse() + { + // If a response was cancelled while streaming, include it in the conversation so it's not lost + if (currentResponseMessage is not null) + { + messages.Add(currentResponseMessage); + } + + currentResponseCancellation?.Cancel(); + currentResponseMessage = null; + } + + private async Task ResetConversationAsync() + { + CancelAnyCurrentResponse(); + messages.Clear(); + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + } + + [Description("Loads the documents needed for performing searches. Must be completed before a search can be executed, but only needs to be completed once.")] + private async Task LoadDocumentsAsync() + { + await InvokeAsync(StateHasChanged); + await Search.LoadDocumentsAsync(); + } + + [Description("Searches for information using a phrase or keyword. Relies on documents already being loaded.")] + private async Task> SearchAsync( + [Description("The phrase to search for.")] string searchPhrase, + [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) + { + await InvokeAsync(StateHasChanged); + var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); + return results.Select(result => + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); + } + + public void Dispose() + => currentResponseCancellation?.Cancel(); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor.css new file mode 100644 index 00000000000..98ed1ba7d1e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor.css @@ -0,0 +1,11 @@ +.chat-container { + position: sticky; + bottom: 0; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 1.5rem; + border-top-width: 1px; + background-color: #F3F4F6; + border-color: #E5E7EB; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor new file mode 100644 index 00000000000..667189beabd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor @@ -0,0 +1,39 @@ +@using System.Web +@if (!string.IsNullOrWhiteSpace(viewerUrl)) +{ + + + + +
+
@File
+
@Quote
+
+
+} + +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public string? Quote { get; set; } + + private string? viewerUrl; + + protected override void OnParametersSet() + { + viewerUrl = null; + + // If you ingest other types of content besides Markdown or PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".md")) + { + viewerUrl = $"lib/markdown_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#:~:text={Uri.EscapeDataString(Quote ?? "")}"; + } + else if (File.EndsWith(".pdf")) + { + var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#search={HttpUtility.UrlEncode(search)}&phrase=true"; + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css new file mode 100644 index 00000000000..0ca029b7e64 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css @@ -0,0 +1,37 @@ +.citation { + display: inline-flex; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 1rem; + margin-right: 1rem; + border-bottom: 2px solid #a770de; + gap: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: #ffffff; +} + + .citation[href]:hover { + outline: 1px solid #865cb1; + } + + .citation svg { + width: 1.5rem; + height: 1.5rem; + } + + .citation:active { + background-color: rgba(0,0,0,0.05); + } + +.citation-content { + display: flex; + flex-direction: column; +} + +.citation-file { + font-weight: 600; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor new file mode 100644 index 00000000000..0e9d9c8894a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
+
+ +
+ +

aichatweb

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css new file mode 100644 index 00000000000..6adcc414540 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css @@ -0,0 +1,25 @@ +.chat-header-container { + top: 0; + padding: 1.5rem; +} + +.chat-header-controls { + margin-bottom: 1.5rem; +} + +h1 { + overflow: hidden; + text-overflow: ellipsis; +} + +.new-chat-icon { + width: 1.25rem; + height: 1.25rem; + color: rgb(55, 65, 81); +} + +@media (min-width: 768px) { + .chat-header-container { + position: sticky; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor new file mode 100644 index 00000000000..e87ac6ccf47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css new file mode 100644 index 00000000000..3b26c9af316 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css @@ -0,0 +1,57 @@ +.input-box { + display: flex; + flex-direction: column; + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-top: 0.75rem; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.tools { + display: flex; + margin-top: 1rem; + align-items: center; +} + +.tool-icon { + width: 1.25rem; + height: 1.25rem; +} + +.send-button { + color: var(--send-button-color); + margin-left: auto; +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js new file mode 100644 index 00000000000..39e18ac7b74 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 00000000000..33367fa3974 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,123 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Xml +@using System.Xml.Linq + +@if (Message.Role == ChatRole.User) +{ +
+ @Message.Text +
+} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
+
+
+ + + +
+
+
Assistant
+
+ + + @foreach (var citation in citations ?? []) + { + + } +
+
+ } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } + else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) + { + + } + } +} + +@code { + private static readonly ConditionalWeakTable SubscribersLookup = new(); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); + + private List<(string File, string Quote)>? citations; + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + + if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) + { + ParseCitations(text); + } + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } + + private void ParseCitations(string text) + { + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css new file mode 100644 index 00000000000..10453454be8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css @@ -0,0 +1,120 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + color: #1F2937; + white-space: pre-wrap; +} + +.assistant-message, .assistant-search { + display: grid; + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.25rem; +} + +.assistant-message-header { + font-weight: 600; +} + +.assistant-message-text { + grid-column-start: 2; +} + +.assistant-message-icon { + display: flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + width: 1.5rem; + height: 1.5rem; + color: #ffffff; + background: #9b72ce; +} + + .assistant-message-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.assistant-search-icon { + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; +} + + .assistant-search-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search-content { + align-content: center; +} + +.assistant-search-phrase { + font-weight: 600; +} + +/* Default styling for markdown-formatted assistant messages */ +::deep ul { + list-style-type: disc; + margin-left: 1.5rem; +} + +::deep ol { + list-style-type: decimal; + margin-left: 1.5rem; +} + +::deep li { + margin: 0.5rem 0; +} + +::deep strong { + font-weight: 600; +} + +::deep h3 { + margin: 1rem 0; + font-weight: 600; +} + +::deep p + p { + margin-top: 1rem; +} + +::deep table { + margin: 1rem 0; +} + +::deep th { + text-align: left; + border-bottom: 1px solid silver; +} + +::deep th, ::deep td { + padding: 0.1rem 0.5rem; +} + +::deep th, ::deep tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} + +::deep pre > code { + background-color: white; + display: block; + padding: 0.5rem 1rem; + margin: 1rem 0; + overflow-x: auto; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor new file mode 100644 index 00000000000..d245f455f11 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS + +
+ + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
@NoMessagesContent
+ } +
+
+ +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Activates the auto-scrolling behavior + await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css new file mode 100644 index 00000000000..6fbf083c7fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css @@ -0,0 +1,22 @@ +.message-list-container { + margin: 2rem 1.5rem; + flex-grow: 1; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: calc(40vh - 18rem); +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor new file mode 100644 index 00000000000..69ca922a8ce --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
+ @foreach (var suggestion in suggestions) + { + + } +
+} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.GetResponseAsync( + [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], + cancellationToken: cancellation.Token); + if (!response.TryGetResult(out suggestions)) + { + suggestions = null; + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await DispatchExceptionAsync(ex); + } + } + + private async Task AddSuggestionAsync(string text) + { + await OnSelected.InvokeAsync(new(ChatRole.User, text)); + } + + private IEnumerable ReduceMessages(IReadOnlyList messages) + { + // Get any leading system messages, plus up to 5 user/assistant messages + // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long + var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); + var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); + return systemMessages.Concat(otherMessages); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css new file mode 100644 index 00000000000..b291042c6d4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css @@ -0,0 +1,9 @@ +.suggestions { + text-align: right; + white-space: nowrap; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; + display: flex; + margin-bottom: 0.75rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Error.razor new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Routes.razor new file mode 100644 index 00000000000..f756e19dfbc --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/_Imports.razor new file mode 100644 index 00000000000..9a70e8453ef --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Extensions.AI +@using Microsoft.JSInterop +@using aichatweb +@using aichatweb.Components +@using aichatweb.Components.Layout +@using aichatweb.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs new file mode 100644 index 00000000000..429840c1d69 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs @@ -0,0 +1,53 @@ +using System.ClientModel; +using Microsoft.Extensions.AI; +using OpenAI; +using aichatweb.Components; +using aichatweb.Services; +using aichatweb.Services.Ingestion; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +// You will need to set the endpoint and key to your own values +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set OpenAI:Key YOUR-API-KEY + +var openAIClient = new OpenAIClient( + new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); + +#pragma warning disable OPENAI001 // GetResponsesClient() is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetResponsesClient().AsIChatClient("gpt-4o-mini"); +#pragma warning restore OPENAI001 + +var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); + +var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); +var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; +builder.Services.AddSqliteVectorStore(_ => vectorStoreConnectionString); +builder.Services.AddSqliteCollection(IngestedChunk.CollectionName, vectorStoreConnectionString); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddKeyedSingleton("ingestion_directory", new DirectoryInfo(Path.Combine(builder.Environment.WebRootPath, "Data"))); +builder.Services.AddChatClient(chatClient).UseFunctionInvocation().UseLogging(); +builder.Services.AddEmbeddingGenerator(embeddingGenerator); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); + +app.UseStaticFiles(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Properties/launchSettings.json new file mode 100644 index 00000000000..6e300d8a802 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9995;http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/README.md new file mode 100644 index 00000000000..9119f1965f6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/README.md @@ -0,0 +1,19 @@ +# AI Chat with Custom Data + +This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). + +>[!NOTE] +> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. + +# Configure the AI Model Provider +## Using OpenAI + +To call the OpenAI REST API, you will need an API key. To obtain one, first [create a new OpenAI account](https://platform.openai.com/signup) or [log in](https://platform.openai.com/login). Next, navigate to the API key page and select "Create new secret key", optionally naming the key. Make sure to save your API key somewhere safe and do not share it with anyone. + +From the command line, configure your API key for this project using .NET User Secrets by running the following commands: + +```sh +cd <> +dotnet user-secrets set OpenAI:Key YOUR-API-KEY +``` + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs new file mode 100644 index 00000000000..68af3ef20fb --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class IngestedChunk +{ + public const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model + public const string VectorDistanceFunction = DistanceFunction.CosineDistance; + public const string CollectionName = "data-aichatweb-chunks"; + + [VectorStoreKey(StorageName = "key")] + [JsonPropertyName("key")] + public required Guid Key { get; set; } + + [VectorStoreData(StorageName = "documentid")] + [JsonPropertyName("documentid")] + public required string DocumentId { get; set; } + + [VectorStoreData(StorageName = "content")] + [JsonPropertyName("content")] + public required string Text { get; set; } + + [VectorStoreData(StorageName = "context")] + [JsonPropertyName("context")] + public string? Context { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction, StorageName = "embedding")] + [JsonPropertyName("embedding")] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DataIngestor.cs new file mode 100644 index 00000000000..d97b986b694 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DataIngestor.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DataIngestion; +using Microsoft.Extensions.DataIngestion.Chunkers; +using Microsoft.Extensions.VectorData; +using Microsoft.ML.Tokenizers; + +namespace aichatweb.Services.Ingestion; + +public class DataIngestor( + ILogger logger, + ILoggerFactory loggerFactory, + VectorStore vectorStore, + IEmbeddingGenerator> embeddingGenerator) +{ + public async Task IngestDataAsync(DirectoryInfo directory, string searchPattern) + { + using var writer = new VectorStoreWriter(vectorStore, dimensionCount: IngestedChunk.VectorDimensions, new() + { + CollectionName = IngestedChunk.CollectionName, + DistanceFunction = IngestedChunk.VectorDistanceFunction, + IncrementalIngestion = false, + }); + + using var pipeline = new IngestionPipeline( + reader: new DocumentReader(directory), + chunker: new SemanticSimilarityChunker(embeddingGenerator, new(TiktokenTokenizer.CreateForModel("gpt-4o"))), + writer: writer, + loggerFactory: loggerFactory); + + await foreach (var result in pipeline.ProcessAsync(directory, searchPattern)) + { + logger.LogInformation("Completed processing '{id}'. Succeeded: '{succeeded}'.", result.DocumentId, result.Succeeded); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DocumentReader.cs new file mode 100644 index 00000000000..315a6ad3d53 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DocumentReader.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DataIngestion; + +namespace aichatweb.Services.Ingestion; + +internal sealed class DocumentReader(DirectoryInfo rootDirectory) : IngestionDocumentReader +{ + private readonly MarkdownReader _markdownReader = new(); + private readonly PdfPigReader _pdfReader = new(); + + public override Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + if (Path.IsPathFullyQualified(identifier)) + { + // Normalize the identifier to its relative path + identifier = Path.GetRelativePath(rootDirectory.FullName, identifier); + } + + mediaType = GetCustomMediaType(source) ?? mediaType; + return base.ReadAsync(source, identifier, mediaType, cancellationToken); + } + + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + => mediaType switch + { + "application/pdf" => _pdfReader.ReadAsync(source, identifier, mediaType, cancellationToken), + "text/markdown" => _markdownReader.ReadAsync(source, identifier, mediaType, cancellationToken), + _ => throw new InvalidOperationException($"Unsupported media type '{mediaType}'"), + }; + + private static string? GetCustomMediaType(FileInfo source) + => source.Extension switch + { + ".md" => "text/markdown", + _ => null + }; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/PdfPigReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/PdfPigReader.cs new file mode 100644 index 00000000000..f6de539eb22 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/PdfPigReader.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DataIngestion; +using UglyToad.PdfPig; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; + +namespace aichatweb.Services.Ingestion; + +internal sealed class PdfPigReader : IngestionDocumentReader +{ + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + using var pdf = PdfDocument.Open(source); + var document = new IngestionDocument(identifier); + foreach (var page in pdf.GetPages()) + { + document.Sections.Add(GetPageSection(page)); + } + return Task.FromResult(document); + } + + private static IngestionDocumentSection GetPageSection(Page pdfPage) + { + var section = new IngestionDocumentSection + { + PageNumber = pdfPage.Number, + }; + + var letters = pdfPage.Letters; + var words = NearestNeighbourWordExtractor.Instance.GetWords(letters); + + foreach (var textBlock in DocstrumBoundingBoxes.Instance.GetBlocks(words)) + { + section.Elements.Add(new IngestionDocumentParagraph(textBlock.Text) + { + Text = textBlock.Text + }); + } + + return section; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/SemanticSearch.cs new file mode 100644 index 00000000000..8072f8bcddb --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/SemanticSearch.cs @@ -0,0 +1,27 @@ +using aichatweb.Services.Ingestion; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class SemanticSearch( + VectorStoreCollection vectorCollection, + [FromKeyedServices("ingestion_directory")] DirectoryInfo ingestionDirectory, + DataIngestor dataIngestor) +{ + private Task? _ingestionTask; + + public async Task LoadDocumentsAsync() => await ( _ingestionTask ??= dataIngestor.IngestDataAsync(ingestionDirectory, searchPattern: "*.*")); + + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) + { + // Ensure documents have been loaded before searching + await LoadDocumentsAsync(); + + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions + { + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, + }); + + return await nearest.Select(result => result.Record).ToListAsync(); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/aichatweb.csproj new file mode 100644 index 00000000000..a4536e86ee6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/aichatweb.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.Development.json new file mode 100644 index 00000000000..e22bd83cf3a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.json new file mode 100644 index 00000000000..d286041f99d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } From b066397d2aa15a8ba1d0996c118630af3474dc82 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 12:24:11 -0700 Subject: [PATCH 06/10] Add mock provider to aiagent-webapi template with canned responses Introduces the 'mock' provider option (--provider mock) to the aiagent-webapi project template. The mock provider uses Microsoft.Extensions.AI.Testing's MockChatClient seeded with 3 canned responses for the story writer/editor workflow (generate a story, edit for tone, format as HTML), allowing the template to be explored without any external AI provider credentials while still demonstrating the multi-agent pipeline with writer and editor stages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- ...icrosoft.Agents.AI.ProjectTemplates.csproj | 1 + .../.template.config/template.json | 21 ++- .../AIAgentWebApi-CSharp.csproj-in | 3 + .../MockChatClientExtensions.cs | 69 +++++++++ .../AIAgentWebApi-CSharp/MockServices.cs | 144 ++++++++++++++++++ .../templates/AIAgentWebApi-CSharp/Program.cs | 3 + .../templates/AIAgentWebApi-CSharp/README.md | 27 +++- .../AIAgentWebAPIExecutionTests.cs | 2 +- .../AIAgentWebAPISnapshotTests.cs | 1 + .../aiagent-webapi/Program.cs | 1 + .../aiagent-webapi/README.md | 4 +- .../aiagent-webapi/Program.cs | 1 + .../aiagent-webapi/README.md | 4 +- .../MockChatClientExtensions.cs | 69 +++++++++ .../aiagent-webapi/MockServices.cs | 144 ++++++++++++++++++ .../aiagent-webapi/Program.cs | 57 +++++++ .../Properties/launchSettings.json | 25 +++ .../aiagent-webapi/README.md | 104 +++++++++++++ .../aiagent-webapi/aiagent-webapi.csproj | 20 +++ .../aiagent-webapi/appsettings.json | 9 ++ .../aiagent-webapi/Program.cs | 1 + .../aiagent-webapi/README.md | 4 +- .../aiagent-webapi/Program.cs | 1 + .../aiagent-webapi/README.md | 4 +- 24 files changed, 706 insertions(+), 13 deletions(-) create mode 100644 src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs create mode 100644 src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj index afbfd93c4b7..3022febd7d0 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj @@ -30,6 +30,7 @@ + + + + diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs new file mode 100644 index 00000000000..34ab37da82d --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.AI; + +internal static class MockChatClientExtensions +{ + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + return client.AddResponse( + requestPredicate, + async (_, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response + "\n\n")); + }, + singleUse); + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelay, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, 0, nameof(maxDelay)); + + if (minDelay is { } configuredMinimumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, configuredMinimumDelay, nameof(maxDelay)); + } + } + } +} diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs new file mode 100644 index 00000000000..5b9389227ed --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.AI; + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static request => IsWriterStage(request), + """ + ## Welcome to the Mock Writing Room + + Meet The Writer, The Editor, and The Formatter. They are ready for their curtain call. + + Try: **Write a .NET story.**, **Edit this story.**, or **Format for publishing.** + """, + maxDelay: 2000) + .AddResponse( + static request => HasMessageContent(request, "Welcome to the Mock Writing Room"), + """ + The room is ready when you are. Bring a draft, and it will come back polished. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsWritePrompt(request), + """ + Maya opened her first .NET console project during an afternoon study session. The program printed "Hello, World!", but she wanted it to ask for a number and tell her whether it was even. + + At first, compiler errors made every small change feel impossible. Instead of guessing, she read each message, used `int.TryParse`, and ran the app after every improvement. + + By the end of the session, Maya had a working program and a short list of ideas for her next project. She realized that learning .NET was not about knowing everything at once; it was about trying one small step, seeing what happened, and learning from it. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "Maya opened her first .NET console project during an afternoon study session."), + """ + **Title**: Maya's First Green Check + + Maya opened her first .NET console project during an afternoon study session. It printed "Hello, World!", but she wanted it to ask for a number and report whether it was even. + + Compiler errors made every small change feel impossible at first. Rather than guessing, Maya read each message, used `int.TryParse` to handle input safely, and ran the app after every improvement. + + By the end of the session, she had a working program and a short list of ideas for her next project. Maya learned that .NET did not require her to know everything at once: progress came from taking one small step, observing the result, and learning from it. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsEditPrompt(request), + """ + maya opened her first .net console project and wanted it to check whether a number was even. compiler errors made her frustrated at first. + + she read the error messages, used int.tryparse, and kept running the program after each change. by the end, it worked and she was excited to build another project. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "maya opened her first .net console project and wanted it to check whether a number was even."), + """ + Maya opened her first .NET console project hoping to write a program that could identify even numbers. Compiler errors frustrated her at first, but she chose to slow down and read each message carefully. + + She used `int.TryParse` to make the input safer and ran the program after every change. By the end of the session, Maya had a working app and a new confidence that learning .NET happens one experiment at a time. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsFormatPrompt(request), + """ + Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change."), + """ + **Title**: Maya's First .NET Project + + Maya began her first .NET console app with a simple goal: identify whether a number was even. When compiler errors appeared, she slowed down, read the messages, and made one change at a time. + + She tried `int.TryParse`, tested each improvement, and watched the program gradually come together. The project taught Maya that every small experiment is part of becoming a confident .NET developer. + """, + maxDelay: 2000); + + private static bool IsWritePrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "write"); + + private static bool IsEditPrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "edit"); + + private static bool IsFormatPrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "format"); + + private static bool IsWriterStage(MockChatClientRequest request) => + !HasAssistantMessage(request); + + private static bool HasAssistantMessage(MockChatClientRequest request) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Role == ChatRole.Assistant && + !string.IsNullOrWhiteSpace(message.Text)) + { + return true; + } + } + + return false; + } + + private static bool HasDraftContent(MockChatClientRequest request, string text) => + request.LastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true || + HasAssistantContent(request, text); + + private static bool HasInteractivePrompt(MockChatClientRequest request, string text) + { + string? lastUserText = request.LastUserText; + return lastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true && + lastUserText.Contains("Welcome to the Mock Writing Room", StringComparison.OrdinalIgnoreCase) is false; + } + + private static bool HasMessageContent(MockChatClientRequest request, string text) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true) + { + return true; + } + } + + return false; + } + + private static bool HasAssistantContent(MockChatClientRequest request, string text) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Role == ChatRole.Assistant && + message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true) + { + return true; + } + } + + return false; + } +} diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs index 7bc6241dcd9..a3ad2a26d62 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs @@ -33,6 +33,8 @@ "gpt-4o-mini", new ApiKeyCredential(builder.Configuration["OPENAI_KEY"] ?? throw new InvalidOperationException("Missing configuration: OPENAI_KEY"))) .AsIChatClient(); +#elif (IsMock) +var chatClient = MockServices.CreateChatClient(); #elif (IsAzureOpenAI) // You will need to set the endpoint to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: @@ -87,6 +89,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md index 8dd93d1ecca..29c3698b2c3 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md @@ -4,7 +4,9 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ## Prerequisites - + +- No external AI provider prerequisites + - An OpenAI API key - An Azure OpenAI service deployment @@ -16,7 +18,20 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ### 1. Configure Your AI Service - + +#### Mock Configuration + +This application uses a deterministic mock chat provider seeded for the writer/editor workflow. + +No API keys or model runtimes are required. + +Try prompts such as: + +1. `Write a short story about a student learning .NET.` +2. `Edit this story for grammar and style.` +3. `Format the final story for publishing.` + + #### OpenAI Configuration This application uses the OpenAI Platform (model: gpt-4o-mini). You'll need to configure your OpenAI API key: @@ -163,6 +178,7 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) @@ -197,7 +213,12 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ## Troubleshooting - + +**Problem**: Responses seem repetitive. + +**Solution**: The mock provider is intentionally deterministic. Use one of the three seeded workflow prompts listed above. + + **Problem**: Application fails with "Missing configuration: OPENAI_KEY" **Solution**: Make sure you've configured your OpenAI API key using one of the methods described above. diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs index dca4e7769f8..5f5b972e04e 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs @@ -36,7 +36,7 @@ public AIAgentWebAPIExecutionTests(TemplateExecutionTestFixture fixture, ITestOu public static IEnumerable GetSupportedProjectConfigurations() { (string name, string[] values)[] allOptionValues = [ - ("--provider", ["azureopenai", "ollama", "openai"]), + ("--provider", ["mock", "azureopenai", "ollama", "openai"]), ("--managed-identity", ["true", "false"]), ("--framework", ["net8.0", "net9.0", "net10.0"]) ]; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs index 1cb35bf7167..238a2060ba0 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs @@ -22,6 +22,7 @@ public AIAgentWebAPISnapshotTests(ITestOutputHelper log) } [Theory] + [InlineData("--provider=mock")] [InlineData("--provider=openai")] [InlineData("--provider=ollama")] [InlineData("--provider=azureopenai", "--managed-identity=true")] diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/Program.cs index 3ece18c0d08..27f95ad43ff 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/Program.cs @@ -46,6 +46,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/README.md index 222e83f1188..aefba461390 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/README.md @@ -88,13 +88,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/Program.cs index 037ac3deb7e..27315a315a4 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/Program.cs @@ -48,6 +48,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/README.md index 99157e36906..7e8f5ac72f6 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/README.md @@ -60,13 +60,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs new file mode 100644 index 00000000000..a0d702fbb7f --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.AI; + +internal static class MockChatClientExtensions +{ + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + return client.AddResponse( + requestPredicate, + async (_, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response + "\n\n")); + }, + singleUse); + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelay, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, 0, nameof(maxDelay)); + + if (minDelay is { } configuredMinimumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, configuredMinimumDelay, nameof(maxDelay)); + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs new file mode 100644 index 00000000000..f82509e5945 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.AI; + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static request => IsWriterStage(request), + """ + ## Welcome to the Mock Writing Room + + Meet The Writer, The Editor, and The Formatter. They are ready for their curtain call. + + Try: **Write a .NET story.**, **Edit this story.**, or **Format for publishing.** + """, + maxDelay: 2000) + .AddResponse( + static request => HasMessageContent(request, "Welcome to the Mock Writing Room"), + """ + The room is ready when you are. Bring a draft, and it will come back polished. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsWritePrompt(request), + """ + Maya opened her first .NET console project during an afternoon study session. The program printed "Hello, World!", but she wanted it to ask for a number and tell her whether it was even. + + At first, compiler errors made every small change feel impossible. Instead of guessing, she read each message, used `int.TryParse`, and ran the app after every improvement. + + By the end of the session, Maya had a working program and a short list of ideas for her next project. She realized that learning .NET was not about knowing everything at once; it was about trying one small step, seeing what happened, and learning from it. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "Maya opened her first .NET console project during an afternoon study session."), + """ + **Title**: Maya's First Green Check + + Maya opened her first .NET console project during an afternoon study session. It printed "Hello, World!", but she wanted it to ask for a number and report whether it was even. + + Compiler errors made every small change feel impossible at first. Rather than guessing, Maya read each message, used `int.TryParse` to handle input safely, and ran the app after every improvement. + + By the end of the session, she had a working program and a short list of ideas for her next project. Maya learned that .NET did not require her to know everything at once: progress came from taking one small step, observing the result, and learning from it. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsEditPrompt(request), + """ + maya opened her first .net console project and wanted it to check whether a number was even. compiler errors made her frustrated at first. + + she read the error messages, used int.tryparse, and kept running the program after each change. by the end, it worked and she was excited to build another project. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "maya opened her first .net console project and wanted it to check whether a number was even."), + """ + Maya opened her first .NET console project hoping to write a program that could identify even numbers. Compiler errors frustrated her at first, but she chose to slow down and read each message carefully. + + She used `int.TryParse` to make the input safer and ran the program after every change. By the end of the session, Maya had a working app and a new confidence that learning .NET happens one experiment at a time. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsFormatPrompt(request), + """ + Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change."), + """ + **Title**: Maya's First .NET Project + + Maya began her first .NET console app with a simple goal: identify whether a number was even. When compiler errors appeared, she slowed down, read the messages, and made one change at a time. + + She tried `int.TryParse`, tested each improvement, and watched the program gradually come together. The project taught Maya that every small experiment is part of becoming a confident .NET developer. + """, + maxDelay: 2000); + + private static bool IsWritePrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "write"); + + private static bool IsEditPrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "edit"); + + private static bool IsFormatPrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "format"); + + private static bool IsWriterStage(MockChatClientRequest request) => + !HasAssistantMessage(request); + + private static bool HasAssistantMessage(MockChatClientRequest request) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Role == ChatRole.Assistant && + !string.IsNullOrWhiteSpace(message.Text)) + { + return true; + } + } + + return false; + } + + private static bool HasDraftContent(MockChatClientRequest request, string text) => + request.LastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true || + HasAssistantContent(request, text); + + private static bool HasInteractivePrompt(MockChatClientRequest request, string text) + { + string? lastUserText = request.LastUserText; + return lastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true && + lastUserText.Contains("Welcome to the Mock Writing Room", StringComparison.OrdinalIgnoreCase) is false; + } + + private static bool HasMessageContent(MockChatClientRequest request, string text) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true) + { + return true; + } + } + + return false; + } + + private static bool HasAssistantContent(MockChatClientRequest request, string text) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Role == ChatRole.Assistant && + message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true) + { + return true; + } + } + + return false; + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs new file mode 100644 index 00000000000..6ccb091734a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs @@ -0,0 +1,57 @@ +using System.ComponentModel; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +var builder = WebApplication.CreateBuilder(args); + +var chatClient = MockServices.CreateChatClient(); + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + agents: + [ + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") + ] +)).AddAsAIAgent("publisher-agent"); + +// Register services for OpenAI responses and conversations (also required for DevUI) +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Properties/launchSettings.json new file mode 100644 index 00000000000..d71b1876d35 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:9995;http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md new file mode 100644 index 00000000000..8aaac3ba211 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md @@ -0,0 +1,104 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + +- No external AI provider prerequisites + +## Getting Started + +### 1. Configure Your AI Service + +#### Mock Configuration + +This application uses a deterministic mock chat provider seeded for the writer/editor workflow. + +No API keys or model runtimes are required. + +Try prompts such as: + +1. `Write a short story about a student learning .NET.` +2. `Edit this story for grammar and style.` +3. `Format the final story for publishing.` + + +### 2. Run the Application + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:9996` +- HTTPS: `https://localhost:9995` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `mock` - Deterministic mock provider + - `azureopenai` - Azure OpenAI + - `openai` - OpenAI Platform + - `ollama` - Ollama (local development) + +- **`--chat-model`**: Specify the chat model/deployment name + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) + +## Troubleshooting + +**Problem**: Responses seem repetitive. + +**Solution**: The mock provider is intentionally deterministic. Use one of the three seeded workflow prompts listed above. + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj new file mode 100644 index 00000000000..e8661283e17 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json new file mode 100644 index 00000000000..223027717b4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/Program.cs index dfc311d6e17..ecbd90dc4ad 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/Program.cs @@ -35,6 +35,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/README.md index 13f6f85451d..e2510b10a2f 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/README.md @@ -71,13 +71,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/Program.cs index efed4c96ad5..aed22f49d25 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/Program.cs @@ -41,6 +41,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/README.md index dfd846707b6..b9533b75ce8 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/README.md @@ -90,13 +90,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) From b4bfef9e6058fe2d8f5a7a37e8156510cb878646 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 24 Jul 2026 16:07:48 -0700 Subject: [PATCH 07/10] Mark MEAI.Testing as preview with Experimental APIs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../Microsoft.Extensions.AI.Testing.csproj | 3 +-- .../Microsoft.Extensions.AI.Testing/MockChatClient.cs | 3 +++ .../Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs | 3 +++ .../Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs | 3 +++ src/Libraries/Microsoft.Extensions.AI.Testing/README.md | 3 ++- .../AIAgentWebApi-CSharp/MockChatClientExtensions.cs | 3 +++ .../templates/AIAgentWebApi-CSharp/MockServices.cs | 3 +++ .../Services/LexicalMockEmbeddingGenerator.cs | 3 +++ .../AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs | 3 +++ .../AIChatWeb-CSharp.Web/Services/MockServices.cs | 4 ++++ src/Shared/DiagnosticIds/DiagnosticIds.cs | 1 + .../Chunkers/SemanticSimilarityChunkerTests.cs | 4 ++++ .../IngestionPipelineTests.cs | 4 ++++ .../Writers/InMemoryVectorStoreWriterTests.cs | 4 ++++ .../Writers/SqliteVectorStoreWriterTests.cs | 4 ++++ .../Writers/VectorStoreWriterTests.cs | 4 ++++ .../aiagent-webapi/MockChatClientExtensions.cs | 3 +++ .../aiagent-webapi/MockServices.cs | 3 +++ .../aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs | 3 +++ .../aichatweb.Web/Services/MockChatClientExtensions.cs | 3 +++ .../aichatweb/aichatweb.Web/Services/MockServices.cs | 4 ++++ .../aichatweb/Services/LexicalMockEmbeddingGenerator.cs | 3 +++ .../aichatweb/Services/MockChatClientExtensions.cs | 3 +++ .../aichatweb/Services/MockServices.cs | 4 ++++ 24 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj index c13459e7461..de798bd94d9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj @@ -10,8 +10,7 @@ - dev - EXTEXP0019 + preview false n/a n/a diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs index e5f05422f8b..e2b0ac46d72 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -29,6 +31,7 @@ namespace Microsoft.Extensions.AI; /// citations, reasoning, tool calls, usage, metadata, errors, and other chat-client behavior. /// /// +[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)] public class MockChatClient : IChatClient { private static ChatResponse CloneResponse(ChatResponse response) diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs index b7d4c51e228..022d4c86455 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -16,6 +18,7 @@ namespace Microsoft.Extensions.AI; /// /// is called. /// +[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)] public sealed class MockChatClientRequest { /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs index ea05467f0c0..ae8f0b856d3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -17,6 +19,7 @@ namespace Microsoft.Extensions.AI; /// Configure to produce the embeddings required by a test. The callback receives /// the original input enumerable and cancellation token. records every generation request. /// +[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)] public class MockEmbeddingGenerator : IEmbeddingGenerator> { private int _callCount; diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md index 07333b5976c..3e0368b3252 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md @@ -3,7 +3,8 @@ Deterministic test doubles for applications built on `Microsoft.Extensions.AI`. > [!IMPORTANT] -> This package is for **mocking and deterministic testing** of chat and embedding behavior. +> This **preview** package is for **mocking and deterministic testing** of chat and embedding behavior. Its APIs are +> experimental (`MEAI001`) and may change or be removed in future releases. ## Install diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs index 34ab37da82d..fd642cb6290 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.AI; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockChatClientExtensions { /// Adds a canned response for matching requests. @@ -67,3 +69,4 @@ private static void ValidateDelayRange(int? minDelay, int? maxDelay) } } } +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs index 5b9389227ed..a44d52ecabf 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.AI; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockServices { internal static MockChatClient CreateChatClient() => @@ -142,3 +144,4 @@ private static bool HasAssistantContent(MockChatClientRequest request, string te return false; } } +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs index 6c43f4e5d43..b2df852e252 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs @@ -6,6 +6,8 @@ namespace AIChatWeb_CSharp.Web.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator { private readonly int _dimensions; @@ -102,3 +104,4 @@ private static void AddFeature(string value, int start, int length, float[] vect vector[(int)(hash % (uint)vector.Length)] += 1; } } +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs index b71e15bbc49..8067a6b1d62 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs @@ -6,6 +6,8 @@ namespace AIChatWeb_CSharp.Web.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockChatClientExtensions { private const string LoadDocumentsCallId = "mock-load-documents"; @@ -292,3 +294,4 @@ private static void ValidateDelayRange(int? minDelay, int? maxDelay) } } } +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs index 64bd0dc0c8d..55a5bd87d7a 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.AI; + namespace AIChatWeb_CSharp.Web.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockServices { internal static MockChatClient CreateChatClient() => @@ -155,3 +158,4 @@ private static bool LastQuestionContains(MockChatClientRequest request, params s return null; } } +#pragma warning restore MEAI001 diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index abddbf61c5a..17ad1211730 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -60,6 +60,7 @@ internal static class Experiments internal const string AIToolSearch = AIExperiments; internal const string AIRealTime = AIExperiments; internal const string AIFiles = AIExperiments; + internal const string AITesting = AIExperiments; internal const string AIOpenAIRequestPolicies = AIExperiments; // These diagnostic IDs are defined by the OpenAI package for its experimental APIs. diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs index 16c3bd40f62..8f64a87255d 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs @@ -9,6 +9,8 @@ using Microsoft.ML.Tokenizers; using Xunit; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests { public class SemanticSimilarityChunkerTests : DocumentChunkerTests @@ -261,3 +263,5 @@ private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => } } } + +#pragma warning restore MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs index e1e87363a13..99e35399eca 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs @@ -16,6 +16,8 @@ using OpenTelemetry.Trace; using Xunit; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Tests; #pragma warning disable S881 // Increment (++) and decrement (--) operators should not be used in a method call or mixed with other operators in an expression @@ -276,3 +278,5 @@ private static void AssertErrorActivities(List activities, int expecte Assert.All(failed, a => Assert.Equal(ExpectedException.ExceptionMessage, a.StatusDescription)); } } + +#pragma warning restore MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs index 7969a74acef..9550ae5d085 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs @@ -5,6 +5,8 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Writers.Tests; public class InMemoryVectorStoreWriterTests : VectorStoreWriterTests @@ -12,3 +14,5 @@ public class InMemoryVectorStoreWriterTests : VectorStoreWriterTests protected override VectorStore CreateVectorStore(MockEmbeddingGenerator testEmbeddingGenerator) => new InMemoryVectorStore(new() { EmbeddingGenerator = testEmbeddingGenerator }); } + +#pragma warning restore MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs index 5751c99c3ec..23c186ee5c6 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs @@ -7,6 +7,8 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Writers.Tests; public sealed class SqliteVectorStoreWriterTests : VectorStoreWriterTests, IDisposable @@ -18,3 +20,5 @@ public sealed class SqliteVectorStoreWriterTests : VectorStoreWriterTests, IDisp protected override VectorStore CreateVectorStore(MockEmbeddingGenerator testEmbeddingGenerator) => new SqliteVectorStore($"Data Source={_tempFile};Pooling=false", new() { EmbeddingGenerator = testEmbeddingGenerator }); } + +#pragma warning restore MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs index ac31f9b437a..b8fca6ae082 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.VectorData; using Xunit; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Writers.Tests; public abstract class VectorStoreWriterTests @@ -179,3 +181,5 @@ private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => Task.FromResult>>([new(new float[] { 0, 1, 2, 3 })]), }; } + +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs index a0d702fbb7f..b8b9713b073 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.AI; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockChatClientExtensions { /// Adds a canned response for matching requests. @@ -67,3 +69,4 @@ private static void ValidateDelayRange(int? minDelay, int? maxDelay) } } } +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs index f82509e5945..21c685f58ae 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.AI; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockServices { internal static MockChatClient CreateChatClient() => @@ -142,3 +144,4 @@ private static bool HasAssistantContent(MockChatClientRequest request, string te return false; } } +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs index cd7af1fecc7..8f9e483bc9d 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs @@ -6,6 +6,8 @@ namespace aichatweb.Web.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator { private readonly int _dimensions; @@ -102,3 +104,4 @@ private static void AddFeature(string value, int start, int length, float[] vect vector[(int)(hash % (uint)vector.Length)] += 1; } } +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs index 1444bf2fb91..45ed824b906 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs @@ -6,6 +6,8 @@ namespace aichatweb.Web.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockChatClientExtensions { private const string LoadDocumentsCallId = "mock-load-documents"; @@ -292,3 +294,4 @@ private static void ValidateDelayRange(int? minDelay, int? maxDelay) } } } +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs index 73991ba2513..a9b682a296d 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.AI; + namespace aichatweb.Web.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockServices { internal static MockChatClient CreateChatClient() => @@ -155,3 +158,4 @@ private static bool LastQuestionContains(MockChatClientRequest request, params s return null; } } +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs index 742bc6d93d5..c3e411aa47c 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs @@ -6,6 +6,8 @@ namespace aichatweb.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator { private readonly int _dimensions; @@ -102,3 +104,4 @@ private static void AddFeature(string value, int start, int length, float[] vect vector[(int)(hash % (uint)vector.Length)] += 1; } } +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs index e0ea691e8d7..56aa94a3735 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs @@ -6,6 +6,8 @@ namespace aichatweb.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockChatClientExtensions { private const string LoadDocumentsCallId = "mock-load-documents"; @@ -292,3 +294,4 @@ private static void ValidateDelayRange(int? minDelay, int? maxDelay) } } } +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs index 35eab095ce1..4c883e6865c 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.AI; + namespace aichatweb.Services; +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + internal static class MockServices { internal static MockChatClient CreateChatClient() => @@ -155,3 +158,4 @@ private static bool LastQuestionContains(MockChatClientRequest request, params s return null; } } +#pragma warning restore MEAI001 From 9b8efd4e1a1612cbbba7c17f72491483313e5935 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 24 Jul 2026 23:33:55 -0700 Subject: [PATCH 08/10] Fix local SQLite template key type Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../AIChatWeb-CSharp.Web/Services/IngestedChunk.cs | 2 +- .../aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs index 55202558bb4..75994063439 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs @@ -19,7 +19,7 @@ public class IngestedChunk [VectorStoreKey(StorageName = "key")] [JsonPropertyName("key")] -#if (IsMock) +#if (IsLocalVectorStore) public required string Key { get; set; } #else public required Guid Key { get; set; } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs index 68af3ef20fb..a7136d1988d 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs @@ -11,7 +11,7 @@ public class IngestedChunk [VectorStoreKey(StorageName = "key")] [JsonPropertyName("key")] - public required Guid Key { get; set; } + public required string Key { get; set; } [VectorStoreData(StorageName = "documentid")] [JsonPropertyName("documentid")] From f4548670b090116ee69ff150a756cd630385c771 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Sat, 25 Jul 2026 21:32:36 -0700 Subject: [PATCH 09/10] Add dictionary response seeding to MockChatClient Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../Microsoft.Extensions.AI.Testing.json | 189 ++++++++ .../MockChatClient.AddResponses.cs | 123 ++++++ .../MockChatClient.cs | 100 +++-- .../Microsoft.Extensions.AI.Testing/README.md | 120 ++++- .../MockChatClientReadmeSamplesTests.cs | 410 ++++++++++++++++++ .../MockChatClientTests.cs | 248 +++++++++++ 6 files changed, 1149 insertions(+), 41 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json new file mode 100644 index 00000000000..52e7a513563 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json @@ -0,0 +1,189 @@ +{ + "Name": "Microsoft.Extensions.AI.Testing, Version=10.9.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Types": [ + { + "Type": "class Microsoft.Extensions.AI.MockChatClient : Microsoft.Extensions.AI.IChatClient, System.IDisposable", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.MockChatClient.MockChatClient();", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddException(System.Func requestPredicate, System.Func exceptionFactory, bool singleUse = false);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponse(System.Func requestPredicate, System.Func> getResponse, bool singleUse = false);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponse(System.Func requestPredicate, System.Func> getResponse, System.Func> getStreamingResponse, bool singleUse = false);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponses(System.Collections.Generic.Dictionary responses, System.Func? requestPredicate = null, bool singleUse = false, System.Func>? getResponse = null);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponses(System.Collections.Generic.IEnumerable> responses, System.Func? requestPredicate = null, bool singleUse = false, System.Func>? getResponse = null);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponsesFromDictionary(System.Collections.Generic.Dictionary responses, System.Func? requestPredicate, bool singleUse, System.Func>? getResponse);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponsesFromEnumerable(System.Collections.Generic.IEnumerable> responses, System.Func? requestPredicate, bool singleUse, System.Func>? getResponse);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddSeed(System.Func requestPredicate, System.Func> getResponse, System.Func> getStreamingResponse, bool singleUse);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddStreamingResponse(System.Func requestPredicate, System.Func> getResponse, bool singleUse = false);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.ClearResponses();", + "Stage": "Experimental" + }, + { + "Member": "void Microsoft.Extensions.AI.MockChatClient.Dispose();", + "Stage": "Experimental" + }, + { + "Member": "virtual void Microsoft.Extensions.AI.MockChatClient.Dispose(bool disposing);", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Threading.Tasks.Task Microsoft.Extensions.AI.MockChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "virtual object? Microsoft.Extensions.AI.MockChatClient.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.MockChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient.SeededResponse Microsoft.Extensions.AI.MockChatClient.MatchSeededResponse(Microsoft.Extensions.AI.MockChatClientRequest request);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.MockChatClient.Requests { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.IServiceProvider? Microsoft.Extensions.AI.MockChatClient.Services { get; set; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.MockChatClient.SeededResponse", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.MockChatClient.SeededResponse.SeededResponse();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Func Microsoft.Extensions.AI.MockChatClient.SeededResponse.RequestPredicate { get; init; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func> Microsoft.Extensions.AI.MockChatClient.SeededResponse.ResponseFactory { get; init; }", + "Stage": "Experimental" + }, + { + "Member": "bool Microsoft.Extensions.AI.MockChatClient.SeededResponse.SingleUse { get; init; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func> Microsoft.Extensions.AI.MockChatClient.SeededResponse.StreamingFactory { get; init; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.MockChatClientRequest", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.MockChatClientRequest.MockChatClientRequest(System.Collections.Generic.IReadOnlyList messages, Microsoft.Extensions.AI.ChatOptions? options, bool isStreaming);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.MockChatClientRequest.IsStreaming { get; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.MockChatClientRequest.LastUserText { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.MockChatClientRequest.Messages { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.ChatOptions? Microsoft.Extensions.AI.MockChatClientRequest.Options { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.MockEmbeddingGenerator : Microsoft.Extensions.AI.IEmbeddingGenerator>, Microsoft.Extensions.AI.IEmbeddingGenerator, System.IDisposable", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.MockEmbeddingGenerator.MockEmbeddingGenerator();", + "Stage": "Experimental" + }, + { + "Member": "void Microsoft.Extensions.AI.MockEmbeddingGenerator.Dispose();", + "Stage": "Experimental" + }, + { + "Member": "virtual void Microsoft.Extensions.AI.MockEmbeddingGenerator.Dispose(bool disposing);", + "Stage": "Experimental" + }, + { + "Member": "System.Threading.Tasks.Task>> Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Threading.Tasks.Task>> Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateCoreAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options, System.Threading.CancellationToken cancellationToken);", + "Stage": "Experimental" + }, + { + "Member": "virtual object? Microsoft.Extensions.AI.MockEmbeddingGenerator.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "int Microsoft.Extensions.AI.MockEmbeddingGenerator.CallCount { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func, Microsoft.Extensions.AI.EmbeddingGenerationOptions?, System.Threading.CancellationToken, System.Threading.Tasks.Task>>>? Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateAsyncCallback { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func Microsoft.Extensions.AI.MockEmbeddingGenerator.GetServiceCallback { get; set; }", + "Stage": "Experimental" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs new file mode 100644 index 00000000000..8638e53e822 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +public partial class MockChatClient +{ + /// Seeds text responses from a dictionary for matching requests. + /// The response text keyed by request match values. + /// + /// An optional predicate used to select whether a response key applies to a request. By default, this uses + /// string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase). + /// + /// + /// to remove every response after its first matching request; otherwise the + /// responses remain reusable. + /// + /// + /// An optional asynchronous function applied to each selected response. The function receives the response and the + /// cancellation token from the matching chat-client call. + /// + /// The same instance for chaining. + /// + /// Dictionary entries are seeded in enumeration order. When multiple entries match the same request, the last + /// entry wins. Add more specific matches last. + /// + /// is . + /// This instance has been disposed. + public virtual MockChatClient AddResponses( + Dictionary responses, + Func? requestPredicate = null, + bool singleUse = false, + Func>? getResponse = null) => + AddResponsesFromDictionary(responses, requestPredicate, singleUse, getResponse); + + /// Seeds text responses from an enumerable collection for matching requests. + /// The response text keyed by request match values. + /// + /// An optional predicate used to select whether a response key applies to a request. By default, this uses + /// string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase). + /// + /// + /// to remove every response after its first matching request; otherwise the + /// responses remain reusable. + /// + /// + /// An optional asynchronous function applied to each selected response. The function receives the response and the + /// cancellation token from the matching chat-client call. + /// + /// The same instance for chaining. + /// + /// Entries are seeded in enumeration order. When multiple entries match the same request, the last entry wins. + /// Unlike dictionaries, enumerable collections can contain repeated keys. + /// + /// is . + /// This instance has been disposed. + public virtual MockChatClient AddResponses( + IEnumerable> responses, + Func? requestPredicate = null, + bool singleUse = false, + Func>? getResponse = null) => + AddResponsesFromEnumerable(responses, requestPredicate, singleUse, getResponse); + + /// Adds text response seeds from a dictionary. + /// The response text keyed by request match values. + /// Predicate used to select whether a response key applies to a request. + /// + /// to remove every response after its first matching request; otherwise the + /// responses remain reusable. + /// + /// An asynchronous function applied to each selected response. + /// The same instance for chaining. + protected virtual MockChatClient AddResponsesFromDictionary( + Dictionary responses, + Func? requestPredicate, + bool singleUse, + Func>? getResponse) + => AddResponsesFromEnumerable(responses, requestPredicate, singleUse, getResponse); + + /// Adds text response seeds from an enumerable collection. + /// The response text keyed by request match values. + /// Predicate used to select whether a response key applies to a request. + /// + /// to remove every response after its first matching request; otherwise the + /// responses remain reusable. + /// + /// An asynchronous function applied to each selected response. + /// The same instance for chaining. + protected virtual MockChatClient AddResponsesFromEnumerable( + IEnumerable> responses, + Func? requestPredicate, + bool singleUse, + Func>? getResponse) + { + _ = Throw.IfNull(responses); + ThrowIfDisposed(); + + requestPredicate ??= static (request, key) => + string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase); + getResponse ??= static (response, _) => Task.FromResult(response); + + foreach (KeyValuePair response in responses) + { + string key = Throw.IfNull(response.Key); + string value = Throw.IfNull(response.Value); + + _ = AddResponse( + request => requestPredicate(request, key), + (_, cancellationToken) => getResponse( + new ChatResponse(new ChatMessage(ChatRole.Assistant, value)), + cancellationToken), + singleUse); + } + + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs index e2b0ac46d72..236158685e8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs @@ -32,7 +32,7 @@ namespace Microsoft.Extensions.AI; /// /// [Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)] -public class MockChatClient : IChatClient +public partial class MockChatClient : IChatClient { private static ChatResponse CloneResponse(ChatResponse response) { @@ -337,24 +337,15 @@ public void Dispose() GC.SuppressFinalize(this); } - /// Releases resources used by this mock client. - /// when called from . - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - lock (_sync) - { - _disposed = true; - _seededResponses.Clear(); - _requests.Clear(); - } - } - - private MockChatClient AddSeed( + /// Adds a response seed. + /// Predicate used to select whether this seed applies to a request. + /// Factory that creates a response for a matching request. + /// Factory that creates streaming updates for a matching request. + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + protected virtual MockChatClient AddSeed( Func requestPredicate, Func> getResponse, Func> getStreamingResponse, @@ -380,21 +371,15 @@ private MockChatClient AddSeed( return this; } - private MockChatClientRequest RecordRequest(IEnumerable messages, ChatOptions? options, bool isStreaming) + /// Finds the most recently added seed that matches a request. + /// The request to match. + /// The matching seed. + /// is . + /// No seeded response matched the request. + protected virtual SeededResponse MatchSeededResponse(MockChatClientRequest request) { - ChatMessage[] messageArray = messages.Select(m => Throw.IfNull(m).Clone()).ToArray(); - var request = new MockChatClientRequest(messageArray, options, isStreaming); - - lock (_sync) - { - _requests.Add(request); - } + request = Throw.IfNull(request); - return request; - } - - private SeededResponse MatchSeededResponse(MockChatClientRequest request) - { lock (_sync) { for (int i = _seededResponses.Count - 1; i >= 0; i--) @@ -418,22 +403,57 @@ private SeededResponse MatchSeededResponse(MockChatClientRequest request) $"No seeded response matched. Last user text: '{request.LastUserText ?? ""}'."); } - private void ThrowIfDisposed() + /// Represents a response seed. + protected sealed class SeededResponse { - if (_disposed) + /// Gets the predicate used to select whether this seed applies to a request. + public Func RequestPredicate { get; init; } = default!; + + /// Gets the factory that creates a response for a matching request. + public Func> ResponseFactory { get; init; } = default!; + + /// Gets the factory that creates streaming updates for a matching request. + public Func> StreamingFactory { get; init; } = default!; + + /// Gets a value indicating whether this seed is removed after its first matching request. + public bool SingleUse { get; init; } + } + + /// Releases resources used by this mock client. + /// when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) { - throw new ObjectDisposedException(nameof(MockChatClient)); + return; + } + + lock (_sync) + { + _disposed = true; + _seededResponses.Clear(); + _requests.Clear(); } } - private sealed class SeededResponse + private MockChatClientRequest RecordRequest(IEnumerable messages, ChatOptions? options, bool isStreaming) { - public Func RequestPredicate { get; init; } = default!; + ChatMessage[] messageArray = messages.Select(m => Throw.IfNull(m).Clone()).ToArray(); + var request = new MockChatClientRequest(messageArray, options, isStreaming); - public Func> ResponseFactory { get; init; } = default!; + lock (_sync) + { + _requests.Add(request); + } - public Func> StreamingFactory { get; init; } = default!; + return request; + } - public bool SingleUse { get; init; } + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MockChatClient)); + } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md index 3e0368b3252..0a7999a32f3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md @@ -31,7 +31,7 @@ Or in a project file: | Behavior | Details | | --- | --- | | Starts empty | No responses are predefined. | -| Match order | Responses are checked in reverse insertion order; the most recently added matching response wins. | +| Match order | Last response added wins. Add the most specific matches last. | | Consumption | Seeds are reusable by default. | | One-time seed | Set `singleUse: true` to remove a seed after its first match. | | Unmatched request | Throws `InvalidOperationException` with the last user message text. | @@ -60,6 +60,124 @@ ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "hello Console.WriteLine(response.Text); ``` +### Response dictionary + +Use `AddResponses` when a response dictionary is more convenient than individual response factories. The default +predicate is `string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase)`. Entries are seeded in +dictionary order, so the last matching entry wins. + +```csharp +var client = new MockChatClient().AddResponses(new() +{ + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", +}); +``` + +A dictionary deserialized from JSON works the same way: + +```csharp +using System.Text.Json; + +var client = new MockChatClient().AddResponses( + JsonSerializer.Deserialize>( + """ + { + "hello": "Hello from a deterministic mock.", + "goodbye": "Goodbye from a deterministic mock." + } + """)!); +``` + +Use an `IEnumerable>` only when repeated keys are needed. Entries are seeded in +enumeration order, so the last matching entry wins: + +```csharp +client.AddResponses( + new KeyValuePair[] + { + new("hello", "Hello again. Nice to see you."), + new("hello", "Hello. Nice to meet you."), + }, + singleUse: true); +``` + +Use `AddResponse` to return fully populated `ChatResponse` instances, including non-text content such as tool calls: + +```csharp +var client = new MockChatClient() + .AddResponse( + static request => request.LastUserText == "get-weather", + static (_, _) => Task.FromResult( + new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("weather-call", "GetWeather")])))); +``` + +Pass a predicate when the dictionary key needs a different matching rule: + +```csharp +client.AddResponses(new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + static (request, key) => request.LastUserText?.StartsWith(key, StringComparison.OrdinalIgnoreCase) is true); +``` + +`singleUse` applies to every dictionary response. A custom predicate can map distinct dictionary keys to the same prompt: + +```csharp +var client = new MockChatClient() + .AddResponses( + new() + { + ["hello:2"] = "Hello again. Nice to see you.", + ["hello:1"] = "Hello. Nice to meet you.", + }, + static (request, key) => + { + string promptKey = key.Split(new[] { ':' }, 2)[0]; + return string.Equals(request.LastUserText, promptKey, StringComparison.OrdinalIgnoreCase); + }, + singleUse: true); +``` + +Chat requests can include images as `DataContent`. A predicate can match image MIME types: + +```csharp +var client = new MockChatClient() + .AddResponses( + new() + { + ["image/jpeg"] = "I see you shared a JPEG.", + ["image/png"] = "I see you shared a PNG.", + }, + static (request, mediaType) => request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase))); +``` + +Pass `getResponse` to apply common asynchronous behavior to every selected response. String values are converted to +`ChatResponse` instances before `getResponse` is invoked: + +```csharp +var client = new MockChatClient() + .AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + getResponse: async (response, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + return response; + }); +``` + ## Seeding patterns ### Non-streaming response diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs new file mode 100644 index 00000000000..86542758d66 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs @@ -0,0 +1,410 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class MockChatClientReadmeSamplesTests +{ + [Fact] + public async Task QuickStart() + { + var client = new MockChatClient() + .AddResponse( + static _ => true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Fallback response."))), + singleUse: true) + .AddResponse( + static request => request.LastUserText?.Contains("hello", StringComparison.OrdinalIgnoreCase) is true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello from a deterministic mock.")))); + + try + { + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "hello")]); + Console.WriteLine(response.Text); + + Assert.Equal("Hello from a deterministic mock.", response.Text); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task ResponseDictionary() + { + var client = new MockChatClient().AddResponses(new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }); + + try + { + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye")])).Text); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task JsonResponseDictionary() + { + var client = new MockChatClient().AddResponses( + JsonSerializer.Deserialize>( + """ + { + "hello": "Hello from a deterministic mock.", + "goodbye": "Goodbye from a deterministic mock." + } + """)!); + + try + { + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye")])).Text); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task EnumerableResponseDictionary() + { + using var client = new MockChatClient(); + + client.AddResponses( + new KeyValuePair[] + { + new("hello", "Hello again. Nice to see you."), + new("hello", "Hello. Nice to meet you."), + }, + singleUse: true); + + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + } + + [Fact] + public async Task FunctionCallResponse() + { + var client = new MockChatClient() + .AddResponse( + static request => request.LastUserText == "get-weather", + static (_, _) => Task.FromResult( + new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("weather-call", "GetWeather")])))); + + try + { + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "get-weather")]); + FunctionCallContent functionCall = Assert.IsType( + Assert.Single(Assert.Single(response.Messages).Contents)); + + Assert.Equal("weather-call", functionCall.CallId); + Assert.Equal("GetWeather", functionCall.Name); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task CustomDictionaryPredicate() + { + using var client = new MockChatClient(); + + client.AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + static (request, key) => request.LastUserText?.StartsWith(key, StringComparison.OrdinalIgnoreCase) is true); + + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello there")])).Text); + Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye for now")])).Text); + } + + [Fact] + public async Task SingleUseDictionaryResponses() + { + var client = new MockChatClient() + .AddResponses( + new() + { + ["hello:2"] = "Hello again. Nice to see you.", + ["hello:1"] = "Hello. Nice to meet you.", + }, + static (request, key) => + { + string promptKey = key.Split(new[] { ':' }, 2)[0]; + return string.Equals(request.LastUserText, promptKey, StringComparison.OrdinalIgnoreCase); + }, + singleUse: true); + + try + { + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task ImageResponseDictionary() + { + var client = new MockChatClient() + .AddResponses( + new() + { + ["image/jpeg"] = "I see you shared a JPEG.", + ["image/png"] = "I see you shared a PNG.", + }, + static (request, mediaType) => request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase))); + + try + { + Assert.Equal( + "I see you shared a JPEG.", + (await client.GetResponseAsync([new(ChatRole.User, [new DataContent(new byte[] { 0xFF }, "image/jpeg")])])).Text); + Assert.Equal( + "I see you shared a PNG.", + (await client.GetResponseAsync([new(ChatRole.User, [new DataContent(new byte[] { 0x89 }, "image/png")])])).Text); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task ResponseDictionaryWrapper() + { + var client = new MockChatClient() + .AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + getResponse: async (response, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + return response; + }); + + try + { + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + } + finally + { + client.Dispose(); + } + } + + [Fact] + public async Task NonStreamingResponse() + { + using var client = new MockChatClient(); + + client.AddResponse( + static request => request.LastUserText == "explain", + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer")))); + + Assert.Equal("Complete answer", (await client.GetResponseAsync([new(ChatRole.User, "explain")])).Text); + } + + [Fact] + public async Task StreamingResponse() + { + using var client = new MockChatClient(); + + client.AddStreamingResponse( + static request => request.LastUserText == "stream", + static (_, cancellationToken) => GetUpdatesAsync(cancellationToken)); + + static async IAsyncEnumerable GetUpdatesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 1 "); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 2"); + } + + List updates = await ToListAsync(client.GetStreamingResponseAsync([new(ChatRole.User, "stream")])); + Assert.Equal("Part 1 ", updates[0].Text); + Assert.Equal("Part 2", updates[1].Text); + } + + [Fact] + public async Task DistinctStreamingAndNonStreamingResponses() + { + using var client = new MockChatClient(); + + client.AddResponse( + static request => request.LastUserText == "both", + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer"))), + static (_, cancellationToken) => GetUpdatesAsync(cancellationToken)); + + static async IAsyncEnumerable GetUpdatesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 1 "); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 2"); + } + + Assert.Equal("Complete answer", (await client.GetResponseAsync([new(ChatRole.User, "both")])).Text); + List updates = await ToListAsync(client.GetStreamingResponseAsync([new(ChatRole.User, "both")])); + Assert.Equal("Part 1 ", updates[0].Text); + Assert.Equal("Part 2", updates[1].Text); + } + + [Fact] + public async Task RichChatResponse() + { + using var client = new MockChatClient(); + + client.AddResponse( + static request => request.LastUserText == "sources", + static (_, _) => Task.FromResult( + new ChatResponse( + [ + new ChatMessage( + ChatRole.Assistant, + [ + new TextContent("TrailMaster tracks your progress.") + { + Annotations = + [ + new CitationAnnotation + { + Title = "Example_GPS_Watch.md", + Snippet = "track your progress", + } + ] + }, + new TextReasoningContent("The document describes tracking features.") + ]) + ]) + { + Usage = new UsageDetails { InputTokenCount = 3, OutputTokenCount = 7 } + })); + + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "sources")]); + ChatMessage message = Assert.Single(response.Messages); + TextContent content = Assert.IsType(message.Contents[0]); + + Assert.Equal("TrailMaster tracks your progress.", content.Text); + Assert.Equal("Example_GPS_Watch.md", Assert.IsType(Assert.Single(content.Annotations!)).Title); + Assert.Equal("The document describes tracking features.", Assert.IsType(message.Contents[1]).Text); + Assert.Equal(3, response.Usage!.InputTokenCount); + Assert.Equal(7, response.Usage.OutputTokenCount); + } + + [Fact] + public async Task RequestAssertions() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "response")))); + _ = await client.GetResponseAsync([new(ChatRole.User, "hello")]); + + MockChatClientRequest first = client.Requests[0]; + string? lastUserText = first.LastUserText; + bool wasStreaming = first.IsStreaming; + + Assert.Equal("hello", lastUserText); + Assert.False(wasStreaming); + } + + [Fact] + public async Task ClearAndReseed() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "old scenario")))); + _ = await client.GetResponseAsync([new(ChatRole.User, "old")]); + + client.ClearResponses() + .AddResponse( + static _ => true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "New test scenario.")))); + + Assert.Equal("New test scenario.", (await client.GetResponseAsync([new(ChatRole.User, "new")])).Text); + Assert.Equal(2, client.Requests.Count); + } + + [Fact] + public async Task MockErrors() + { + using var client = new MockChatClient(); + + client.AddException( + static request => request.LastUserText == "UNAVAILABLE", + static () => new HttpRequestException("The provider is temporarily unavailable.")); + + HttpRequestException exception = await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "UNAVAILABLE")])); + Assert.Equal("The provider is temporarily unavailable.", exception.Message); + } + + [Fact] + public async Task MockEmbeddings() + { + using var embeddings = new MockEmbeddingGenerator + { + GenerateAsyncCallback = static (_, _, _) => + Task.FromResult>>( + [new(new float[] { 0.1f, 0.2f, 0.3f })]), + }; + + GeneratedEmbeddings> result = await embeddings.GenerateAsync(["trail"]); + Console.WriteLine(embeddings.CallCount); + + Assert.Equal(1, embeddings.CallCount); + Assert.Equal(new float[] { 0.1f, 0.2f, 0.3f }, Assert.Single(result).Vector.ToArray()); + } + + private static async Task> ToListAsync(IAsyncEnumerable updates) + { + var results = new List(); + await foreach (ChatResponseUpdate update in updates) + { + results.Add(update); + } + + return results; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs index a157ea04bcd..c16ce81ee58 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -29,6 +30,206 @@ public async Task AddResponse_MatchesMostRecentlyAddedSeed_AndFallsBackAfterSing Assert.Contains("third request", ex.Message); } + [Fact] + public async Task AddResponses_UsesOrdinalIgnoreCaseExactMatches() + { + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }); + + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "HELLO")])).Text); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "hello there")])); + } + + [Fact] + public async Task AddResponses_SeedsJsonDeserializedResponsesInOrder() + { + using var client = new MockChatClient(); + client.AddResponses( + JsonSerializer.Deserialize>( + """ + { + "weather": "General weather response.", + "weather today": "Specific weather response." + } + """)!, + static (request, key) => request.LastUserText?.Contains(key, StringComparison.OrdinalIgnoreCase) is true); + + Assert.Equal("Specific weather response.", (await client.GetResponseAsync([new(ChatRole.User, "weather today")])).Text); + } + + [Fact] + public async Task AddResponses_AppliesSingleUseToDictionaryResponses() + { + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello:2"] = "Hello again. Nice to see you.", + ["hello:1"] = "Hello. Nice to meet you.", + }, + static (request, key) => string.Equals( + request.LastUserText, + key.Split(new[] { ':' }, 2)[0], + StringComparison.OrdinalIgnoreCase), + singleUse: true); + + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "hello")])); + } + + [Fact] + public async Task AddResponses_EnumerableResponsesSupportRepeatedKeys() + { + using var client = new MockChatClient(); + client.AddResponses( + new KeyValuePair[] + { + new("hello", "Hello again. Nice to see you."), + new("hello", "Hello. Nice to meet you."), + }, + singleUse: true); + + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "hello")])); + } + + [Fact] + public async Task AddResponses_AppliesResponseWrapperToStringResponses() + { + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken observedToken = default; + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello"] = "Hello", + ["goodbye"] = "Goodbye", + }, + getResponse: (response, cancellationToken) => + { + observedToken = cancellationToken; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, $"{response.Text} from wrapper"))); + }); + + ChatResponse result = await client.GetResponseAsync( + [new(ChatRole.User, "hello")], + cancellationToken: cancellationTokenSource.Token); + + Assert.Equal("Hello from wrapper", result.Text); + Assert.Equal(cancellationTokenSource.Token, observedToken); + } + + [Fact] + public async Task AddResponse_ReturnsNonTextChatResponse() + { + using var client = new MockChatClient(); + client.AddResponse( + static request => request.LastUserText == "get-weather", + static (_, _) => Task.FromResult( + new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("weather-call", "GetWeather")])))); + + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "get-weather")]); + + FunctionCallContent functionCall = Assert.IsType( + Assert.Single(Assert.Single(response.Messages).Contents)); + Assert.Equal("weather-call", functionCall.CallId); + Assert.Equal("GetWeather", functionCall.Name); + } + + [Fact] + public async Task AddResponses_MatchesImageRequestsByMediaType() + { + const string JpegResponse = "I see you shared a JPEG."; + const string PngResponse = "I see you shared a PNG."; + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["image/jpeg"] = JpegResponse, + ["image/png"] = PngResponse, + }, + static (request, mediaType) => request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase))); + + ChatResponse jpegResponse = await client.GetResponseAsync( + [ + new ChatMessage( + ChatRole.User, + [new DataContent(new byte[] { 0xFF }, "image/jpeg")]) + ]); + ChatResponse pngResponse = await client.GetResponseAsync( + [ + new ChatMessage( + ChatRole.User, + [new DataContent(new byte[] { 0x89 }, "image/png")]) + ]); + + Assert.Equal(JpegResponse, jpegResponse.Text); + Assert.Equal(PngResponse, pngResponse.Text); + } + + [Fact] + public async Task AddResponses_ResponseWrapperReceivesCancellationToken() + { + CancellationToken observedToken = default; + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["cancel"] = "unreachable", + ["other"] = "Other response", + }, + getResponse: async (response, cancellationToken) => + { + observedToken = cancellationToken; + await Task.Delay(Timeout.Infinite, cancellationToken); + return response; + }); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => + client.GetResponseAsync([new(ChatRole.User, "cancel")], cancellationToken: cancellationTokenSource.Token)); + + Assert.Equal(cancellationTokenSource.Token, observedToken); + } + + [Fact] + public async Task ExtensibilityHooks_AreOverridable() + { + using var client = new ExtensibleMockChatClient(); + client.AddResponses( + new() + { + ["hello"] = "Hello", + ["goodbye"] = "Goodbye", + }); + + Assert.Equal("Hello", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal(1, client.AddResponsesFromDictionaryCallCount); + Assert.Equal(1, client.AddResponsesFromEnumerableCallCount); + Assert.Equal(2, client.AddSeedCallCount); + Assert.Equal(1, client.MatchSeededResponseCallCount); + } + [Fact] public async Task AddResponse_IsReusableByDefault() { @@ -424,6 +625,53 @@ public void MockEmbeddingGenerator_ConfiguresServiceResolution() Assert.Same(expected, generator.GetService(typeof(object))); } + private sealed class ExtensibleMockChatClient : MockChatClient + { + public int AddSeedCallCount { get; private set; } + + public int AddResponsesFromDictionaryCallCount { get; private set; } + + public int AddResponsesFromEnumerableCallCount { get; private set; } + + public int MatchSeededResponseCallCount { get; private set; } + + protected override MockChatClient AddSeed( + Func requestPredicate, + Func> getResponse, + Func> getStreamingResponse, + bool singleUse) + { + AddSeedCallCount++; + return base.AddSeed(requestPredicate, getResponse, getStreamingResponse, singleUse); + } + + protected override MockChatClient AddResponsesFromDictionary( + Dictionary responses, + Func? requestPredicate, + bool singleUse, + Func>? getResponse) + { + AddResponsesFromDictionaryCallCount++; + return base.AddResponsesFromDictionary(responses, requestPredicate, singleUse, getResponse); + } + + protected override MockChatClient AddResponsesFromEnumerable( + IEnumerable> responses, + Func? requestPredicate, + bool singleUse, + Func>? getResponse) + { + AddResponsesFromEnumerableCallCount++; + return base.AddResponsesFromEnumerable(responses, requestPredicate, singleUse, getResponse); + } + + protected override SeededResponse MatchSeededResponse(MockChatClientRequest request) + { + MatchSeededResponseCallCount++; + return base.MatchSeededResponse(request); + } + } + private sealed class DerivedMockEmbeddingGenerator(GeneratedEmbeddings> expected) : MockEmbeddingGenerator { protected override Task>> GenerateCoreAsync( From 829ceff8202c5439a66ac556f3a9bbca4b0e52fc Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Mon, 27 Jul 2026 04:33:23 -0700 Subject: [PATCH 10/10] Dispose mock clients in README sample tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../MockChatClientReadmeSamplesTests.cs | 190 +++++++----------- 1 file changed, 72 insertions(+), 118 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs index 86542758d66..95123187e46 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs @@ -18,7 +18,8 @@ public class MockChatClientReadmeSamplesTests [Fact] public async Task QuickStart() { - var client = new MockChatClient() + using var client = new MockChatClient(); + client .AddResponse( static _ => true, static (_, _) => Task.FromResult( @@ -29,43 +30,31 @@ public async Task QuickStart() static (_, _) => Task.FromResult( new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello from a deterministic mock.")))); - try - { - ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "hello")]); - Console.WriteLine(response.Text); + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "hello")]); + Console.WriteLine(response.Text); - Assert.Equal("Hello from a deterministic mock.", response.Text); - } - finally - { - client.Dispose(); - } + Assert.Equal("Hello from a deterministic mock.", response.Text); } [Fact] public async Task ResponseDictionary() { - var client = new MockChatClient().AddResponses(new() + using var client = new MockChatClient(); + client.AddResponses(new() { ["hello"] = "Hello from a deterministic mock.", ["goodbye"] = "Goodbye from a deterministic mock.", }); - try - { - Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); - Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye")])).Text); - } - finally - { - client.Dispose(); - } + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye")])).Text); } [Fact] public async Task JsonResponseDictionary() { - var client = new MockChatClient().AddResponses( + using var client = new MockChatClient(); + client.AddResponses( JsonSerializer.Deserialize>( """ { @@ -74,15 +63,8 @@ public async Task JsonResponseDictionary() } """)!); - try - { - Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); - Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye")])).Text); - } - finally - { - client.Dispose(); - } + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye")])).Text); } [Fact] @@ -105,28 +87,21 @@ public async Task EnumerableResponseDictionary() [Fact] public async Task FunctionCallResponse() { - var client = new MockChatClient() - .AddResponse( - static request => request.LastUserText == "get-weather", - static (_, _) => Task.FromResult( - new ChatResponse( - new ChatMessage( - ChatRole.Assistant, - [new FunctionCallContent("weather-call", "GetWeather")])))); + using var client = new MockChatClient(); + client.AddResponse( + static request => request.LastUserText == "get-weather", + static (_, _) => Task.FromResult( + new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("weather-call", "GetWeather")])))); - try - { - ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "get-weather")]); - FunctionCallContent functionCall = Assert.IsType( - Assert.Single(Assert.Single(response.Messages).Contents)); + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "get-weather")]); + FunctionCallContent functionCall = Assert.IsType( + Assert.Single(Assert.Single(response.Messages).Contents)); - Assert.Equal("weather-call", functionCall.CallId); - Assert.Equal("GetWeather", functionCall.Name); - } - finally - { - client.Dispose(); - } + Assert.Equal("weather-call", functionCall.CallId); + Assert.Equal("GetWeather", functionCall.Name); } [Fact] @@ -149,85 +124,64 @@ public async Task CustomDictionaryPredicate() [Fact] public async Task SingleUseDictionaryResponses() { - var client = new MockChatClient() - .AddResponses( - new() - { - ["hello:2"] = "Hello again. Nice to see you.", - ["hello:1"] = "Hello. Nice to meet you.", - }, - static (request, key) => - { - string promptKey = key.Split(new[] { ':' }, 2)[0]; - return string.Equals(request.LastUserText, promptKey, StringComparison.OrdinalIgnoreCase); - }, - singleUse: true); + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello:2"] = "Hello again. Nice to see you.", + ["hello:1"] = "Hello. Nice to meet you.", + }, + static (request, key) => + { + string promptKey = key.Split(new[] { ':' }, 2)[0]; + return string.Equals(request.LastUserText, promptKey, StringComparison.OrdinalIgnoreCase); + }, + singleUse: true); - try - { - Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); - Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); - } - finally - { - client.Dispose(); - } + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); } [Fact] public async Task ImageResponseDictionary() { - var client = new MockChatClient() - .AddResponses( - new() - { - ["image/jpeg"] = "I see you shared a JPEG.", - ["image/png"] = "I see you shared a PNG.", - }, - static (request, mediaType) => request.Messages - .SelectMany(static message => message.Contents) - .OfType() - .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase))); - - try - { - Assert.Equal( - "I see you shared a JPEG.", - (await client.GetResponseAsync([new(ChatRole.User, [new DataContent(new byte[] { 0xFF }, "image/jpeg")])])).Text); - Assert.Equal( - "I see you shared a PNG.", - (await client.GetResponseAsync([new(ChatRole.User, [new DataContent(new byte[] { 0x89 }, "image/png")])])).Text); - } - finally - { - client.Dispose(); - } + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["image/jpeg"] = "I see you shared a JPEG.", + ["image/png"] = "I see you shared a PNG.", + }, + static (request, mediaType) => request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase))); + + Assert.Equal( + "I see you shared a JPEG.", + (await client.GetResponseAsync([new(ChatRole.User, [new DataContent(new byte[] { 0xFF }, "image/jpeg")])])).Text); + Assert.Equal( + "I see you shared a PNG.", + (await client.GetResponseAsync([new(ChatRole.User, [new DataContent(new byte[] { 0x89 }, "image/png")])])).Text); } [Fact] public async Task ResponseDictionaryWrapper() { - var client = new MockChatClient() - .AddResponses( - new() - { - ["hello"] = "Hello from a deterministic mock.", - ["goodbye"] = "Goodbye from a deterministic mock.", - }, - getResponse: async (response, cancellationToken) => - { - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - return response; - }); + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + getResponse: async (response, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + return response; + }); - try - { - Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); - } - finally - { - client.Dispose(); - } + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); } [Fact]