diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 43065aa..49b7ffb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,13 +21,9 @@ "ms-azuretools.vscode-bicep", "ms-azuretools.vscode-docker", "ms-vscode.js-debug" ] - }, - "settings": { - "remote.autoForwardPorts": true, - "dotnet.defaultSolution": "cosmos-copilot.sln" - } + } }, - "postStartCommand": "dotnet dev-certs https --trust" + // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [5000, 5001], // "portsAttributes": { @@ -39,6 +35,9 @@ // Use 'postCreateCommand' to run commands after the container is created. // "postCreateCommand": "dotnet restore", + // Configure tool-specific properties. + // "customizations": {}, + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" } diff --git a/.gitignore b/.gitignore index 8ab1ae6..35ff676 100644 --- a/.gitignore +++ b/.gitignore @@ -399,10 +399,3 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -appsettings.Development.json -src/cosmos-copilot.WebApp/Properties/ServiceDependencies/local/* -serviceDependencies.local.json - -# Bicep compiled files -infra/**/*.json -!infra/**/main.parameters.json diff --git a/README.md b/README.md index 1ade2b2..5ee501f 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,28 @@ --- page_type: sample languages: - - azdeveloper - - bicep - - aspx-csharp - - csharp - - dockerfile - - nosql +- azdeveloper +- bicep +- aspx-csharp +- csharp +- dockerfile +- nosql products: - - azure - - azure-cosmos-db - - azure-app-service - - azure-openai +- azure +- azure-cosmos-db +- azure-app-service +- azure-openai urlFragment: ai-samples name: Build Copilot app using Azure Cosmos DB for NoSQL -description: Build a Copilot app using Azure Cosmos DB for NoSQL, Azure OpenAI Service, Semantic Kernel, and .NET Aspire +description: Build a Copilot app using Azure Cosmos DB for NoSQL, Azure OpenAI Service, Azure App Service and Semantic Kernel --- -# Build a Copilot app using Azure Cosmos DB for NoSQL, Azure OpenAI Service, with Semantic Kernel +# Build a Copilot app using Azure Cosmos DB for NoSQL, Azure OpenAI Service, Azure App Service and Semantic Kernel -[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/AzureCosmosDB/cosmosdb-nosql-copilot) +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)]([placeholder](https://codespaces.new/AzureCosmosDB/cosmosdb-nosql-copilot)) [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/AzureCosmosDB/cosmosdb-nosql-copilot) -[![Unit Tests](https://github.com/AzureCosmosDB/cosmosdb-nosql-copilot/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/AzureCosmosDB/cosmosdb-nosql-copilot/actions/workflows/unit-tests.yml) -This sample application shows how to build a multi-tenant, multi-user, Generative-AI RAG Pattern application using Azure Cosmos DB for NoSQL with its new vector database capabilities, including full-text and hybrid search with Azure OpenAI Service using Semantic Kernel. The sample provides practical guidance on many concepts you will need to design and build these types of applications. Note that some features are implemented using Cosmos DB native SDK until Semantic Kernel can support them. +This sample application shows how to build a multi-tenant, multi-user, Generative-AI RAG Pattern application using Azure Cosmos DB for NoSQL with its new vector database capabilities with Azure OpenAI Service on Azure App Service. This sample shows both using Native SDKs as well as Semantic Kernel integration. The sample provides practical guidance on many concepts you will need to design and build these types of applications. ## Important Security Notice @@ -33,63 +32,53 @@ This template, the application code and configuration it contains, has been buil This application demonstrates the following concepts and how to implement them: -- How to build a highly scalable, multi-tenant & user, RAG Pattern application. -- Generating embeddings and completions. +- How to build a highly scalable, multi-tenant & user, Generative-AI chat application using Azure Cosmos DB for NoSQL. +- Generating completions and embeddings using Azure OpenAI Service. - Managing a context window (chat history) for natural conversational interactions with an LLM. -- Manage per-request token consumption for Azure OpenAI Service requests. -- Building a semantic cache for improved performance and cost. -- Implementing full-text and hybrid search using Azure Cosmos DB for NoSQL. +- Manage per-request token consumption and payload sizes for Azure OpenAI Service requests. +- Building a semantic cache using Azure Cosmos DB for NoSQL vector search for improved performance and cost. +- Using the Semantic Kernel SDK for completion and embeddings generation. +- Implementing RAG Pattern using vector search in Azure Cosmos DB for NoSQL on custom data to augment generated responses from an LLM. ### Architecture Diagram ![Architecture Diagram](./media/cosmos-nosql-copilot-diagram.png) ### User Experience - ![Cosmos Copilot app user interface](./media/screenshot.png) + ## Getting Started ### Prerequisites - Azure subscription. - Subscription access to Azure OpenAI service. Start here to [Request Access to Azure OpenAI Service](https://aka.ms/oaiapply). If you have access, see below for ensuring enough quota to deploy. +- Enroll in the [Azure Cosmos DB for NoSQL Vector Search Preview](https://learn.microsoft.com/azure/cosmos-db/nosql/vector-search#enroll-in-the-vector-search-preview-feature) (See below for more details) - .NET 8 or above. [Download](https://dotnet.microsoft.com/download/dotnet/8.0) - [Azure Developer CLI](https://aka.ms/azd-install) - Visual Studio, VS Code, GitHub Codespaces or another editor to edit or view the source for this sample. -#### Deploying Azure OpenAI supported regions - -The models used for this sample are **gpt-4o** and **text-3-large**. These models are not deployed in all regions and are not always present in the same region. The regions shown in the main.bicep are the known regions both models are supported in at the time this readme was last updated. To check if these models are available in additional regions, see [Azure OpenAI Service Models](https://learn.microsoft.com/azure/ai-services/openai/concepts/models) - -#### Checking Azure OpenAI quota limits - -For this sample to deploy successfully, there needs to be enough Azure OpenAI quota for the models used by this sample within your subscription. This sample deploys a new Azure OpenAI account with two models, **gpt-4o with 10K tokens** per minute and **text-3-large with 5k tokens** per minute. For more information on how to check your model quota and change it, see [Manage Azure OpenAI Service Quota](https://learn.microsoft.com/azure/ai-services/openai/how-to/quota) -#### Azure Subscription Permission Requirements + #### Vector search Preview details + This lab utilizes a preview feature, **Vector search for Azure Cosmos DB for NoSQL** which requires preview feature registration. Follow the below steps to register. You must be enrolled before you can deploy this solution: + + 1. Navigate to your Azure Cosmos DB for NoSQL resource page. + 1. Select the "Features" pane under the "Settings" menu item. + 1. Select for “Vector Search in Azure Cosmos DB for NoSQL”. + 1. Read the description to confirm you want to enroll in the preview. + 1. Select "Enable" to enroll in the Vector Search preview. -This solution deploys [user-assigned managed identities](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview) and defines then applies Azure Cosmos DB RBAC permissions to this identity. At a minimum you will need the following Azure RBAC roles assigned to your identity in your Azure subscription or [Subscription Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/privileged#owner) access which will give you both of the following. + #### Checking Azure OpenAI quota limits -- [Manged Identity Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/identity#managed-identity-contributor) -- [DocumentDB Account Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/databases#documentdb-account-contributor) + For this sample to deploy successfully, there needs to be enough Azure OpenAI quota for the models used by this sample within your subscription. This sample deploys a new Azure OpenAI account with two models, **gpt-4o with 10K tokens** per minute and **text-3-large with 5k tokens** per minute. For more information on how to check your model quota and change it, see [Manage Azure OpenAI Service Quota](https://learn.microsoft.com/azure/ai-services/openai/how-to/quota) -#### Full-Text & Hyrbrid Search Feature + #### Azure Subscription Permission Requirements -Full-text and hybrid search in Azure Cosmos DB is in Preview and only available to a subset of regions at this time. This feature is commented out in the GetChatCompletionAsync() function in the ChatService. To use this feature you must deploy this sample in either `northcentralus` or `uksouth`. + This solution deploys [user-assigned managed identities](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview) and defines then applies Azure Cosmos DB RBAC permissions to this identity. At a minimum you will need the following Azure RBAC roles assigned to your identity in your Azure subscription or [Subscription Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/privileged#owner) access which will give you both of the following. -To utilize this feature during preview, update **main.bicep** in the section below and enter either of the two regions listed above as the value for `location` - -```bicep -module database 'app/database.bicep' = { - name: 'database' - scope: resourceGroup - params: { - accountName: !empty(cosmosDbAccountName) ? cosmosDbAccountName : '${abbreviations.cosmosDbAccount}-${resourceToken}' - location: 'northcentralus' - tags: tags - } -} -``` + - [Manged Identity Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/identity#managed-identity-contributor) + - [DocumentDB Account Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/databases#documentdb-account-contributor) ### GitHub Codespaces @@ -143,25 +132,23 @@ A related option is VS Code Dev Containers, which will open the project in your 1. Run the following command to download the solution locally to your machine: - ```bash - azd init -t AzureCosmosDB/cosmosdb-nosql-copilot - ``` + ```bash + azd init -t AzureCosmosDB/cosmosdb-nosql-copilot + ``` 1. From the terminal, navigate to the /infra directory in this solution. 1. Log in to AZD. - - ```bash - azd auth login - ``` + + ```bash + azd auth login + ``` 1. Provision the Azure services, build your local solution container, and deploy the application. - - ```bash - azd up - ``` - -1. Follow the prompts for the subscription and select a region to deploy. **NOTE:** If intending to use the Full-Text or Hybrid search feature please see [Full-Text & Hyrbrid Search Feature](#full-text--hyrbrid-search-feature) + + ```bash + azd up + ``` ### Setting up local debugging @@ -174,35 +161,35 @@ To modify values for the Quickstarts, locate the value of `UserSecretsId` in the your-guid-here ``` - Locate the secrets.json file and open with a text editor. - Windows: `C:\Users\\AppData\Roaming\Microsoft\UserSecrets\\secrets.json` - macOS/Linux: `~/.microsoft/usersecrets//secrets.json` + ### Quickstart Follow the Quickstarts in this solution to go through the concepts for building RAG Pattern apps and the features in this sample and how to implement them yourself. Please see [Quickstarts](quickstart.md) + ## Clean up 1. Open a terminal and navigate to the /infra directory in this solution. -1. Type azd down (--force and --purge ensure the Azure OpenAI models are deleted) - - ```bash - azd down --force --purge - ``` +1. Type azd down + + ```bash + azd down + ``` ## Guidance ### Region Availability -This template uses gpt-4o and text-embedding-3-large models which may not be available in all Azure regions. Check for [up-to-date region availability](https://learn.microsoft.com/azure/ai-services/openai/concepts/models) and select a region during deployment accordingly. - * We recommend using 'canadaeast', 'eastus', 'eastus2', 'francecentral', 'japaneast', 'norwayeast', 'polandcentral', 'southindia' - 'swedencentral', 'switzerlandnorth', or 'westus3' +This template uses gpt-4o and text-embedding-3-large models which may not be available in all Azure regions. Check for [up-to-date region availability](https://learn.microsoft.com/azure/ai-services/openai/concepts/models#standard-deployment-model-availability) and select a region during deployment accordingly + * We recommend using `eastus2', 'eastus', 'japaneast', 'uksouth', 'northeurope', or 'westus3' ### Costs @@ -220,7 +207,7 @@ Average Monthly Cost: To learn more about the services and features demonstrated in this sample, see the following: -- [Azure Cosmos DB for NoSQL Vector Search announcement](https://aka.ms/VectorSearchGaFtsPreview) +- [Azure Cosmos DB for NoSQL Vector Search announcement](https://aka.ms/CosmosDBDiskANNBlog/) - [Azure OpenAI Service documentation](https://learn.microsoft.com/azure/cognitive-services/openai/) - [Semantic Kernel](https://learn.microsoft.com/semantic-kernel/overview) - [Azure App Service documentation](https://learn.microsoft.com/azure/app-service/) diff --git a/azure.yaml b/azure.yaml index e675001..18004ee 100644 --- a/azure.yaml +++ b/azure.yaml @@ -5,7 +5,7 @@ metadata: template: cosmos-copilot services: web: - project: ./src/cosmos-copilot.WebApp/cosmos-copilot.WebApp.csproj + project: ./src language: csharp host: appservice hooks: @@ -27,19 +27,17 @@ hooks: 'OpenAi:Endpoint' = $env:AZURE_OPENAI_ACCOUNT_ENDPOINT 'OpenAi:CompletionDeploymentName' = $env:AZURE_OPENAI_COMPLETION_DEPLOYMENT_NAME 'OpenAi:EmbeddingDeploymentName' = $env:AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME - 'OpenAi:MaxRagTokens' = $env:AZURE_OPENAI_MAX_RAG_TOKENS - 'OpenAi:MaxContextTokens' = $env:AZURE_OPENAI_MAX_CONTEXT_TOKENS 'CosmosDb:Endpoint' = $env:AZURE_COSMOS_DB_ENDPOINT 'CosmosDb:Database' = $env:AZURE_COSMOS_DB_DATABASE_NAME 'CosmosDb:ChatContainer' = $env:AZURE_COSMOS_DB_CHAT_CONTAINER_NAME 'CosmosDb:CacheContainer' = $env:AZURE_COSMOS_DB_CACHE_CONTAINER_NAME 'CosmosDb:ProductContainer' = $env:AZURE_COSMOS_DB_PRODUCT_CONTAINER_NAME - 'CosmosDb:ProductDataSourceURI' = $env:AZURE_COSMOS_DB_PRODUCT_DATA_SOURCE_URI - 'Chat:MaxContextWindow' = $env:AZURE_CHAT_MAX_CONTEXT_WINDOW + 'CosmosDb:ProductDataSourceURI' = $env:AZURE_COSMOS_DB_PRODUCT_DATA_SOURCE + 'Chat:MaxConversationTokens' = $env:AZURE_CHAT_MAX_CONVERSATION_TOKENS 'Chat:CacheSimilarityScore' = $env:AZURE_CHAT_CACHE_SIMILARITY_SCORE 'Chat:ProductMaxResults' = $env:AZURE_CHAT_PRODUCT_MAX_RESULTS } - $userSecrets | ConvertTo-Json | dotnet user-secrets set --project ./src/cosmos-copilot.WebApp/cosmos-copilot.WebApp.csproj + $userSecrets | ConvertTo-Json | dotnet user-secrets set --project ./src/cosmos-copilot.csproj shell: pwsh continueOnError: false interactive: true @@ -49,19 +47,17 @@ hooks: --arg openAiEndpoint $AZURE_OPENAI_ACCOUNT_ENDPOINT \ --arg openAiCompletionDeploymentName $AZURE_OPENAI_COMPLETION_DEPLOYMENT_NAME \ --arg openAiEmbeddingDeploymentName $AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME \ - --arg openAiMaxRagTokens $AZURE_OPENAI_MAX_RAG_TOKENS \ - --arg openAiMaxContextTokens $AZURE_OPENAI_MAX_CONTEXT_TOKENS \ --arg cosmosDbEndpoint $AZURE_COSMOS_DB_ENDPOINT \ --arg cosmosDbDatabase $AZURE_COSMOS_DB_DATABASE_NAME \ --arg cosmosDbChatContainer $AZURE_COSMOS_DB_CHAT_CONTAINER_NAME \ --arg cosmosDbCacheContainer $AZURE_COSMOS_DB_CACHE_CONTAINER_NAME \ --arg cosmosDbProductContainer $AZURE_COSMOS_DB_PRODUCT_CONTAINER_NAME \ - --arg cosmosDbProductDataSourceURI $AZURE_COSMOS_DB_PRODUCT_DATA_SOURCE_URI \ - --arg chatMaxContextWindow $AZURE_CHAT_MAX_CONTEXT_WINDOW \ - --arg chatCacheSimilarityScore $AZURE_CHAT_CACHE_SIMILARITY_SCORE \ - --arg chatProductMaxResults $AZURE_CHAT_PRODUCT_MAX_RESULTS \ - '{"OpenAi:Endpoint":$openAiEndpoint,"OpenAi:CompletionDeploymentName":$openAiCompletionDeploymentName,"OpenAi:EmbeddingDeploymentName":$openAiEmbeddingDeploymentName,"OpenAi:MaxRagTokens":$openAiMaxRagTokens,"OpenAi:MaxContextTokens":$openAiMaxContextTokens,"CosmosDb:Endpoint":$cosmosDbEndpoint,"CosmosDb:Database":$cosmosDbDatabase,"CosmosDb:ChatContainer":$cosmosDbChatContainer,"CosmosDb:CacheContainer":$cosmosDbCacheContainer,"CosmosDb:ProductContainer":$cosmosDbProductContainer,"CosmosDb:ProductDataSourceURI":$cosmosDbProductDataSourceURI,"Chat:MaxContextWindow":$chatMaxContextWindow,"Chat:CacheSimilarityScore":$chatCacheSimilarityScore,"Chat:ProductMaxResults":$chatProductMaxResults}') - echo $userSecrets | dotnet user-secrets set --project ./src/cosmos-copilot.WebApp/cosmos-copilot.WebApp.csproj + --arg cosmosDbProductDataSourceURI $AZURE_COSMOS_DB_PRODUCT_DATA_SOURCE \ + --arg maxConversationTokens $AZURE_CHAT_MAX_CONVERSATION_TOKENS \ + --arg cacheSimilarityScore $AZURE_CHAT_CACHE_SIMILARITY_SCORE \ + --arg productMaxResults $AZURE_CHAT_PRODUCT_MAX_RESULTS \ + '{"OpenAi:Endpoint":$openAiEndpoint,"OpenAi:CompletionDeploymentName":$openAiCompletionDeploymentName,"OpenAi:EmbeddingDeploymentName":$openAiEmbeddingDeploymentName,"CosmosDb:Endpoint":$cosmosDbEndpoint,"CosmosDb:Database":$cosmosDbDatabase,"CosmosDb:ChatContainer":$cosmosDbChatContainer,"CosmosDb:CacheContainer":$cosmosDbCacheContainer,"CosmosDb:ProductContainer":$cosmosDbProductContainer,"CosmosDb:ProductDataSource":$cosmosDbProductDataSourceURI,"Chat:MaxConversationTokens":$maxConversationTokens,"Chat:CacheSimilarityScore":$cacheSimilarityScore,"Chat:ProductMaxResults":$productMaxResults}') + echo $userSecrets | dotnet user-secrets set --project ./src/cosmos-copilot.csproj shell: sh continueOnError: false interactive: true diff --git a/infra/app/database.bicep b/infra/app/database.bicep index 5687146..047401b 100644 --- a/infra/app/database.bicep +++ b/infra/app/database.bicep @@ -29,12 +29,6 @@ var containers = [ { path: '/sessionId/?' } - { - path: '/type/?' - } - { - path: '/timeStamp/?' - } ] excludedPaths: [ { @@ -45,9 +39,6 @@ var containers = [ vectorEmbeddingPolicy: { vectorEmbeddings: [] } - fullTextPolicy: { - fullTextPaths: [] - } } { name: 'cache' // Container for cached messages @@ -62,13 +53,11 @@ var containers = [ path: '/*' } ] - excludedPaths: [ - - ] + //excludedPaths: [{}] vectorIndexes: [ { path: '/vectors' - type: 'diskANN' + type: 'quantizedFlat' } ] } @@ -82,9 +71,6 @@ var containers = [ } ] } - fullTextPolicy: { - fullTextPaths: [] - } } { name: 'products' // Container for products @@ -103,15 +89,7 @@ var containers = [ vectorIndexes: [ { path: '/vectors' - type: 'diskANN' - } - ] - fullTextIndexes: [ - { - path: '/tags' - } - { - path: '/description' + type: 'quantizedFlat' } ] } @@ -125,19 +103,6 @@ var containers = [ } ] } - fullTextPolicy: { - defaultLanguage: 'en-US' - fullTextPaths: [ - { - path: '/tags' - language: 'en-US' - } - { - path: '/description' - language: 'en-US' - } - ] - } } ] @@ -149,7 +114,6 @@ module cosmosDbAccount '../core/database/cosmos-db/nosql/account.bicep' = { tags: tags enableServerless: true enableVectorSearch: true - enableNoSQLFullTextSearch: true disableKeyBasedAuth: true } } @@ -176,7 +140,6 @@ module cosmosDbContainers '../core/database/cosmos-db/nosql/container.bicep' = [ partitionKeyPaths: container.partitionKeyPaths indexingPolicy: container.indexingPolicy vectorEmbeddingPolicy: container.vectorEmbeddingPolicy - fullTextPolicy: container.fullTextPolicy } } ] diff --git a/infra/app/security.bicep b/infra/app/security.bicep index 5f6c971..d3467ba 100644 --- a/infra/app/security.bicep +++ b/infra/app/security.bicep @@ -8,9 +8,6 @@ param appPrincipalId string = '' @description('Id of the user principals to assign database and application roles.') param userPrincipalId string = '' -@description('Principal type used for the role assignment.') -param principalType string = '' - resource database 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' existing = { name: databaseAccountName } @@ -34,7 +31,6 @@ module nosqlAppAssignment '../core/database/cosmos-db/nosql/role/assignment.bice targetAccountName: database.name // Existing account roleDefinitionId: nosqlDefinition.outputs.id // New role definition principalId: appPrincipalId // Principal to assign role - principalType: principalType // Principal type for assigning role } } @@ -44,7 +40,6 @@ module nosqlUserAssignment '../core/database/cosmos-db/nosql/role/assignment.bic targetAccountName: database.name // Existing account roleDefinitionId: nosqlDefinition.outputs.id // New role definition principalId: userPrincipalId ?? '' // Principal to assign role - principalType: principalType // Principal type for assigning role } } @@ -56,7 +51,7 @@ module openaiAppAssignment '../core/security/role/assignment.bicep' = if (!empty '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' ) // Cognitive Services OpenAI User built-in role principalId: appPrincipalId // Principal to assign role - principalType: 'ServicePrincipal' // Specify the principal type // was 'None' but this appears to cause issues + principalType: 'None' // Don't specify the principal type } } @@ -68,7 +63,7 @@ module openaiUserAssignment '../core/security/role/assignment.bicep' = if (!empt '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' ) // Cognitive Services OpenAI User built-in role principalId: userPrincipalId // Principal to assign role - principalType: !empty(principalType) ? principalType : 'User' // Principal type or current deployment user + principalType: 'User' // Current deployment user } } diff --git a/infra/app/web.bicep b/infra/app/web.bicep index 78569d0..cd37e9a 100644 --- a/infra/app/web.bicep +++ b/infra/app/web.bicep @@ -18,8 +18,6 @@ param openAiAccountEndpoint string type openAiOptions = { completionDeploymentName: string embeddingDeploymentName: string - maxRagTokens: string - maxContextTokens: string } @description('Application configuration settings for OpenAI.') @@ -30,13 +28,13 @@ type cosmosDbOptions = { chatContainer: string cacheContainer: string productContainer: string - productDataSourceUri: string + productDataSource: string } @description('Application configuration settings for Azure Cosmos DB.') param cosmosDbSettings cosmosDbOptions type chatOptions = { - maxContextWindow: string + maxConversationTokens: string cacheSimilarityScore: string productMaxResults: string } @@ -90,17 +88,15 @@ module appServiceWebAppConfig '../core/host/app-service/config.bicep' = { OPENAI__ENDPOINT: openAiAccountEndpoint OPENAI__COMPLETIONDEPLOYMENTNAME: openAiSettings.completionDeploymentName OPENAI__EMBEDDINGDEPLOYMENTNAME: openAiSettings.embeddingDeploymentName - OPENAI__MAXRAGTOKENS: openAiSettings.maxRagTokens - OPENAI__MAXCONTEXTTOKENS: openAiSettings.maxContextTokens COSMOSDB__ENDPOINT: databaseAccountEndpoint COSMOSDB__DATABASE: cosmosDbSettings.database COSMOSDB__CHATCONTAINER: cosmosDbSettings.chatContainer COSMOSDB__CACHECONTAINER: cosmosDbSettings.cacheContainer COSMOSDB__PRODUCTCONTAINER: cosmosDbSettings.productContainer - COSMOSDB__PRODUCTDATASOURCEURI: cosmosDbSettings.productDataSourceUri - CHAT__MAXCONTEXTWINDOW: chatSettings.maxContextWindow - CHAT__CACHESIMILARITYSCORE: chatSettings.cacheSimilarityScore - CHAT__PRODUCTMAXRESULTS: chatSettings.productMaxResults + COSMOSDB__PRODUCTDATASOURCE: cosmosDbSettings.productDataSource + CHAT_MAXCONVERSATIONTOKENS: chatSettings.maxConversationTokens + CHAT_CACHESIMILARITYSCORE: chatSettings.cacheSimilarityScore + CHAT_PRODUCTMAXRESULTS: chatSettings.productMaxResults AZURE_CLIENT_ID: userAssignedManagedIdentity.clientId } } diff --git a/infra/core/database/cosmos-db/account.bicep b/infra/core/database/cosmos-db/account.bicep index 7c9d5d6..19590d6 100644 --- a/infra/core/database/cosmos-db/account.bicep +++ b/infra/core/database/cosmos-db/account.bicep @@ -4,7 +4,7 @@ param name string param location string = resourceGroup().location param tags object = {} -@allowed(['GlobalDocumentDB']) +@allowed(['GlobalDocumentDB', 'MongoDB', 'Parse']) @description('Sets the kind of account.') param kind string = 'GlobalDocumentDB' @@ -14,9 +14,6 @@ param enableServerless bool = true @description('Enables NoSQL vector search for this account. Defaults to false.') param enableNoSQLVectorSearch bool = false -@description('Enables NoSQL full text search for this account. Defaults to false.') -param enableNoSQLFullTextSearch bool = false - @description('Disables key-based authentication. Defaults to false.') param disableKeyBasedAuth bool = false @@ -39,6 +36,11 @@ resource account 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { ] enableAutomaticFailover: false enableMultipleWriteLocations: false + apiProperties: (kind == 'MongoDB') + ? { + serverVersion: '4.2' + } + : {} disableLocalAuth: disableKeyBasedAuth capabilities: union( (enableServerless) @@ -54,14 +56,7 @@ resource account 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { name: 'EnableNoSQLVectorSearch' } ] - : [], - (enableNoSQLFullTextSearch) - ? [ - { - name: 'EnableNoSQLFullTextSearch' - } - ] - : [] + : [] ) } } diff --git a/infra/core/database/cosmos-db/nosql/account.bicep b/infra/core/database/cosmos-db/nosql/account.bicep index 6023d27..31a029b 100644 --- a/infra/core/database/cosmos-db/nosql/account.bicep +++ b/infra/core/database/cosmos-db/nosql/account.bicep @@ -13,9 +13,6 @@ param disableKeyBasedAuth bool = false @description('Enables vector search for this account. Defaults to false.') param enableVectorSearch bool = false -@description('Enables NoSQL full text search for this account. Defaults to false.') -param enableNoSQLFullTextSearch bool = false - module account '../account.bicep' = { name: 'cosmos-db-nosql-account' params: { @@ -25,7 +22,6 @@ module account '../account.bicep' = { kind: 'GlobalDocumentDB' enableServerless: enableServerless enableNoSQLVectorSearch: enableVectorSearch - enableNoSQLFullTextSearch: enableNoSQLFullTextSearch disableKeyBasedAuth: disableKeyBasedAuth } } diff --git a/infra/core/database/cosmos-db/nosql/container.bicep b/infra/core/database/cosmos-db/nosql/container.bicep index e31a199..24c87ab 100644 --- a/infra/core/database/cosmos-db/nosql/container.bicep +++ b/infra/core/database/cosmos-db/nosql/container.bicep @@ -29,9 +29,6 @@ param indexingPolicy object = {} @description('Optional vector embedding policy for the container.') param vectorEmbeddingPolicy object = {} -@description('Optional full text policy for the container.') -param fullTextPolicy object = {} - var options = setThroughput ? autoscale ? { @@ -77,11 +74,6 @@ resource container 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/container ? { vectorEmbeddingPolicy: vectorEmbeddingPolicy } - : {}, - !empty(fullTextPolicy) - ? { - fullTextPolicy: fullTextPolicy - } : {} ) } diff --git a/infra/core/database/cosmos-db/nosql/role/assignment.bicep b/infra/core/database/cosmos-db/nosql/role/assignment.bicep index f59be03..9a9d981 100644 --- a/infra/core/database/cosmos-db/nosql/role/assignment.bicep +++ b/infra/core/database/cosmos-db/nosql/role/assignment.bicep @@ -9,9 +9,6 @@ param roleDefinitionId string @description('Id of the principal to assign the role definition for the account.') param principalId string -@description('Principal type used for the role assignment.') -param principalType string - resource account 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' existing = { name: targetAccountName } @@ -23,7 +20,6 @@ resource assignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@20 principalId: principalId roleDefinitionId: roleDefinitionId scope: account.id - principalType: principalType } } diff --git a/infra/core/security/role/assignment.bicep b/infra/core/security/role/assignment.bicep index e796ec7..2362f41 100644 --- a/infra/core/security/role/assignment.bicep +++ b/infra/core/security/role/assignment.bicep @@ -6,6 +6,14 @@ param roleDefinitionId string @description('Id of the principal to assign the role definition for the account.') param principalId string +@allowed([ + 'Device' + 'ForeignGroup' + 'Group' + 'ServicePrincipal' + 'User' + 'None' +]) @description('Type of principal associated with the principal Id.') param principalType string diff --git a/media/quickstart-conversational-context.png b/media/quickstart-conversational-context.png index 385140c..c714c90 100644 Binary files a/media/quickstart-conversational-context.png and b/media/quickstart-conversational-context.png differ diff --git a/media/quickstart-semantic-cache-miss.png b/media/quickstart-semantic-cache-miss.png index ef2935b..17bbeee 100644 Binary files a/media/quickstart-semantic-cache-miss.png and b/media/quickstart-semantic-cache-miss.png differ diff --git a/media/quickstart-semantic-cache.png b/media/quickstart-semantic-cache.png index 0390210..50771b1 100644 Binary files a/media/quickstart-semantic-cache.png and b/media/quickstart-semantic-cache.png differ diff --git a/media/screenshot.png b/media/screenshot.png index 40e10fb..37d0d70 100644 Binary files a/media/screenshot.png and b/media/screenshot.png differ diff --git a/quickstart.md b/quickstart.md index 8f50da2..e2195d1 100644 --- a/quickstart.md +++ b/quickstart.md @@ -12,10 +12,11 @@ Humans interact with each other through conversations that have some *context* o Let's observe this in action. Launch the application in a debug session (F5) from Visual Studio or VS Code, then follow these steps: 1. Start a new Chat Session by clicking, "Create New Chat" in the upper left part of the page. -1. Enter a question, `What bikes do you have`, the app will respond with a listing of bikes and information about each of them. -1. Enter a follow up without context, `Any in carbon fiber`, the app will respond with a list of bikes made from carbon fiber. +1. Enter a question, `What bikes do you have?`, the app will respond with a listing of bikes and information about each of them. +1. Enter a follow up without context, `Do you have any in red?`, the app will respond with a list of red bikes. +1. Enter a third follow up, `Any with a 48 inch frame?`, the app will respond with a list of bikes with 48" frames. -The LLM is able to keep context for the conversation and answer appropriately. Also note here the value of the tokens displayed for both the user and the assistant, as well as the time in milliseconds. These are important. We will explore why in this section below. +The LLM is able to keep context for the conversation and answer appropriately. Also note here the value of the tokens displayed for both the user prompt and the completion generated by the LLM. These are important. We will explore why in this section below. ![Cosmos Copilot app user conversation](./media/quickstart-conversational-context.png) @@ -23,7 +24,7 @@ The LLM is able to keep context for the conversation and answer appropriately. A Large language models require chat history to generate contextually relevant results. But there is a limit how much text you can send to an LLM. Large language models have limits on how much text they can process in a request and output in a response. These limits are measured in **tokens**. Tokens are the compute currency for large language models and represent words or part of a word. On average 4 characters is one token. Because of this limit on tokens, it is necessary to limit how many are consumed in a request and response. This can be a bit tricky. You need to simultaneously ensure enough context for the LLM to generate a correct response, while avoiding negative results of consuming too many tokens which can include incomplete results or unexpected behavior. -In this application we highlight a way to control for token usage by allowing you to configure how large the context window can be (length of chat history). This is done using the configuration value, **MaxConversationTokens** which is stored in the secrets.json for this solution. +In this application we highlight a way to control for token usage by allowing you to configure how large the context window can be (length of chat history). This is done using the configuration value, **MaxConversationTokens** which is stored in the secrets.json or appsettings.json file. Another thing that can impact token consumption is when external data is sent to an LLM to provide additional information. This is know as Retrieval Augmented Generation or *RAG Pattern*. In this pattern, data from a database (or any external source) is used to augment or *ground* the LLM by providing additional information for it to generate a response. The amount of data from these external sources can get rather large. It is possible to consume many thousands of tokens per request/response depending on how much data is sent. @@ -33,14 +34,7 @@ A second way to control token consumption is by limiting the amount of data retu A better method for managing token consumption is to take both the context window and the results from a database and pass it to a tokenizer such as [Tiktoken](https://github.com/openai/tiktoken) to calculate how many tokens the results will consume in a request to an LLM. This application uses the [Microsoft.ML.Tokenizers](https://github.com/microsoft/Tokenizer) (a .NET implementation of OpenAI's Tiktoken) to calculate tokens for the user prompt to store in the chat history. -In a production system, a more complete solution would be to leverage a tokenizer to independently measure chat history and RAG Pattern results, then determine where to reduce the size so that it fits within the maximum token limits for your application's requests to an LLM. These usually involve tokenizers to measure tokens before calling an LLM. - -This sample uses both approaches for managing token consumption by an LLM by limiting the context window and vector query results as well as using a tokenizer to ensure the request to the LLM will not go over the maximum amount of allowed tokens. It does this with the following configuration values: - -1. *maxContextWindow*: The context window for the chat history is limited to three prompts and completions by default. -1. *productMaxResults*: This limits the number of items returned in a vector query for products. -1. *maxContextTokens*: This limits the number of tokens for the context window sent in the request to the LLM. -1. *maxRagTokens*: This limits the number of tokens for the vector query results sent in the request to the LLM. +This sample uses overly simplistic approaches to highlight the impact chat history and RAG Pattern data have on token usage. In a production system, a more complete solution would be to leverage a tokenizer to independently measure chat history and RAG Pattern results, then determine where to reduce the size so that it fits within the maximum token limits for your application's requests to an LLM. These usually involve tokenizers to measure tokens before calling an LLM. # Semantic Cache @@ -69,18 +63,18 @@ The solution is to *cache the context window* for the conversation. In this way, In your application, start with a completely clean cache by clicking the *clear cache* at the top right of the page. 1. Start a new Chat Session by clicking, "Create New Chat" in the upper left part of the page. -1. Enter a question, `What bikes do you have`, the app will respond with a listing of bikes. -1. Enter a follow up, `Any in carbon fiber`, the app will respond with a list of bikes made with carbon fiber. -1. Enter a third follow up, `Any using Shimano gears`, the app will respond with a list of bikes with Shimano gears. +1. Enter a question, `What bikes do you have?`, the app will respond with a listing of bikes. +1. Enter a follow up, `Do you have any in red?`, the app will respond with a list of red bikes. +1. Enter a third follow up, `Any with a 48 inch frame?`, the app will respond with a list of bikes with 48" frames. -Notice how each of the completions generated by the LLM all have values for *Tokens:* and *Time:* and have a *Cache Hit: False*. +Notice how each of the completions generated by the LLM all have values for *Tokens:* and have a *Cache Hit: False*. Next, select a different user from the drop down at the top of the page. 1. Start a new Chat Session by clicking, "Create New Chat" in the upper left part of the page. -1. Enter a question, `What bikes do you have`, the app will respond with a listing of bikes. Notice the *Cache Hit: True* and *Tokens: 0* in the response. -1. Enter a follow up, `Any in carbon fiber`, the app will respond with a list of bikes made with carbon fiber. Also notice its a cache hit. -1. Enter a third follow up, `Any using Shimano gears`, the app will respond with a list of bikes with Shimano gears. And of course it hits the cache as well. +1. Enter a question, `What bikes do you have?`, the app will respond with a listing of bikes. Notice the *Cache Hit: True* and *Tokens: 0* in the response. +1. Enter a follow up, `Do you have any in red?`, the app will respond with a list of red bikes. Also notice its a cache hit. +1. Enter a third follow up, `Any with a 48 inch frame?`, the app will respond with a list of bikes with 48" frames. And of course it hits the cache as well. ![Cosmos Copilot app semantic cache](./media/quickstart-semantic-cache.png) @@ -96,59 +90,42 @@ In practice, setting the similarity score value for a cache can be tricky. To hi ## Quickstart: Semantic Cache & Similarity Score -Let's try one more series of prompts to test the similarity score with our cache. - -1. Select a new user from the drop-down at the top of the web app and create a new chat session. +Let's try one more prompt to test the similarity score with our cache. -1. Type this series of prompts: - 1. `What bikes do you have` - 1. `Any in carbon fiber` - 1. `Got any with Shimano components` - -1. Notice for the first two we got cache hits but for last one has *Cache Hit: False* and *Tokens:* is not zero. +1. In the same chat session, enter a variation of the last prompt from the previous Quickstart, `How about a 48 inch frame?`. Notice the response has *Cache Hit: False* and *Tokens:* is not zero. ![Cosmos Copilot app semantic cache miss](./media/quickstart-semantic-cache-miss.png) -This was essentially the same question with the same intent. So why didn't it result in a cache hit? The reason is the similarity score. It defaults to a value of `0.95`. This means that the question must be nearly exactly the same as what was cached. +This was essentially the same question with the same intent. So why didn't it result in a cache hit? The reason is the similarity score. It defaults to a value of `0.99`. This means that the question must be nearly exactly the same as what was cached. Let's adjust this and try again. 1. Stop the current debug session. -1. In Visual Studio or VS Code, open **secrets.json** or **appsettings.development.json** if you are using that instead and modify the **CacheSimilarityScore** value and adjust it from `0.95` to `0.90`. Save the file. +1. In Visual Studio or VS Code, open **secrets.json** or **appsettings.development.json** if you are using that instead and modify the **CacheSimilarityScore** value and adjust it from `0.99` to `0.90`. Save the file. -1. Restart the app and enter the same sequence for the first two prompts and another variation of the last prompt: - 1. `What bikes do you have` - 1. `Any in carbon fiber` - 1. `Got any using Shimano`. This time you should get a cache hit. +1. Restart the app and enter another variation of the last prompt, `How about 48 inch frames?`. This time you should get a cache hit. Spend time trying different sequences of questions (and follow up questions) and then modifying them with different similarity scores. You can click on **Clear Cache** if you want to start over and do the same series of questions again. -## Quickstart: Hybrid Search (optional) - -In this Quickstart, we'll compare the results between vector search and hybrid search that combines vector search with full-text search and re-ranks the results. In this solution the description and tags array have a full-text index on them so words in the user prompt are used to search these two properties for the text you submit. +# Semantic Kernel -**NOTE:** To do this Quickstart you need to have modified the main.bicep file to deploy the Cosmos DB account in one of the regions where Full-Text Search is available. +This project highlights the LLM orchestration SDK created by Microsft Research called, Semantic Kernel. Semantic Kernel is an open-source SDK that lets you easily build agents that can call your existing code. As a highly extensible SDK, you can use Semantic Kernel with models from OpenAI, Azure OpenAI, Hugging Face, and more! You can connect it to various vector databases using built-in connectors. By combining your existing C#, Python, and Java code with these models, you can build agents that answer questions and automate processes. -1. Clear the cache and all of the chat sessions for the users in the application. +The usage in this sample is very simple and just show the built-in plugins for OpenAI, intended to just give you a quick start in exploring its features and capabilities. -1. Select a new user from the drop-down at the top of the web app and create a new chat session. +**Note:** This solution yet doesn't implement the Azure Cosmos DB NoSQL connectors for Semantic Kernel. This will be in an upcoming update for this sample. -1. Type this prompt, `Do you have any lightweight bikes on special` - -1. Click the *Clear Cache* at the top right of the screen. - -1. Close the browser or end the debug session in Visual Studio or VS Code. - -1. Navigate to the ChatService in the cosmos-copilot.WebApp project. Locate the following lines. Comment out the call to `SearchProductsAsync()` and uncomment `HybridSearchProductsAsync()` +## Quickstart: Semantic Kernel +Here we will change all the calls made directly to OpenAI Service to go though Semantic Kernel. Open the solution for the application. Follow the steps: +1. In ChatService, comment the lines that call `_openAiService` then uncomment the ones below that call `_semanticKernelService` in these three places. + 1. `GetChatCompletionAsync()` + 1. `SummarizeChatSessionNameAsync()` + 1. `GetCacheAsync()` 1. Restart the application. -1. Select a new user from the drop-down at the top of the web app and create a new chat session. - -1. Type this prompt, `Do you have any lightweight bikes on special` - -1. Notice the differences between the results. The results are based upon a combination of the vector search that are reranked by the full-text search of the user prompt. This is another feature that is worth experimenting with the same prompts using both methods to see the results. +You won't see any difference in the execution for the application. The purpose of SDK's like Semantic Kernel is to provide a surface area for building more complex types of these applications. Navigate to the Semantic Kernel class and you can see how the built-in OpenAI plugins are used. # Conclusion diff --git a/src/.dockerignore b/src/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/src/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/src/App.razor b/src/App.razor new file mode 100644 index 0000000..6fd3ed1 --- /dev/null +++ b/src/App.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/Components/Confirmation.razor b/src/Components/Confirmation.razor new file mode 100644 index 0000000..3a42450 --- /dev/null +++ b/src/Components/Confirmation.razor @@ -0,0 +1,64 @@ + +@code { + [Parameter] public string? Caption { get; set; } + [Parameter] public string? Message { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + [Parameter] public Category Type { get; set; } + private Task Cancel() + { + return OnClose.InvokeAsync(false); + } + private Task Ok() + { + return OnClose.InvokeAsync(true); + } + public enum Category + { + Okay, + SaveNot, + DeleteNot + } +} \ No newline at end of file diff --git a/src/Components/Input.razor b/src/Components/Input.razor new file mode 100644 index 0000000..e209bac --- /dev/null +++ b/src/Components/Input.razor @@ -0,0 +1,48 @@ + +@code { + [Parameter] public string? Caption { get; set; } + [Parameter] public string? Value { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + public string? ReturnValue { get; set; } + + private Task Cancel() + { + return OnClose.InvokeAsync(""); + } + private Task Ok() + { + return OnClose.InvokeAsync(ReturnValue); + } + + public Task Enter(KeyboardEventArgs e) + { + if (e.Code == "Enter" || e.Code == "NumpadEnter") + { + return OnClose.InvokeAsync(ReturnValue); + } + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Constants/Interface.cs b/src/Constants/Interface.cs new file mode 100644 index 0000000..b7d0113 --- /dev/null +++ b/src/Constants/Interface.cs @@ -0,0 +1,6 @@ +namespace Cosmos.Copilot.Constants; + +public static class Interface +{ + public static readonly string EMPTY_SESSION = "empty-session-404"; +} \ No newline at end of file diff --git a/src/Constants/Participants.cs b/src/Constants/Participants.cs new file mode 100644 index 0000000..1f45ff7 --- /dev/null +++ b/src/Constants/Participants.cs @@ -0,0 +1,7 @@ +namespace Cosmos.Copilot.Constants; + +public enum Participants +{ + User = 0, + Assistant +} \ No newline at end of file diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..a63791d --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,24 @@ +#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER app +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["cosmos-copilot.csproj", "."] +RUN dotnet restore "./cosmos-copilot.csproj" +COPY . . +WORKDIR "/src/." +RUN dotnet build "./cosmos-copilot.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./cosmos-copilot.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "cosmos-copilot.dll"] \ No newline at end of file diff --git a/src/Models/CacheItem.cs b/src/Models/CacheItem.cs new file mode 100644 index 0000000..6b412d6 --- /dev/null +++ b/src/Models/CacheItem.cs @@ -0,0 +1,22 @@ +namespace Cosmos.Copilot.Models; + +public record CacheItem +{ + /// + /// Unique identifier + /// + public string Id { get; set; } + + public float[] Vectors { get; set; } + public string Prompts { get; set; } + + public string Completion { get; set; } + + public CacheItem(float[] vectors, string prompts, string completion) + { + Id = Guid.NewGuid().ToString(); + Vectors = vectors; + Prompts = prompts; + Completion = completion; + } +} diff --git a/src/Models/Message.cs b/src/Models/Message.cs new file mode 100644 index 0000000..948b86a --- /dev/null +++ b/src/Models/Message.cs @@ -0,0 +1,49 @@ +namespace Cosmos.Copilot.Models; + +public record Message +{ + /// + /// Unique identifier + /// + public string Id { get; set; } + + public string Type { get; set; } + + /// + /// Partition key- L1 + /// + public string TenantId { get; set; } + /// + /// Partition key- L2 + /// + public string UserId { get; set; } + /// + /// Partition key- L3 + /// + public string SessionId { get; set; } + + public DateTime TimeStamp { get; set; } + + public string Prompt { get; set; } + + public int PromptTokens { get; set; } + + public string Completion { get; set; } + + public int CompletionTokens { get; set; } + public bool CacheHit {get; set;} + public Message(string tenantId, string userId, string sessionId, int promptTokens, string prompt, string completion = "", int completionTokens = 0, bool cacheHit = false) + { + Id = Guid.NewGuid().ToString(); + Type = nameof(Message); + TenantId = tenantId; + UserId = userId; + SessionId = sessionId; + TimeStamp = DateTime.UtcNow; + Prompt = prompt; + PromptTokens = promptTokens; + Completion = completion; + CompletionTokens = completionTokens; + CacheHit = cacheHit; + } +} \ No newline at end of file diff --git a/src/Models/Product.cs b/src/Models/Product.cs new file mode 100644 index 0000000..7877bee --- /dev/null +++ b/src/Models/Product.cs @@ -0,0 +1,42 @@ +using Azure; + +namespace Cosmos.Copilot.Models +{ + public class Product + { + public string id { get; set; } + public string categoryId { get; set; } + public string categoryName { get; set; } + public string sku { get; set; } + public string name { get; set; } + public string description { get; set; } + public double price { get; set; } + public List tags { get; set; } + public float[]? vectors { get; set; } + + public Product(string id, string categoryId, string categoryName, string sku, string name, string description, double price, List tags, float[]? vectors = null) + { + this.id = id; + this.categoryId = categoryId; + this.categoryName = categoryName; + this.sku = sku; + this.name = name; + this.description = description; + this.price = price; + this.tags = tags; + this.vectors = vectors; + } + + } + public class Tag + { + public string id { get; set; } + public string name { get; set; } + + public Tag(string id, string name) + { + this.id = id; + this.name = name; + } + } +} diff --git a/src/Models/Session.cs b/src/Models/Session.cs new file mode 100644 index 0000000..cb24bd8 --- /dev/null +++ b/src/Models/Session.cs @@ -0,0 +1,58 @@ +using Newtonsoft.Json; + +namespace Cosmos.Copilot.Models; + +public record Session +{ + /// + /// Unique identifier + /// + public string Id { get; set; } + + public string Type { get; set; } + + + /// + /// Partition key- L1 + /// + public string TenantId { get; set; } + /// + /// Partition key- L2 + /// + public string UserId { get; set; } + /// + /// Partition key- L3 + /// + public string SessionId { get; set; } + + public int? Tokens { get; set; } + + public string Name { get; set; } + + [JsonIgnore] + public List Messages { get; set; } + + public Session(string tenantId, string userId) + { + Id = Guid.NewGuid().ToString(); + Type = nameof(Session); + SessionId = this.Id; + UserId = userId; + TenantId= tenantId; + Tokens = 0; + Name = "New Chat"; + Messages = new List(); + } + + public void AddMessage(Message message) + { + Messages.Add(message); + } + + public void UpdateMessage(Message message) + { + var match = Messages.Single(m => m.Id == message.Id); + var index = Messages.IndexOf(match); + Messages[index] = message; + } +} \ No newline at end of file diff --git a/src/Models/UserParameters.cs b/src/Models/UserParameters.cs new file mode 100644 index 0000000..10f9977 --- /dev/null +++ b/src/Models/UserParameters.cs @@ -0,0 +1,18 @@ +using Microsoft.Azure.Cosmos; + +namespace Cosmos.Copilot.Models +{ + public class UserParameters + { + public string UserId { get; set; } + public string TenantId { get; set; } + + public UserParameters(string _userId, string _tenantId) + { + UserId = _userId; + TenantId = _tenantId; + } + } + + +} diff --git a/src/Options/Chat.cs b/src/Options/Chat.cs new file mode 100644 index 0000000..664afff --- /dev/null +++ b/src/Options/Chat.cs @@ -0,0 +1,10 @@ +namespace Cosmos.Copilot.Options; + +public record Chat +{ + public required string MaxConversationTokens { get; init; } + + public required string CacheSimilarityScore { get; init; } + + public required string ProductMaxResults { get; init; } +} diff --git a/src/Options/CosmosDb.cs b/src/Options/CosmosDb.cs new file mode 100644 index 0000000..128a80c --- /dev/null +++ b/src/Options/CosmosDb.cs @@ -0,0 +1,16 @@ +namespace Cosmos.Copilot.Options; + +public record CosmosDb +{ + public required string Endpoint { get; init; } + + public required string Database { get; init; } + + public required string ChatContainer { get; init; } + + public required string CacheContainer { get; init; } + + public required string ProductContainer { get; init; } + + public required string ProductDataSourceURI { get; init; } +}; \ No newline at end of file diff --git a/src/Options/OpenAi.cs b/src/Options/OpenAi.cs new file mode 100644 index 0000000..288e583 --- /dev/null +++ b/src/Options/OpenAi.cs @@ -0,0 +1,10 @@ +namespace Cosmos.Copilot.Options; + +public record OpenAi +{ + public required string Endpoint { get; init; } + + public required string CompletionDeploymentName { get; init; } + + public required string EmbeddingDeploymentName { get; init; } +} \ No newline at end of file diff --git a/src/Options/SemanticKernel.cs b/src/Options/SemanticKernel.cs new file mode 100644 index 0000000..df5371a --- /dev/null +++ b/src/Options/SemanticKernel.cs @@ -0,0 +1,10 @@ +namespace Cosmos.Copilot.Options; + +public record SemanticKernel +{ + public required string Endpoint { get; init; } + + public required string CompletionDeploymentName { get; init; } + + public required string EmbeddingDeploymentName { get; init; } +} diff --git a/src/Pages/ChatPane.razor b/src/Pages/ChatPane.razor new file mode 100644 index 0000000..4007dcc --- /dev/null +++ b/src/Pages/ChatPane.razor @@ -0,0 +1,368 @@ +@using Cosmos.Copilot.Constants +@using Cosmos.Copilot.Services +@using Humanizer +@using System.Text.RegularExpressions; +@inject ChatService chatService +@inject IJSRuntime JSRuntime + +
+ @if (ShowHeader) + { + + } + +
+
+
@GetChatSessionName()
+
+
+ +
+
+ +
+
+ +
+ @if (CurrentSession is null) + { +
+
+
+ Loading... +
+ Loading... +
+

+ Please wait while your chat loads. +

+
+ } + else if (CurrentSession.SessionId == Interface.EMPTY_SESSION) + { +
+

+ + No Chats Available +

+

+ Use the New Chat option to start a new chat. +

+
+ } + else + { + if (_messagesInChat is null || _loadingComplete == false) + { +
+
+
+ Loading... +
+ Loading... +
+

+ Please wait while your chat loads. +

+
+ } + else + { + if (_messagesInChat.Count == 0) + { +
+

+ + Get Started +

+

+ Start chatting with your helpful AI assistant. +

+
+ } + else + { +
+ @foreach (var msg in _messagesInChat) + { +
+
+ + User + Tokens: @msg.PromptTokens + @msg.TimeStamp.Humanize() +
+
+ + @{ + MarkupString prompt = FixupText(msg.Prompt); + } + @prompt +
+
+
+
+ + Assistant + Cache Hit: @msg.CacheHit + Tokens: @msg.CompletionTokens + @msg.TimeStamp.Humanize() +
+
+ + @{ + MarkupString completion = FixupText(msg.Completion); + } + @completion +
+
+ } +
+ } + } + } +
+
+ @if (CurrentSession is not null && CurrentSession?.SessionId != Interface.EMPTY_SESSION) + { +
+ + + +
+ } +
+
+ +@code { + + // Inject NavigationManager + [Inject] + private NavigationManager NavigationManager { get; set; } + + [Parameter] + public EventCallback OnChatUpdated { get; set; } + + [Parameter] + public EventCallback OnUserChanged { get; set; } + + [Parameter] + public Session? CurrentSession { get; set; } + + [Parameter] + public bool ShowHeader { get; set; } + + [Parameter] + public EventCallback OnNavBarVisibilityUpdated { get; set; } + + private string? UserPrompt { get; set; } + + private string? UserPromptSet { get; set; } + + private string? Tenant; + private string? User; + + private List DummyUsers = new List { "Mark", "James", "Sandeep","Sajee" }; + + private List? _messagesInChat; + private static event EventHandler? _onMessagePosted; + + + private bool _loadingComplete; + + private MarkupString FixupText(string input) + { + var text = input.Replace("\n", "
"); + Regex rgx = new Regex("```"); + var matches = rgx.Matches(text); + if (matches.Count() > 0) + for (int i = 0; i < matches.Count; i++) + if (i % 2 == 0) + // even = start code block tag + text = rgx.Replace(text, "
", 1, i);
+                else
+                    // odd = end code block tag
+                    text = rgx.Replace(text, "
", 1, i); + + MarkupString html = new MarkupString(text); + + return html; + } + + private async Task UpdateSelectedUser(ChangeEventArgs e) + { + if (string.IsNullOrEmpty(Tenant)) + return; + + CurrentSession = null; + User = e.Value.ToString(); + NavigationManager.NavigateTo($"?Tenant={Tenant}"); + await OnUserChanged.InvokeAsync(new UserParameters (User, Tenant)); + } + + async private Task ToggleNavMenu() + { + await OnNavBarVisibilityUpdated.InvokeAsync(); + } + + public async Task ReloadChatMessagesAsync() + { + if (CurrentSession is not null) + { + _messagesInChat = await chatService.GetChatSessionMessagesAsync(Tenant,User,CurrentSession.SessionId); + } + } + + protected override async Task OnInitializedAsync() + { + // Read query string parameters using NavigationManager + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + var queryString = System.Web.HttpUtility.ParseQueryString(uri.Query); + + // Get the value of the "myParameter" query string parameter + Tenant = queryString["Tenant"]; + + if (string.IsNullOrEmpty(Tenant)) + Tenant = "t1"; + + if (string.IsNullOrEmpty(User)) + User = Tenant + "." + DummyUsers[0]; + + var changeEventArgs = new ChangeEventArgs { Value = User }; + await UpdateSelectedUser(changeEventArgs); + + await chatService.InitializeAsync(); + + _onMessagePosted += async (o, e) => + { + await this.InvokeAsync(async () => + { + if (e.SessionId == CurrentSession?.SessionId) + { + await this.ReloadChatMessagesAsync(); + this.StateHasChanged(); + } + }); + }; + + } + + protected override async Task OnParametersSetAsync() + { + if (CurrentSession is null) + { + return; + } + + if (CurrentSession.SessionId != Interface.EMPTY_SESSION & CurrentSession.SessionId is not null) + { + _messagesInChat = await chatService.GetChatSessionMessagesAsync(Tenant, User, CurrentSession?.SessionId); + } + + _loadingComplete = true; + } + + public void ChangeCurrentChatSession(Session session) + { + CurrentSession = session; + } + + public async Task Enter(KeyboardEventArgs e) + { + if (e.Code == "Enter" || e.Code == "NumpadEnter") + { + await SubmitPromptAsync(); + } + } + + private async Task SubmitPromptAsync() + { + if (CurrentSession?.SessionId == Interface.EMPTY_SESSION || UserPrompt == String.Empty || UserPrompt is null) + { + return; + } + + if (UserPrompt != String.Empty) + { + UserPromptSet = String.Empty; + } + + var message = await chatService.GetChatCompletionAsync(Tenant, User,CurrentSession?.SessionId, UserPrompt); + CurrentSession!.Tokens += message.CompletionTokens + message.PromptTokens; + + if(_messagesInChat?.Count == 1) + { + string newSessionName; + newSessionName = await chatService.SummarizeChatSessionNameAsync(Tenant, User, CurrentSession?.SessionId); + + if (CurrentSession is not null) + { + CurrentSession.Name = newSessionName; + } + } + await OnChatUpdated.InvokeAsync(CurrentSession); + + if (_onMessagePosted is not null && CurrentSession is not null) + { + _onMessagePosted.Invoke(null, CurrentSession); + } + + await ScrollLastChatToView(); + } + + private string GetChatSessionName() => CurrentSession switch + { + null => String.Empty, + (Session s) when s.SessionId == Interface.EMPTY_SESSION => String.Empty, + _ => CurrentSession.Name + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await ScrollLastChatToView(); + } + + /// + /// This is a hack to get the scroll to work. Need to find a better way to do this. + /// + private async Task ScrollLastChatToView() + { + await JSRuntime.InvokeAsync("scrollToLastMessage"); + } + + private async Task ClearCache() + { + await chatService.ClearCacheAsync(); + } +} diff --git a/src/Pages/Error.cshtml b/src/Pages/Error.cshtml new file mode 100644 index 0000000..a366cd5 --- /dev/null +++ b/src/Pages/Error.cshtml @@ -0,0 +1,42 @@ +@page +@model Cosmos.Copilot.Pages.ErrorModel + + + + + + + + Error + + + + + +
+
+

Error.

+

An error occurred while processing your request.

+ + @if (Model.ShowRequestId) + { +

+ Request ID: @Model.RequestId +

+ } + +

Development Mode

+

+ Swapping to the Development environment displays 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. +

+
+
+ + + diff --git a/src/Pages/Error.cshtml.cs b/src/Pages/Error.cshtml.cs new file mode 100644 index 0000000..6ea7366 --- /dev/null +++ b/src/Pages/Error.cshtml.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.Diagnostics; + +namespace Cosmos.Copilot.Pages; + +[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] +[IgnoreAntiforgeryToken] +public class ErrorModel : PageModel +{ + public string? RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + private readonly ILogger _logger; + + public ErrorModel(ILogger logger) + { + _logger = logger; + } + + public void OnGet() + { + RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; + _logger.LogError($"An error occurred while processing your request."); + } +} \ No newline at end of file diff --git a/src/Pages/Index.razor b/src/Pages/Index.razor new file mode 100644 index 0000000..b700881 --- /dev/null +++ b/src/Pages/Index.razor @@ -0,0 +1,66 @@ +@page "/" + +Azure Cosmos DB & Azure OpenAI Service +
+ @if (!IsNavMenuCollapsed) + { + + } +
+ +
+
+ +@code { + + [Parameter] + public EventCallback OnChatUpdated { get; set; } = default!; + + [Parameter] + public EventCallback OnUserChanged { get; set; } = default!; + + private Session? CurrentSession; + private ChatPane? ChatPane = default; + private NavMenu? NavMenu = default; + private bool IsNavMenuCollapsed { get; set; } + + private void UpdateNavBarVisibility() + { + IsNavMenuCollapsed = !IsNavMenuCollapsed; + } + + protected override void OnInitialized() + { + NavMenu = new NavMenu(); + ChatPane = new ChatPane(); + } + + public async void LoadChatEventHandlerAsync(Session session) + { + CurrentSession = session; + + if (ChatPane is not null) + { + ChatPane.ChangeCurrentChatSession(session); + } + + // Inform blazor the UI needs updating + await InvokeAsync(StateHasChanged); + } + + public async void ForceRefreshAsync(Session session) + { + // Inform blazor the UI needs updating + await InvokeAsync(StateHasChanged); + + NavMenu?.UpdateNavMenuDisplay("Rename by Open AI", session); + } + + public async void ForceReloadAsync(UserParameters uparam) + { + // Inform blazor the NavMenu needs updating + await InvokeAsync(StateHasChanged); + + NavMenu?.SetSelectedUser("User Changed", uparam.UserId, uparam.TenantId); + } +} \ No newline at end of file diff --git a/src/Pages/NavMenu.razor b/src/Pages/NavMenu.razor new file mode 100644 index 0000000..b8a9211 --- /dev/null +++ b/src/Pages/NavMenu.razor @@ -0,0 +1,368 @@ +@using Cosmos.Copilot.Constants +@using Cosmos.Copilot.Services +@inject ChatService chatService + + +
+ + + +
+ @if (_loadingComplete == true) + { +
+ +
+ } +
+
+ + + +@if (_deletePopUpOpen) +{ + + +} + + +@if (_renamePopUpOpen) +{ + + +} + + +@code { + + + [Parameter] + public EventCallback OnChatClicked { get; set; } = default!; + + [Parameter] + public static List ChatSessions { get; set; } = new(); + + [Parameter] + public EventCallback OnNavBarVisibilityUpdated { get; set; } + + [Parameter] + public EventCallback OnThemeUpdated { get; set; } + + private string? _sessionId; + private string? _popUpText; + private bool _deletePopUpOpen = false; + private bool _loadingComplete; + private bool _renamePopUpOpen = false; + + private string? Tenant; + private string? User; + public Session? CurrentSession; + + private static event EventHandler? OnNavMenuChanged; + + async private Task ToggleNavMenu() + { + await OnNavBarVisibilityUpdated.InvokeAsync(); + } + + async private Task ChangeTheme() + { + await OnThemeUpdated.InvokeAsync(); + } + + protected override void OnInitialized() + { + + OnNavMenuChanged += async (o, e) => + { + await this.InvokeAsync(async () => + { + this.StateHasChanged(); + + await LoadCurrentChatAsync(); + }); + }; + } + + private void OpenConfirmation(string id, string title) + { + _deletePopUpOpen = true; + _sessionId = id; + _popUpText = $"Do you want to delete the chat \"{title}\"?"; + } + + + public async void SetSelectedUser(string reason = "", string? _user = null, string? _tenant = null) + { + _loadingComplete = false; + + ChatSessions = new List(); + Tenant = _tenant; + User = _user; + CurrentSession = null; + + } + public void UpdateNavMenuDisplay(string reason = "", Session? _session = null) + { + if (_session is not null) + { + int index = ChatSessions.FindIndex(s => s.SessionId == _session.SessionId); + ChatSessions[index].Tokens = _session.Tokens; + ChatSessions[index].Name = _session.Name; + } + + if (OnNavMenuChanged is not null) + { + OnNavMenuChanged.Invoke(null, reason); + } + } + + private async Task OnConfirmationClose(bool isOk) + { + bool updateCurrentChat=false; + + if (CurrentSession is not null & _sessionId == CurrentSession?.SessionId) + updateCurrentChat = true; + + if (isOk) + { + _deletePopUpOpen = false; + await chatService.DeleteChatSessionAsync(Tenant, User, _sessionId); + + int index = ChatSessions.FindIndex(s => s.SessionId == _sessionId); + ChatSessions.RemoveAt(index); + + _deletePopUpOpen = false; + + UpdateNavMenuDisplay("Delete"); + + if (!updateCurrentChat) + return; + + CurrentSession = new Session(Tenant, User); + CurrentSession.SessionId = Interface.EMPTY_SESSION; + CurrentSession.Name = string.Empty; + + if (ChatSessions is not null & ChatSessions?.Count > 0) + { + var match = ChatSessions?.FirstOrDefault(); + if (match is not null) + { + CurrentSession.SessionId = match.SessionId; + CurrentSession.Name = match.Name; + CurrentSession.Tokens = match.Tokens; + } + } + + await LoadCurrentChatAsync(); + } + + _deletePopUpOpen = false; + } + + private void OpenInput(string id, string title) + { + _renamePopUpOpen = true; + _sessionId = id; + _popUpText = title; + } + + private async Task OnInputClose(string newName) + { + if (newName!="") + { + bool updateCurrentChat = false; + + if (_sessionId == CurrentSession?.SessionId) + { + updateCurrentChat = true; + } + + await chatService.RenameChatSessionAsync(Tenant, User, _sessionId, newName); + int index = ChatSessions.FindIndex(s => s.SessionId == _sessionId); + ChatSessions[index].Name = newName; + + _renamePopUpOpen = false; + + UpdateNavMenuDisplay("Rename"); + + if (!updateCurrentChat) + { + return; + } + + if (CurrentSession is not null) + { + CurrentSession.Name = newName; + } + await LoadCurrentChatAsync(); + } + + _renamePopUpOpen = false; + } + + private async Task NewChat() + { + var session = await chatService.CreateNewChatSessionAsync(Tenant, User); + ChatSessions.Add(session); + if (ChatSessions.Count == 1) + { + CurrentSession = ChatSessions[0] with { }; + await LoadCurrentChatAsync(); + } + + UpdateNavMenuDisplay("Add"); + } + + protected override async Task OnParametersSetAsync() + { + if (_loadingComplete == true) + return; + + _loadingComplete = false; + + ChatSessions = await chatService.GetAllChatSessionsAsync(Tenant, User); + if (CurrentSession is not null && ChatSessions is not null & ChatSessions?.Count > 0) + { + var match = ChatSessions?.FirstOrDefault(); + if (match is not null) + { + CurrentSession.SessionId = match.SessionId; + CurrentSession.Name = match.Name; + CurrentSession.Tokens = match.Tokens; + } + } + + _loadingComplete = true; + await LoadCurrentChatAsync(); + + } + + private async Task LoadCurrentChatAsync() + { + int index = 0; + if (CurrentSession is not null & ChatSessions is not null & ChatSessions?.Count > 0) + { + index = ChatSessions?.FindIndex(s => s.SessionId == CurrentSession?.SessionId) ?? 0; + } + if (CurrentSession is null || index < 0) + { + CurrentSession = new Session(Tenant, User); + CurrentSession.SessionId = Interface.EMPTY_SESSION; + CurrentSession.Name = string.Empty; + + if (ChatSessions is not null & ChatSessions?.Count > 0) + { + var match = ChatSessions?.FirstOrDefault(); + if (match is not null) + { + CurrentSession.SessionId = match.SessionId; + CurrentSession.Name = match.Name; + CurrentSession.Tokens = match.Tokens; + } + } + } + + await OnChatClicked.InvokeAsync(CurrentSession); + return 0; + } + + async private Task LoadChat(string _sessionId, string sessionName, int? tokens) + { + if (ChatSessions is null) return 0; + + if (CurrentSession is null) + CurrentSession = new Session(Tenant, User); + + CurrentSession.SessionId = _sessionId; + CurrentSession.Name = sessionName; + CurrentSession.Tokens = tokens; + + await LoadCurrentChatAsync(); + + return 0; + } + + private bool IsActiveSession(string _sessionId) => CurrentSession switch + { + null => true, + (Session s) when s.SessionId == _sessionId => true, + _ => false + }; + + public string SafeSubstring(string text, int maxLength) => text switch + { + null => string.Empty, + _ => text.Length > maxLength ? text.Substring(0, maxLength) + "..." : text + }; +} \ No newline at end of file diff --git a/src/Pages/_Host.cshtml b/src/Pages/_Host.cshtml new file mode 100644 index 0000000..60b63dd --- /dev/null +++ b/src/Pages/_Host.cshtml @@ -0,0 +1,8 @@ +@page "/" +@namespace Cosmos.Copilot.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@{ + Layout = "_Layout"; +} + + diff --git a/src/Pages/_Layout.cshtml b/src/Pages/_Layout.cshtml new file mode 100644 index 0000000..1226193 --- /dev/null +++ b/src/Pages/_Layout.cshtml @@ -0,0 +1,36 @@ +@using Microsoft.AspNetCore.Components.Web +@namespace Cosmos.Copilot.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + + + + + + + + + + + + + + + + @RenderBody() + + + + \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..011b744 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,128 @@ +using Cosmos.Copilot.Options; +using Cosmos.Copilot.Services; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +builder.RegisterConfiguration(); +builder.Services.AddRazorPages(); +builder.Services.AddServerSideBlazor(); +builder.Services.RegisterServices(); + +//builder.Configuration +// .AddJsonFile("secrets.json", optional: true, reloadOnChange: true) +// .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) +// .AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true) +// .AddEnvironmentVariables(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error"); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseRouting(); + +app.MapBlazorHub(); +app.MapFallbackToPage("/_Host"); + +await app.RunAsync(); + +static class ProgramExtensions +{ + public static void RegisterConfiguration(this WebApplicationBuilder builder) + { + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(nameof(CosmosDb))); + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(nameof(OpenAi))); + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(nameof(SemanticKernel))); + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(nameof(Chat))); + } + + public static void RegisterServices(this IServiceCollection services) + { + services.AddSingleton((provider) => + { + var cosmosDbOptions = provider.GetRequiredService>(); + if (cosmosDbOptions is null) + { + throw new ArgumentException($"{nameof(IOptions)} was not resolved through dependency injection."); + } + else + { + return new CosmosDbService( + endpoint: cosmosDbOptions.Value?.Endpoint ?? String.Empty, + databaseName: cosmosDbOptions.Value?.Database ?? String.Empty, + chatContainerName: cosmosDbOptions.Value?.ChatContainer ?? String.Empty, + cacheContainerName: cosmosDbOptions.Value?.CacheContainer ?? String.Empty, + productContainerName: cosmosDbOptions.Value?.ProductContainer ?? String.Empty, + productDataSourceURI: cosmosDbOptions.Value?.ProductDataSourceURI ?? String.Empty + ); + } + }); + services.AddSingleton((provider) => + { + var openAiOptions = provider.GetRequiredService>(); + if (openAiOptions is null) + { + throw new ArgumentException($"{nameof(IOptions)} was not resolved through dependency injection."); + } + else + { + return new OpenAiService( + endpoint: openAiOptions.Value?.Endpoint ?? String.Empty, + completionDeploymentName: openAiOptions.Value?.CompletionDeploymentName ?? String.Empty, + embeddingDeploymentName: openAiOptions.Value?.EmbeddingDeploymentName ?? String.Empty + ); + } + }); + services.AddSingleton((provider) => + { + var semanticKernalOptions = provider.GetRequiredService>(); + if (semanticKernalOptions is null) + { + throw new ArgumentException($"{nameof(IOptions)} was not resolved through dependency injection."); + } + else + { + return new SemanticKernelService( + endpoint: semanticKernalOptions.Value?.Endpoint ?? String.Empty, + completionDeploymentName: semanticKernalOptions.Value?.CompletionDeploymentName ?? String.Empty, + embeddingDeploymentName: semanticKernalOptions.Value?.EmbeddingDeploymentName ?? String.Empty + ); + } + }); + services.AddSingleton((provider) => + { + var chatOptions = provider.GetRequiredService>(); + if (chatOptions is null) + { + throw new ArgumentException($"{nameof(IOptions)} was not resolved through dependency injection."); + } + else + { + var cosmosDbService = provider.GetRequiredService(); + var openAiService = provider.GetRequiredService(); + var semanticKernelService = provider.GetRequiredService(); + return new ChatService( + openAiService: openAiService, + cosmosDbService: cosmosDbService, + semanticKernelService: semanticKernelService, + maxConversationTokens: chatOptions.Value?.MaxConversationTokens ?? String.Empty, + cacheSimilarityScore: chatOptions.Value?.CacheSimilarityScore ?? String.Empty, + productMaxResults: chatOptions.Value?.ProductMaxResults ?? String.Empty + ); + } + }); + } +} diff --git a/src/Properties/launchSettings.json b/src/Properties/launchSettings.json new file mode 100644 index 0000000..71fe7ff --- /dev/null +++ b/src/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "profiles": { + "cosmos-copilot": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:8100", + "hotReloadProfile": "blazorwasm" + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true + } + } +} \ No newline at end of file diff --git a/src/Services/ChatService.cs b/src/Services/ChatService.cs new file mode 100644 index 0000000..fd9639a --- /dev/null +++ b/src/Services/ChatService.cs @@ -0,0 +1,291 @@ +using Cosmos.Copilot.Models; +using Microsoft.ML.Tokenizers; + +namespace Cosmos.Copilot.Services; + +public class ChatService +{ + + private readonly CosmosDbService _cosmosDbService; + private readonly OpenAiService _openAiService; + private readonly SemanticKernelService _semanticKernelService; + private readonly int _maxConversationTokens; + private readonly double _cacheSimilarityScore; + private readonly int _productMaxResults; + + public ChatService(CosmosDbService cosmosDbService, OpenAiService openAiService, SemanticKernelService semanticKernelService, string maxConversationTokens, string cacheSimilarityScore, string productMaxResults) + { + _cosmosDbService = cosmosDbService; + _openAiService = openAiService; + _semanticKernelService = semanticKernelService; + + _maxConversationTokens = Int32.TryParse(maxConversationTokens, out _maxConversationTokens) ? _maxConversationTokens : 100; + _cacheSimilarityScore = Double.TryParse(cacheSimilarityScore, out _cacheSimilarityScore) ? _cacheSimilarityScore : 0.99; + _productMaxResults = Int32.TryParse(productMaxResults, out _productMaxResults) ? _productMaxResults: 10; + } + + public async Task InitializeAsync() + { + await _cosmosDbService.LoadProductDataAsync(); + } + + /// + /// Returns list of chat session ids and names for left-hand nav to bind to (display Name and ChatSessionId as hidden) + /// + public async Task> GetAllChatSessionsAsync(string tenantId, string userId) + { + return await _cosmosDbService.GetSessionsAsync(tenantId,userId); + } + + /// + /// Returns the chat messages to display on the main web page when the user selects a chat from the left-hand nav + /// + public async Task> GetChatSessionMessagesAsync(string tenantId, string userId,string? sessionId) + { + ArgumentNullException.ThrowIfNull(tenantId); + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(sessionId); + + return await _cosmosDbService.GetSessionMessagesAsync(tenantId,userId, sessionId); ; + } + + /// + /// User creates a new Chat Session. + /// + public async Task CreateNewChatSessionAsync(string tenantId, string userId) + { + + Session session = new(tenantId, userId); + + await _cosmosDbService.InsertSessionAsync(tenantId, userId,session); + + return session; + + } + + /// + /// Rename the Chat Session from "New Chat" to the summary provided by OpenAI + /// + public async Task RenameChatSessionAsync(string tenantId, string userId,string? sessionId, string newChatSessionName) + { + ArgumentNullException.ThrowIfNull(tenantId); + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(sessionId); + + Session session = await _cosmosDbService.GetSessionAsync(tenantId, userId,sessionId); + + session.Name = newChatSessionName; + + await _cosmosDbService.UpdateSessionAsync(tenantId, userId,session); + } + + /// + /// User deletes a chat session + /// + public async Task DeleteChatSessionAsync(string tenantId, string userId, string? sessionId) + { + ArgumentNullException.ThrowIfNull(tenantId); + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(sessionId); + + await _cosmosDbService.DeleteSessionAndMessagesAsync( tenantId, userId, sessionId); + } + + /// + /// Get a completion for a user prompt from Azure OpenAi Service + /// This is the main LLM Workflow for the Chat Service + /// + public async Task GetChatCompletionAsync(string tenantId, string userId, string? sessionId, string promptText) + { + ArgumentNullException.ThrowIfNull(tenantId); + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(sessionId); + + //Create a message object for the new User Prompt and calculate the tokens for the prompt + Message chatMessage = await CreateChatMessageAsync(tenantId, userId, sessionId, promptText); + + //Grab context window from the conversation history up to the maximum configured tokens + List contextWindow = await GetChatSessionContextWindow( tenantId, userId, sessionId); + + //Perform a cache search to see if this prompt has already been used in the same context window as this conversation + (string cachePrompts, float[] cacheVectors, string cacheResponse) = await GetCacheAsync(contextWindow); + + //Cache hit, return the cached completion + if (!string.IsNullOrEmpty(cacheResponse)) + { + chatMessage.CacheHit = true; + chatMessage.Completion = cacheResponse; + chatMessage.CompletionTokens = 0; + + //Persist the prompt/completion, update the session tokens + await UpdateSessionAndMessage( tenantId, userId, sessionId, chatMessage); + + return chatMessage; + } + else //Cache miss, send to OpenAI to generate a completion + { + //Generate embeddings for the user prompt + float[] promptVectors = await _openAiService.GetEmbeddingsAsync(promptText); + //float[] promptVectors = await _semanticKernelService.GetEmbeddingsAsync(promptText); + + //These functions are for doing RAG Pattern completions + List products = await _cosmosDbService.SearchProductsAsync(promptVectors, _productMaxResults); + (chatMessage.Completion, chatMessage.CompletionTokens) = await _openAiService.GetRagCompletionAsync(sessionId, contextWindow, products); + //(chatMessage.Completion, chatMessage.CompletionTokens) = await _semanticKernelService.GetRagCompletionAsync(sessionId, contextWindow, products); + + //Cache the prompts in the current context window and their vectors with the generated completion + await CachePutAsync(cachePrompts, cacheVectors, chatMessage.Completion); + } + + + //Persist the prompt/completion, update the session tokens + await UpdateSessionAndMessage( tenantId, userId, sessionId, chatMessage); + + return chatMessage; + } + + /// + /// Get the context window for this conversation. This is used in cache search as well as generating completions + /// + private async Task> GetChatSessionContextWindow(string tenantId, string userId, string sessionId) + { + ArgumentNullException.ThrowIfNull(tenantId); + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(sessionId); + + int? tokensUsed = 0; + + List allMessages = await _cosmosDbService.GetSessionMessagesAsync(tenantId, userId, sessionId); + List contextWindow = new List(); + + //Start at the end of the list and work backwards + //This includes the latest user prompt which is already cached + for (int i = allMessages.Count - 1; i >= 0; i--) + { + tokensUsed += allMessages[i].PromptTokens + allMessages[i].CompletionTokens; + + if (tokensUsed > _maxConversationTokens) + break; + + contextWindow.Add(allMessages[i]); + } + + //Invert the chat messages to put back into chronological order + contextWindow = contextWindow.Reverse().ToList(); + + return contextWindow; + + } + + /// + /// Use OpenAI to summarize the conversation to give it a relevant name on the web page + /// + public async Task SummarizeChatSessionNameAsync(string tenantId, string userId, string? sessionId) + { + ArgumentNullException.ThrowIfNull(tenantId); + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(sessionId); + + //Get the messages for the session + List messages = await _cosmosDbService.GetSessionMessagesAsync( tenantId, userId, sessionId); + + //Create a conversation string from the messages + string conversationText = string.Join(" ", messages.Select(m => m.Prompt + " " + m.Completion)); + + //Send to OpenAI to summarize the conversation + string completionText = await _openAiService.SummarizeAsync(sessionId, conversationText); + //string completionText = await _semanticKernelService.SummarizeConversationAsync(conversationText); + + await RenameChatSessionAsync( tenantId, userId, sessionId, completionText); + + return completionText; + } + + /// + /// Add user prompt to a new chat session message object, calculate token count for prompt text. + /// + private async Task CreateChatMessageAsync(string tenantId, string userId, string sessionId, string promptText) + { + ArgumentNullException.ThrowIfNull(tenantId); + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(sessionId); + + //Calculate tokens for the user prompt message. + int promptTokens = GetTokens(promptText); + + //Create a new message object. + Message chatMessage = new(tenantId, userId, sessionId, promptTokens, promptText, ""); + + await _cosmosDbService.InsertMessageAsync(tenantId, userId, chatMessage); + + return chatMessage; + } + + /// + /// Update session with user prompt and completion tokens and update the cache + /// + private async Task UpdateSessionAndMessage(string tenantId, string userId, string sessionId, Message chatMessage) + { + ArgumentNullException.ThrowIfNull(tenantId); + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(sessionId); + + //Update the tokens used in the session + Session session = await _cosmosDbService.GetSessionAsync(tenantId, userId, sessionId); + session.Tokens += chatMessage.PromptTokens + chatMessage.CompletionTokens; + + //Insert new message and Update session in a transaction + await _cosmosDbService.UpsertSessionBatchAsync( tenantId, userId, session, chatMessage); + + } + + /// + /// Calculate the number of tokens from the user prompt + /// + private int GetTokens(string userPrompt) + { + + Tokenizer _tokenizer = Tokenizer.CreateTiktokenForModel("gpt-3.5-turbo"); + + return _tokenizer.CountTokens(userPrompt); + + } + + /// + /// Query the semantic cache with user prompt vectors for the current context window in this conversation + /// + private async Task<(string cachePrompts, float[] cacheVectors, string cacheResponse)> GetCacheAsync(List contextWindow) + { + //Grab the user prompts for the context window + string prompts = string.Join(Environment.NewLine, contextWindow.Select(m => m.Prompt)); + + //Get the embeddings for the user prompts + float[] vectors = await _openAiService.GetEmbeddingsAsync(prompts); + //float[] vectors = await _semanticKernelService.GetEmbeddingsAsync(prompts); + + //Check the cache for similar vectors + string response = await _cosmosDbService.GetCacheAsync(vectors, _cacheSimilarityScore); + + return (prompts, vectors, response); + } + + /// + /// Cache the last generated completion with user prompt vectors for the current context window in this conversation + /// + private async Task CachePutAsync(string cachePrompts, float[] cacheVectors, string generatedCompletion) + { + //Include the user prompts text to view. They are not used in the cache search. + CacheItem cacheItem = new(cacheVectors, cachePrompts, generatedCompletion); + + //Put the prompts, vectors and completion into the cache + await _cosmosDbService.CachePutAsync(cacheItem); + } + + /// + /// Clear the Semantic Cache + /// + public async Task ClearCacheAsync() + { + await _cosmosDbService.CacheClearAsync(); + } +} diff --git a/src/Services/CosmosDbService.cs b/src/Services/CosmosDbService.cs new file mode 100644 index 0000000..078ca08 --- /dev/null +++ b/src/Services/CosmosDbService.cs @@ -0,0 +1,504 @@ +using Azure.Core; +using Azure.Identity; +using Cosmos.Copilot.Models; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Fluent; +using Microsoft.Azure.Cosmos.Serialization.HybridRow.Schemas; +using Newtonsoft.Json; +using System.Text.Json; +using Container = Microsoft.Azure.Cosmos.Container; +using PartitionKey = Microsoft.Azure.Cosmos.PartitionKey; + +namespace Cosmos.Copilot.Services; + +/// +/// Service to access Azure Cosmos DB for NoSQL. +/// +public class CosmosDbService +{ + private readonly Container _chatContainer; + private readonly Container _cacheContainer; + private readonly Container _productContainer; + private readonly string _productDataSourceURI; + + /// + /// Creates a new instance of the service. + /// + /// Endpoint URI. + /// Name of the database to access. + /// Name of the chat container to access. + /// Name of the cache container to access. + /// Name of the product container to access. + /// Thrown when endpoint, key, databaseName, cacheContainername or chatContainerName is either null or empty. + /// + /// This constructor will validate credentials and create a service client instance. + /// + public CosmosDbService(string endpoint, string databaseName, string chatContainerName, string cacheContainerName, string productContainerName, string productDataSourceURI) + { + ArgumentNullException.ThrowIfNullOrEmpty(endpoint); + ArgumentNullException.ThrowIfNullOrEmpty(databaseName); + ArgumentNullException.ThrowIfNullOrEmpty(chatContainerName); + ArgumentNullException.ThrowIfNullOrEmpty(cacheContainerName); + ArgumentNullException.ThrowIfNullOrEmpty(productContainerName); + ArgumentNullException.ThrowIfNullOrEmpty(productDataSourceURI); + + _productDataSourceURI = productDataSourceURI; + + CosmosSerializationOptions options = new() + { + PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase + }; + + TokenCredential credential = new DefaultAzureCredential(); + CosmosClient client = new CosmosClientBuilder(endpoint, credential) + .WithSerializerOptions(options) + .Build(); + + + Database database = client.GetDatabase(databaseName)!; + Container chatContainer = database.GetContainer(chatContainerName)!; + Container cacheContainer = database.GetContainer(cacheContainerName)!; + Container productContainer = database.GetContainer(productContainerName)!; + + + _chatContainer = chatContainer ?? + throw new ArgumentException("Unable to connect to existing Azure Cosmos DB container or database."); + + _cacheContainer = cacheContainer ?? + throw new ArgumentException("Unable to connect to existing Azure Cosmos DB container or database."); + + _productContainer = productContainer ?? + throw new ArgumentException("Unable to connect to existing Azure Cosmos DB container or database."); + } + + public async Task LoadProductDataAsync() + { + + //Read the product container to see if there are any items + Product? item = null; + try + { + item = await _productContainer.ReadItemAsync(id: "027D0B9A-F9D9-4C96-8213-C8546C4AAE71", partitionKey: new PartitionKey("26C74104-40BC-4541-8EF5-9892F7F03D72")); + } + catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { } + + if (item is null) + { + string json = ""; + string jsonFilePath = _productDataSourceURI; //URI to the vectorized product JSON file + HttpClient client = new HttpClient(); + HttpResponseMessage response = await client.GetAsync(jsonFilePath); + if(response.IsSuccessStatusCode) + json = await response.Content.ReadAsStringAsync(); + + List products = JsonConvert.DeserializeObject>(json)!; + + + + foreach (var product in products) + { + try + { + await InsertProductAsync(product); + } + catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Conflict) + { + Console.WriteLine($"Error: {ex.Message}, Product Name: {product.name}"); + } + } + } + } + /// + /// Helper function to generate a full or partial hierarchical partition key based on parameters. + /// + /// Id of Tenant. + /// Id of User. + /// Session Id of Chat/Session + /// Newly created chat session item. + private static PartitionKey GetPK(string tenantId, string userId, string sessionId) + { + if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(userId) && !string.IsNullOrEmpty(sessionId)) + { + PartitionKey partitionKey = new PartitionKeyBuilder() + .Add(tenantId) + .Add(userId) + .Add(sessionId) + .Build(); + + return partitionKey; + } + else if(!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(userId)) + { + + PartitionKey partitionKey = new PartitionKeyBuilder() + .Add(tenantId) + .Add(userId) + .Build(); + + return partitionKey; + + } + else + { + PartitionKey partitionKey = new PartitionKeyBuilder() + .Add(tenantId) + .Build(); + + return partitionKey; + } + } + /// + /// Creates a new chat session. + /// + /// Id of Tenant. + /// Id of User. + /// Chat session item to create. + /// Newly created chat session item. + public async Task InsertSessionAsync(string tenantId, string userId, Session session) + { + PartitionKey partitionKey = GetPK(tenantId, userId,session.SessionId); + return await _chatContainer.CreateItemAsync( + item: session, + partitionKey: partitionKey + ); + } + + /// + /// Creates a new chat message. + /// + /// Id of Tenant. + /// Id of User. + /// Chat message item to create. + /// Newly created chat message item. + public async Task InsertMessageAsync(string tenantId, string userId, Message message) + { + PartitionKey partitionKey = GetPK(tenantId, userId, message.SessionId); + Message newMessage = message with { TimeStamp = DateTime.UtcNow }; + return await _chatContainer.CreateItemAsync( + item: message, + partitionKey: partitionKey + ); + } + + /// + /// Gets a list of all current chat sessions. + /// + /// Id of Tenant. + /// Id of User. + /// List of distinct chat session items. + public async Task> GetSessionsAsync(string tenantId, string userId) + { + PartitionKey partitionKey = GetPK(tenantId, userId, string.Empty); + + QueryDefinition query = new QueryDefinition("SELECT DISTINCT * FROM c WHERE c.type = @type") + .WithParameter("@type", nameof(Session)); + + FeedIterator response = _chatContainer.GetItemQueryIterator(query, null, new QueryRequestOptions() { PartitionKey = partitionKey }); + + List output = new(); + while (response.HasMoreResults) + { + FeedResponse results = await response.ReadNextAsync(); + output.AddRange(results); + } + return output; + } + + /// + /// Gets a list of all current chat messages for a specified session identifier. + /// + /// Id of Tenant. + /// Id of User. + /// Chat session identifier used to filter messsages. + /// List of chat message items for the specified session. + public async Task> GetSessionMessagesAsync(string tenantId, string userId, string sessionId) + { + PartitionKey partitionKey = GetPK(tenantId, userId, sessionId); + + QueryDefinition query = new QueryDefinition("SELECT * FROM c WHERE c.sessionId = @sessionId AND c.type = @type") + .WithParameter("@sessionId", sessionId) + .WithParameter("@type", nameof(Message)); + + FeedIterator results = _chatContainer.GetItemQueryIterator(query, null, new QueryRequestOptions() { PartitionKey = partitionKey }); + + List output = new(); + while (results.HasMoreResults) + { + FeedResponse response = await results.ReadNextAsync(); + output.AddRange(response); + } + return output; + } + + /// + /// Updates an existing chat session. + /// + /// Id of Tenant. + /// Id of User. + /// Chat session item to update. + /// Revised created chat session item. + public async Task UpdateSessionAsync(string tenantId, string userId, Session session) + { + PartitionKey partitionKey = GetPK(tenantId, userId, session.SessionId); + return await _chatContainer.ReplaceItemAsync( + item: session, + id: session.Id, + partitionKey: partitionKey + ); + } + + /// + /// Returns an existing chat session. + /// + /// Id of Tenant. + /// Id of User. + /// Chat session id for the session to return. + /// Chat session item. + public async Task GetSessionAsync(string tenantId, string userId, string sessionId) + { + PartitionKey partitionKey = GetPK(tenantId, userId, sessionId); + return await _chatContainer.ReadItemAsync( + partitionKey: partitionKey, + id: sessionId + ); + } + + /// + /// Batch create chat message and update session. + /// + /// Id of Tenant. + /// Id of User. + /// Chat message and session items to create or replace. + public async Task UpsertSessionBatchAsync(string tenantId, string userId, params dynamic[] messages) + { + + //Make sure items are all in the same partition + if (messages.Select(m => m.SessionId).Distinct().Count() > 1) + { + throw new ArgumentException("All items must have the same partition key."); + } + + PartitionKey partitionKey = GetPK(tenantId, userId, messages[0].SessionId); + TransactionalBatch batch = _chatContainer.CreateTransactionalBatch(partitionKey); + + foreach (var message in messages) + { + batch.UpsertItem(item: message); + } + + await batch.ExecuteAsync(); + } + + /// + /// Batch deletes an existing chat session and all related messages. + /// + /// Id of Tenant. + /// Id of User. + /// Chat session identifier used to flag messages and sessions for deletion. + + public async Task DeleteSessionAndMessagesAsync(string tenantId, string userId, string sessionId) + { + PartitionKey partitionKey = GetPK(tenantId, userId, sessionId); + + QueryDefinition query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.sessionId = @sessionId") + .WithParameter("@sessionId", sessionId); + + FeedIterator response = _chatContainer.GetItemQueryIterator(query); + + TransactionalBatch batch = _chatContainer.CreateTransactionalBatch(partitionKey); + while (response.HasMoreResults) + { + FeedResponse results = await response.ReadNextAsync(); + foreach (var itemId in results) + { + batch.DeleteItem( + id: itemId + ); + } + } + await batch.ExecuteAsync(); + } + + /// + /// Upserts a new product. + /// + /// Product item to create or update. + /// Newly created product item. + public async Task InsertProductAsync(Product product) + { + PartitionKey partitionKey = new(product.categoryId); + return await _productContainer.CreateItemAsync( + item: product, + partitionKey: partitionKey + ); + } + + /// + /// Delete a product. + /// + /// Product item to delete. + public async Task DeleteProductAsync(Product product) + { + PartitionKey partitionKey = new(product.categoryId); + await _productContainer.DeleteItemAsync( + id: product.id, + partitionKey: partitionKey + ); + } + + /// + /// Search vectors for similar products. + /// + /// Product item to delete. + /// Array of similar product items. + public async Task> SearchProductsAsync(float[] vectors, int productMaxResults) + { + List results = new(); + + //Return only the properties we need to generate a completion. Often don't need id values. + + //{productMaxResults} + string queryText = $""" + SELECT + Top @maxResults + c.categoryName, c.sku, c.name, c.description, c.price, c.tags, VectorDistance(c.vectors, @vectors) as similarityScore + FROM c + ORDER BY VectorDistance(c.vectors, @vectors) + """; + + var queryDef = new QueryDefinition( + query: queryText) + .WithParameter("@maxResults", productMaxResults) + .WithParameter("@vectors", vectors); + + using FeedIterator resultSet = _productContainer.GetItemQueryIterator(queryDefinition: queryDef); + + while (resultSet.HasMoreResults) + { + FeedResponse response = await resultSet.ReadNextAsync(); + + results.AddRange(response); + } + + return results; + } + + + /// + /// Find a cache item. + /// Select Top 1 to get only get one result. + /// OrderBy DESC to return the highest similary score first. + /// Use a subquery to get the similarity score so we can then use in a WHERE clause + /// + /// Vectors to do the semantic search in the cache. + /// Value to determine how similar the vectors. >0.99 is exact match. + public async Task GetCacheAsync(float[] vectors, double similarityScore) + { + + string cacheResponse = ""; + + string queryText = $""" + SELECT Top 1 c.prompt, c.completion, VectorDistance(c.vectors, @vectors) as similarityScore + FROM c + WHERE VectorDistance(c.vectors, @vectors) > @similarityScore + ORDER BY VectorDistance(c.vectors, @vectors) + """; + + //string queryText = $""" + // SELECT Top 1 x.prompt, x.completion, x.similarityScore + // FROM (SELECT c.prompt, c.completion, VectorDistance(c.vectors, @vectors, false) as similarityScore FROM c) + // x WHERE x.similarityScore > @similarityScore ORDER BY x.similarityScore desc + // """; + + var queryDef = new QueryDefinition( + query: queryText) + .WithParameter("@vectors", vectors) + .WithParameter("@similarityScore", similarityScore); + + using FeedIterator resultSet = _cacheContainer.GetItemQueryIterator(queryDefinition: queryDef); + + while (resultSet.HasMoreResults) + { + FeedResponse response = await resultSet.ReadNextAsync(); + + foreach (CacheItem item in response) + { + cacheResponse = item.Completion; + return cacheResponse; + } + } + + return cacheResponse; + } + + /// + /// Add a new cache item. + /// + /// Vectors used to perform the semantic search. + /// Text value of the vectors in the search. + /// Text value of the previously generated response to return to the user. + public async Task CachePutAsync(CacheItem cacheItem) + { + + await _cacheContainer.UpsertItemAsync(item: cacheItem); + } + + /// + /// Remove a cache item using its vectors. + /// + /// Vectors used to perform the semantic search. Similarity Score is set to 0.99 for exact match + public async Task CacheRemoveAsync(float[] vectors) + { + double similarityScore = 0.99; + //string queryText = "SELECT Top 1 c.id FROM (SELECT c.id, VectorDistance(c.vectors, @vectors, false) as similarityScore FROM c) x WHERE x.similarityScore > @similarityScore ORDER BY x.similarityScore desc"; + + string queryText = $""" + SELECT Top 1 c.id + FROM c + WHERE VectorDistance(c.vectors, @vectors) > @similarityScore + ORDER BY VectorDistance(c.vectors, @vectors) + """; + + var queryDef = new QueryDefinition( + query: queryText) + .WithParameter("@vectors", vectors) + .WithParameter("@similarityScore", similarityScore); + + using FeedIterator resultSet = _cacheContainer.GetItemQueryIterator(queryDefinition: queryDef); + + while (resultSet.HasMoreResults) + { + FeedResponse response = await resultSet.ReadNextAsync(); + + foreach (CacheItem item in response) + { + await _cacheContainer.DeleteItemAsync(partitionKey: new PartitionKey(item.Id), id: item.Id); + return; + } + } + } + + /// + /// Clear the cache of all cache items. + /// + public async Task CacheClearAsync() + { + + string queryText = "SELECT c.id FROM c"; + + var queryDef = new QueryDefinition(query: queryText); + + using FeedIterator resultSet = _cacheContainer.GetItemQueryIterator(queryDefinition: queryDef); + + while (resultSet.HasMoreResults) + { + FeedResponse response = await resultSet.ReadNextAsync(); + + foreach (CacheItem item in response) + { + await _cacheContainer.DeleteItemAsync(partitionKey: new PartitionKey(item.Id), id: item.Id); + } + } + } +} \ No newline at end of file diff --git a/src/Services/OpenAiService.cs b/src/Services/OpenAiService.cs new file mode 100644 index 0000000..615460a --- /dev/null +++ b/src/Services/OpenAiService.cs @@ -0,0 +1,208 @@ +using Azure; +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Identity; +using Cosmos.Copilot.Models; +using Newtonsoft.Json; + +namespace Cosmos.Copilot.Services; + +/// +/// Service to access Azure OpenAI. +/// +public class OpenAiService +{ + private readonly string _completionDeploymentName = String.Empty; + private readonly string _embeddingDeploymentName = String.Empty; + private readonly OpenAIClient _client; + + /// + /// System prompt to send with user prompts to instruct the model for chat session + /// + private readonly string _systemPrompt = @" + You are an AI assistant that helps people find information. + Provide concise answers that are polite and professional." + Environment.NewLine; + + /// + /// System prompt to send with user prompts as a Retail AI Assistant for chat session + /// + private readonly string _systemPromptRetailAssistant = @" + You are an intelligent assistant for the Cosmic Works Bike Company. + You are designed to provide helpful answers to user questions about + bike products and accessories provided in JSON format below. + + Instructions: + - Only answer questions related to the information provided below, + - Don't reference any product data not provided below. + - If you're unsure of an answer, you can say ""I don't know"" or ""I'm not sure"" and recommend users search themselves. + + Text of relevant information:"; + + /// + /// System prompt to send with user prompts to instruct the model for summarization + /// + private readonly string _summarizePrompt = @" + Summarize this prompt in one or two words to use as a label in a button on a web page. + Do not use any punctuation." + Environment.NewLine; + + /// + /// Creates a new instance of the service. + /// + /// Endpoint URI. + /// Name of the deployed Azure OpenAI completion model. + /// Name of the deployed Azure OpenAI embedding model. + /// Thrown when endpoint, key, or modelName is either null or empty. + /// + /// This constructor will validate credentials and create a HTTP client instance. + /// + public OpenAiService(string endpoint, string completionDeploymentName, string embeddingDeploymentName) + { + ArgumentNullException.ThrowIfNullOrEmpty(endpoint); + ArgumentNullException.ThrowIfNullOrEmpty(completionDeploymentName); + ArgumentNullException.ThrowIfNullOrEmpty(embeddingDeploymentName); + + _completionDeploymentName = completionDeploymentName; + _embeddingDeploymentName = embeddingDeploymentName; + + TokenCredential credential = new DefaultAzureCredential(); + _client = new OpenAIClient(new Uri(endpoint), credential); + } + + /// + /// Sends a prompt to the deployed OpenAI LLM model and returns the response. + /// + /// Chat session identifier for the current conversation. + /// List of Message objects containign the context window (chat history) to send to the model. + /// Generated response along with tokens used to generate it. + public async Task<(string completion, int tokens)> GetChatCompletionAsync(string sessionId, List conversation) + { + + //Serialize the conversation to a string to send to OpenAI + string conversationString = string.Join(Environment.NewLine, conversation.Select(m => m.Prompt + " " + m.Completion)); + + ChatCompletionsOptions options = new() + { + DeploymentName = _completionDeploymentName, + Messages = + { + new ChatRequestSystemMessage(_systemPrompt), + new ChatRequestUserMessage(conversationString) + }, + User = sessionId, + MaxTokens = 1000, + Temperature = 0.2f, + NucleusSamplingFactor = 0.7f, + FrequencyPenalty = 0, + PresencePenalty = 0 + }; + + Response completionsResponse = await _client.GetChatCompletionsAsync(options); + + ChatCompletions completions = completionsResponse.Value; + + string completion = completions.Choices[0].Message.Content; + int tokens = completions.Usage.CompletionTokens; + + + return (completion, tokens); + } + + /// + /// Sends a prompt and vector search results to the deployed OpenAI LLM model and returns the response. + /// + /// Chat session identifier for the current conversation. + /// List of Message objects containign the context window (chat history) to send to the model. + /// List of Product objects containing vector search results to augment the LLM completion. + /// Generated response along with tokens used to generate it. + public async Task<(string completion, int tokens)> GetRagCompletionAsync(string sessionId, List contextWindow, List products) + { + //Serialize List to a JSON string to send to OpenAI + string productsString = JsonConvert.SerializeObject(products); + + //Serialize the conversation to a string to send to OpenAI + string contextWindowString = string.Join(Environment.NewLine, contextWindow.Select(m => m.Prompt + " " + m.Completion)); + + ChatCompletionsOptions options = new() + { + DeploymentName = _completionDeploymentName, + Messages = + { + new ChatRequestSystemMessage(_systemPromptRetailAssistant + productsString), + new ChatRequestUserMessage(contextWindowString) + }, + User = sessionId, + MaxTokens = 1000, + Temperature = 0.2f, + NucleusSamplingFactor = 0.7f, + FrequencyPenalty = 0, + PresencePenalty = 0 + }; + + Response completionsResponse = await _client.GetChatCompletionsAsync(options); + + ChatCompletions completions = completionsResponse.Value; + + string completion = completions.Choices[0].Message.Content; + int tokens = completions.Usage.CompletionTokens; + + + return (completion, tokens); + } + + /// + /// Sends the existing conversation to the OpenAI model and returns a two word summary. + /// + /// Chat session identifier for the current conversation. + /// conversation history to send to OpenAI. + /// Summarization response from the OpenAI completion model deployment. + public async Task SummarizeAsync(string sessionId, string conversationText) + { + + ChatRequestSystemMessage systemMessage = new(_summarizePrompt); + ChatRequestUserMessage userMessage = new(conversationText); + + ChatCompletionsOptions options = new() + { + DeploymentName = _completionDeploymentName, + Messages = { + systemMessage, + userMessage + }, + User = sessionId, + MaxTokens = 200, + Temperature = 0.0f, + NucleusSamplingFactor = 1.0f, + FrequencyPenalty = 0, + PresencePenalty = 0 + }; + + Response completionsResponse = await _client.GetChatCompletionsAsync(options); + + ChatCompletions completions = completionsResponse.Value; + + string completionText = completions.Choices[0].Message.Content; + + return completionText; + } + + /// + /// Generates embeddings from the deployed OpenAI embeddings model and returns an array of vectors. + /// + /// Text to send to OpenAI. + /// Array of vectors from the OpenAI embedding model deployment. + public async Task GetEmbeddingsAsync(string input) + { + + float[] embedding = new float[0]; + + EmbeddingsOptions options = new EmbeddingsOptions(_embeddingDeploymentName, new List { input }); + + var response = await _client.GetEmbeddingsAsync(options); + + Embeddings embeddings = response.Value; + + embedding = embeddings.Data[0].Embedding.ToArray(); + + return embedding; + } +} diff --git a/src/Services/SemanticKernelService.cs b/src/Services/SemanticKernelService.cs new file mode 100644 index 0000000..5bafcb3 --- /dev/null +++ b/src/Services/SemanticKernelService.cs @@ -0,0 +1,202 @@ +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Cosmos.Copilot.Models; +using Microsoft.SemanticKernel.Embeddings; +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Identity; +using Newtonsoft.Json; + +namespace Cosmos.Copilot.Services; + +/// +/// Semantic Kernel implementation for Azure OpenAI. +/// +public class SemanticKernelService +{ + //Semantic Kernel + readonly Kernel kernel; + + /// + /// System prompt to send with user prompts to instruct the model for chat session + /// + private readonly string _systemPrompt = @" + You are an AI assistant that helps people find information. + Provide concise answers that are polite and professional."; + + /// + /// System prompt to send with user prompts as a Retail AI Assistant for chat session + /// + private readonly string _systemPromptRetailAssistant = @" + You are an intelligent assistant for the Cosmic Works Bike Company. + You are designed to provide helpful answers to user questions about + bike products and accessories provided in JSON format below. + + Instructions: + - Only answer questions related to the information provided below, + - Don't reference any product data not provided below. + - If you're unsure of an answer, you can say ""I don't know"" or ""I'm not sure"" and recommend users search themselves. + + Text of relevant information:"; + + /// + /// System prompt to send with user prompts to instruct the model for summarization + /// + private readonly string _summarizePrompt = @" + Summarize this text. One to three words maximum length. + Plain text only. No punctuation, markup or tags."; + + /// + /// Creates a new instance of the Semantic Kernel. + /// + /// Endpoint URI. + /// Name of the deployed Azure OpenAI completion model. + /// Name of the deployed Azure OpenAI embedding model. + /// Thrown when endpoint, key, or modelName is either null or empty. + /// + /// This constructor will validate credentials and create a Semantic Kernel instance. + /// + public SemanticKernelService(string endpoint, string completionDeploymentName, string embeddingDeploymentName) + { + ArgumentNullException.ThrowIfNullOrEmpty(endpoint); + ArgumentNullException.ThrowIfNullOrEmpty(completionDeploymentName); + ArgumentNullException.ThrowIfNullOrEmpty(embeddingDeploymentName); + + TokenCredential credential = new DefaultAzureCredential(); + // Initialize the Semantic Kernel + kernel = Kernel.CreateBuilder() + .AddAzureOpenAIChatCompletion(completionDeploymentName, endpoint, credential) + .AddAzureOpenAITextEmbeddingGeneration(embeddingDeploymentName, endpoint, credential) + .Build(); + } + + /// + /// Generates a completion using a user prompt with chat history to Semantic Kernel and returns the response. + /// + /// Chat session identifier for the current conversation. + /// List of Message objects containign the context window (chat history) to send to the model. + /// Generated response along with tokens used to generate it. + public async Task<(string completion, int tokens)> GetChatCompletionAsync(string sessionId, List contextWindow) + { + var skChatHistory = new ChatHistory(); + skChatHistory.AddSystemMessage(_systemPrompt); + + foreach (var message in contextWindow) + { + skChatHistory.AddUserMessage(message.Prompt); + if (message.Completion != string.Empty) + skChatHistory.AddAssistantMessage(message.Completion); + } + + PromptExecutionSettings settings = new() + { + ExtensionData = new Dictionary() + { + { "Temperature", 0.2 }, + { "TopP", 0.7 }, + { "MaxTokens", 1000 } + } + }; + + + var result = await kernel.GetRequiredService().GetChatMessageContentAsync(skChatHistory, settings); + + CompletionsUsage completionUsage = (CompletionsUsage)result.Metadata!["Usage"]!; + + string completion = result.Items[0].ToString()!; + int tokens = completionUsage.CompletionTokens; + + return (completion, tokens); + } + + /// + /// Generates a completion using a user prompt with chat history and vector search results to Semantic Kernel and returns the response. + /// + /// Chat session identifier for the current conversation. + /// List of Message objects containing the context window (chat history) to send to the model. + /// List of Product objects containing vector search results to send to the model. + /// Generated response along with tokens used to generate it. + public async Task<(string completion, int tokens)> GetRagCompletionAsync(string sessionId, List contextWindow, List products) + { + //Serialize List to a JSON string to send to OpenAI + string productsString = JsonConvert.SerializeObject(products); + + var skChatHistory = new ChatHistory(); + skChatHistory.AddSystemMessage(_systemPromptRetailAssistant + productsString); + + + foreach (var message in contextWindow) + { + skChatHistory.AddUserMessage(message.Prompt); + if (message.Completion != string.Empty) + skChatHistory.AddAssistantMessage(message.Completion); + } + + PromptExecutionSettings settings = new() + { + ExtensionData = new Dictionary() + { + { "Temperature", 0.2 }, + { "TopP", 0.7 }, + { "MaxTokens", 1000 } + } + }; + + + var result = await kernel.GetRequiredService().GetChatMessageContentAsync(skChatHistory, settings); + + CompletionsUsage completionUsage = (CompletionsUsage)result.Metadata!["Usage"]!; + + string completion = result.Items[0].ToString()!; + int tokens = completionUsage.CompletionTokens; + + return (completion, tokens); + } + + + /// + /// Generates embeddings from the deployed OpenAI embeddings model using Semantic Kernel. + /// + /// Text to send to OpenAI. + /// Array of vectors from the OpenAI embedding model deployment. + public async Task GetEmbeddingsAsync(string text) + { + var embeddings = await kernel.GetRequiredService().GenerateEmbeddingAsync(text); + + float[] embeddingsArray = embeddings.ToArray(); + + return embeddingsArray; + } + + /// + /// Sends the existing conversation to the Semantic Kernel and returns a two word summary. + /// + /// Chat session identifier for the current conversation. + /// conversation history to send to Semantic Kernel. + /// Summarization response from the OpenAI completion model deployment. + public async Task SummarizeConversationAsync(string conversation) + { + //return await summarizePlugin.SummarizeConversationAsync(conversation, kernel); + + var skChatHistory = new ChatHistory(); + skChatHistory.AddSystemMessage(_summarizePrompt); + skChatHistory.AddUserMessage(conversation); + + PromptExecutionSettings settings = new() + { + ExtensionData = new Dictionary() + { + { "Temperature", 0.0 }, + { "TopP", 1.0 }, + { "MaxTokens", 100 } + } + }; + + + var result = await kernel.GetRequiredService().GetChatMessageContentAsync(skChatHistory, settings); + + string completion = result.Items[0].ToString()!; + + return completion; + } +} diff --git a/src/Shared/MainLayout.razor b/src/Shared/MainLayout.razor new file mode 100644 index 0000000..cd8d099 --- /dev/null +++ b/src/Shared/MainLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase +Azure Cosmos DB + ChatGPT +@Body \ No newline at end of file diff --git a/src/_Imports.razor b/src/_Imports.razor new file mode 100644 index 0000000..70cd6a1 --- /dev/null +++ b/src/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Cosmos.Copilot +@using Cosmos.Copilot.Models +@using Cosmos.Copilot.Shared +@using Cosmos.Copilot.Components diff --git a/src/appsettings.json b/src/appsettings.json new file mode 100644 index 0000000..ab8c669 --- /dev/null +++ b/src/appsettings.json @@ -0,0 +1,33 @@ +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "CosmosDb": { + "Endpoint": "", + "Database": "cosmoscopilotdb", + "ChatContainer": "chat", + "CacheContainer": "cache", + "ProductContainer": "products", + "ProductDataSourceURI": "https://cosmosdbcosmicworks.blob.core.windows.net/cosmic-works-vectorized/product-text-3-large-1536.json" + }, + "OpenAi": { + "Endpoint": "", + "CompletionDeploymentName": "", + "EmbeddingDeploymentName": "" + }, + "SemanticKernel": { + "Endpoint": "", + "CompletionDeploymentName": "", + "EmbeddingDeploymentName": "" + }, + "Chat": { + "MaxConversationTokens": "100", + "CacheSimilarityScore": "0.99", + "ProductMaxResults": "10" + } +} \ No newline at end of file diff --git a/src/cosmos-copilot.csproj b/src/cosmos-copilot.csproj new file mode 100644 index 0000000..111693a --- /dev/null +++ b/src/cosmos-copilot.csproj @@ -0,0 +1,34 @@ + + + net8.0 + enable + enable + Cosmos.Copilot + faca8719-db54-4203-bb6e-cabe9c31df22 + + + + + + + + + + + + + + + + + + + Always + + + + $(NoWarn);SKEXP0001,SKEXP0010 + Linux + . + + \ No newline at end of file diff --git a/src/cosmos-copilot.sln b/src/cosmos-copilot.sln index 7710509..80d115b 100644 --- a/src/cosmos-copilot.sln +++ b/src/cosmos-copilot.sln @@ -3,11 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.4.33213.308 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "cosmos-copilot.AppHost", "cosmos-copilot.AppHost\cosmos-copilot.AppHost.csproj", "{FA8CBED7-7D23-4936-A4BC-7C910CEF074E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "cosmos-copilot.ServiceDefaults", "cosmos-copilot.ServiceDefaults\cosmos-copilot.ServiceDefaults.csproj", "{3BD36A7B-6692-4984-AC15-323F47D23C4A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "cosmos-copilot.WebApp", "cosmos-copilot.WebApp\cosmos-copilot.WebApp.csproj", "{1EFFAAEE-E02F-4CFA-B1CC-40C07190EFA8}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "cosmos-copilot", "cosmos-copilot.csproj", "{AB2C218B-2B3F-46CA-A50F-EF5648363AE5}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,18 +11,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {FA8CBED7-7D23-4936-A4BC-7C910CEF074E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA8CBED7-7D23-4936-A4BC-7C910CEF074E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA8CBED7-7D23-4936-A4BC-7C910CEF074E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA8CBED7-7D23-4936-A4BC-7C910CEF074E}.Release|Any CPU.Build.0 = Release|Any CPU - {3BD36A7B-6692-4984-AC15-323F47D23C4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3BD36A7B-6692-4984-AC15-323F47D23C4A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3BD36A7B-6692-4984-AC15-323F47D23C4A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3BD36A7B-6692-4984-AC15-323F47D23C4A}.Release|Any CPU.Build.0 = Release|Any CPU - {1EFFAAEE-E02F-4CFA-B1CC-40C07190EFA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1EFFAAEE-E02F-4CFA-B1CC-40C07190EFA8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1EFFAAEE-E02F-4CFA-B1CC-40C07190EFA8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1EFFAAEE-E02F-4CFA-B1CC-40C07190EFA8}.Release|Any CPU.Build.0 = Release|Any CPU + {AB2C218B-2B3F-46CA-A50F-EF5648363AE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB2C218B-2B3F-46CA-A50F-EF5648363AE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB2C218B-2B3F-46CA-A50F-EF5648363AE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB2C218B-2B3F-46CA-A50F-EF5648363AE5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/nuget.config b/src/nuget.config new file mode 100644 index 0000000..2c70539 --- /dev/null +++ b/src/nuget.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/wwwroot/favicon.ico b/src/wwwroot/favicon.ico new file mode 100644 index 0000000..8e96706 Binary files /dev/null and b/src/wwwroot/favicon.ico differ diff --git a/src/wwwroot/js/site.js b/src/wwwroot/js/site.js new file mode 100644 index 0000000..f0259e0 --- /dev/null +++ b/src/wwwroot/js/site.js @@ -0,0 +1,9 @@ +function scrollToLastMessage() +{ + if (document.getElementById('MessagesInChatdiv')) { + var elem = document.getElementById('MessagesInChatdiv'); + elem.scrollTop = elem.scrollHeight; + return true; + } + return false; +} \ No newline at end of file