From f175ae07160203973e37cb900bdfd49320491474 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 10:43:54 +0000 Subject: [PATCH 01/13] Building a near-empty project template --- .../Microsoft.Extensions.AI.Templates.csproj | 32 +++++++++++++++ .../Microsoft.Extensions.AI.Templates.json | 0 .../README.md | 3 ++ .../src/MyApp1/.template.config/icon.png | Bin 0 -> 5548 bytes .../src/MyApp1/.template.config/ide.host.json | 7 ++++ .../src/MyApp1/.template.config/template.json | 37 ++++++++++++++++++ .../src/MyApp1/MyApp1.csproj | 10 +++++ .../src/MyApp1/Program.cs | 1 + 8 files changed, 90 insertions(+) create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/README.md create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/icon.png create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/ide.host.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/template.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/MyApp1.csproj create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/Program.cs 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..7f425df55a2 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -0,0 +1,32 @@ + + + + Template + $(NetCoreTargetFrameworks);netstandard2.0 + Project templates for Microsoft.Extensions.AI. + dotnet-new;templates;ai + + normal + AI + n/a + n/a + + true + false + true + false + false + content + false + true + true + + $(NoWarn);SA1633 + + + + + + + + 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/MyApp1/.template.config/icon.png b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..800c44ca2dfb31024ef3eb78081767c102e9e002 GIT binary patch literal 5548 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 + + + net9.0 + enable + enable + d5681fae-b21b-4114-b781-48180f08c0c4 + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/Program.cs new file mode 100644 index 00000000000..bca346b852d --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/Program.cs @@ -0,0 +1 @@ +Console.WriteLine("Hello, world!"); From d2671ebbc9edfb4e2883ef494f7229b031ca71fd Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 10:48:12 +0000 Subject: [PATCH 02/13] Declare "preview" stage --- .../Microsoft.Extensions.AI.Templates.csproj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 7f425df55a2..4bc742252e0 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -6,10 +6,11 @@ Project templates for Microsoft.Extensions.AI. dotnet-new;templates;ai - normal + preview + true AI - n/a - n/a + 0 + 0 true false From 9ef7c1b2afd3990f02a5b2edec3dc6eb2f9bb624 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 10:55:40 +0000 Subject: [PATCH 03/13] Avoid build error --- src/ProjectTemplates/Directory.Build.props | 7 +++++++ .../Microsoft.Extensions.AI.Templates.csproj | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/ProjectTemplates/Directory.Build.props diff --git a/src/ProjectTemplates/Directory.Build.props b/src/ProjectTemplates/Directory.Build.props new file mode 100644 index 00000000000..44f996baede --- /dev/null +++ b/src/ProjectTemplates/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + $(NoWarn);SA1633 + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 4bc742252e0..5a669224b73 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -21,8 +21,6 @@ false true true - - $(NoWarn);SA1633 From 461f8531b98a3b5e1382f03249e6113d0ca777a8 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 14:41:07 +0000 Subject: [PATCH 04/13] Reorganize projects to enable multi-project output in future --- .../Microsoft.Extensions.AI.Templates.csproj | 2 +- .../.template.config/icon.png | Bin .../.template.config/ide.host.json | 0 .../.template.config/template.json | 17 ++++++++++++++++- .../ChatWithCustomData.Web.csproj} | 0 .../ChatWithCustomData.Web}/Program.cs | 2 ++ 6 files changed, 19 insertions(+), 2 deletions(-) rename src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/{MyApp1 => ChatWithCustomData}/.template.config/icon.png (100%) rename src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/{MyApp1 => ChatWithCustomData}/.template.config/ide.host.json (100%) rename src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/{MyApp1 => ChatWithCustomData}/.template.config/template.json (58%) rename src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/{MyApp1/MyApp1.csproj => ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj} (100%) rename src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/{MyApp1 => ChatWithCustomData/ChatWithCustomData.Web}/Program.cs (50%) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 5a669224b73..40250a8d7ab 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/icon.png b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/icon.png similarity index 100% rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/icon.png rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/icon.png diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json similarity index 100% rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/ide.host.json rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json similarity index 58% rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/template.json rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json index f4f0746592b..fd1a2014939 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -7,7 +7,7 @@ "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": "MyApp1", + "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" @@ -15,6 +15,21 @@ "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. + "condition": "true", + "rename": { + "ChatWithCustomData.Web/": "./" + } + } + ] + }], "symbols":{ "framework": { "type": "parameter", diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/MyApp1.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj similarity index 100% rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/MyApp1.csproj rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Program.cs similarity index 50% rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/Program.cs rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Program.cs index bca346b852d..a4421f99929 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/MyApp1/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Program.cs @@ -1 +1,3 @@ +namespace ChatWithCustomData.Web; + Console.WriteLine("Hello, world!"); From 91855fd85d42530506f7edeb0d2faebba266d8aa Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 14:52:03 +0000 Subject: [PATCH 05/13] Add content --- .../ChatWithCustomData.Web/.gitignore | 3 + .../ChatWithCustomData.Web.csproj | 42 +++- .../Components/App.razor | 24 +++ .../Components/Layout/LoadingSpinner.razor | 1 + .../Layout/LoadingSpinner.razor.css | 89 +++++++++ .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Components/Pages/Chat/Chat.razor | 110 +++++++++++ .../Components/Pages/Chat/ChatCitation.razor | 20 ++ .../Pages/Chat/ChatCitation.razor.css | 3 + .../Components/Pages/Chat/ChatHeader.razor | 18 ++ .../Components/Pages/Chat/ChatInput.razor | 58 ++++++ .../Components/Pages/Chat/ChatInput.razor.css | 41 ++++ .../Components/Pages/Chat/ChatInput.razor.js | 43 +++++ .../Pages/Chat/ChatMessageItem.razor | 94 +++++++++ .../Pages/Chat/ChatMessageItem.razor.css | 56 ++++++ .../Pages/Chat/ChatMessageList.razor | 42 ++++ .../Pages/Chat/ChatMessageList.razor.css | 11 ++ .../Pages/Chat/ChatMessageList.razor.js | 34 ++++ .../Pages/Chat/ChatSuggestions.razor | 78 ++++++++ .../Components/Pages/Error.razor | 36 ++++ .../Components/Routes.razor | 6 + .../Components/_Imports.razor | 13 ++ .../ChatWithCustomData.Web/Data/Example.pdf | Bin 0 -> 76671 bytes .../ChatWithCustomData.Web/Program.cs | 123 +++++++++++- .../Services/Ingestion/DataIngestor.cs | 62 ++++++ .../Services/Ingestion/IIngestionSource.cs | 14 ++ .../Ingestion/IngestionCacheDbContext.cs | 44 +++++ .../Services/Ingestion/PDFDirectorySource.cs | 80 ++++++++ .../Services/JsonVectorStore.cs | 179 ++++++++++++++++++ .../Services/SemanticSearch.cs | 31 +++ .../Services/SemanticSearchRecord.cs | 21 ++ .../ChatWithCustomData.Web/Tailwind.targets | 22 +++ .../appsettings.Development.json | 9 + .../ChatWithCustomData.Web/appsettings.json | 10 + .../ChatWithCustomData.Web/package.json | 13 ++ .../ChatWithCustomData.Web/rollup.config.js | 11 ++ .../ChatWithCustomData.Web/tailwind.config.js | 7 + .../ChatWithCustomData.Web/wwwroot/app.css | 70 +++++++ .../ChatWithCustomData.Web/wwwroot/lib.js | 14 ++ 40 files changed, 1558 insertions(+), 3 deletions(-) create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/.gitignore create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/App.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/LoadingSpinner.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/LoadingSpinner.razor.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/MainLayout.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Layout/MainLayout.razor.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/Chat.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatCitation.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatHeader.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatInput.razor.js create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Pages/Error.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/Routes.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Components/_Imports.razor create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Data/Example.pdf create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/DataIngestor.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/IIngestionSource.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/IngestionCacheDbContext.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/PDFDirectorySource.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/JsonVectorStore.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/SemanticSearch.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/SemanticSearchRecord.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Tailwind.targets create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/appsettings.Development.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/appsettings.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/package.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/rollup.config.js create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/tailwind.config.js create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/wwwroot/app.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/wwwroot/lib.js 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..bf6f72aad83 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/.gitignore @@ -0,0 +1,3 @@ +*.db +*.db-* +wwwroot/lib.out.js diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj index d5d19c528e9..d1d241159f2 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj @@ -1,4 +1,6 @@ - + + + net9.0 @@ -7,4 +9,42 @@ 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..e98f578b964 --- /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 0000000000000000000000000000000000000000..f5b90d0a876d2a6d631df0f18ec4d4d28d8f2fd4 GIT binary patch literal 76671 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 literal 0 HcmV?d00001 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 index a4421f99929..ea72207a156 100644 --- 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 @@ -1,3 +1,122 @@ -namespace ChatWithCustomData.Web; +#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 -Console.WriteLine("Hello, world!"); +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..97acd8e0449 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/Services/Ingestion/DataIngestor.cs @@ -0,0 +1,62 @@ +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); + if (modifiedDoc.Records.Count > 0) + { + await vectorCollection.DeleteBatchAsync(modifiedDoc.Records.Select(r => r.Id)); + + var newRecords = await source.CreateRecordsForDocumentAsync(embeddingGenerator, modifiedDoc.Id); + await foreach (var id in vectorCollection.UpsertBatchAsync(newRecords)) { } + + modifiedDoc.Records.Clear(); + modifiedDoc.Records.AddRange(newRecords.Select(r => new IngestedRecord { Id = r.Key, DocumentId = modifiedDoc.Id })); + + if (ingestionCacheDb.Entry(modifiedDoc).State == EntityState.Detached) + { + ingestionCacheDb.Documents.Add(modifiedDoc); + } + } + } + + await ingestionCacheDb.SaveChangesAsync(); + logger.LogInformation("Ingestion is up-to-date"); + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/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 }); + } + } +}); From 4bc962efc2f127e12b5e3b982dac1efeefd0873f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 14:54:12 +0000 Subject: [PATCH 06/13] Add symbols --- .../.template.config/ide.host.json | 12 ++ .../.template.config/template.json | 163 +++++++++++++++++- 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json index 5f7c03ba7fe..c799d67243a 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/ide.host.json @@ -3,5 +3,17 @@ "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 index fd1a2014939..bf8b58a6985 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -3,7 +3,7 @@ "author": "Microsoft", "classifications": [ "Common", "AI", "Web" ], "identity": "Microsoft.Extensions.AI.Templates.Chat.CSharp", - "name": "Chat with Custom Data", + "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", @@ -47,6 +47,167 @@ "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" } } } From 15d37647e70ad42d8c917a985fe4592930e25f05 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 15:12:02 +0000 Subject: [PATCH 07/13] Build fixes --- src/ProjectTemplates/.gitignore | 5 +++++ src/ProjectTemplates/Directory.Build.props | 4 +++- .../ChatWithCustomData.Web/.gitignore | 1 + .../ChatWithCustomData.Web.csproj | 18 +++++++++--------- .../Components/Pages/Chat/Chat.razor | 6 +++--- 5 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 src/ProjectTemplates/.gitignore diff --git a/src/ProjectTemplates/.gitignore b/src/ProjectTemplates/.gitignore new file mode 100644 index 00000000000..1321fe48636 --- /dev/null +++ b/src/ProjectTemplates/.gitignore @@ -0,0 +1,5 @@ +# 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 diff --git a/src/ProjectTemplates/Directory.Build.props b/src/ProjectTemplates/Directory.Build.props index 44f996baede..79a1a4c9714 100644 --- a/src/ProjectTemplates/Directory.Build.props +++ b/src/ProjectTemplates/Directory.Build.props @@ -2,6 +2,8 @@ - $(NoWarn);SA1633 + $(NoWarn);SA1633;CS1591 + true + false
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 index bf6f72aad83..ce34cc25bc9 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/.gitignore +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/.gitignore @@ -1,3 +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 b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj index d1d241159f2..905731856fe 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj @@ -10,31 +10,31 @@ - + - - +#elif (IsAzureOpenAi) + +#elif (IsGHModels) - +#elif (IsAzureAiFoundry) - +#else --> + - - + +#endif --> 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 index e98f578b964..f2e8c8f58b4 100644 --- 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 @@ -52,7 +52,7 @@ chatSuggestions?.Clear(); await chatInput!.FocusAsync(); -@*#if (IsOllama) +#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(); @@ -69,10 +69,10 @@ responseText.Text += chunk.Text; ChatMessageItem.NotifyChanged(currentResponseMessage); } -#endif*@ +#endif // Store the final response in the conversation, and begin getting suggestions - messages.Add(currentResponseMessage); + messages.Add(currentResponseMessage!); currentResponseMessage = null; chatSuggestions?.Update(messages); } From f7559ac324d25f37c1d6d24687701879f01f4ad9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 15:33:11 +0000 Subject: [PATCH 08/13] Fix content inclusion --- .../Microsoft.Extensions.AI.Templates.csproj | 2 +- .../src/ChatWithCustomData/.template.config/template.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 40250a8d7ab..71719206ec3 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json index bf8b58a6985..f2d494f5f64 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -23,7 +23,6 @@ // For now, we only produce single-project output. // Later when we support multi-project output with Qdrant on Docker, we'll also emit // a second project ChatWithCustomData.AppHost and hence will suppress this renaming. - "condition": "true", "rename": { "ChatWithCustomData.Web/": "./" } From 2c3882b94cfe595dbe6c57f8b9f2dbf447191a29 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 15:36:47 +0000 Subject: [PATCH 09/13] Fix reference --- .../ChatWithCustomData.Web/ChatWithCustomData.Web.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj index 905731856fe..6e2a1e0194b 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj @@ -12,8 +12,6 @@ <_ProjectsToBuild Include="$(MSBuildThisFileDirectory)..\src\**\*.csproj" Exclude="@(_ProjectsToExclude)" /> From d6b93a0d0ec7cc6877cd1fc8a3d264abed2cc065 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 18:44:20 +0000 Subject: [PATCH 12/13] Revert "Don't build the templated project itself in CI" This reverts commit 5f6b171870d5652d31c5c5cf8d1c372342dce9ba. --- eng/build.proj | 1 - 1 file changed, 1 deletion(-) diff --git a/eng/build.proj b/eng/build.proj index 905a4a10aa3..9c3a38d8ef0 100644 --- a/eng/build.proj +++ b/eng/build.proj @@ -1,6 +1,5 @@ - <_ProjectsToExclude Include="$(MSBuildThisFileDirectory)..\src\ProjectTemplates\Microsoft.Extensions.AI.Templates\src\**\*.csproj" /> <_ProjectsToBuild Include="$(MSBuildThisFileDirectory)..\src\**\*.csproj" Exclude="@(_ProjectsToExclude)" /> From eda3e57c5f13573d9611ed4ea0506ac284e29e88 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 3 Feb 2025 18:55:59 +0000 Subject: [PATCH 13/13] To avoid build issues, have the template project csproj not be a csproj in source control --- src/ProjectTemplates/.gitignore | 2 ++ .../Microsoft.Extensions.AI.Templates.csproj | 11 ++++++++++- ...ta.Web.csproj => ChatWithCustomData.Web.csproj.in} | 0 3 files changed, 12 insertions(+), 1 deletion(-) rename src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/{ChatWithCustomData.Web.csproj => ChatWithCustomData.Web.csproj.in} (100%) diff --git a/src/ProjectTemplates/.gitignore b/src/ProjectTemplates/.gitignore index 1321fe48636..74832506557 100644 --- a/src/ProjectTemplates/.gitignore +++ b/src/ProjectTemplates/.gitignore @@ -3,3 +3,5 @@ # 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/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 71719206ec3..c62b03d7fe1 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -24,8 +24,17 @@ - + + + + + + <_CsprojFiles Include="src\**\*.csproj.in" /> + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj.in similarity index 100% rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web/ChatWithCustomData.Web.csproj.in