diff --git a/src/ProjectTemplates/.gitignore b/src/ProjectTemplates/.gitignore new file mode 100644 index 00000000000..74832506557 --- /dev/null +++ b/src/ProjectTemplates/.gitignore @@ -0,0 +1,7 @@ +# We're not tracking any package-lock.json files in source control here because +# we don't ship pre-generated NPM lockfiles in the templates. But we don't put +# them in the project template's .gitignore file because they should be tracked +# in source control when people actually create projects from the templates. +package-lock.json + +*/src/**/*.csproj diff --git a/src/ProjectTemplates/Directory.Build.props b/src/ProjectTemplates/Directory.Build.props new file mode 100644 index 00000000000..79a1a4c9714 --- /dev/null +++ b/src/ProjectTemplates/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + $(NoWarn);SA1633;CS1591 + true + false + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj new file mode 100644 index 00000000000..c62b03d7fe1 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -0,0 +1,40 @@ + + + + Template + $(NetCoreTargetFrameworks);netstandard2.0 + Project templates for Microsoft.Extensions.AI. + dotnet-new;templates;ai + + preview + true + AI + 0 + 0 + + true + false + true + false + false + content + false + true + true + + + + + + + + + + + + <_CsprojFiles Include="src\**\*.csproj.in" /> + + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/README.md new file mode 100644 index 00000000000..dcfac54fecc --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/README.md @@ -0,0 +1,3 @@ +# Microsoft.Extensions.AI.Templates + +Provides project templates for Microsoft.Extensions.AI. diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/icon.png b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/icon.png new file mode 100644 index 00000000000..800c44ca2df Binary files /dev/null and b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/icon.png differ 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 new file mode 100644 index 00000000000..c799d67243a --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/ide.host", + "order": 0, + "icon": "icon.png", + "symbolInfo": [ + { + "id": "AiServiceProvider", + "isVisible": true + }, + { + "id": "UseManagedIdentity", + "isVisible": true + }, + { + "id": "VectorStore", + "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 new file mode 100644 index 00000000000..f2d494f5f64 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -0,0 +1,212 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Microsoft", + "classifications": [ "Common", "AI", "Web" ], + "identity": "Microsoft.Extensions.AI.Templates.Chat.CSharp", + "name": "AI Chat with Custom Data", + "description": "A project template for creating an AI chat application. It can perform retrieval-augmented generation (RAG) using your own data.", + "shortName": "chat", + "defaultName": "ChatApp", + "sourceName": "ChatWithCustomData.Web", // 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 + "tags": { + "language": "C#", + "type": "project" + }, + "guids": [ + "d5681fae-b21b-4114-b781-48180f08c0c4" + ], + "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. + "rename": { + "ChatWithCustomData.Web/": "./" + } + } + ] + }], + "symbols":{ + "framework": { + "type": "parameter", + "description": "The target framework for the project.", + "datatype": "choice", + "choices": [ + { + "choice": "net9.0", + "description": "Target net9.0" + } + ], + "replaces": "net9.0", + "defaultValue": "net9.0" + }, + "hostIdentifier": { + "type": "bind", + "binding": "HostIdentifier" + }, + "AiServiceProvider": { + "type": "parameter", + "displayName": "_AI service provider", + "datatype": "choice", + "defaultValue": "azureopenai", + "choices": [ + // { + // "choice": "githubmodels", + // "displayName": "GitHub Models", + // "description": "Uses GitHub Models" + // }, + { + "choice": "azureopenai", + "displayName": "Azure OpenAI", + "description": "Uses Azure OpenAI service" + }, + { + "choice": "openai", + "displayName": "OpenAI Platform", + "description": "Uses the OpenAI Platform" + }, + { + "choice": "ollama", + "displayName": "Ollama (for local development)", + "description": "Uses Ollama with the llama3.2 model for local development" + } + // { + // "choice": "azureaifoundry", + // "displayName": "Azure AI Foundry (Preview)", + // "description": "Uses Azure AI Foundry (Preview)" + // } + ] + }, + "UseManagedIdentity": { + "type": "parameter", + "displayName": "Use managed identity", + "datatype": "bool", + "defaultValue": "true", + "isEnabled": "(AiServiceProvider == \"azureopenai\")", + "description": "Use managed identity to access Azure services" + }, + "VectorStore": { + "type": "parameter", + "displayName": "_Vector store", + "datatype": "choice", + "defaultValue": "local", + "choices": [ + { + "choice": "local", + "displayName": "Local on-disk (for prototyping)", + "description": "Uses a JSON file on disk. You can change the implementation to a real vector database before publishing." + }, + { + "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." + } + ] + }, + "IsAzureOpenAi": { + "type": "computed", + "value": "(AiServiceProvider == \"azureopenai\")" + }, + "IsOpenAi": { + "type": "computed", + "value": "(AiServiceProvider == \"openai\")" + }, + "IsGHModels": { + "type": "computed", + "value": "(AiServiceProvider == \"githubmodels\")" + }, + "IsOllama": { + "type": "computed", + "value": "(AiServiceProvider == \"ollama\")" + }, + "IsAzureAiFoundry": { + "type": "computed", + "value": "(AiServiceProvider == \"azureaifoundry\")" + }, + "UseAzureAISearch": { + "type": "computed", + "value": "(VectorStore == \"azureaisearch\")" + }, + "UseLocalVectorStore": { + "type": "computed", + "value": "(VectorStore == \"local\")" + }, + "ChatModel": { + "type": "parameter", + "displayName": "Model/deployment for chat completions. Example: gpt-4o-mini", + "description": "Model/deployment for chat completions. Example: gpt-4o-mini" + }, + "EmbeddingModel": { + "type": "parameter", + "displayName": "Model/deployment for embeddings. Example: text-embedding-3-small", + "description": "Model/deployment for embeddings. Example: text-embedding-3-small" + }, + "OpenAiChatModelDefault": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "gpt-4o-mini" + } + }, + "OpenAiEmbeddingModelDefault": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "text-embedding-3-small" + } + }, + "OpenAiChatModel": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "ChatModel", + "fallbackVariableName": "OpenAiChatModelDefault" + }, + "replaces": "gpt-4o-mini" + }, + "OpenAiEmbeddingModel": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "EmbeddingModel", + "fallbackVariableName": "OpenAiEmbeddingModelDefault" + }, + "replaces": "text-embedding-3-small" + }, + "OllamaChatModelDefault": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "llama3.1" + } + }, + "OllamaEmbeddingModelDefault": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "all-minilm" + } + }, + "OllamaChatModel": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "ChatModel", + "fallbackVariableName": "OllamaChatModelDefault" + }, + "replaces": "llama3.2" + }, + "OllamaEmbeddingModel": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "EmbeddingModel", + "fallbackVariableName": "OllamaEmbeddingModelDefault" + }, + "replaces": "all-minilm" + } + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/.gitignore b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/.gitignore new file mode 100644 index 00000000000..ce34cc25bc9 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/.gitignore @@ -0,0 +1,4 @@ +*.db +*.db-* +wwwroot/lib.out.js +wwwroot/*.generated.css diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj.in new file mode 100644 index 00000000000..6e2a1e0194b --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj.in @@ -0,0 +1,48 @@ + + + + + + net9.0 + enable + enable + d5681fae-b21b-4114-b781-48180f08c0c4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/App.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/App.razor new file mode 100644 index 00000000000..85cb8297f28 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/LoadingSpinner.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/LoadingSpinner.razor new file mode 100644 index 00000000000..116455ce45b --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/LoadingSpinner.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/MainLayout.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..96fbbe6cc42 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/MainLayout.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..60cec92d5e5 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/Chat.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/Chat.razor new file mode 100644 index 00000000000..f2e8c8f58b4 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/Chat.razor @@ -0,0 +1,110 @@ +@page "/" +@using System.ComponentModel +@inject IChatClient ChatClient +@inject NavigationManager Nav +@inject SemanticSearch Search + +Chat + + + + + Ask me anything about ChatWithCustomData.Web. + + +
+ + +
+ +@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 @@ +
+ + + +
+
@File
+
@Quote
+
+
+ +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public int? PageNumber { get; set; } + + [Parameter] + public required string Quote { get; set; } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatCitation.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatCitation.razor.css new file mode 100644 index 00000000000..29b3b5cf6c0 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatCitation.razor.css @@ -0,0 +1,3 @@ +.citation { + border-bottom: 2px solid #a770de; +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatHeader.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatHeader.razor new file mode 100644 index 00000000000..a2edf2f87bf --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,18 @@ +
+
+ +
+ +

ChatWithCustomData.Web

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor new file mode 100644 index 00000000000..7b2b1040669 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor @@ -0,0 +1,58 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor.css new file mode 100644 index 00000000000..39ba6959c6f --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor.css @@ -0,0 +1,41 @@ +.input-box { + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.send-button { + color: var(--send-button-color); +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor.js new file mode 100644 index 00000000000..96ee2ac87c4 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageItem.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 00000000000..911def6d99a --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,94 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Linq + +@if (Message.Role == ChatRole.User) +{ +
+ @Message.Text +
+} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
+
+
+ + + +
+
+
Assistant
+
+ + + @foreach (var citation in citations ?? []) + { + + } +
+
+ } + else if (content is FunctionCallContent { Name: "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 readonly ConditionalWeakTable SubscribersLookup = new(); + private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.Compiled); + + private List<(string File, int? Page, string Quote)>? citations; + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + + if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) + { + ParseCitations(text); + } + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } + + private void ParseCitations(string text) + { + var matches = CitationRegex.Matches(text); + citations = matches.Any() + ? matches.Select(m => (m.Groups["file"].Value, int.TryParse(m.Groups["page"].Value, out var page) ? page : (int?)null, m.Groups["quote"].Value)).ToList() + : null; + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageItem.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageItem.razor.css new file mode 100644 index 00000000000..214e10ce2db --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageItem.razor.css @@ -0,0 +1,56 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); +} + +.assistant-message { + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); +} + +/* Default styling for markdown-formatted assistant messages */ +::deep ul { + list-style-type: disc; + margin-left: 1.5rem; +} + +::deep ol { + list-style-type: decimal; + margin-left: 1.5rem; +} + +::deep li { + margin: 0.5rem 0; +} + +::deep strong { + font-weight: 600; +} + +::deep h3 { + margin: 1rem 0; + font-weight: 600; +} + +::deep p + p { + margin-top: 1rem; +} + +::deep table { + margin: 1rem 0; +} + +::deep th { + text-align: left; + border-bottom: 1px solid silver; +} + +::deep th, ::deep td { + padding: 0.1rem 0.5rem; +} + +::deep th, ::deep tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor new file mode 100644 index 00000000000..eacbd752d49 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS + +
+ + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
@NoMessagesContent
+ } +
+
+ +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Activates the auto-scrolling behavior + await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor.css new file mode 100644 index 00000000000..ac764cd0209 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor.css @@ -0,0 +1,11 @@ +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: 10vh; +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor.js b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor.js new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatSuggestions.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatSuggestions.razor new file mode 100644 index 00000000000..b7190095a50 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
+ @foreach (var suggestion in suggestions) + { + + } +
+} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.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 }); + } + } +});