From 045c18b10914fc0e664ab761dcc711987ee3fd16 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 25 Jun 2026 18:29:42 -0400 Subject: [PATCH] Add Foundry Local AI chat template provider --- eng/packages/ProjectTemplates.props | 1 + .../.template.config/template.json | 41 ++++++ .../AIChatWeb-CSharp.Web.csproj-in | 3 + .../AIChatWeb-CSharp.Web/Program.cs | 49 ++++++- .../AIChatWeb-CSharp.Web/README.md | 28 ++++ .../Services/IngestedChunk.cs | 2 + .../appsettings.Development.json | 7 + .../AIChatWeb-CSharp.Web/appsettings.json | 7 + .../AIChatWebSnapshotTests.cs | 1 + .../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 | 82 +++++++++++ .../aichatweb/Properties/launchSettings.json | 23 +++ .../aichatweb/README.md | 34 +++++ .../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 | 23 +++ .../aichatweb/appsettings.Development.json | 14 ++ .../aichatweb/appsettings.json | 15 ++ 46 files changed, 1556 insertions(+), 2 deletions(-) create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/App.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/LoadingSpinner.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/MainLayout.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/MainLayout.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/SurveyPrompt.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/Chat.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/Chat.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatInput.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Error.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Routes.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/_Imports.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/README.md create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/IngestedChunk.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/Ingestion/DataIngestor.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/Ingestion/DocumentReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/Ingestion/PdfPigReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/SemanticSearch.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/aichatweb.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/appsettings.json diff --git a/eng/packages/ProjectTemplates.props b/eng/packages/ProjectTemplates.props index eb053b5270d..385410facee 100644 --- a/eng/packages/ProjectTemplates.props +++ b/eng/packages/ProjectTemplates.props @@ -19,6 +19,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 4058affe004..feff7817f47 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 @@ -149,6 +149,11 @@ "choice": "openai", "displayName": "OpenAI Platform", "description": "Uses the OpenAI Platform" + }, + { + "choice": "foundrylocal", + "displayName": "Foundry Local (on-device)", + "description": "Uses Foundry Local with the qwen3-4b and qwen3-embedding-0.6b models" } // { // "choice": "azureaifoundry", @@ -223,6 +228,10 @@ "type": "computed", "value": "(AiServiceProvider == \"ollama\")" }, + "IsFoundryLocal": { + "type": "computed", + "value": "(AiServiceProvider == \"foundrylocal\")" + }, "IsAzureAIFoundry": { "type": "computed", "value": "(AiServiceProvider == \"azureaifoundry\")" @@ -317,6 +326,38 @@ }, "replaces": "all-minilm" }, + "FoundryLocalChatModelDefault": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "qwen3-4b" + } + }, + "FoundryLocalEmbeddingModelDefault": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "qwen3-embedding-0.6b" + } + }, + "FoundryLocalChatModel": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "ChatModel", + "fallbackVariableName": "FoundryLocalChatModelDefault" + }, + "replaces": "qwen3-4b" + }, + "FoundryLocalEmbeddingModel": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "EmbeddingModel", + "fallbackVariableName": "FoundryLocalEmbeddingModelDefault" + }, + "replaces": "qwen3-embedding-0.6b" + }, "webHttpPort": { "type": "parameter", "datatype": "integer", 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 3d8811c209e..ab0339b7ed3 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 @@ -15,6 +15,9 @@ #elif ((IsGHModels || IsOpenAI) && !IsAspire) +#elif (IsFoundryLocal && !IsAspire) + + #elif (IsAzureAIFoundry) 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..87a8372174d 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,8 +1,12 @@ -#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) +#if (IsGHModels || IsOpenAI || IsFoundryLocal || (IsAzureOpenAI && !IsManagedIdentity)) using System.ClientModel; #elif (IsAzureOpenAI && IsManagedIdentity) using System.ClientModel.Primitives; #endif +#if (IsFoundryLocal) +using Microsoft.AI.Foundry.Local; +using Microsoft.Extensions.Logging.Abstractions; +#endif #if (IsAzureAISearch && !IsManagedIdentity) using Azure; #elif (IsManagedIdentity) @@ -11,7 +15,7 @@ using Microsoft.Extensions.AI; #if (IsOllama) using OllamaSharp; -#elif (IsGHModels || IsOpenAI || IsAzureOpenAI) +#elif (IsGHModels || IsOpenAI || IsFoundryLocal || IsAzureOpenAI) using OpenAI; #endif using AIChatWeb_CSharp.Web.Components; @@ -40,6 +44,47 @@ "llama3.2"); IEmbeddingGenerator> embeddingGenerator = new OllamaApiClient(new Uri("http://localhost:11434"), "all-minilm"); +#elif (IsFoundryLocal) +var chatAlias = builder.Configuration["FoundryLocal:ChatModel"] ?? "qwen3-4b"; +var embeddingAlias = builder.Configuration["FoundryLocal:EmbeddingModel"] ?? "qwen3-embedding-0.6b"; +var foundryServiceUrl = builder.Configuration["FoundryLocal:ServiceUrl"] ?? "http://127.0.0.1:5273"; + +await FoundryLocalManager.CreateAsync(new Configuration +{ + AppName = "AIChatWeb-CSharp", + Web = new Configuration.WebService { Urls = foundryServiceUrl } +}, NullLogger.Instance); +var foundryManager = FoundryLocalManager.Instance; +await foundryManager.StartWebServiceAsync(); +var foundryCatalog = await foundryManager.GetCatalogAsync(); + +async Task EnsureFoundryModelAsync(string alias) +{ + var model = await foundryCatalog.GetModelAsync(alias) + ?? throw new InvalidOperationException( + $"Foundry Local model '{alias}' was not found in the catalog. Run 'foundry model list' to see available models."); + if (!await model.IsCachedAsync()) + { + Console.WriteLine($"Foundry Local: downloading model '{alias}' (first run only)..."); + await model.DownloadAsync(_ => { }); + } + if (!await model.IsLoadedAsync()) + { + await model.LoadAsync(); + } + return model.Id; +} + +var chatModelId = await EnsureFoundryModelAsync(chatAlias); +var embeddingModelId = await EnsureFoundryModelAsync(embeddingAlias); + +var foundryEndpointUrl = foundryManager.Urls?.FirstOrDefault() ?? foundryServiceUrl; +var foundryEndpoint = new Uri($"{foundryEndpointUrl.TrimEnd('/')}/v1"); +var foundryClient = new OpenAIClient( + new ApiKeyCredential("unused"), + new OpenAIClientOptions { Endpoint = foundryEndpoint }); +var chatClient = foundryClient.GetChatClient(chatModelId).AsIChatClient(); +var embeddingGenerator = foundryClient.GetEmbeddingClient(embeddingModelId).AsIEmbeddingGenerator(); #elif (IsOpenAI) // 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 14677ca5372..ff12cff5c41 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 @@ -92,6 +92,34 @@ ollama pull all-minilm ### 3. Learn more about Ollama Once the models are installed, you can start using them in your application. Refer to the [Ollama documentation](https://github.com/ollama/ollama/blob/main/docs/README.md) for detailed instructions on how to explore models locally. +#### ---#endif +#### ---#if (IsFoundryLocal) +## Setting up a local environment using Foundry Local +This project is configured to use Foundry Local, which runs models on your workstation through a local OpenAI-compatible endpoint. It does not need an API key. + +### 1. Install Foundry Local +Install Foundry Local for your operating system by following the [Foundry Local documentation](https://learn.microsoft.com/azure/ai-foundry/foundry-local/). + +### 2. Run the app +The app starts the Foundry Local service for you. On first run, it downloads the configured models, then loads them into the local service. + +The default chat model alias is `qwen3-4b`. The default embedding model alias is `qwen3-embedding-0.6b`. + +### 3. Override model aliases or the service URL +You can change the defaults in `appsettings.json`, `appsettings.Development.json`, or user secrets: + +```json +{ + "FoundryLocal": { + "ChatModel": "qwen3-4b", + "EmbeddingModel": "qwen3-embedding-0.6b", + "ServiceUrl": "http://127.0.0.1:5273" + } +} +``` + +Use `FoundryLocal:ServiceUrl` if another local process already uses the default port. + #### ---#endif #### ---#if (IsAzureOpenAI) ## Using Azure 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..658ecbc05c3 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 @@ -7,6 +7,8 @@ public class IngestedChunk { #if (IsOllama) public const int VectorDimensions = 384; // 384 is the default vector size for the all-minilm embedding model +#elif (IsFoundryLocal) + public const int VectorDimensions = 1024; // 1024 is the default vector size for the qwen3-embedding-0.6b embedding model #else public const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model #endif diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/appsettings.Development.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/appsettings.Development.json index d7b2fc5dca0..5dde9fc2245 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/appsettings.Development.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/appsettings.Development.json @@ -6,4 +6,11 @@ "Microsoft.EntityFrameworkCore": "Warning" } } +//#if (IsFoundryLocal) + ,"FoundryLocal": { + "ChatModel": "qwen3-4b", + "EmbeddingModel": "qwen3-embedding-0.6b", + "ServiceUrl": "http://127.0.0.1:5273" + } +//#endif } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/appsettings.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/appsettings.json index 46bdb452246..ca99e211be7 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/appsettings.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/appsettings.json @@ -6,5 +6,12 @@ "Microsoft.EntityFrameworkCore": "Warning" } }, +//#if (IsFoundryLocal) + "FoundryLocal": { + "ChatModel": "qwen3-4b", + "EmbeddingModel": "qwen3-embedding-0.6b", + "ServiceUrl": "http://127.0.0.1:5273" + }, +//#endif "AllowedHosts": "*" } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs index dbe71cdbdef..538318010b6 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs @@ -32,6 +32,7 @@ public AIChatWebSnapshotTests(ITestOutputHelper log) [InlineData /* Defaults: --provider=githubmodels --vector-store=local */] [InlineData("--provider=ollama", "--vector-store=qdrant")] [InlineData("--provider=openai", "--vector-store=azureaisearch")] + [InlineData("--provider=foundrylocal", "--vector-store=local", "--webHttpPort", "9996", "--webHttpsPort", "9995")] [InlineData("--aspire")] [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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..60cec92d5e5 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/SurveyPrompt.razor new file mode 100644 index 00000000000..77557f20173 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/SurveyPrompt.razor @@ -0,0 +1,13 @@ +
+ + +
+ 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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css new file mode 100644 index 00000000000..c939b902afb --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css @@ -0,0 +1,20 @@ +.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/Chat.razor new file mode 100644 index 00000000000..6fc5881c18f --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -0,0 +1,134 @@ +@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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/Chat.razor.css new file mode 100644 index 00000000000..439b52ed18a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css new file mode 100644 index 00000000000..1fe16f7219d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 00000000000..e45d92ab5f9 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,107 @@ +@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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css new file mode 100644 index 00000000000..dcc7ee8bd8a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Program.cs new file mode 100644 index 00000000000..51daf45081c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Program.cs @@ -0,0 +1,82 @@ +using System.ClientModel; +using Microsoft.AI.Foundry.Local; +using Microsoft.Extensions.Logging.Abstractions; +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(); + +var chatAlias = builder.Configuration["FoundryLocal:ChatModel"] ?? "qwen3-4b"; +var embeddingAlias = builder.Configuration["FoundryLocal:EmbeddingModel"] ?? "qwen3-embedding-0.6b"; +var foundryServiceUrl = builder.Configuration["FoundryLocal:ServiceUrl"] ?? "http://127.0.0.1:5273"; + +await FoundryLocalManager.CreateAsync(new Configuration +{ + AppName = "aichatweb", + Web = new Configuration.WebService { Urls = foundryServiceUrl } +}, NullLogger.Instance); +var foundryManager = FoundryLocalManager.Instance; +await foundryManager.StartWebServiceAsync(); +var foundryCatalog = await foundryManager.GetCatalogAsync(); + +async Task EnsureFoundryModelAsync(string alias) +{ + var model = await foundryCatalog.GetModelAsync(alias) + ?? throw new InvalidOperationException( + $"Foundry Local model '{alias}' was not found in the catalog. Run 'foundry model list' to see available models."); + if (!await model.IsCachedAsync()) + { + Console.WriteLine($"Foundry Local: downloading model '{alias}' (first run only)..."); + await model.DownloadAsync(_ => { }); + } + if (!await model.IsLoadedAsync()) + { + await model.LoadAsync(); + } + return model.Id; +} + +var chatModelId = await EnsureFoundryModelAsync(chatAlias); +var embeddingModelId = await EnsureFoundryModelAsync(embeddingAlias); + +var foundryEndpointUrl = foundryManager.Urls?.FirstOrDefault() ?? foundryServiceUrl; +var foundryEndpoint = new Uri($"{foundryEndpointUrl.TrimEnd('/')}/v1"); +var foundryClient = new OpenAIClient( + new ApiKeyCredential("unused"), + new OpenAIClientOptions { Endpoint = foundryEndpoint }); +var chatClient = foundryClient.GetChatClient(chatModelId).AsIChatClient(); +var embeddingGenerator = foundryClient.GetEmbeddingClient(embeddingModelId).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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Properties/launchSettings.json new file mode 100644 index 00000000000..a7cc40a553c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/README.md new file mode 100644 index 00000000000..75ad9811358 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/README.md @@ -0,0 +1,34 @@ +# 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 +## Setting up a local environment using Foundry Local +This project is configured to use Foundry Local, which runs models on your workstation through a local OpenAI-compatible endpoint. It does not need an API key. + +### 1. Install Foundry Local +Install Foundry Local for your operating system by following the [Foundry Local documentation](https://learn.microsoft.com/azure/ai-foundry/foundry-local/). + +### 2. Run the app +The app starts the Foundry Local service for you. On first run, it downloads the configured models, then loads them into the local service. + +The default chat model alias is `qwen3-4b`. The default embedding model alias is `qwen3-embedding-0.6b`. + +### 3. Override model aliases or the service URL +You can change the defaults in `appsettings.json`, `appsettings.Development.json`, or user secrets: + +```json +{ + "FoundryLocal": { + "ChatModel": "qwen3-4b", + "EmbeddingModel": "qwen3-embedding-0.6b", + "ServiceUrl": "http://127.0.0.1:5273" + } +} +``` + +Use `FoundryLocal:ServiceUrl` if another local process already uses the default port. + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/IngestedChunk.cs new file mode 100644 index 00000000000..ba80098560d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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 = 1024; // 1024 is the default vector size for the qwen3-embedding-0.6b embedding 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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/Ingestion/DocumentReader.cs new file mode 100644 index 00000000000..38117fe8930 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/Ingestion/PdfPigReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/Ingestion/PdfPigReader.cs new file mode 100644 index 00000000000..a7c9b3686d8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.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.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/aichatweb.csproj new file mode 100644 index 00000000000..0e556e3d4eb --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/aichatweb.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/appsettings.Development.json new file mode 100644 index 00000000000..9beb3ba3d46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + ,"FoundryLocal": { + "ChatModel": "qwen3-4b", + "EmbeddingModel": "qwen3-embedding-0.6b", + "ServiceUrl": "http://127.0.0.1:5273" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/appsettings.json new file mode 100644 index 00000000000..ed957100477 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.foundryl_l_webHttpPort_9996_webHttpsPort_9995.verified/aichatweb/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "FoundryLocal": { + "ChatModel": "qwen3-4b", + "EmbeddingModel": "qwen3-embedding-0.6b", + "ServiceUrl": "http://127.0.0.1:5273" + }, + "AllowedHosts": "*" +}