+
+@code {
+ private readonly 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 format, always formatted as XML:
+ verbatim quote here.
+ The quote must be max 5 words, taken directly from search result text, 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();
+ ChatCompletion response = await ChatClient.CompleteAsync(messages, chatOptions, currentResponseCancellation.Token);
+ currentResponseMessage = response.Message;
+ ChatMessageItem.NotifyChanged(currentResponseMessage);
+#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 chunk in ChatClient.CompleteStreamingAsync(messages, chatOptions, currentResponseCancellation.Token))
+ {
+ responseText.Text += chunk.Text;
+ ChatMessageItem.NotifyChanged(currentResponseMessage);
+ }
+#endif
+
+ // Store the final response in the conversation, and begin getting suggestions
+ messages.Add(currentResponseMessage!);
+ currentResponseMessage = null;
+ chatSuggestions?.Update(messages);
+ }
+
+ private void CancelAnyCurrentResponse()
+ {
+ // If a response was cancelled while streaming, include it in the conversation so it's not lost
+ if (currentResponseMessage is not null)
+ {
+ messages.Add(currentResponseMessage);
+ }
+
+ currentResponseCancellation?.Cancel();
+ currentResponseMessage = null;
+ }
+
+ private async Task ResetConversationAsync()
+ {
+ CancelAnyCurrentResponse();
+ messages.Clear();
+ messages.Add(new(ChatRole.System, systemPrompt));
+ chatSuggestions?.Clear();
+ await chatInput!.FocusAsync();
+ }
+
+ private async Task> SearchAsync(
+ [Description("The phrase to search for.")] string searchPhrase,
+ [Description("Whenever possible, specify the filename to search that file only. If you leave this blank, we will search all files.")] string? filenameFilter = null)
+ {
+ await InvokeAsync(StateHasChanged);
+ var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5);
+ return results.Select(result =>
+ $"{result.Text}");
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatCitation.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatCitation.razor
new file mode 100644
index 00000000000..daed418fd74
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatCitation.razor
@@ -0,0 +1,20 @@
+
+ }
+ 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.CompleteAsync(
+ [.. ReduceMessages(messages), new(ChatRole.User, Prompt)],
+ useNativeJsonSchema: true, 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/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Error.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Error.razor
new file mode 100644
index 00000000000..576cc2d2f4d
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.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/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Routes.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Routes.razor
new file mode 100644
index 00000000000..f756e19dfbc
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Routes.razor
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/_Imports.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/_Imports.razor
new file mode 100644
index 00000000000..fdb4ebe5b7c
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.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 ChatWithCustomData.Web
+@using ChatWithCustomData.Web.Components
+@using ChatWithCustomData.Web.Components.Layout
+@using ChatWithCustomData.Web.Services
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Data/Example.pdf b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Data/Example.pdf
new file mode 100644
index 00000000000..f5b90d0a876
Binary files /dev/null and b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Data/Example.pdf differ
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Program.cs
new file mode 100644
index 00000000000..ea72207a156
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Program.cs
@@ -0,0 +1,122 @@
+#if (IsOllama)
+using OllamaSharp;
+#elif (IsOpenAi || IsGHModels)
+using OpenAI;
+#else
+using Azure.AI.OpenAI;
+#if (UseManagedIdentity)
+using Azure;
+using Azure.Identity;
+#endif
+#endif
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+using ChatWithCustomData.Web.Components;
+using ChatWithCustomData.Web.Services;
+using ChatWithCustomData.Web.Services.Ingestion;
+using System.ClientModel;
+#if (UseAzureAISearch)
+using Azure.Search.Documents.Indexes;
+using Microsoft.SemanticKernel.Connectors.AzureAISearch;
+#endif
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddRazorComponents().AddInteractiveServerComponents();
+#if (IsGHModels)
+// You will need to set the endpoint and key to your own values
+// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
+// cd this-project-directory
+// dotnet user-secrets set GitHubModels:Token YOUR-GITHUB-TOKEN
+var ghToken = builder.Configuration["GitHubModels:Token"];
+
+var credential = new ApiKeyCredential(ghToken);
+var openAIOptions = new OpenAIClientOptions()
+{
+ Endpoint = new Uri("https://models.inference.ai.azure.com")
+};
+
+var ghModelsClient = new OpenAIClient(credential, openAIOptions);
+var chatClient = ghModelsClient.AsChatClient("gpt-4o-mini");
+var embeddingGenerator = ghModelsClient.AsEmbeddingGenerator("text-embedding-3-small");
+#elif (IsOllama)
+IChatClient chatClient = new OllamaApiClient(new Uri("http://localhost:11434"),
+ "llama3.2");
+IEmbeddingGenerator> embeddingGenerator = new OllamaApiClient(new Uri("http://localhost:11434"),
+ "all-minilm");
+#elif (IsOpenAi)
+// You will need to set the endpoint and key to your own values
+// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
+// cd this-project-directory
+// dotnet user-secrets set OpenAI:Key YOUR-API-KEY
+var openAIClient = new OpenAIClient(
+ new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key")));
+var chatClient = openAIClient.AsChatClient("gpt-4o-mini");
+var embeddingGenerator = openAIClient.AsEmbeddingGenerator("text-embedding-3-small");
+#elif (IsAzureAiFoundry)
+
+#else
+// You will need to set the endpoint and key to your own values
+// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
+// cd this-project-directory
+// dotnet user-secrets set AzureOpenAi:Endpoint https://YOUR-DEPLOYMENT-NAME.openai.azure.com
+#if (!UseManagedIdentity)
+// dotnet user-secrets set AzureOpenAi:Key YOUR-API-KEY
+#endif
+var azureOpenAi = new AzureOpenAIClient(
+ new Uri(builder.Configuration["AzureOpenAi:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Endpoint")),
+#if (UseManagedIdentity)
+ new DefaultAzureCredential());
+#else
+ new ApiKeyCredential(builder.Configuration["AzureOpenAi:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Key")));
+#endif
+var chatClient = azureOpenAi.AsChatClient("gpt-4o-mini");
+var embeddingGenerator = azureOpenAi.AsEmbeddingGenerator("text-embedding-3-small");
+#endif
+
+#if (UseAzureAISearch)
+// You will need to set the endpoint and key to your own values
+// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
+// cd this-project-directory
+// dotnet user-secrets set AzureAISearch:Endpoint https://YOUR-DEPLOYMENT-NAME.search.windows.net
+// dotnet user-secrets set AzureAISearch:Key YOUR-API-KEY
+var vectorStore = new AzureAISearchVectorStore(
+ new SearchIndexClient(
+ new Uri(builder.Configuration["AzureAISearch:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Endpoint")),
+ new AzureKeyCredential(builder.Configuration["AzureAISearch:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Key"))));
+#else
+var vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store"));
+#endif
+
+builder.Services.AddSingleton(vectorStore);
+builder.Services.AddScoped();
+builder.Services.AddSingleton();
+builder.Services.AddChatClient(chatClient).UseFunctionInvocation();
+builder.Services.AddEmbeddingGenerator(embeddingGenerator);
+
+builder.Services.AddDbContext(options =>
+ options.UseSqlite("Data Source=ingestioncache.db"));
+
+var app = builder.Build();
+IngestionCacheDbContext.Initialize(app.Services);
+
+// 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.MapStaticAssets();
+app.MapRazorComponents()
+ .AddInteractiveServerRenderMode();
+
+await DataIngestor.IngestDataAsync(
+ app.Services,
+ new PDFDirectorySource(Path.Combine(builder.Environment.ContentRootPath, "Data")));
+
+app.Run();
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/DataIngestor.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/DataIngestor.cs
new file mode 100644
index 00000000000..8dc133ac95c
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/DataIngestor.cs
@@ -0,0 +1,60 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+
+namespace ChatWithCustomData.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)
+ {
+ var vectorCollection = vectorStore.GetCollection("data");
+ 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);
+
+ 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/Services/Ingestion/IIngestionSource.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/IIngestionSource.cs
new file mode 100644
index 00000000000..0e8bca6ecb0
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/IIngestionSource.cs
@@ -0,0 +1,14 @@
+using Microsoft.Extensions.AI;
+
+namespace ChatWithCustomData.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/Services/Ingestion/IngestionCacheDbContext.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/IngestionCacheDbContext.cs
new file mode 100644
index 00000000000..59218e2a3cd
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/IngestionCacheDbContext.cs
@@ -0,0 +1,44 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace ChatWithCustomData.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; } = new();
+}
+
+public class IngestedRecord
+{
+ public required string Id { get; set; }
+ public required string DocumentId { get; set; }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/PDFDirectorySource.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/PDFDirectorySource.cs
new file mode 100644
index 00000000000..cdede52e47a
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/PDFDirectorySource.cs
@@ -0,0 +1,80 @@
+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.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
+ {
+ Key = $"{Path.GetFileNameWithoutExtension(documentId)}_{pair.First.PageNumber}_{pair.First.IndexOnPage}",
+ 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/Services/JsonVectorStore.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/JsonVectorStore.cs
new file mode 100644
index 00000000000..63b47a2dcd4
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/JsonVectorStore.cs
@@ -0,0 +1,179 @@
+using Microsoft.Extensions.VectorData;
+using System.Numerics.Tensors;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+
+namespace ChatWithCustomData.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 = new();
+ await WriteToDiskAsync(cancellationToken);
+ }
+
+ public async Task CreateCollectionIfNotExistsAsync(CancellationToken cancellationToken = default)
+ {
+ if (_records is null)
+ {
+ await CreateCollectionAsync(cancellationToken);
+ }
+ }
+
+ public Task DeleteAsync(TKey key, DeleteRecordOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ _records!.Remove(key);
+ return WriteToDiskAsync(cancellationToken);
+ }
+
+ public Task DeleteBatchAsync(IEnumerable keys, DeleteRecordOptions? options = null, 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, UpsertRecordOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ var key = _getKey(record);
+ _records![key] = record;
+ await WriteToDiskAsync(cancellationToken);
+ return key;
+ }
+
+ public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, UpsertRecordOptions? options = null, [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;
+
+ foreach (var clause in options?.Filter?.FilterClauses ?? [])
+ {
+ 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.");
+ }
+ }
+
+ 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/Services/SemanticSearch.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/SemanticSearch.cs
new file mode 100644
index 00000000000..584be3d5573
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/SemanticSearch.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+
+namespace ChatWithCustomData.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);
+ var vectorCollection = vectorStore.GetCollection("data");
+ var filter = filenameFilter is { Length: > 0 }
+ ? new VectorSearchFilter().EqualTo(nameof(SemanticSearchRecord.FileName), filenameFilter)
+ : null;
+
+ var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions
+ {
+ Top = maxResults,
+ Filter = filter,
+ });
+ 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/Services/SemanticSearchRecord.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/SemanticSearchRecord.cs
new file mode 100644
index 00000000000..f07fa6625ea
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/SemanticSearchRecord.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.VectorData;
+
+namespace ChatWithCustomData.Web.Services;
+
+public class SemanticSearchRecord
+{
+ [VectorStoreRecordKey]
+ public required string Key { get; set; }
+
+ [VectorStoreRecordData]
+ 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/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Tailwind.targets b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Tailwind.targets
new file mode 100644
index 00000000000..3312cef35d0
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Tailwind.targets
@@ -0,0 +1,22 @@
+
+
+ wwwroot\app.css
+ wwwroot\app.generated.css
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/appsettings.Development.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/appsettings.Development.json
new file mode 100644
index 00000000000..d7b2fc5dca0
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/appsettings.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/appsettings.json
new file mode 100644
index 00000000000..46bdb452246
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/package.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/package.json
new file mode 100644
index 00000000000..1e0f1dc1989
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/package.json
@@ -0,0 +1,13 @@
+{
+ "type": "module",
+ "scripts": {
+ "build": "rollup -c"
+ },
+ "devDependencies": {
+ "dompurify": "^3.2.3",
+ "marked": "^15.0.6",
+ "rollup": "^4.30.1",
+ "rollup-plugin-node-resolve": "^5.2.0",
+ "tailwindcss": "^3.4.17"
+ }
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/rollup.config.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/rollup.config.js
new file mode 100644
index 00000000000..e6499497c46
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/rollup.config.js
@@ -0,0 +1,11 @@
+import resolve from 'rollup-plugin-node-resolve';
+
+export default {
+ input: 'wwwroot/lib.js',
+ output: {
+ file: 'wwwroot/lib.out.js',
+ format: 'iife',
+ name: 'lib'
+ },
+ plugins: [resolve()]
+};
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/tailwind.config.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/tailwind.config.js
new file mode 100644
index 00000000000..d954681ff1d
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/tailwind.config.js
@@ -0,0 +1,7 @@
+module.exports = {
+ content: ["./**/*.{razor,html,cshtml}"],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/wwwroot/app.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/wwwroot/app.css
new file mode 100644
index 00000000000..fc68381171b
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/wwwroot/app.css
@@ -0,0 +1,70 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html {
+ @apply main-background-gradient;
+ min-height: 100vh;
+}
+
+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 {
+ @apply text-4xl font-semibold;
+}
+
+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."
+ }
+
+.main-background-gradient {
+ background: linear-gradient(to bottom, rgb(225 227 233), #f4f4f4 25rem);
+}
+
+.btn-default {
+ @apply border bg-gray-300 border-gray-400 hover:bg-gray-200 active:bg-gray-300 px-3 py-1 rounded text-sm font-semibold flex items-center gap-1;
+}
+
+.btn-subtle {
+ @apply border border-gray-300 hover:border-blue-300 hover:bg-blue-100 active:border-gray-300 px-3 py-1 rounded text-sm flex items-center gap-1;
+}
+
+ .page-width {
+ max-width: 1024px;
+ @apply mx-auto;
+ }
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/wwwroot/lib.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/wwwroot/lib.js
new file mode 100644
index 00000000000..0201afbab1b
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/wwwroot/lib.js
@@ -0,0 +1,14 @@
+import DOMPurify from 'dompurify';
+import * as marked from 'marked';
+const purify = DOMPurify(window);
+
+customElements.define('assistant-message', class extends HTMLElement {
+ static observedAttributes = ['markdown'];
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (name === 'markdown') {
+ const elements = marked.parse(newValue);
+ this.innerHTML = purify.sanitize(elements, { KEEP_CONTENT: false });
+ }
+ }
+});