diff --git a/.gitignore b/.gitignore
index 4ef61ee7ac4..603aeba2a26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -316,3 +316,6 @@ BenchmarkDotNet.artifacts/
*.binlog
/eng/scripts/repo-digest.html
**/.DS_Store
+
+# VERIFY
+!**/*.verified/**
diff --git a/eng/Versions.props b/eng/Versions.props
index d6b9d859c89..72bb7b1e39d 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -147,14 +147,23 @@
4.8.03.3.4
+ 9.1.0
+ 9.1.0-preview.1.25121.101.0.0-beta.3
- 2.2.0-beta.1
+ 2.2.0-beta.31.13.211.6.0
- 1.37.0-preview
- 1.37.0
+ 9.2.2-beta.236
+ 9.2.2-beta.236
+ 9.2.2-beta.236
+ 9.2.2-beta.236
+ 9.1.0
+ 1.41.0-preview
+ 1.41.0-preview
+ 1.41.05.1.9
- 2.2.0-beta.1
+ 2.2.0-beta.3
+ 1.9.00.1.96.0.1
+ <_LocalChatTemplateVariant>aspire
+
+ <_ChatWithCustomDataContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\ChatWithCustomData\
- 9.3.0-preview.1.25161.3
+ 9.3.0-preview.1.25161.39.0.3
-
- false
+
+ falsefalse
- $(TemplatePinnedMicrosoftExtensionsAIVersion)
+ $(TemplatePinnedRepoPackagesVersion)$(TemplatePinnedMicrosoftEntityFrameworkCoreSqliteVersion)
- $(Version)
+ $(Version)$(MicrosoftEntityFrameworkCoreSqliteVersion)
- <_TemplateUsingJustBuiltPackages Condition="'$(TemplateMicrosoftExtensionsAIVersion)' == '$(Version)'">true
+ <_TemplateUsingJustBuiltPackages Condition="'$(TemplateRepoPackagesVersion)' == '$(Version)'">true
@@ -32,33 +40,59 @@
ArtifactsShippingPackagesDir=$(ArtifactsShippingPackagesDir);
- OllamaSharpVersion=$(OllamaSharpVersion);
- OpenAIVersion=$(OpenAIVersion);
+ AspireVersion=$(AspireVersion);
+ AspireAzureAIOpenAIVersion=$(AspireAzureAIOpenAIVersion);
AzureAIProjectsVersion=$(AzureAIProjectsVersion);
AzureAIOpenAIVersion=$(AzureAIOpenAIVersion);
AzureIdentityVersion=$(AzureIdentityVersion);
+ AzureSearchDocumentsVersion=$(AzureSearchDocumentsVersion);
+ CommunityToolkitAspireHostingOllamaVersion=$(CommunityToolkitAspireHostingOllamaVersion);
+ CommunityToolkitAspireHostingSqliteVersion=$(CommunityToolkitAspireHostingSqliteVersion);
+ CommunityToolkitAspireMicrosoftEntityFrameworkCoreSqliteVersion=$(CommunityToolkitAspireMicrosoftEntityFrameworkCoreSqliteVersion);
+ CommunityToolkitAspireOllamaSharpVersion=$(CommunityToolkitAspireOllamaSharpVersion);
MicrosoftEntityFrameworkCoreSqliteVersion=$(TemplateMicrosoftEntityFrameworkCoreSqliteVersion);
- MicrosoftExtensionsAIVersion=$(TemplateMicrosoftExtensionsAIVersion);
+ MicrosoftExtensionsAIVersion=$(TemplateRepoPackagesVersion);
+ MicrosoftExtensionsHttpResilienceVersion=$(TemplateRepoPackagesVersion);
+ MicrosoftExtensionsServiceDiscoveryVersion=$(MicrosoftExtensionsServiceDiscoveryVersion);
+ MicrosoftSemanticKernelConnectorsAzureAISearchVersion=$(MicrosoftSemanticKernelConnectorsAzureAISearchVersion);
+ MicrosoftSemanticKernelConnectorsQdrantVersion=$(MicrosoftSemanticKernelConnectorsQdrantVersion);
MicrosoftSemanticKernelCoreVersion=$(MicrosoftSemanticKernelCoreVersion);
+ OllamaSharpVersion=$(OllamaSharpVersion);
+ OpenAIVersion=$(OpenAIVersion);
+ OpenTelemetryVersion=$(OpenTelemetryVersion);
PdfPigVersion=$(PdfPigVersion);
SystemLinqAsyncVersion=$(SystemLinqAsyncVersion);
- AzureSearchDocumentsVersion=$(AzureSearchDocumentsVersion);
- MicrosoftSemanticKernelConnectorsAzureAISearchVersion=$(MicrosoftSemanticKernelConnectorsAzureAISearchVersion);
+
+
+ LocalChatTemplateVariant=$(_LocalChatTemplateVariant);
+ UsingJustBuiltPackages=$(_TemplateUsingJustBuiltPackages);
+ Include="$(_ChatWithCustomDataContentRoot)ChatWithCustomData-CSharp.sln.in"
+ OutputPath="$(_ChatWithCustomDataContentRoot)ChatWithCustomData-CSharp.sln" />
+
+
+
+
+
<_GeneratedContentEnablingJustBuiltPackages
- Include="$(_ChatWithCustomDataWebContentRoot)NuGet.config.in"
- OutputPath="$(_ChatWithCustomDataWebContentRoot)NuGet.config" />
- <_GeneratedContentEnablingJustBuiltPackages
- Include="$(_ChatWithCustomDataWebContentRoot)Directory.Build.targets.in"
- OutputPath="$(_ChatWithCustomDataWebContentRoot)Directory.Build.targets" />
+ Include="$(_ChatWithCustomDataContentRoot)NuGet.config.in"
+ OutputPath="$(_ChatWithCustomDataContentRoot)NuGet.config" />
Template
- $(NetCoreTargetFrameworks);netstandard2.0
+ $(NetCoreTargetFrameworks)Project templates for Microsoft.Extensions.AI.dotnet-new;templates;ai
@@ -22,15 +22,24 @@
true
-
+
+
+
+
@@ -40,13 +49,14 @@
Exclude="
**\bin\**;
**\obj\**;
+ **\.vs\**;
**\node_modules\**;
**\*.user;
**\*.in;
**\*.out.js;
**\*.generated.css;
**\package-lock.json;
- **\ingestioncache.db;
+ **\ingestioncache.*;
**\NuGet.config;
**\Directory.Build.targets;" />
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/dotnetcli.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/dotnetcli.host.json
index 16e02d30dfd..591ec48b4a6 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/dotnetcli.host.json
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/dotnetcli.host.json
@@ -1,10 +1,44 @@
{
"$schema": "https://json.schemastore.org/dotnetcli.host",
"symbolInfo": {
- "kestrelHttpPort": {
+ "AiServiceProvider": {
+ "longName": "provider",
+ "shortName": ""
+ },
+ "VectorStore": {
+ "longName": "vector-store",
+ "shortName": ""
+ },
+ "UseManagedIdentity": {
+ "longName": "managed-identity",
+ "shortName": ""
+ },
+ "UseAspire": {
+ "longName": "aspire",
+ "shortName": ""
+ },
+ "webHttpPort": {
+ "isHidden": true
+ },
+ "webHttpsPort": {
+ "isHidden": true
+ },
+ "appHostHttpPort": {
+ "isHidden": true
+ },
+ "appHostOtlpHttpPort": {
+ "isHidden": true
+ },
+ "appHostResourceHttpPort": {
+ "isHidden": true
+ },
+ "appHostHttpsPort": {
+ "isHidden": true
+ },
+ "appHostOtlpHttpsPort": {
"isHidden": true
},
- "kestrelHttpsPort": {
+ "appHostResourceHttpsPort": {
"isHidden": true
}
},
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json
index 6e9808b12d8..c8b140fd1a2 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json
@@ -15,6 +15,10 @@
{
"id": "VectorStore",
"isVisible": true
+ },
+ {
+ "id": "UseAspire",
+ "isVisible": true
}
]
}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json
index 004f406b368..18b1bdf9c28 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json
@@ -1,39 +1,85 @@
{
"$schema": "http://json.schemastore.org/template",
"author": "Microsoft",
- "classifications": [ "Common", "AI", "Web" ],
+ "classifications": [ "Common", "AI", "Web", "Blazor", ".NET Aspire" ],
"identity": "Microsoft.Extensions.AI.Templates.WebChat.CSharp",
"name": "AI Chat Web App",
"description": "A project template for creating an AI chat application, which uses retrieval-augmented generation (RAG) to chat with your own data.",
"shortName": "aichatweb",
"defaultName": "ChatApp",
- // The placeholder sourceName needs to contain a dash to ensure the CSS bundle asset uses the assembly identity name
- // TODO: When we support multi-project output, this needs to change to ChatWithCustomData, then we need some other
- // technique to make it avoid emitting a .Web suffix in the single-project case.
- "sourceName": "ChatWithCustomData.Web-CSharp",
+ "sourceName": "ChatWithCustomData-CSharp",
"preferNameDirectory": true,
"tags": {
"language": "C#",
"type": "project"
},
"guids": [
- "d5681fae-b21b-4114-b781-48180f08c0c4"
+ "d5681fae-b21b-4114-b781-48180f08c0c4",
+ "b2f4f5e9-1083-472c-8c3b-f055ac67ba54",
+ "FAE04EC0-301F-11D3-BF4B-00C04F79EFBC",
+ "4DF52F58-4890-4A0A-BC25-5C3D167B3490",
+ "2222CF31-6E3A-42E7-AD3A-A56B14EAD9D0",
+ "A7D19173-F0AD-4A8D-B8EA-E12DE203E409"
],
"primaryOutputs": [
- {"path": "./ChatWithCustomData.Web-CSharp.csproj"},
- {"path": "./README.md"}
+ {
+ "condition": "(!IsAspire)",
+ "path": "./ChatWithCustomData-CSharp.csproj"
+ },
+ {
+ "condition": "(IsAspire && (HostIdentifier == \"dotnetcli\" || HostIdentifier == \"dotnetcli-preview\"))",
+ "path": "./ChatWithCustomData-CSharp.sln"
+ },
+ {
+ "condition": "(IsAspire)",
+ "path": "./ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj"
+ },
+ {
+ "condition": "(IsAspire)",
+ "path": "./ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj"
+ },
+ {
+ "condition": "(IsAspire)",
+ "path": "./ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj"
+ },
+ {
+ "path": "./README.md"
+ }
],
"sources": [{
"source": "./",
"target": "./",
"modifiers": [
{
- // For now, we only produce single-project output.
- // Later when we support multi-project output with Qdrant on Docker, we'll also emit
- // a second project ChatWithCustomData.AppHost and hence will suppress this renaming.
+ "condition": "(!IsAspire)",
+ "rename": {
+ "ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj": "ChatWithCustomData-CSharp.csproj",
+ "ChatWithCustomData-CSharp.Web/": "./"
+ },
+ "exclude": [
+ "ChatWithCustomData-CSharp.AppHost/**",
+ "ChatWithCustomData-CSharp.ServiceDefaults/**",
+ "ChatWithCustomData-CSharp.Web/Program.Aspire.cs",
+ "README.Aspire.md",
+ "*.sln"
+ ]
+ },
+ {
+ "condition": "(IsAspire)",
+ "exclude": [
+ "ChatWithCustomData-CSharp.Web/Program.cs",
+ "ChatWithCustomData-CSharp.Web/README.md"
+ ],
"rename": {
- "ChatWithCustomData.Web-CSharp/": "./"
+ "Program.Aspire.cs": "Program.cs",
+ "README.Aspire.md": "README.md"
}
+ },
+ {
+ "condition": "(!UseLocalVectorStore)",
+ "exclude": [
+ "ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs"
+ ]
}
]
}],
@@ -104,6 +150,11 @@
"choice": "azureaisearch",
"displayName": "Azure AI Search",
"description": "Uses Azure AI Search. This also avoids the need to define a data ingestion pipeline, since it's managed by Azure AI Search."
+ },
+ {
+ "choice": "qdrant",
+ "displayName": "Qdrant",
+ "description": "Uses Qdrant in a Docker container, orchestrated using Aspire."
}
]
},
@@ -112,9 +163,20 @@
"displayName": "Use keyless authentication for Azure services",
"datatype": "bool",
"defaultValue": "true",
- "isEnabled": "(AiServiceProvider == \"azureopenai\" || AiServiceProvider == \"azureaifoundry\" || VectorStore == \"azureaisearch\")",
+ "isEnabled": "(!UseAspire && VectorStore != \"qdrant\" && (AiServiceProvider == \"azureopenai\" || AiServiceProvider == \"azureaifoundry\" || VectorStore == \"azureaisearch\"))",
"description": "Use managed identity to access Azure services"
},
+ "UseAspire": {
+ "type": "parameter",
+ "displayName": "Use Aspire orchestration",
+ "datatype": "bool",
+ "defaultValue": "false",
+ "description": "Create the project as a distributed application using .NET Aspire."
+ },
+ "IsAspire": {
+ "type": "computed",
+ "value": "(UseAspire || VectorStore == \"qdrant\")"
+ },
"IsAzureOpenAI": {
"type": "computed",
"value": "(AiServiceProvider == \"azureopenai\")"
@@ -143,6 +205,10 @@
"type": "computed",
"value": "(VectorStore == \"local\")"
},
+ "UseQdrant": {
+ "type": "computed",
+ "value": "(VectorStore == \"qdrant\")"
+ },
"UseAzure": {
"type": "computed",
"value": "(IsAzureOpenAI || IsAzureAiFoundry || UseAzureAISearch)"
@@ -221,12 +287,12 @@
},
"replaces": "all-minilm"
},
- "kestrelHttpPort": {
+ "webHttpPort": {
"type": "parameter",
"datatype": "integer",
- "description": "Port number to use for the HTTP endpoint in launchSettings.json."
+ "description": "Port number to use for the HTTP endpoint in the launchSettings.json of the Web project."
},
- "kestrelHttpPortGenerated": {
+ "webHttpPortGenerated": {
"type": "generated",
"generator": "port",
"parameters": {
@@ -234,24 +300,24 @@
"high": 5300
}
},
- "kestrelHttpPortReplacer": {
+ "webHttpPortReplacer": {
"type": "generated",
"generator": "coalesce",
"parameters": {
- "sourceVariableName": "kestrelHttpPort",
- "fallbackVariableName": "kestrelHttpPortGenerated"
+ "sourceVariableName": "webHttpPort",
+ "fallbackVariableName": "webHttpPortGenerated"
},
"replaces": "5000",
"onlyIf": [{
"after": "localhost:"
}]
},
- "kestrelHttpsPort": {
+ "webHttpsPort": {
"type": "parameter",
"datatype": "integer",
- "description": "Port number to use for the HTTPS endpoint in launchSettings.json."
+ "description": "Port number to use for the HTTPS endpoint in the launchSettings.json of the Web project."
},
- "kestrelHttpsPortGenerated": {
+ "webHttpsPortGenerated": {
"type": "generated",
"generator": "port",
"parameters": {
@@ -259,23 +325,196 @@
"high": 7300
}
},
- "kestrelHttpsPortReplacer": {
+ "webHttpsPortReplacer": {
"type": "generated",
"generator": "coalesce",
"parameters": {
- "sourceVariableName": "kestrelHttpsPort",
- "fallbackVariableName": "kestrelHttpsPortGenerated"
+ "sourceVariableName": "webHttpsPort",
+ "fallbackVariableName": "webHttpsPortGenerated"
},
"replaces": "5001",
"onlyIf": [{
"after": "localhost:"
}]
},
+ "appHostHttpPort": {
+ "type": "parameter",
+ "datatype": "integer",
+ "description": "Port number to use for the HTTP endpoint in the launchSettings.json of the AppHost project."
+ },
+ "appHostHttpPortGenerated": {
+ "type": "generated",
+ "generator": "port",
+ "parameters": {
+ "low": 15000,
+ "high": 15300
+ }
+ },
+ "appHostHttpPortReplacer": {
+ "type": "generated",
+ "generator": "coalesce",
+ "parameters": {
+ "sourceVariableName": "appHostHttpPort",
+ "fallbackVariableName": "appHostHttpPortGenerated"
+ },
+ "replaces": "15000",
+ "onlyIf": [{
+ "after": "localhost:"
+ }]
+ },
+ "appHostOtlpHttpPort": {
+ "type": "parameter",
+ "datatype": "integer",
+ "description": "Port number to use for the OTLP HTTP endpoint in launchSettings.json of the AppHost project."
+ },
+ "appHostOtlpHttpPortGenerated": {
+ "type": "generated",
+ "generator": "port",
+ "parameters": {
+ "low": 19000,
+ "high": 19300
+ }
+ },
+ "appHostOtlpHttpPortReplacer": {
+ "type": "generated",
+ "generator": "coalesce",
+ "parameters": {
+ "sourceVariableName": "appHostOtlpHttpPort",
+ "fallbackVariableName": "appHostOtlpHttpPortGenerated"
+ },
+ "replaces": "19000",
+ "onlyIf": [{
+ "after": "localhost:"
+ }]
+ },
+ "appHostResourceHttpPort": {
+ "type": "parameter",
+ "datatype": "integer",
+ "description": "Port number to use for the resource service HTTP endpoint in launchSettings.json of the AppHost project."
+ },
+ "appHostResourceHttpPortGenerated": {
+ "type": "generated",
+ "generator": "port",
+ "parameters": {
+ "low": 20000,
+ "high": 20300
+ }
+ },
+ "appHostResourceHttpPortReplacer": {
+ "type": "generated",
+ "generator": "coalesce",
+ "parameters": {
+ "sourceVariableName": "appHostResourceHttpPort",
+ "fallbackVariableName": "appHostResourceHttpPortGenerated"
+ },
+ "replaces": "20000",
+ "onlyIf": [{
+ "after": "localhost:"
+ }]
+ },
+ "appHostHttpsPort": {
+ "type": "parameter",
+ "datatype": "integer",
+ "description": "Port number to use for the HTTPS endpoint in launchSettings.json of the AppHost project."
+ },
+ "appHostHttpsPortGenerated": {
+ "type": "generated",
+ "generator": "port",
+ "parameters": {
+ "low": 17000,
+ "high": 17300
+ }
+ },
+ "appHostHttpsPortReplacer": {
+ "type": "generated",
+ "generator": "coalesce",
+ "parameters": {
+ "sourceVariableName": "appHostHttpsPort",
+ "fallbackVariableName": "appHostHttpsPortGenerated"
+ },
+ "replaces": "17000",
+ "onlyIf": [{
+ "after": "localhost:"
+ }]
+ },
+ "appHostOtlpHttpsPort": {
+ "type": "parameter",
+ "datatype": "integer",
+ "description": "Port number to use for the OTLP HTTPS endpoint in launchSettings.json of the AppHost project."
+ },
+ "appHostOtlpHttpsPortGenerated": {
+ "type": "generated",
+ "generator": "port",
+ "parameters": {
+ "low": 21000,
+ "high": 21300
+ }
+ },
+ "appHostOtlpHttpsPortReplacer": {
+ "type": "generated",
+ "generator": "coalesce",
+ "parameters": {
+ "sourceVariableName": "appHostOtlpHttpsPort",
+ "fallbackVariableName": "appHostOtlpHttpsPortGenerated"
+ },
+ "replaces": "21000",
+ "onlyIf": [{
+ "after": "localhost:"
+ }]
+ },
+ "appHostResourceHttpsPort": {
+ "type": "parameter",
+ "datatype": "integer",
+ "description": "Port number to use for the resource service HTTPS endpoint in launchSettings.json of the AppHost project."
+ },
+ "appHostResourceHttpsPortGenerated": {
+ "type": "generated",
+ "generator": "port",
+ "parameters": {
+ "low": 22000,
+ "high": 22300
+ }
+ },
+ "appHostResourceHttpsPortReplacer": {
+ "type": "generated",
+ "generator": "coalesce",
+ "parameters": {
+ "sourceVariableName": "appHostResourceHttpsPort",
+ "fallbackVariableName": "appHostResourceHttpsPortGenerated"
+ },
+ "replaces": "22000",
+ "onlyIf": [{
+ "after": "localhost:"
+ }]
+ },
"vectorStoreIndexNameReplacer": {
"type": "derived",
"valueSource": "name",
"valueTransform": "vectorStoreIndexNameTransform",
- "replaces": "data-ChatWithCustomData.Web-CSharp-ingestion"
+ "replaces": "data-ChatWithCustomData-CSharp.Web-ingestion"
+ },
+ "webProjectNamespaceAdjuster": {
+ "type": "generated",
+ "generator": "switch",
+ "replaces": ".Web",
+ "onlyIf": [{
+ "after": "ChatWithCustomData_CSharp"
+ }, {
+ "after": "ChatWithCustomData-CSharp"
+ }],
+ "parameters": {
+ "evaluator": "C++",
+ "cases": [
+ {
+ "condition": "(IsAspire)",
+ "value": ".Web"
+ },
+ {
+ "condition": "(!IsAspire)",
+ "value": ""
+ }
+ ]
+ }
}
},
"forms": {
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in
new file mode 100644
index 00000000000..fef5822aa2b
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in
@@ -0,0 +1,29 @@
+
+
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ true
+ b2f4f5e9-1083-472c-8c3b-f055ac67ba54
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs
new file mode 100644
index 00000000000..d19afaef3c3
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs
@@ -0,0 +1,65 @@
+var builder = DistributedApplication.CreateBuilder(args);
+#if (IsOllama) // ASPIRE PARAMETERS
+#else // IsAzureOpenAI || IsOpenAI || IsGHModels
+
+// 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");
+#endif
+#if (UseAzureAISearch)
+
+// 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:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY"
+var azureAISearch = builder.AddConnectionString("azureAISearch");
+#endif
+#if (IsOllama) // AI SERVICE PROVIDER CONFIGURATION
+
+var ollama = builder.AddOllama("ollama")
+ .WithDataVolume();
+var chat = ollama.AddModel("chat", "llama3.2");
+var embeddings = ollama.AddModel("embeddings", "all-minilm");
+#endif
+#if (UseAzureAISearch) // VECTOR DATABASE CONFIGURATION
+#elif (UseQdrant)
+
+var vectorDB = builder.AddQdrant("vectordb")
+ .WithDataVolume()
+ .WithLifetime(ContainerLifetime.Persistent);
+#else // UseLocalVectorStore
+#endif
+
+var ingestionCache = builder.AddSqlite("ingestionCache");
+
+var webApp = builder.AddProject("aichatweb-app");
+#if (IsOllama) // AI SERVICE PROVIDER REFERENCES
+webApp
+ .WithReference(chat)
+ .WithReference(embeddings)
+ .WaitFor(chat)
+ .WaitFor(embeddings);
+#else // IsAzureOpenAI || IsOpenAI || IsGHModels
+webApp.WithReference(openai);
+#endif
+#if (UseAzureAISearch) // VECTOR DATABASE REFERENCES
+webApp.WithReference(azureAISearch);
+#elif (UseQdrant)
+webApp
+ .WithReference(vectorDB)
+ .WaitFor(vectorDB);
+#else // UseLocalVectorStore
+#endif
+webApp
+ .WithReference(ingestionCache)
+ .WaitFor(ingestionCache);
+
+builder.Build().Run();
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000000..cff9159f816
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.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:17000;http://localhost:15000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000"
+ }
+ }
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/appsettings.Development.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/appsettings.Development.json
new file mode 100644
index 00000000000..0c208ae9181
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/appsettings.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/appsettings.json
new file mode 100644
index 00000000000..31c092aa450
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in
new file mode 100644
index 00000000000..77276eab4a0
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in
@@ -0,0 +1,22 @@
+
+
+
+ net9.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000000..ea4463979d5
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs
@@ -0,0 +1,121 @@
+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 .NET 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
+{
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // 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()
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddSource("Experimental.Microsoft.Extensions.AI");
+ });
+
+ 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("/health");
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/ChatWithCustomData.Web-CSharp.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in
similarity index 59%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/ChatWithCustomData.Web-CSharp.csproj.in
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in
index 92cb05131b3..d151e749689 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/ChatWithCustomData.Web-CSharp.csproj.in
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in
@@ -8,31 +8,56 @@
+
+#endif -->
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/App.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/App.razor
similarity index 87%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/App.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/App.razor
index b5f1f06ce9c..cdb94f6074b 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/App.razor
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/App.razor
@@ -6,7 +6,7 @@
-
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/LoadingSpinner.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/LoadingSpinner.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/LoadingSpinner.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/LoadingSpinner.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/LoadingSpinner.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/LoadingSpinner.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/LoadingSpinner.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/LoadingSpinner.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/MainLayout.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/MainLayout.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/MainLayout.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/MainLayout.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/MainLayout.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/MainLayout.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/MainLayout.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/MainLayout.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/SurveyPrompt.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/SurveyPrompt.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/SurveyPrompt.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/SurveyPrompt.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/SurveyPrompt.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/SurveyPrompt.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Layout/SurveyPrompt.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Layout/SurveyPrompt.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor
new file mode 100644
index 00000000000..9c6a3169144
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor
@@ -0,0 +1,126 @@
+@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 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 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()
+ {
+ messages.Add(new(ChatRole.System, SystemPrompt));
+ chatOptions.Tools = [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();
+
+@*#if (IsOllama)
+ // Display a new response from the IChatClient, streaming responses
+ // aren't supported because Ollama will not support both streaming and using Tools
+ currentResponseCancellation = new();
+ var response = await ChatClient.GetResponseAsync(messages, chatOptions, currentResponseCancellation.Token);
+
+ // Store responses in the conversation, and begin getting suggestions
+ messages.AddMessages(response);
+#else*@
+ // 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], chatOptions, currentResponseCancellation.Token))
+ {
+ messages.AddMessages(update, filter: c => c is not TextContent);
+ responseText.Text += update.Text;
+ ChatMessageItem.NotifyChanged(currentResponseMessage);
+ }
+
+ // Store the final response in the conversation, and begin getting suggestions
+ messages.Add(currentResponseMessage!);
+ currentResponseMessage = null;
+@*#endif*@
+ 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));
+ chatSuggestions?.Clear();
+ await chatInput!.FocusAsync();
+ }
+
+ [Description("Searches for information using a phrase or keyword")]
+ 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/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/Chat.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/Chat.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatCitation.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatCitation.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatCitation.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatCitation.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatCitation.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatCitation.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatCitation.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatCitation.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatHeader.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatHeader.razor
similarity index 90%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatHeader.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatHeader.razor
index 0c5171c9848..c6194fb6c3c 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatHeader.razor
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatHeader.razor
@@ -8,7 +8,7 @@
-
ChatWithCustomData.Web-CSharp
+
ChatWithCustomData-CSharp.Web
@code {
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatHeader.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatHeader.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatHeader.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatHeader.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatInput.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatInput.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatInput.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatInput.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatInput.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatInput.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatInput.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatInput.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatInput.razor.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatInput.razor.js
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatInput.razor.js
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatInput.razor.js
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageItem.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageItem.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageItem.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageItem.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageList.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageList.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageList.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageList.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageList.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageList.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageList.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageList.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageList.razor.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageList.razor.js
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatMessageList.razor.js
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatMessageList.razor.js
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatSuggestions.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatSuggestions.razor
similarity index 96%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatSuggestions.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatSuggestions.razor
index 250576a9efd..69ca922a8ce 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatSuggestions.razor
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatSuggestions.razor
@@ -48,7 +48,7 @@
{
var response = await ChatClient.GetResponseAsync(
[.. ReduceMessages(messages), new(ChatRole.User, Prompt)],
- useNativeJsonSchema: true, cancellationToken: cancellation.Token);
+ cancellationToken: cancellation.Token);
if (!response.TryGetResult(out suggestions))
{
suggestions = null;
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatSuggestions.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatSuggestions.razor.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Chat/ChatSuggestions.razor.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/ChatSuggestions.razor.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Error.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Error.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Pages/Error.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Error.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Routes.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Routes.razor
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/Routes.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Routes.razor
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/_Imports.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/_Imports.razor
similarity index 66%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/_Imports.razor
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/_Imports.razor
index 12927b7cdf8..9ad55554648 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Components/_Imports.razor
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/_Imports.razor
@@ -7,7 +7,7 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.Extensions.AI
@using Microsoft.JSInterop
-@using ChatWithCustomData.Web_CSharp
-@using ChatWithCustomData.Web_CSharp.Components
-@using ChatWithCustomData.Web_CSharp.Components.Layout
-@using ChatWithCustomData.Web_CSharp.Services
+@using ChatWithCustomData_CSharp.Web
+@using ChatWithCustomData_CSharp.Web.Components
+@using ChatWithCustomData_CSharp.Web.Components.Layout
+@using ChatWithCustomData_CSharp.Web.Services
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Directory.Build.targets.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Directory.Build.targets.in
new file mode 100644
index 00000000000..f63c2a79144
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Directory.Build.targets.in
@@ -0,0 +1,21 @@
+
+
+
+
+
+ <_LocalChatTemplateVariant>${LocalChatTemplateVariant}
+
+
+
+
+
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs
new file mode 100644
index 00000000000..c1a90ca8123
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs
@@ -0,0 +1,82 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+using ChatWithCustomData_CSharp.Web.Components;
+using ChatWithCustomData_CSharp.Web.Services;
+using ChatWithCustomData_CSharp.Web.Services.Ingestion;
+#if (IsOllama)
+#else // IsAzureOpenAI || IsOpenAI || IsGHModels
+using OpenAI;
+#endif
+#if (UseAzureAISearch)
+using Microsoft.SemanticKernel.Connectors.AzureAISearch;
+#elif (UseQdrant)
+using Microsoft.SemanticKernel.Connectors.Qdrant;
+#endif
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
+builder.Services.AddRazorComponents().AddInteractiveServerComponents();
+
+#if (IsOllama)
+builder.AddOllamaApiClient("chat")
+ .AddChatClient()
+ .UseFunctionInvocation()
+ .UseOpenTelemetry(configure: c =>
+ c.EnableSensitiveData = builder.Environment.IsDevelopment());
+builder.AddOllamaApiClient("embeddings")
+ .AddEmbeddingGenerator();
+#elif (IsAzureAiFoundry)
+#else // IsAzureOpenAI || IsOpenAI || IsGHModels
+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");
+#endif
+
+#if (UseAzureAISearch)
+builder.AddAzureSearchClient("azureAISearch");
+
+builder.Services.AddSingleton();
+#elif (UseQdrant)
+builder.AddQdrantClient("vectordb");
+
+builder.Services.AddSingleton();
+#else // UseLocalVectorStore
+var vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store"));
+builder.Services.AddSingleton(vectorStore);
+#endif
+builder.Services.AddScoped();
+builder.Services.AddSingleton();
+builder.AddSqliteDbContext("ingestionCache");
+
+var app = builder.Build();
+IngestionCacheDbContext.Initialize(app.Services);
+
+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();
+
+// By default, we ingest PDF files from the /wwwroot/Data directory. You can ingest from
+// other sources by implementing IIngestionSource.
+// Important: ensure that any content you ingest is trusted, as it may be reflected back
+// to users or could be a source of prompt injection risk.
+await DataIngestor.IngestDataAsync(
+ app.Services,
+ new PDFDirectorySource(Path.Combine(builder.Environment.WebRootPath, "Data")));
+
+app.Run();
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs
similarity index 96%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Program.cs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs
index aa7a4dbaa34..756a2d4415c 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Program.cs
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs
@@ -1,9 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
-using ChatWithCustomData.Web_CSharp.Components;
-using ChatWithCustomData.Web_CSharp.Services;
-using ChatWithCustomData.Web_CSharp.Services.Ingestion;
+using ChatWithCustomData_CSharp.Web.Components;
+using ChatWithCustomData_CSharp.Web.Services;
+using ChatWithCustomData_CSharp.Web.Services.Ingestion;
#if(IsAzureOpenAI || UseAzureAISearch)
using Azure;
#if (UseManagedIdentity)
@@ -59,7 +59,7 @@
var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();
#elif (IsAzureAiFoundry)
-#else
+#else // 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:
// cd this-project-directory
@@ -94,7 +94,7 @@
#else
new AzureKeyCredential(builder.Configuration["AzureAISearch:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Key. See the README for details."))));
#endif
-#else
+#else // UseLocalVectorStore
var vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store"));
#endif
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Properties/launchSettings.json
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Properties/launchSettings.json
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Properties/launchSettings.json
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/README.md
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs
new file mode 100644
index 00000000000..4fb2f9ba370
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs
@@ -0,0 +1,67 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+
+namespace ChatWithCustomData_CSharp.Web.Services.Ingestion;
+
+public class DataIngestor(
+ ILogger logger,
+ IEmbeddingGenerator> embeddingGenerator,
+ IVectorStore vectorStore,
+ IngestionCacheDbContext ingestionCacheDb)
+{
+ public static async Task IngestDataAsync(IServiceProvider services, IIngestionSource source)
+ {
+ using var scope = services.CreateScope();
+ var ingestor = scope.ServiceProvider.GetRequiredService();
+ await ingestor.IngestDataAsync(source);
+ }
+
+ public async Task IngestDataAsync(IIngestionSource source)
+ {
+#if (UseQdrant)
+ var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData-CSharp.Web-ingestion");
+#else
+ var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData-CSharp.Web-ingestion");
+#endif
+ await vectorCollection.CreateCollectionIfNotExistsAsync();
+
+ var documentsForSource = ingestionCacheDb.Documents
+ .Where(d => d.SourceId == source.SourceId)
+ .Include(d => d.Records);
+
+ var deletedFiles = await source.GetDeletedDocumentsAsync(documentsForSource);
+ foreach (var deletedFile in deletedFiles)
+ {
+ logger.LogInformation("Removing ingested data for {file}", deletedFile.Id);
+ await vectorCollection.DeleteBatchAsync(deletedFile.Records.Select(r => r.Id));
+ ingestionCacheDb.Documents.Remove(deletedFile);
+ }
+ await ingestionCacheDb.SaveChangesAsync();
+
+ var modifiedDocs = await source.GetNewOrModifiedDocumentsAsync(documentsForSource);
+ foreach (var modifiedDoc in modifiedDocs)
+ {
+ logger.LogInformation("Processing {file}", modifiedDoc.Id);
+
+ if (modifiedDoc.Records.Count > 0)
+ {
+ await vectorCollection.DeleteBatchAsync(modifiedDoc.Records.Select(r => r.Id));
+ }
+
+ var newRecords = await source.CreateRecordsForDocumentAsync(embeddingGenerator, modifiedDoc.Id);
+ await foreach (var id in vectorCollection.UpsertBatchAsync(newRecords)) { }
+
+ modifiedDoc.Records.Clear();
+ modifiedDoc.Records.AddRange(newRecords.Select(r => new IngestedRecord { Id = r.Key, DocumentId = modifiedDoc.Id }));
+
+ if (ingestionCacheDb.Entry(modifiedDoc).State == EntityState.Detached)
+ {
+ ingestionCacheDb.Documents.Add(modifiedDoc);
+ }
+ }
+
+ await ingestionCacheDb.SaveChangesAsync();
+ logger.LogInformation("Ingestion is up-to-date");
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/IIngestionSource.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IIngestionSource.cs
similarity index 89%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/IIngestionSource.cs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IIngestionSource.cs
index cf8445ae31a..73c728865af 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/IIngestionSource.cs
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IIngestionSource.cs
@@ -1,6 +1,6 @@
using Microsoft.Extensions.AI;
-namespace ChatWithCustomData.Web_CSharp.Services.Ingestion;
+namespace ChatWithCustomData_CSharp.Web.Services.Ingestion;
public interface IIngestionSource
{
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IngestionCacheDbContext.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IngestionCacheDbContext.cs
new file mode 100644
index 00000000000..71be4c82cdf
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IngestionCacheDbContext.cs
@@ -0,0 +1,48 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace ChatWithCustomData_CSharp.Web.Services.Ingestion;
+
+// A DbContext that keeps track of which documents have been ingested.
+// This makes it possible to avoid re-ingesting documents that have not changed,
+// and to delete documents that have been removed from the underlying source.
+public class IngestionCacheDbContext : DbContext
+{
+ public IngestionCacheDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet Documents { get; set; } = default!;
+ public DbSet Records { get; set; } = default!;
+
+ public static void Initialize(IServiceProvider services)
+ {
+ using var scope = services.CreateScope();
+ using var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.EnsureCreated();
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+ modelBuilder.Entity().HasMany(d => d.Records).WithOne().HasForeignKey(r => r.DocumentId).OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+public class IngestedDocument
+{
+ // TODO: Make Id+SourceId a composite key
+ public required string Id { get; set; }
+ public required string SourceId { get; set; }
+ public required string Version { get; set; }
+ public List Records { get; set; } = [];
+}
+
+public class IngestedRecord
+{
+#if (UseQdrant)
+ public required Guid Id { get; set; }
+#else
+ public required string Id { get; set; }
+#endif
+ public required string DocumentId { get; set; }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs
new file mode 100644
index 00000000000..6354d04d375
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs
@@ -0,0 +1,85 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.SemanticKernel.Text;
+using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter;
+using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor;
+using UglyToad.PdfPig;
+using Microsoft.Extensions.AI;
+using UglyToad.PdfPig.Content;
+
+namespace ChatWithCustomData_CSharp.Web.Services.Ingestion;
+
+public class PDFDirectorySource(string sourceDirectory) : IIngestionSource
+{
+ public static string SourceFileId(string path) => Path.GetFileName(path);
+
+ public string SourceId => $"{nameof(PDFDirectorySource)}:{sourceDirectory}";
+
+ public async Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments)
+ {
+ var results = new List();
+ var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf");
+
+ foreach (var sourceFile in sourceFiles)
+ {
+ var sourceFileId = SourceFileId(sourceFile);
+ var sourceFileVersion = File.GetLastWriteTimeUtc(sourceFile).ToString("o");
+
+ var existingDocument = await existingDocuments.Where(d => d.SourceId == SourceId && d.Id == sourceFileId).FirstOrDefaultAsync();
+ if (existingDocument is null)
+ {
+ results.Add(new() { Id = sourceFileId, Version = sourceFileVersion, SourceId = SourceId });
+ }
+ else if (existingDocument.Version != sourceFileVersion)
+ {
+ existingDocument.Version = sourceFileVersion;
+ results.Add(existingDocument);
+ }
+ }
+
+ return results;
+ }
+
+ public async Task> GetDeletedDocumentsAsync(IQueryable existingDocuments)
+ {
+ var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf");
+ var sourceFileIds = sourceFiles.Select(SourceFileId).ToList();
+ return await existingDocuments
+ .Where(d => !sourceFileIds.Contains(d.Id))
+ .ToListAsync();
+ }
+
+ public async Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId)
+ {
+ using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, documentId));
+ var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList();
+
+ var embeddings = await embeddingGenerator.GenerateAsync(paragraphs.Select(c => c.Text));
+
+ return paragraphs.Zip(embeddings).Select((pair, index) => new SemanticSearchRecord
+ {
+#if (UseQdrant)
+ Key = Guid.CreateVersion7(),
+#else
+ Key = $"{Path.GetFileNameWithoutExtension(documentId)}_{pair.First.PageNumber}_{pair.First.IndexOnPage}",
+#endif
+ FileName = documentId,
+ PageNumber = pair.First.PageNumber,
+ Text = pair.First.Text,
+ Vector = pair.Second.Vector,
+ });
+ }
+
+ private static IEnumerable<(int PageNumber, int IndexOnPage, string Text)> GetPageParagraphs(Page pdfPage)
+ {
+ var letters = pdfPage.Letters;
+ var words = NearestNeighbourWordExtractor.Instance.GetWords(letters);
+ var textBlocks = DocstrumBoundingBoxes.Instance.GetBlocks(words);
+ var pageText = string.Join(Environment.NewLine + Environment.NewLine,
+ textBlocks.Select(t => t.Text.ReplaceLineEndings(" ")));
+
+#pragma warning disable SKEXP0050 // Type is for evaluation purposes only
+ return TextChunker.SplitPlainTextParagraphs([pageText], 200)
+ .Select((text, index) => (pdfPage.Number, index, text));
+#pragma warning restore SKEXP0050 // Type is for evaluation purposes only
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/JsonVectorStore.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs
similarity index 79%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/JsonVectorStore.cs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs
index cb787c3bbef..09425f6a00a 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/JsonVectorStore.cs
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs
@@ -4,14 +4,14 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
-namespace ChatWithCustomData.Web_CSharp.Services;
+namespace ChatWithCustomData_CSharp.Web.Services;
///
/// This IVectorStore implementation is for prototyping only. Do not use this in production.
/// In production, you must replace this with a real vector store. There are many IVectorStore
/// implementations available, including ones for standalone vector databases like Qdrant or Milvus,
/// or for integrating with relational databases such as SQL Server or PostgreSQL.
-///
+///
/// This implementation stores the vector records in large JSON files on disk. It is very inefficient
/// and is provided only for convenience when prototyping.
///
@@ -63,13 +63,13 @@ public async Task CreateCollectionIfNotExistsAsync(CancellationToken cancellatio
}
}
- public Task DeleteAsync(TKey key, DeleteRecordOptions? options = null, CancellationToken cancellationToken = default)
+ public Task DeleteAsync(TKey key, CancellationToken cancellationToken = default)
{
_records!.Remove(key);
return WriteToDiskAsync(cancellationToken);
}
- public Task DeleteBatchAsync(IEnumerable keys, DeleteRecordOptions? options = null, CancellationToken cancellationToken = default)
+ public Task DeleteBatchAsync(IEnumerable keys, CancellationToken cancellationToken = default)
{
foreach (var key in keys)
{
@@ -92,7 +92,7 @@ public Task DeleteCollectionAsync(CancellationToken cancellationToken = default)
public IAsyncEnumerable GetBatchAsync(IEnumerable keys, GetRecordOptions? options = null, CancellationToken cancellationToken = default)
=> keys.Select(key => _records!.GetValueOrDefault(key)!).Where(r => r is not null).ToAsyncEnumerable();
- public async Task UpsertAsync(TRecord record, UpsertRecordOptions? options = null, CancellationToken cancellationToken = default)
+ public async Task UpsertAsync(TRecord record, CancellationToken cancellationToken = default)
{
var key = _getKey(record);
_records![key] = record;
@@ -100,7 +100,7 @@ public async Task UpsertAsync(TRecord record, UpsertRecordOptions? options
return key;
}
- public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, UpsertRecordOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var results = new List();
foreach (var record in records)
@@ -118,7 +118,7 @@ public async IAsyncEnumerable UpsertBatchAsync(IEnumerable record
}
}
- public Task> VectorizedSearchAsync(TVector vector, VectorSearchOptions? options = null, CancellationToken cancellationToken = default)
+ public Task> VectorizedSearchAsync(TVector vector, VectorSearchOptions? options = null, CancellationToken cancellationToken = default)
{
if (vector is not ReadOnlyMemory floatVector)
{
@@ -126,25 +126,16 @@ public Task> VectorizedSearchAsync(TVector
}
IEnumerable filteredRecords = _records!.Values;
-
- foreach (var clause in options?.Filter?.FilterClauses ?? [])
+ if (options?.Filter is { } filter)
{
- if (clause is EqualToFilterClause equalClause)
- {
- var propertyInfo = typeof(TRecord).GetProperty(equalClause.FieldName);
- filteredRecords = filteredRecords.Where(record => propertyInfo!.GetValue(record)!.Equals(equalClause.Value));
- }
- else
- {
- throw new NotSupportedException($"The provided filter clause type {clause.GetType().FullName} is not supported.");
- }
+ filteredRecords = filteredRecords.AsQueryable().Where(filter);
}
- var ranked = (from record in filteredRecords
- let candidateVector = _getVector(record)
- let similarity = TensorPrimitives.CosineSimilarity(candidateVector.Span, floatVector.Span)
- orderby similarity descending
- select (Record: record, Similarity: similarity));
+ var ranked = from record in filteredRecords
+ let candidateVector = _getVector(record)
+ let similarity = TensorPrimitives.CosineSimilarity(candidateVector.Span, floatVector.Span)
+ orderby similarity descending
+ select (Record: record, Similarity: similarity);
var results = ranked.Skip(options?.Skip ?? 0).Take(options?.Top ?? int.MaxValue);
return Task.FromResult(new VectorSearchResults(
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs
new file mode 100644
index 00000000000..cbe0b8b1554
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+
+namespace ChatWithCustomData_CSharp.Web.Services;
+
+public class SemanticSearch(
+ IEmbeddingGenerator> embeddingGenerator,
+ IVectorStore vectorStore)
+{
+ public async Task> SearchAsync(string text, string? filenameFilter, int maxResults)
+ {
+ var queryEmbedding = await embeddingGenerator.GenerateEmbeddingVectorAsync(text);
+#if (UseQdrant)
+ var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData-CSharp.Web-ingestion");
+#else
+ var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData-CSharp.Web-ingestion");
+#endif
+
+ var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions
+ {
+ Top = maxResults,
+ Filter = filenameFilter is { Length: > 0 } ? record => record.FileName == filenameFilter : null,
+ });
+ var results = new List();
+ await foreach (var item in nearest.Results)
+ {
+ results.Add(item.Record);
+ }
+
+ return results;
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/SemanticSearchRecord.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearchRecord.cs
similarity index 80%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/SemanticSearchRecord.cs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearchRecord.cs
index 814c0058457..13f11d54294 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/SemanticSearchRecord.cs
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearchRecord.cs
@@ -1,13 +1,17 @@
using Microsoft.Extensions.VectorData;
-namespace ChatWithCustomData.Web_CSharp.Services;
+namespace ChatWithCustomData_CSharp.Web.Services;
public class SemanticSearchRecord
{
[VectorStoreRecordKey]
+#if (UseQdrant)
+ public required Guid Key { get; set; }
+#else
public required string Key { get; set; }
+#endif
- [VectorStoreRecordData]
+ [VectorStoreRecordData(IsFilterable = true)]
public required string FileName { get; set; }
[VectorStoreRecordData]
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/appsettings.Development.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/appsettings.Development.json
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/appsettings.Development.json
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/appsettings.Development.json
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/appsettings.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/appsettings.json
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/appsettings.json
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/appsettings.json
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/Data/Example_Emergency_Survival_Kit.pdf b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/Data/Example_Emergency_Survival_Kit.pdf
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/Data/Example_GPS_Watch.pdf b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/Data/Example_GPS_Watch.pdf
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/Data/Example_GPS_Watch.pdf
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/Data/Example_GPS_Watch.pdf
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/app.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/app.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/app.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/app.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/app.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/app.js
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/app.js
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/app.js
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/favicon.ico b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/favicon.ico
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/favicon.ico
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/favicon.ico
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/dompurify/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/dompurify/README.md
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/dompurify/README.md
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/dompurify/README.md
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/dompurify/dist/purify.es.mjs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/dompurify/dist/purify.es.mjs
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/dompurify/dist/purify.es.mjs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/dompurify/dist/purify.es.mjs
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/marked/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/marked/README.md
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/marked/README.md
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/marked/README.md
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/marked/dist/marked.esm.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/marked/dist/marked.esm.js
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/marked/dist/marked.esm.js
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/marked/dist/marked.esm.js
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdf_viewer/viewer.html b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdf_viewer/viewer.html
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdf_viewer/viewer.html
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdf_viewer/viewer.html
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdf_viewer/viewer.mjs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdf_viewer/viewer.mjs
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdf_viewer/viewer.mjs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdf_viewer/viewer.mjs
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/README.md
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/README.md
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/README.md
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/tailwindcss/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/tailwindcss/README.md
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/tailwindcss/README.md
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/tailwindcss/README.md
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/tailwindcss/dist/preflight.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/tailwindcss/dist/preflight.css
similarity index 100%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib/tailwindcss/dist/preflight.css
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib/tailwindcss/dist/preflight.css
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.sln.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.sln.in
new file mode 100644
index 00000000000..aa1c261a2a3
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.sln.in
@@ -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("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatWithCustomData-CSharp.AppHost", "ChatWithCustomData-CSharp.AppHost\ChatWithCustomData-CSharp.AppHost.csproj", "{4DF52F58-4890-4A0A-BC25-5C3D167B3490}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatWithCustomData-CSharp.ServiceDefaults", "ChatWithCustomData-CSharp.ServiceDefaults\ChatWithCustomData-CSharp.ServiceDefaults.csproj", "{2222CF31-6E3A-42E7-AD3A-A56B14EAD9D0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatWithCustomData-CSharp.Web", "ChatWithCustomData-CSharp.Web\ChatWithCustomData-CSharp.Web.csproj", "{A7D19173-F0AD-4A8D-B8EA-E12DE203E409}"
+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
+ {4DF52F58-4890-4A0A-BC25-5C3D167B3490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4DF52F58-4890-4A0A-BC25-5C3D167B3490}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4DF52F58-4890-4A0A-BC25-5C3D167B3490}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4DF52F58-4890-4A0A-BC25-5C3D167B3490}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2222CF31-6E3A-42E7-AD3A-A56B14EAD9D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2222CF31-6E3A-42E7-AD3A-A56B14EAD9D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2222CF31-6E3A-42E7-AD3A-A56B14EAD9D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2222CF31-6E3A-42E7-AD3A-A56B14EAD9D0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A7D19173-F0AD-4A8D-B8EA-E12DE203E409}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A7D19173-F0AD-4A8D-B8EA-E12DE203E409}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A7D19173-F0AD-4A8D-B8EA-E12DE203E409}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A7D19173-F0AD-4A8D-B8EA-E12DE203E409}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Directory.Build.targets.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in
similarity index 77%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Directory.Build.targets.in
rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in
index 8fe33082cd6..66ea183ef70 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Directory.Build.targets.in
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in
@@ -5,8 +5,13 @@
+
+ <_UsingJustBuiltPackages>${UsingJustBuiltPackages}
+
+
+
+
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md
new file mode 100644
index 00000000000..5d211e3d96d
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md
@@ -0,0 +1,163 @@
+# 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-template-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.
+
+#### ---#if (UseAzure)
+### Prerequisites
+To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/).
+
+#### ---#endif
+# 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 ChatWithCustomData-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 ChatWithCustomData-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
+
+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.
+
+#### ---#if (hostIdentifier == "vs")
+Configure your API key for this project, using .NET User Secrets:
+
+1. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets".
+2. This will open a secrets.json file where you can store your API key without them being tracked in source control. Add the following key and value to the file:
+
+ ```json
+ {
+ "ConnectionStrings:openai": "Key=YOUR-API-KEY"
+ }
+ ```
+
+#### ---#else
+From the command line, configure your API key for this project using .NET User Secrets by running the following commands:
+
+```sh
+cd ChatWithCustomData-CSharp.AppHost
+dotnet user-secrets set ConnectionStrings:openai "Key=YOUR-API-KEY"
+```
+#### ---#endif
+#### ---#endif
+#### ---#if (IsOllama)
+## Setting up a local environment for Ollama
+This project is configured to run Ollama in a Docker container.
+
+To get started, download, install, and run Docker Desktop from the [official website](https://www.docker.com/). Follow the installation instructions specific to your operating system.
+
+Note: Ollama and Docker are excellent open source products, but are not maintained by Microsoft.
+#### ---#endif
+#### ---#if (IsAzureOpenAI)
+## Using Azure OpenAI
+
+To use Azure OpenAI, you will need an Azure account and an Azure OpenAI Service resource. For detailed instructions, see the [Azure OpenAI Service documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource).
+
+### 1. Create an Azure OpenAI Service Resource
+[Create an Azure OpenAI Service resource](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal).
+
+### 2. Deploy the Models
+Deploy the `gpt-4o-mini` and `text-embedding-3-small` models to your Azure OpenAI Service resource. When creating those deployments, give them the same names as the models (`gpt-4o-mini` and `text-embedding-3-small`). See the Azure OpenAI documentation to learn how to [Deploy a model](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model).
+
+### 3. Configure API Key and Endpoint
+Configure your Azure OpenAI API key and endpoint for this project, using .NET User Secrets:
+ 1. In the Azure Portal, navigate to your Azure OpenAI resource.
+ 2. Copy the "Endpoint" URL and "Key 1" from the "Keys and Endpoint" section.
+#### ---#if (hostIdentifier == "vs")
+ 3. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets".
+ 4. This will open a secrets.json file where you can store your API key and endpoint without it being tracked in source control. Add the following keys & values to the file:
+
+ ```json
+ {
+ "ConnectionStrings:openai": "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY"
+ }
+ ```
+#### ---#else
+ 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands:
+
+ ```sh
+ cd ChatWithCustomData-CSharp.AppHost
+ dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY"
+ ```
+#### ---#endif
+
+Make sure to replace `YOUR-API-KEY` and `YOUR-DEPLOYMENT-NAME` with your actual Azure OpenAI key and endpoint. Make sure your endpoint URL is formatted like https://YOUR-DEPLOYMENT-NAME.openai.azure.com/ (do not include any path after .openai.azure.com/).
+#### ---#endif
+#### ---#if (UseAzureAISearch)
+
+## Configure Azure AI Search
+
+To use Azure AI Search, you will need an Azure account and an Azure AI Search resource. For detailed instructions, see the [Azure AI Search documentation](https://learn.microsoft.com/azure/search/search-create-service-portal).
+
+### 1. Create an Azure AI Search Resource
+Follow the instructions in the [Azure portal](https://portal.azure.com/) to create an Azure AI Search resource. Note that there is a free tier for the service but it is not currently the default setting on the portal.
+
+Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `$$VectorStoreIndexName$$` index using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch.
+
+### 3. Configure API Key and Endpoint
+ Configure your Azure AI Search API key and endpoint for this project, using .NET User Secrets:
+ 1. In the Azure Portal, navigate to your Azure AI Search resource.
+ 2. Copy the "Endpoint" URL and "Primary admin key" from the "Keys" section.
+#### ---#if (hostIdentifier == "vs")
+ 3. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets".
+ 4. This will open a `secrets.json` file where you can store your API key and endpoint without them being tracked in source control. Add the following keys and values to the file:
+
+ ```json
+ {
+ "ConnectionStrings:azureAISearch": "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY"
+ }
+ ```
+#### ---#else
+ 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands:
+
+ ```sh
+ cd ChatWithCustomData-CSharp.AppHost
+ dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY"
+ ```
+#### ---#endif
+
+Make sure to replace `YOUR-DEPLOYMENT-NAME` and `YOUR-API-KEY` with your actual Azure AI Search deployment name and key.
+#### ---#endif
+
+# 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 ChatWithCustomData-CSharp.AppHost project.
+4. Run the project by clicking the "Run" button in the Debug view.
+
+# 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/src/ProjectTemplates/package.json b/src/ProjectTemplates/package.json
index 76456e39ab9..ff2090674f3 100644
--- a/src/ProjectTemplates/package.json
+++ b/src/ProjectTemplates/package.json
@@ -6,7 +6,7 @@
"main": "index.js",
"config": {
"destRoot": {
- "chat": "./Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/wwwroot/lib"
+ "chat": "./Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/wwwroot/lib"
}
},
"scripts": {
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AichatwebTemplatesTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AichatwebTemplatesTests.cs
index cd155f38da7..e09a16f209c 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AichatwebTemplatesTests.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AichatwebTemplatesTests.cs
@@ -1,6 +1,8 @@
// 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.IO;
using System.Linq;
using System.Threading.Tasks;
@@ -20,13 +22,14 @@ public class AichatwebTemplatesTests : TestBase
private static readonly string[] _verificationExcludePatterns = [
"**/bin/**",
"**/obj/**",
+ "**/.vs/**",
"**/node_modules/**",
"**/*.user",
"**/*.in",
"**/*.out.js",
"**/*.generated.css",
"**/package-lock.json",
- "**/ingestioncache.db",
+ "**/ingestioncache.*",
"**/NuGet.config",
"**/Directory.Build.targets",
];
@@ -42,6 +45,17 @@ public AichatwebTemplatesTests(ITestOutputHelper log)
[Fact]
public async Task BasicTest()
+ {
+ await TestTemplateCoreAsync(scenarioName: "Basic");
+ }
+
+ [Fact]
+ public async Task BasicAspireTest()
+ {
+ await TestTemplateCoreAsync(scenarioName: "BasicAspire", templateArgs: ["--aspire"]);
+ }
+
+ private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable? templateArgs = null)
{
string workingDir = TestUtils.CreateTemporaryFolder();
string templateShortName = "aichatweb";
@@ -56,19 +70,24 @@ public async Task BasicTest()
TemplateVerifierOptions options = new TemplateVerifierOptions(templateName: templateShortName)
{
TemplatePath = templateLocation,
+ TemplateSpecificArgs = templateArgs,
SnapshotsDirectory = "Snapshots",
OutputDirectory = workingDir,
DoNotPrependCallerMethodNameToScenarioName = true,
- ScenarioName = "Basic",
+ ScenarioName = scenarioName,
VerificationExcludePatterns = verificationExcludePatterns,
}
.WithCustomScrubbers(
ScrubbersDefinition.Empty.AddScrubber((path, content) =>
{
string filePath = path.UnixifyDirSeparators();
- if (filePath.EndsWith("aichatweb/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj") ||
- filePath.EndsWith("aichatweb/aichatweb.csproj") ||
- filePath.EndsWith("aichatweb/aichatweb.csproj.in"))
+ if (filePath.EndsWith(".sln"))
+ {
+ // Scrub .sln file GUIDs.
+ content.ScrubByRegex(pattern: @"\{.{36}\}", replacement: "{00000000-0000-0000-0000-000000000000}");
+ }
+
+ if (filePath.EndsWith(".csproj"))
{
content.ScrubByRegex("(.*)<\\/UserSecretsId>", "secret");
@@ -78,7 +97,7 @@ public async Task BasicTest()
content.ScrubByRegex(pattern, replacement: "$1");
}
- if (filePath.EndsWith("aichatweb/Properties/launchSettings.json"))
+ if (filePath.EndsWith("launchSettings.json"))
{
content.ScrubByRegex("(http(s?):\\/\\/localhost)\\:(\\d*)", "$1:9999");
}
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor
index 77626e2d565..5e4f8042add 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor
@@ -103,7 +103,7 @@
[Description("Searches for information using a phrase or keyword")]
private async Task> SearchAsync(
[Description("The phrase to search for.")] string searchPhrase,
- [Description("Whenever possible, specify the filename to search that file only. If not provided, the search includes all files.")] string? filenameFilter = null)
+ [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);
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor
index 250576a9efd..69ca922a8ce 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor
@@ -48,7 +48,7 @@
{
var response = await ChatClient.GetResponseAsync(
[.. ReduceMessages(messages), new(ChatRole.User, Prompt)],
- useNativeJsonSchema: true, cancellationToken: cancellation.Token);
+ cancellationToken: cancellation.Token);
if (!response.TryGetResult(out suggestions))
{
suggestions = null;
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs
index 015bed3787f..d18307ead2b 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs
@@ -44,7 +44,7 @@ public async Task IngestDataAsync(IIngestionSource source)
{
await vectorCollection.DeleteBatchAsync(modifiedDoc.Records.Select(r => r.Id));
}
-
+
var newRecords = await source.CreateRecordsForDocumentAsync(embeddingGenerator, modifiedDoc.Id);
await foreach (var id in vectorCollection.UpsertBatchAsync(newRecords)) { }
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs
index fdae81a1dd8..9072a9c2b40 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs
@@ -52,7 +52,7 @@ public async Task> CreateRecordsForDocumentAsy
{
using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, documentId));
var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList();
-
+
var embeddings = await embeddingGenerator.GenerateAsync(paragraphs.Select(c => c.Text));
return paragraphs.Zip(embeddings).Select((pair, index) => new SemanticSearchRecord
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/JsonVectorStore.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/JsonVectorStore.cs
index f9f72fb0d22..8e6d273a27b 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/JsonVectorStore.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/JsonVectorStore.cs
@@ -11,7 +11,7 @@ namespace aichatweb.Services;
/// In production, you must replace this with a real vector store. There are many IVectorStore
/// implementations available, including ones for standalone vector databases like Qdrant or Milvus,
/// or for integrating with relational databases such as SQL Server or PostgreSQL.
-///
+///
/// This implementation stores the vector records in large JSON files on disk. It is very inefficient
/// and is provided only for convenience when prototyping.
///
@@ -63,13 +63,13 @@ public async Task CreateCollectionIfNotExistsAsync(CancellationToken cancellatio
}
}
- public Task DeleteAsync(TKey key, DeleteRecordOptions? options = null, CancellationToken cancellationToken = default)
+ public Task DeleteAsync(TKey key, CancellationToken cancellationToken = default)
{
_records!.Remove(key);
return WriteToDiskAsync(cancellationToken);
}
- public Task DeleteBatchAsync(IEnumerable keys, DeleteRecordOptions? options = null, CancellationToken cancellationToken = default)
+ public Task DeleteBatchAsync(IEnumerable keys, CancellationToken cancellationToken = default)
{
foreach (var key in keys)
{
@@ -92,7 +92,7 @@ public Task DeleteCollectionAsync(CancellationToken cancellationToken = default)
public IAsyncEnumerable GetBatchAsync(IEnumerable keys, GetRecordOptions? options = null, CancellationToken cancellationToken = default)
=> keys.Select(key => _records!.GetValueOrDefault(key)!).Where(r => r is not null).ToAsyncEnumerable();
- public async Task UpsertAsync(TRecord record, UpsertRecordOptions? options = null, CancellationToken cancellationToken = default)
+ public async Task UpsertAsync(TRecord record, CancellationToken cancellationToken = default)
{
var key = _getKey(record);
_records![key] = record;
@@ -100,7 +100,7 @@ public async Task UpsertAsync(TRecord record, UpsertRecordOptions? options
return key;
}
- public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, UpsertRecordOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var results = new List();
foreach (var record in records)
@@ -118,7 +118,7 @@ public async IAsyncEnumerable UpsertBatchAsync(IEnumerable record
}
}
- public Task> VectorizedSearchAsync(TVector vector, VectorSearchOptions? options = null, CancellationToken cancellationToken = default)
+ public Task> VectorizedSearchAsync(TVector vector, VectorSearchOptions? options = null, CancellationToken cancellationToken = default)
{
if (vector is not ReadOnlyMemory floatVector)
{
@@ -126,25 +126,16 @@ public Task> VectorizedSearchAsync(TVector
}
IEnumerable filteredRecords = _records!.Values;
-
- foreach (var clause in options?.Filter?.FilterClauses ?? [])
+ if (options?.Filter is { } filter)
{
- if (clause is EqualToFilterClause equalClause)
- {
- var propertyInfo = typeof(TRecord).GetProperty(equalClause.FieldName);
- filteredRecords = filteredRecords.Where(record => propertyInfo!.GetValue(record)!.Equals(equalClause.Value));
- }
- else
- {
- throw new NotSupportedException($"The provided filter clause type {clause.GetType().FullName} is not supported.");
- }
+ filteredRecords = filteredRecords.AsQueryable().Where(filter);
}
- var ranked = (from record in filteredRecords
- let candidateVector = _getVector(record)
- let similarity = TensorPrimitives.CosineSimilarity(candidateVector.Span, floatVector.Span)
- orderby similarity descending
- select (Record: record, Similarity: similarity));
+ var ranked = from record in filteredRecords
+ let candidateVector = _getVector(record)
+ let similarity = TensorPrimitives.CosineSimilarity(candidateVector.Span, floatVector.Span)
+ orderby similarity descending
+ select (Record: record, Similarity: similarity);
var results = ranked.Skip(options?.Skip ?? 0).Take(options?.Top ?? int.MaxValue);
return Task.FromResult(new VectorSearchResults(
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearch.cs
index e34bbcdc778..6b74a95ace4 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearch.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearch.cs
@@ -11,14 +11,11 @@ public async Task> SearchAsync(string text,
{
var queryEmbedding = await embeddingGenerator.GenerateEmbeddingVectorAsync(text);
var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested");
- var filter = filenameFilter is { Length: > 0 }
- ? new VectorSearchFilter().EqualTo(nameof(SemanticSearchRecord.FileName), filenameFilter)
- : null;
- var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions
+ var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions
{
Top = maxResults,
- Filter = filter,
+ Filter = filenameFilter is { Length: > 0 } ? record => record.FileName == filenameFilter : null,
});
var results = new List();
await foreach (var item in nearest.Results)
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearchRecord.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearchRecord.cs
index e7ad3358779..eb37cef61c8 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearchRecord.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearchRecord.cs
@@ -7,7 +7,7 @@ public class SemanticSearchRecord
[VectorStoreRecordKey]
public required string Key { get; set; }
- [VectorStoreRecordData]
+ [VectorStoreRecordData(IsFilterable = true)]
public required string FileName { get; set; }
[VectorStoreRecordData]
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj
index a73d885e592..12564229579 100644
--- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj
@@ -8,11 +8,11 @@
-
+
-
+
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/README.md
new file mode 100644
index 00000000000..6c3bef45ab2
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/README.md
@@ -0,0 +1,39 @@
+# 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-template-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 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.
+
+# 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.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/Program.cs
new file mode 100644
index 00000000000..1a7cc375e1a
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/Program.cs
@@ -0,0 +1,17 @@
+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 ingestionCache = builder.AddSqlite("ingestionCache");
+
+var webApp = builder.AddProject("aichatweb-app");
+webApp.WithReference(openai);
+webApp
+ .WithReference(ingestionCache)
+ .WaitFor(ingestionCache);
+
+builder.Build().Run();
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000000..4444e808585
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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:9999;http://localhost:9999",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:9999",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999"
+ }
+ }
+ }
+}
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj
new file mode 100644
index 00000000000..d9bc9284c45
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ true
+ secret
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.AppHost/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000000..31884963799
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs
@@ -0,0 +1,121 @@
+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 .NET 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
+{
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // 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()
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddSource("Experimental.Microsoft.Extensions.AI");
+ });
+
+ 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("/health");
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj
new file mode 100644
index 00000000000..92e812df96a
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net9.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor
@@ -0,0 +1 @@
+
+ }
+ else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true)
+ {
+
+
+
+
+
+ Searching:
+ @searchPhrase
+ @if (fcc.Arguments?.TryGetValue("filenameFilter", out var filenameObj) is true && filenameObj is string filename && !string.IsNullOrEmpty(filename))
+ {
+ in @filename
+ }
+
+}
+
+@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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Components/Routes.razor
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Program.cs
new file mode 100644
index 00000000000..729630d235a
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Program.cs
@@ -0,0 +1,53 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+using aichatweb.Web.Components;
+using aichatweb.Web.Services;
+using aichatweb.Web.Services.Ingestion;
+using OpenAI;
+
+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 vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store"));
+builder.Services.AddSingleton(vectorStore);
+builder.Services.AddScoped();
+builder.Services.AddSingleton();
+builder.AddSqliteDbContext("ingestionCache");
+
+var app = builder.Build();
+IngestionCacheDbContext.Initialize(app.Services);
+
+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();
+
+// By default, we ingest PDF files from the /wwwroot/Data directory. You can ingest from
+// other sources by implementing IIngestionSource.
+// Important: ensure that any content you ingest is trusted, as it may be reflected back
+// to users or could be a source of prompt injection risk.
+await DataIngestor.IngestDataAsync(
+ app.Services,
+ new PDFDirectorySource(Path.Combine(builder.Environment.WebRootPath, "Data")));
+
+app.Run();
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json
new file mode 100644
index 00000000000..e2d900a219d
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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:9999",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:9999;http://localhost:9999",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs
similarity index 94%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/DataIngestor.cs
rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs
index fc86ace9594..31b127914dd 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/DataIngestor.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs
@@ -2,7 +2,7 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
-namespace ChatWithCustomData.Web_CSharp.Services.Ingestion;
+namespace aichatweb.Web.Services.Ingestion;
public class DataIngestor(
ILogger logger,
@@ -19,7 +19,7 @@ public static async Task IngestDataAsync(IServiceProvider services, IIngestionSo
public async Task IngestDataAsync(IIngestionSource source)
{
- var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData.Web-CSharp-ingestion");
+ var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested");
await vectorCollection.CreateCollectionIfNotExistsAsync();
var documentsForSource = ingestionCacheDb.Documents
@@ -44,7 +44,7 @@ public async Task IngestDataAsync(IIngestionSource source)
{
await vectorCollection.DeleteBatchAsync(modifiedDoc.Records.Select(r => r.Id));
}
-
+
var newRecords = await source.CreateRecordsForDocumentAsync(embeddingGenerator, modifiedDoc.Id);
await foreach (var id in vectorCollection.UpsertBatchAsync(newRecords)) { }
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs
new file mode 100644
index 00000000000..a9e92b26779
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs
@@ -0,0 +1,14 @@
+using Microsoft.Extensions.AI;
+
+namespace aichatweb.Web.Services.Ingestion;
+
+public interface IIngestionSource
+{
+ string SourceId { get; }
+
+ Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments);
+
+ Task> GetDeletedDocumentsAsync(IQueryable existingDocuments);
+
+ Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId);
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/IngestionCacheDbContext.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IngestionCacheDbContext.cs
similarity index 96%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/IngestionCacheDbContext.cs
rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IngestionCacheDbContext.cs
index 78842253abe..66a02d3a0f6 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/IngestionCacheDbContext.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IngestionCacheDbContext.cs
@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore;
-namespace ChatWithCustomData.Web_CSharp.Services.Ingestion;
+namespace aichatweb.Web.Services.Ingestion;
// A DbContext that keeps track of which documents have been ingested.
// This makes it possible to avoid re-ingesting documents that have not changed,
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/PDFDirectorySource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs
similarity index 98%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/PDFDirectorySource.cs
rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs
index 2b123b545a6..d6a10a626b0 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/Ingestion/PDFDirectorySource.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs
@@ -6,7 +6,7 @@
using Microsoft.Extensions.AI;
using UglyToad.PdfPig.Content;
-namespace ChatWithCustomData.Web_CSharp.Services.Ingestion;
+namespace aichatweb.Web.Services.Ingestion;
public class PDFDirectorySource(string sourceDirectory) : IIngestionSource
{
@@ -52,7 +52,7 @@ public async Task> CreateRecordsForDocumentAsy
{
using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, documentId));
var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList();
-
+
var embeddings = await embeddingGenerator.GenerateAsync(paragraphs.Select(c => c.Text));
return paragraphs.Zip(embeddings).Select((pair, index) => new SemanticSearchRecord
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/JsonVectorStore.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/JsonVectorStore.cs
new file mode 100644
index 00000000000..de36fb68a17
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/JsonVectorStore.cs
@@ -0,0 +1,170 @@
+using Microsoft.Extensions.VectorData;
+using System.Numerics.Tensors;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+
+namespace aichatweb.Web.Services;
+
+///
+/// This IVectorStore implementation is for prototyping only. Do not use this in production.
+/// In production, you must replace this with a real vector store. There are many IVectorStore
+/// implementations available, including ones for standalone vector databases like Qdrant or Milvus,
+/// or for integrating with relational databases such as SQL Server or PostgreSQL.
+///
+/// This implementation stores the vector records in large JSON files on disk. It is very inefficient
+/// and is provided only for convenience when prototyping.
+///
+public class JsonVectorStore(string basePath) : IVectorStore
+{
+ public IVectorStoreRecordCollection GetCollection(string name, VectorStoreRecordDefinition? vectorStoreRecordDefinition = null) where TKey : notnull
+ => new JsonVectorStoreRecordCollection(name, Path.Combine(basePath, name + ".json"), vectorStoreRecordDefinition);
+
+ public IAsyncEnumerable ListCollectionNamesAsync(CancellationToken cancellationToken = default)
+ => Directory.EnumerateFiles(basePath, "*.json").ToAsyncEnumerable();
+
+ private class JsonVectorStoreRecordCollection : IVectorStoreRecordCollection
+ where TKey : notnull
+ {
+ private static readonly Func _getKey = CreateKeyReader();
+ private static readonly Func> _getVector = CreateVectorReader();
+
+ private readonly string _name;
+ private readonly string _filePath;
+ private Dictionary? _records;
+
+ public JsonVectorStoreRecordCollection(string name, string filePath, VectorStoreRecordDefinition? vectorStoreRecordDefinition)
+ {
+ _name = name;
+ _filePath = filePath;
+
+ if (File.Exists(filePath))
+ {
+ _records = JsonSerializer.Deserialize>(File.ReadAllText(filePath));
+ }
+ }
+
+ public string CollectionName => _name;
+
+ public Task CollectionExistsAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(_records is not null);
+
+ public async Task CreateCollectionAsync(CancellationToken cancellationToken = default)
+ {
+ _records = [];
+ await WriteToDiskAsync(cancellationToken);
+ }
+
+ public async Task CreateCollectionIfNotExistsAsync(CancellationToken cancellationToken = default)
+ {
+ if (_records is null)
+ {
+ await CreateCollectionAsync(cancellationToken);
+ }
+ }
+
+ public Task DeleteAsync(TKey key, CancellationToken cancellationToken = default)
+ {
+ _records!.Remove(key);
+ return WriteToDiskAsync(cancellationToken);
+ }
+
+ public Task DeleteBatchAsync(IEnumerable keys, CancellationToken cancellationToken = default)
+ {
+ foreach (var key in keys)
+ {
+ _records!.Remove(key);
+ }
+
+ return WriteToDiskAsync(cancellationToken);
+ }
+
+ public Task DeleteCollectionAsync(CancellationToken cancellationToken = default)
+ {
+ _records = null;
+ File.Delete(_filePath);
+ return Task.CompletedTask;
+ }
+
+ public Task GetAsync(TKey key, GetRecordOptions? options = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(_records!.GetValueOrDefault(key));
+
+ public IAsyncEnumerable GetBatchAsync(IEnumerable keys, GetRecordOptions? options = null, CancellationToken cancellationToken = default)
+ => keys.Select(key => _records!.GetValueOrDefault(key)!).Where(r => r is not null).ToAsyncEnumerable();
+
+ public async Task UpsertAsync(TRecord record, CancellationToken cancellationToken = default)
+ {
+ var key = _getKey(record);
+ _records![key] = record;
+ await WriteToDiskAsync(cancellationToken);
+ return key;
+ }
+
+ public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var results = new List();
+ foreach (var record in records)
+ {
+ var key = _getKey(record);
+ _records![key] = record;
+ results.Add(key);
+ }
+
+ await WriteToDiskAsync(cancellationToken);
+
+ foreach (var key in results)
+ {
+ yield return key;
+ }
+ }
+
+ public Task> VectorizedSearchAsync(TVector vector, VectorSearchOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ if (vector is not ReadOnlyMemory floatVector)
+ {
+ throw new NotSupportedException($"The provided vector type {vector!.GetType().FullName} is not supported.");
+ }
+
+ IEnumerable filteredRecords = _records!.Values;
+ if (options?.Filter is { } filter)
+ {
+ filteredRecords = filteredRecords.AsQueryable().Where(filter);
+ }
+
+ var ranked = from record in filteredRecords
+ let candidateVector = _getVector(record)
+ let similarity = TensorPrimitives.CosineSimilarity(candidateVector.Span, floatVector.Span)
+ orderby similarity descending
+ select (Record: record, Similarity: similarity);
+
+ var results = ranked.Skip(options?.Skip ?? 0).Take(options?.Top ?? int.MaxValue);
+ return Task.FromResult(new VectorSearchResults(
+ results.Select(r => new VectorSearchResult(r.Record, r.Similarity)).ToAsyncEnumerable()));
+ }
+
+ private static Func CreateKeyReader()
+ {
+ var propertyInfo = typeof(TRecord).GetProperties()
+ .Where(p => p.GetCustomAttribute() is not null
+ && p.PropertyType == typeof(TKey))
+ .Single();
+ return record => (TKey)propertyInfo.GetValue(record)!;
+ }
+
+ private static Func> CreateVectorReader()
+ {
+ var propertyInfo = typeof(TRecord).GetProperties()
+ .Where(p => p.GetCustomAttribute() is not null
+ && p.PropertyType == typeof(ReadOnlyMemory))
+ .Single();
+ return record => (ReadOnlyMemory)propertyInfo.GetValue(record)!;
+ }
+
+ private async Task WriteToDiskAsync(CancellationToken cancellationToken = default)
+ {
+ var json = JsonSerializer.Serialize(_records);
+ Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
+ await File.WriteAllTextAsync(_filePath, json, cancellationToken);
+ }
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs
similarity index 68%
rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/SemanticSearch.cs
rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs
index 123136fb26b..5419ac9e7bd 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Services/SemanticSearch.cs
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs
@@ -1,7 +1,7 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
-namespace ChatWithCustomData.Web_CSharp.Services;
+namespace aichatweb.Web.Services;
public class SemanticSearch(
IEmbeddingGenerator> embeddingGenerator,
@@ -10,15 +10,12 @@ public class SemanticSearch(
public async Task> SearchAsync(string text, string? filenameFilter, int maxResults)
{
var queryEmbedding = await embeddingGenerator.GenerateEmbeddingVectorAsync(text);
- var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData.Web-CSharp-ingestion");
- var filter = filenameFilter is { Length: > 0 }
- ? new VectorSearchFilter().EqualTo(nameof(SemanticSearchRecord.FileName), filenameFilter)
- : null;
+ var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested");
- var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions
+ var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions
{
Top = maxResults,
- Filter = filter,
+ Filter = filenameFilter is { Length: > 0 } ? record => record.FileName == filenameFilter : null,
});
var results = new List();
await foreach (var item in nearest.Results)
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearchRecord.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearchRecord.cs
new file mode 100644
index 00000000000..23371589c7e
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearchRecord.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.VectorData;
+
+namespace aichatweb.Web.Services;
+
+public class SemanticSearchRecord
+{
+ [VectorStoreRecordKey]
+ public required string Key { get; set; }
+
+ [VectorStoreRecordData(IsFilterable = true)]
+ public required string FileName { get; set; }
+
+ [VectorStoreRecordData]
+ public int PageNumber { get; set; }
+
+ [VectorStoreRecordData]
+ public required string Text { get; set; }
+
+ [VectorStoreRecordVector(1536, DistanceFunction.CosineSimilarity)] // 1536 is the default vector size for the OpenAI text-embedding-3-small model
+ public ReadOnlyMemory Vector { get; set; }
+}
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj
new file mode 100644
index 00000000000..17e2b446e84
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net9.0
+ enable
+ enable
+ secret
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.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.BasicAspire--aspire.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.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf
new file mode 100644
index 00000000000..94625f0e0e0
Binary files /dev/null and b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf differ
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf
new file mode 100644
index 00000000000..c87df644c58
Binary files /dev/null and b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf differ
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.css
new file mode 100644
index 00000000000..0dec580e2fd
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.css
@@ -0,0 +1,94 @@
+@import url('lib/tailwindcss/dist/preflight.css');
+
+html {
+ min-height: 100vh;
+}
+
+html, .main-background-gradient {
+ background: linear-gradient(to bottom, rgb(225 227 233), #f4f4f4 25rem);
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+ html::after {
+ content: '';
+ background-image: linear-gradient(to right, #3a4ed5, #3acfd5 15%, #d53abf 85%, red);
+ width: 100%;
+ height: 2px;
+ position: fixed;
+ top: 0;
+ }
+
+h1 {
+ font-size: 2.25rem;
+ line-height: 2.5rem;
+ font-weight: 600;
+}
+
+h1:focus {
+ outline: none;
+}
+
+.valid.modified:not([type=checkbox]) {
+ outline: 1px solid #26b050;
+}
+
+.invalid {
+ outline: 1px solid #e50000;
+}
+
+.validation-message {
+ color: #e50000;
+}
+
+.blazor-error-boundary {
+ background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
+ padding: 1rem 1rem 1rem 3.7rem;
+ color: white;
+}
+
+ .blazor-error-boundary::after {
+ content: "An error has occurred."
+ }
+
+.btn-default {
+ display: flex;
+ padding: 0.25rem 0.75rem;
+ gap: 0.25rem;
+ align-items: center;
+ border-radius: 0.25rem;
+ border: 1px solid #9CA3AF;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ font-weight: 600;
+ background-color: #D1D5DB;
+}
+
+ .btn-default:hover {
+ background-color: #E5E7EB;
+ }
+
+.btn-subtle {
+ display: flex;
+ padding: 0.25rem 0.75rem;
+ gap: 0.25rem;
+ align-items: center;
+ border-radius: 0.25rem;
+ border: 1px solid #D1D5DB;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+ .btn-subtle:hover {
+ border-color: #93C5FD;
+ background-color: #DBEAFE;
+ }
+
+.page-width {
+ max-width: 1024px;
+ margin: auto;
+}
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.js
new file mode 100644
index 00000000000..8b2cecd007d
--- /dev/null
+++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire--aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.js
@@ -0,0 +1,24 @@
+import DOMPurify from './lib/dompurify/dist/purify.es.mjs';
+import * as marked from './lib/marked/dist/marked.esm.js';
+
+const purify = DOMPurify(window);
+
+customElements.define('assistant-message', class extends HTMLElement {
+ static observedAttributes = ['markdown'];
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (name === 'markdown') {
+ newValue = newValue.replace(//gs, '');
+ const elements = marked.parse(newValue.replace(/ 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+ args[_key - 1] = arguments[_key];
+ }
+ return apply(func, thisArg, args);
+ };
+}
+/**
+ * Creates a new function that constructs an instance of the given constructor function with the provided arguments.
+ *
+ * @param func - The constructor function to be wrapped and called.
+ * @returns A new function that constructs an instance of the given constructor function with the provided arguments.
+ */
+function unconstruct(func) {
+ return function () {
+ for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
+ args[_key2] = arguments[_key2];
+ }
+ return construct(func, args);
+ };
+}
+/**
+ * Add properties to a lookup table
+ *
+ * @param set - The set to which elements will be added.
+ * @param array - The array containing elements to be added to the set.
+ * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set.
+ * @returns The modified set with added elements.
+ */
+function addToSet(set, array) {
+ let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase;
+ if (setPrototypeOf) {
+ // Make 'in' and truthy checks like Boolean(set.constructor)
+ // independent of any properties defined on Object.prototype.
+ // Prevent prototype setters from intercepting set as a this value.
+ setPrototypeOf(set, null);
+ }
+ let l = array.length;
+ while (l--) {
+ let element = array[l];
+ if (typeof element === 'string') {
+ const lcElement = transformCaseFunc(element);
+ if (lcElement !== element) {
+ // Config presets (e.g. tags.js, attrs.js) are immutable.
+ if (!isFrozen(array)) {
+ array[l] = lcElement;
+ }
+ element = lcElement;
+ }
+ }
+ set[element] = true;
+ }
+ return set;
+}
+/**
+ * Clean up an array to harden against CSPP
+ *
+ * @param array - The array to be cleaned.
+ * @returns The cleaned version of the array
+ */
+function cleanArray(array) {
+ for (let index = 0; index < array.length; index++) {
+ const isPropertyExist = objectHasOwnProperty(array, index);
+ if (!isPropertyExist) {
+ array[index] = null;
+ }
+ }
+ return array;
+}
+/**
+ * Shallow clone an object
+ *
+ * @param object - The object to be cloned.
+ * @returns A new object that copies the original.
+ */
+function clone(object) {
+ const newObject = create(null);
+ for (const [property, value] of entries(object)) {
+ const isPropertyExist = objectHasOwnProperty(object, property);
+ if (isPropertyExist) {
+ if (Array.isArray(value)) {
+ newObject[property] = cleanArray(value);
+ } else if (value && typeof value === 'object' && value.constructor === Object) {
+ newObject[property] = clone(value);
+ } else {
+ newObject[property] = value;
+ }
+ }
+ }
+ return newObject;
+}
+/**
+ * This method automatically checks if the prop is function or getter and behaves accordingly.
+ *
+ * @param object - The object to look up the getter function in its prototype chain.
+ * @param prop - The property name for which to find the getter function.
+ * @returns The getter function found in the prototype chain or a fallback function.
+ */
+function lookupGetter(object, prop) {
+ while (object !== null) {
+ const desc = getOwnPropertyDescriptor(object, prop);
+ if (desc) {
+ if (desc.get) {
+ return unapply(desc.get);
+ }
+ if (typeof desc.value === 'function') {
+ return unapply(desc.value);
+ }
+ }
+ object = getPrototypeOf(object);
+ }
+ function fallbackValue() {
+ return null;
+ }
+ return fallbackValue;
+}
+
+const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']);
+const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']);
+const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']);
+// List of SVG elements that are disallowed by default.
+// We still need to know them so that we can do namespace
+// checks properly in case one wants to add them to
+// allow-list.
+const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']);
+const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']);
+// Similarly to SVG, we want to know all MathML elements,
+// even those that we disallow by default.
+const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']);
+const text = freeze(['#text']);
+
+const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns', 'slot']);
+const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']);
+const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']);
+const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']);
+
+// eslint-disable-next-line unicorn/better-regex
+const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode
+const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm);
+const TMPLIT_EXPR = seal(/\$\{[\w\W]*/gm); // eslint-disable-line unicorn/better-regex
+const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape
+const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape
+const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape
+);
+const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i);
+const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex
+);
+const DOCTYPE_NAME = seal(/^html$/i);
+const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i);
+
+var EXPRESSIONS = /*#__PURE__*/Object.freeze({
+ __proto__: null,
+ ARIA_ATTR: ARIA_ATTR,
+ ATTR_WHITESPACE: ATTR_WHITESPACE,
+ CUSTOM_ELEMENT: CUSTOM_ELEMENT,
+ DATA_ATTR: DATA_ATTR,
+ DOCTYPE_NAME: DOCTYPE_NAME,
+ ERB_EXPR: ERB_EXPR,
+ IS_ALLOWED_URI: IS_ALLOWED_URI,
+ IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA,
+ MUSTACHE_EXPR: MUSTACHE_EXPR,
+ TMPLIT_EXPR: TMPLIT_EXPR
+});
+
+/* eslint-disable @typescript-eslint/indent */
+// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
+const NODE_TYPE = {
+ element: 1,
+ attribute: 2,
+ text: 3,
+ cdataSection: 4,
+ entityReference: 5,
+ // Deprecated
+ entityNode: 6,
+ // Deprecated
+ progressingInstruction: 7,
+ comment: 8,
+ document: 9,
+ documentType: 10,
+ documentFragment: 11,
+ notation: 12 // Deprecated
+};
+const getGlobal = function getGlobal() {
+ return typeof window === 'undefined' ? null : window;
+};
+/**
+ * Creates a no-op policy for internal use only.
+ * Don't export this function outside this module!
+ * @param trustedTypes The policy factory.
+ * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix).
+ * @return The policy created (or null, if Trusted Types
+ * are not supported or creating the policy failed).
+ */
+const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) {
+ if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') {
+ return null;
+ }
+ // Allow the callers to control the unique policy name
+ // by adding a data-tt-policy-suffix to the script element with the DOMPurify.
+ // Policy creation with duplicate names throws in Trusted Types.
+ let suffix = null;
+ const ATTR_NAME = 'data-tt-policy-suffix';
+ if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) {
+ suffix = purifyHostElement.getAttribute(ATTR_NAME);
+ }
+ const policyName = 'dompurify' + (suffix ? '#' + suffix : '');
+ try {
+ return trustedTypes.createPolicy(policyName, {
+ createHTML(html) {
+ return html;
+ },
+ createScriptURL(scriptUrl) {
+ return scriptUrl;
+ }
+ });
+ } catch (_) {
+ // Policy creation failed (most likely another DOMPurify script has
+ // already run). Skip creating the policy, as this will only cause errors
+ // if TT are enforced.
+ console.warn('TrustedTypes policy ' + policyName + ' could not be created.');
+ return null;
+ }
+};
+const _createHooksMap = function _createHooksMap() {
+ return {
+ afterSanitizeAttributes: [],
+ afterSanitizeElements: [],
+ afterSanitizeShadowDOM: [],
+ beforeSanitizeAttributes: [],
+ beforeSanitizeElements: [],
+ beforeSanitizeShadowDOM: [],
+ uponSanitizeAttribute: [],
+ uponSanitizeElement: [],
+ uponSanitizeShadowNode: []
+ };
+};
+function createDOMPurify() {
+ let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
+ const DOMPurify = root => createDOMPurify(root);
+ DOMPurify.version = '3.2.4';
+ DOMPurify.removed = [];
+ if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
+ // Not running in a browser, provide a factory function
+ // so that you can pass your own Window
+ DOMPurify.isSupported = false;
+ return DOMPurify;
+ }
+ let {
+ document
+ } = window;
+ const originalDocument = document;
+ const currentScript = originalDocument.currentScript;
+ const {
+ DocumentFragment,
+ HTMLTemplateElement,
+ Node,
+ Element,
+ NodeFilter,
+ NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap,
+ HTMLFormElement,
+ DOMParser,
+ trustedTypes
+ } = window;
+ const ElementPrototype = Element.prototype;
+ const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');
+ const remove = lookupGetter(ElementPrototype, 'remove');
+ const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');
+ const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');
+ const getParentNode = lookupGetter(ElementPrototype, 'parentNode');
+ // As per issue #47, the web-components registry is inherited by a
+ // new document created via createHTMLDocument. As per the spec
+ // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
+ // a new empty registry is used when creating a template contents owner
+ // document, so we use that as our parent document to ensure nothing
+ // is inherited.
+ if (typeof HTMLTemplateElement === 'function') {
+ const template = document.createElement('template');
+ if (template.content && template.content.ownerDocument) {
+ document = template.content.ownerDocument;
+ }
+ }
+ let trustedTypesPolicy;
+ let emptyHTML = '';
+ const {
+ implementation,
+ createNodeIterator,
+ createDocumentFragment,
+ getElementsByTagName
+ } = document;
+ const {
+ importNode
+ } = originalDocument;
+ let hooks = _createHooksMap();
+ /**
+ * Expose whether this browser supports running the full DOMPurify.
+ */
+ DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined;
+ const {
+ MUSTACHE_EXPR,
+ ERB_EXPR,
+ TMPLIT_EXPR,
+ DATA_ATTR,
+ ARIA_ATTR,
+ IS_SCRIPT_OR_DATA,
+ ATTR_WHITESPACE,
+ CUSTOM_ELEMENT
+ } = EXPRESSIONS;
+ let {
+ IS_ALLOWED_URI: IS_ALLOWED_URI$1
+ } = EXPRESSIONS;
+ /**
+ * We consider the elements and attributes below to be safe. Ideally
+ * don't add any new ones but feel free to remove unwanted ones.
+ */
+ /* allowed element names */
+ let ALLOWED_TAGS = null;
+ const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]);
+ /* Allowed attribute names */
+ let ALLOWED_ATTR = null;
+ const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]);
+ /*
+ * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements.
+ * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements)
+ * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list)
+ * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`.
+ */
+ let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, {
+ tagNameCheck: {
+ writable: true,
+ configurable: false,
+ enumerable: true,
+ value: null
+ },
+ attributeNameCheck: {
+ writable: true,
+ configurable: false,
+ enumerable: true,
+ value: null
+ },
+ allowCustomizedBuiltInElements: {
+ writable: true,
+ configurable: false,
+ enumerable: true,
+ value: false
+ }
+ }));
+ /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */
+ let FORBID_TAGS = null;
+ /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */
+ let FORBID_ATTR = null;
+ /* Decide if ARIA attributes are okay */
+ let ALLOW_ARIA_ATTR = true;
+ /* Decide if custom data attributes are okay */
+ let ALLOW_DATA_ATTR = true;
+ /* Decide if unknown protocols are okay */
+ let ALLOW_UNKNOWN_PROTOCOLS = false;
+ /* Decide if self-closing tags in attributes are allowed.
+ * Usually removed due to a mXSS issue in jQuery 3.0 */
+ let ALLOW_SELF_CLOSE_IN_ATTR = true;
+ /* Output should be safe for common template engines.
+ * This means, DOMPurify removes data attributes, mustaches and ERB
+ */
+ let SAFE_FOR_TEMPLATES = false;
+ /* Output should be safe even for XML used within HTML and alike.
+ * This means, DOMPurify removes comments when containing risky content.
+ */
+ let SAFE_FOR_XML = true;
+ /* Decide if document with ... should be returned */
+ let WHOLE_DOCUMENT = false;
+ /* Track whether config is already set on this instance of DOMPurify. */
+ let SET_CONFIG = false;
+ /* Decide if all elements (e.g. style, script) must be children of
+ * document.body. By default, browsers might move them to document.head */
+ let FORCE_BODY = false;
+ /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html
+ * string (or a TrustedHTML object if Trusted Types are supported).
+ * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead
+ */
+ let RETURN_DOM = false;
+ /* Decide if a DOM `DocumentFragment` should be returned, instead of a html
+ * string (or a TrustedHTML object if Trusted Types are supported) */
+ let RETURN_DOM_FRAGMENT = false;
+ /* Try to return a Trusted Type object instead of a string, return a string in
+ * case Trusted Types are not supported */
+ let RETURN_TRUSTED_TYPE = false;
+ /* Output should be free from DOM clobbering attacks?
+ * This sanitizes markups named with colliding, clobberable built-in DOM APIs.
+ */
+ let SANITIZE_DOM = true;
+ /* Achieve full DOM Clobbering protection by isolating the namespace of named
+ * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules.
+ *
+ * HTML/DOM spec rules that enable DOM Clobbering:
+ * - Named Access on Window (§7.3.3)
+ * - DOM Tree Accessors (§3.1.5)
+ * - Form Element Parent-Child Relations (§4.10.3)
+ * - Iframe srcdoc / Nested WindowProxies (§4.8.5)
+ * - HTMLCollection (§4.2.10.2)
+ *
+ * Namespace isolation is implemented by prefixing `id` and `name` attributes
+ * with a constant string, i.e., `user-content-`
+ */
+ let SANITIZE_NAMED_PROPS = false;
+ const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-';
+ /* Keep element content when removing element? */
+ let KEEP_CONTENT = true;
+ /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead
+ * of importing it into a new Document and returning a sanitized copy */
+ let IN_PLACE = false;
+ /* Allow usage of profiles like html, svg and mathMl */
+ let USE_PROFILES = {};
+ /* Tags to ignore content of when KEEP_CONTENT is true */
+ let FORBID_CONTENTS = null;
+ const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);
+ /* Tags that are safe for data: URIs */
+ let DATA_URI_TAGS = null;
+ const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']);
+ /* Attributes safe for values like "javascript:" */
+ let URI_SAFE_ATTRIBUTES = null;
+ const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']);
+ const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
+ /* Document namespace */
+ let NAMESPACE = HTML_NAMESPACE;
+ let IS_EMPTY_INPUT = false;
+ /* Allowed XHTML+XML namespaces */
+ let ALLOWED_NAMESPACES = null;
+ const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString);
+ let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']);
+ let HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']);
+ // Certain elements are allowed in both SVG and HTML
+ // namespace. We need to specify them explicitly
+ // so that they don't get erroneously deleted from
+ // HTML namespace.
+ const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']);
+ /* Parsing of strict XHTML documents */
+ let PARSER_MEDIA_TYPE = null;
+ const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html'];
+ const DEFAULT_PARSER_MEDIA_TYPE = 'text/html';
+ let transformCaseFunc = null;
+ /* Keep a reference to config to pass to hooks */
+ let CONFIG = null;
+ /* Ideally, do not touch anything below this line */
+ /* ______________________________________________ */
+ const formElement = document.createElement('form');
+ const isRegexOrFunction = function isRegexOrFunction(testValue) {
+ return testValue instanceof RegExp || testValue instanceof Function;
+ };
+ /**
+ * _parseConfig
+ *
+ * @param cfg optional config literal
+ */
+ // eslint-disable-next-line complexity
+ const _parseConfig = function _parseConfig() {
+ let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
+ if (CONFIG && CONFIG === cfg) {
+ return;
+ }
+ /* Shield configuration object from tampering */
+ if (!cfg || typeof cfg !== 'object') {
+ cfg = {};
+ }
+ /* Shield configuration object from prototype pollution */
+ cfg = clone(cfg);
+ PARSER_MEDIA_TYPE =
+ // eslint-disable-next-line unicorn/prefer-includes
+ SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE;
+ // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is.
+ transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase;
+ /* Set configuration parameters */
+ ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS;
+ ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR;
+ ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES;
+ URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR, transformCaseFunc) : DEFAULT_URI_SAFE_ATTRIBUTES;
+ DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS, transformCaseFunc) : DEFAULT_DATA_URI_TAGS;
+ FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS;
+ FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {};
+ FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {};
+ USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false;
+ ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true
+ ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true
+ ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false
+ ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true
+ SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false
+ SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; // Default true
+ WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false
+ RETURN_DOM = cfg.RETURN_DOM || false; // Default false
+ RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false
+ RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false
+ FORCE_BODY = cfg.FORCE_BODY || false; // Default false
+ SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true
+ SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false
+ KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true
+ IN_PLACE = cfg.IN_PLACE || false; // Default false
+ IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI;
+ NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE;
+ MATHML_TEXT_INTEGRATION_POINTS = cfg.MATHML_TEXT_INTEGRATION_POINTS || MATHML_TEXT_INTEGRATION_POINTS;
+ HTML_INTEGRATION_POINTS = cfg.HTML_INTEGRATION_POINTS || HTML_INTEGRATION_POINTS;
+ CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {};
+ if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) {
+ CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck;
+ }
+ if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) {
+ CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck;
+ }
+ if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') {
+ CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements;
+ }
+ if (SAFE_FOR_TEMPLATES) {
+ ALLOW_DATA_ATTR = false;
+ }
+ if (RETURN_DOM_FRAGMENT) {
+ RETURN_DOM = true;
+ }
+ /* Parse profile info */
+ if (USE_PROFILES) {
+ ALLOWED_TAGS = addToSet({}, text);
+ ALLOWED_ATTR = [];
+ if (USE_PROFILES.html === true) {
+ addToSet(ALLOWED_TAGS, html$1);
+ addToSet(ALLOWED_ATTR, html);
+ }
+ if (USE_PROFILES.svg === true) {
+ addToSet(ALLOWED_TAGS, svg$1);
+ addToSet(ALLOWED_ATTR, svg);
+ addToSet(ALLOWED_ATTR, xml);
+ }
+ if (USE_PROFILES.svgFilters === true) {
+ addToSet(ALLOWED_TAGS, svgFilters);
+ addToSet(ALLOWED_ATTR, svg);
+ addToSet(ALLOWED_ATTR, xml);
+ }
+ if (USE_PROFILES.mathMl === true) {
+ addToSet(ALLOWED_TAGS, mathMl$1);
+ addToSet(ALLOWED_ATTR, mathMl);
+ addToSet(ALLOWED_ATTR, xml);
+ }
+ }
+ /* Merge configuration parameters */
+ if (cfg.ADD_TAGS) {
+ if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {
+ ALLOWED_TAGS = clone(ALLOWED_TAGS);
+ }
+ addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc);
+ }
+ if (cfg.ADD_ATTR) {
+ if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) {
+ ALLOWED_ATTR = clone(ALLOWED_ATTR);
+ }
+ addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc);
+ }
+ if (cfg.ADD_URI_SAFE_ATTR) {
+ addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
+ }
+ if (cfg.FORBID_CONTENTS) {
+ if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
+ FORBID_CONTENTS = clone(FORBID_CONTENTS);
+ }
+ addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
+ }
+ /* Add #text in case KEEP_CONTENT is set to true */
+ if (KEEP_CONTENT) {
+ ALLOWED_TAGS['#text'] = true;
+ }
+ /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */
+ if (WHOLE_DOCUMENT) {
+ addToSet(ALLOWED_TAGS, ['html', 'head', 'body']);
+ }
+ /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */
+ if (ALLOWED_TAGS.table) {
+ addToSet(ALLOWED_TAGS, ['tbody']);
+ delete FORBID_TAGS.tbody;
+ }
+ if (cfg.TRUSTED_TYPES_POLICY) {
+ if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {
+ throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');
+ }
+ if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') {
+ throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');
+ }
+ // Overwrite existing TrustedTypes policy.
+ trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;
+ // Sign local variables required by `sanitize`.
+ emptyHTML = trustedTypesPolicy.createHTML('');
+ } else {
+ // Uninitialized policy, attempt to initialize the internal dompurify policy.
+ if (trustedTypesPolicy === undefined) {
+ trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
+ }
+ // If creating the internal policy succeeded sign internal variables.
+ if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') {
+ emptyHTML = trustedTypesPolicy.createHTML('');
+ }
+ }
+ // Prevent further manipulation of configuration.
+ // Not available in IE8, Safari 5, etc.
+ if (freeze) {
+ freeze(cfg);
+ }
+ CONFIG = cfg;
+ };
+ /* Keep track of all possible SVG and MathML tags
+ * so that we can perform the namespace checks
+ * correctly. */
+ const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]);
+ const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]);
+ /**
+ * @param element a DOM element whose namespace is being checked
+ * @returns Return false if the element has a
+ * namespace that a spec-compliant parser would never
+ * return. Return true otherwise.
+ */
+ const _checkValidNamespace = function _checkValidNamespace(element) {
+ let parent = getParentNode(element);
+ // In JSDOM, if we're inside shadow DOM, then parentNode
+ // can be null. We just simulate parent in this case.
+ if (!parent || !parent.tagName) {
+ parent = {
+ namespaceURI: NAMESPACE,
+ tagName: 'template'
+ };
+ }
+ const tagName = stringToLowerCase(element.tagName);
+ const parentTagName = stringToLowerCase(parent.tagName);
+ if (!ALLOWED_NAMESPACES[element.namespaceURI]) {
+ return false;
+ }
+ if (element.namespaceURI === SVG_NAMESPACE) {
+ // The only way to switch from HTML namespace to SVG
+ // is via