diff --git a/.github/workflows/open-ai-app.yml b/.github/workflows/open-ai-app.yml index be7339328..29cf38368 100644 --- a/.github/workflows/open-ai-app.yml +++ b/.github/workflows/open-ai-app.yml @@ -41,8 +41,8 @@ jobs: - name: 📦 Package Next application run: | cd ./site-deploy - zip Nextjs-site.zip ./* .next -qr - + zip Nextjs-site.zip ./* .next -qr + - name: 🔍 Diagnostics run: | ls ./src @@ -61,6 +61,11 @@ jobs: environment: name: "Development" steps: + - name: 🍏 Set up Node.js version + uses: actions/setup-node@v4 + with: + node-version: "20.x" + - name: ⬇️ Download artifact from build job uses: actions/download-artifact@v4 with: @@ -71,7 +76,7 @@ jobs: with: creds: ${{ secrets.AZURE_CREDENTIALS }} - # Set the build during deployment setting to false. This setting was added in the templates to all azd to work, but breaks deployment via webapps-deploy + # Set the build during deployment setting to false. This setting was added in the templates to all azd to work, but breaks deployment via webapps-deploy - name: Azure CLI script uses: azure/CLI@v1 with: @@ -127,5 +132,4 @@ jobs: resource-group-name: ${{ secrets.AZURE_APP_SERVICE_RG_NAME_PROD }} app-name: ${{ secrets.AZURE_APP_SERVICE_NAME_PROD }} package: ${{ github.workspace }}/Nextjs-site.zip - diff --git a/.gitignore b/.gitignore index 27d7814de..0bd4239ca 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ next-env.d.ts .azure/ infra/aad_setup.sh +.vscode diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index ea24ab003..000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Next.js: debug server-side", - "type": "node-terminal", - "request": "launch", - "cwd": "${workspaceFolder}/src", - "command": "npm run dev" - }, - { - "name": "Next.js: debug client-side", - "type": "chrome", - "request": "launch", - "cwd": "${workspaceFolder}/src", - "url": "http://localhost:3000" - }, - { - "name": "Next.js: debug full stack", - "type": "node-terminal", - "request": "launch", - "cwd": "${workspaceFolder}/src", - "command": "npm run dev", - "serverReadyAction": { - "pattern": "started server on .+, url: (https?://.+)", - "uriFormat": "%s", - "action": "debugWithChrome" - } - } - ] - } \ No newline at end of file diff --git a/README.md b/README.md index 805550ad5..dad35ef84 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,16 @@ 1. [Deploy to Azure with GitHub Actions](/docs/4-deploy-to-azure.md) 1. [Add identity provider](/docs/5-add-identity.md) 1. [Chatting with your file](/docs/6-chat-over-file.md) -1. [Environment variables](/docs/7-environment-variables.md) +1. [Persona](/docs/6-persona.md) +1. [Extensions](/docs/8-extensions.md) +1. [Environment variables](/docs/9-environment-variables.md) +1. [Migration considerations](/docs/migration.md) # Introduction _Azure Chat Solution Accelerator powered by Azure Open AI Service_ -![](/images/intro.png) +![](/docs/images/intro.png) _Azure Chat Solution Accelerator powered by Azure Open AI Service_ is a solution accelerator that allows organisations to deploy a private chat tenant in their Azure Subscription, with a familiar user experience and the added capabilities of chatting over your data and files. @@ -38,25 +41,31 @@ You can deploy the application using one of the following options: ### 1. Azure Developer CLI -> **Important** +> [!IMPORTANT] > This section will create Azure resources and deploy the solution from your local environment using the Azure Developer CLI. Note that you do not need to clone this repo to complete these steps. 1. Download the [Azure Developer CLI](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/overview) 1. If you have not cloned this repo, run `azd init -t microsoft/azurechat`. If you have cloned this repo, just run 'azd init' from the repo root directory. 1. Run `azd up` to provision and deploy the application +```pwsh +azd init -t microsoft/azurechat +azd up + +# if you are wanting to see logs run with debug flag +azd up --debug +``` + ### 2. Azure Portal Deployment -> **Warning** +> [!WARNING] > This button will only create Azure resources. You will still need to deploy the application by following the [deploy to Azure section](/docs/4-deploy-to-azure.md) to build and deploy the application using GitHub actions. Click on the Deploy to Azure button to deploy the Azure resources for the application. [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://aka.ms/anzappazurechatgpt) -## Setup Authentication - -> **Important** +> [!IMPORTANT] > The application is protected by an identity provider and follow the steps in [Add an identity provider](/docs/5-add-identity.md) section for adding authentication to your app. [Next](./docs/1-introduction.md) diff --git a/azure.yaml b/azure.yaml index 15bb0292a..26b77081b 100644 --- a/azure.yaml +++ b/azure.yaml @@ -1,8 +1,8 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json -name: azurechat +name: azure-chat metadata: - template: azurechat@0.0.1 + template: azure-chat@0.0.1 services: frontend: project: ./src diff --git a/docs/1-introduction.md b/docs/1-introduction.md index 6a9d671a0..8ef292c24 100644 --- a/docs/1-introduction.md +++ b/docs/1-introduction.md @@ -7,8 +7,8 @@ Please make sure the following prerequisites are in place prior to deploying thi 2. Setup GitHub or Azure AD for Authentication: The [add an identity provider](./5-add-identity.md) section below shows how to configure authentication providers. - > **Note** - > You can configure the authentication provider to your identity solution using [NextAuth providers](https://next-auth.js.org/providers/) +> [!NOTE] +> You can configure the authentication provider to your identity solution using [NextAuth providers](https://next-auth.js.org/providers/) ## 👋🏻 Introduction @@ -20,7 +20,7 @@ _Azure Chat Solution Accelerator powered by Azure Open AI Service_ solution acce - [NextAuth.js](https://next-auth.js.org/): configurable authentication framework for Next.js 13 -- [ai sdk](https://sdk.vercel.ai/docs) Open-source library that simplifies building conversational UI on top Next.js and JavaScript +- [OpenAI sdk](https://github.com/openai/openai-node) NodeJS library that simplifies building conversational UI - [Tailwind CSS](https://tailwindcss.com/): is a utility-first CSS framework that provides a series of predefined classes that can be used to style each element by mixing and matching @@ -38,7 +38,7 @@ The following Azure services can be deployed to expand the feature set of your s - [Azure Document Intelligence](https://learn.microsoft.com/en-GB/azure/ai-services/document-intelligence/) Microsoft Azure Form Recognizer is an automated data processing system that uses AI and OCR to quickly extract text and structure from documents. We use this service for extracting information from documents. -- [Azure Cognitive Search](https://learn.microsoft.com/en-GB/azure/search/) Azure Cognitive Search is an AI-powered platform as a service (PaaS) that helps developers build rich search experiences for applications. We use this service for indexing and retrieving information. +- [Azure AI Search ](https://learn.microsoft.com/en-GB/azure/search/) Azure AI Search is an AI-powered platform as a service (PaaS) that helps developers build rich search experiences for applications. We use this service for indexing and retrieving information. - [Azure OpenAI Embeddings](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/embeddings?tabs=console) for embed content extracted from files. @@ -48,7 +48,7 @@ The following Azure services can be deployed to expand the feature set of your s The following high-level diagram depicts the architecture of the solution accelerator: -![Architecture diagram](/images/architecture.png) +![Architecture diagram](/docs/images/architecture.png) # Azure Deployment Costs @@ -58,13 +58,13 @@ However, you can try the [Azure pricing calculator - Sample Estimate](https://az - Azure App Service: Premium V3 Tier 1 CPU core, 4 GB RAM, 250 GB Storage. Pricing per hour. [Pricing](https://azure.microsoft.com/pricing/details/app-service/linux/) - Azure Open AI: Standard tier, ChatGPT and Embedding models. Pricing per 1K tokens used, and at least 1K tokens are used per question. [Pricing](https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/) - Form Recognizer: SO (Standard) tier using pre-built layout. Pricing per document page, sample documents have 261 pages total. [Pricing](https://azure.microsoft.com/pricing/details/form-recognizer/) -- Azure Cognitive Search: Standard tier, 1 replica, free level of semantic search. Pricing per hour.[Pricing](https://azure.microsoft.com/pricing/details/search/) +- Azure AI Search : Standard tier, 1 replica, free level of semantic search. Pricing per hour.[Pricing](https://azure.microsoft.com/pricing/details/search/) - Azure Cosmos DB: Standard provisioned throughput with ZRS (Zone-redundant storage). Pricing per storage and read operations. [Pricing](https://azure.microsoft.com/en-us/pricing/details/cosmos-db/autoscale-provisioned/) - Azure Monitor: Pay-as-you-go tier. Costs based on data ingested. [Pricing](https://azure.microsoft.com/pricing/details/monitor/) -To reduce costs, you can switch to free SKUs for Azure App Service, Azure Cognitive Search, and Form Recognizer by changing the parameters file under the `./infra` folder. There are some limits to consider; for example, you can have up to 1 free Cognitive Search resource per subscription, and the free Form Recognizer resource only analyzes the first 2 pages of each document. You can also reduce costs associated with the Form Recognizer by reducing the number of documents you upload. +To reduce costs, you can switch to free SKUs for Azure App Service, Azure AI Search , and Form Recognizer by changing the parameters file under the `./infra` folder. There are some limits to consider; for example, you can have up to 1 free Cognitive Search resource per subscription, and the free Form Recognizer resource only analyzes the first 2 pages of each document. You can also reduce costs associated with the Form Recognizer by reducing the number of documents you upload. -> **Warning** +> [!WARNING] > To avoid unnecessary costs, remember to destroy your provisioned resources by deleting the resource group. [Next](/docs/2-provision-azure-resources.md) diff --git a/docs/3-run-locally.md b/docs/3-run-locally.md index c846773a5..cfbf39798 100644 --- a/docs/3-run-locally.md +++ b/docs/3-run-locally.md @@ -11,94 +11,14 @@ Clone this repository locally or fork to your Github account. Run all of the the ## Steps 1. Change directory to the `src` folder -2. Copy the file `.env.example` and rename it to `.env.local`. -3. Populate the environment variables in this file. -
Environment Variables (ref src/.env.example) - - ```bash - # NOTES: - # - Do not use double-quotes and do not delete any of the variables. - # - Make sure that NEXTAUTH_URL=http://localhost:3000 has no comments in the same line. - - # Update your Azure OpenAI details - - # AZURE_OPENAI_API_INSTANCE_NAME should be just the name of azure openai resource and not the full url; - - # AZURE_OPENAI_API_DEPLOYMENT_NAME should be deployment name from your azure openai studio and not the model name. - - # AZURE_OPENAI_API_VERSION should be Supported versions checkout docs https://learn.microsoft.com/en-us/azure/ai-services/openai/reference - - OPENAI_API_KEY= - AZURE_OPENAI_API_INSTANCE_NAME= - AZURE_OPENAI_API_DEPLOYMENT_NAME= - AZURE_OPENAI_API_VERSION=2023-03-15-preview - AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= - - # Update your admin email address - - ADMIN_EMAIL_ADDRESS="you@email.com,you2@email.com" - - # You must have atleast one of the following auth providers configured - - AUTH_GITHUB_ID= - AUTH_GITHUB_SECRET= - AZURE_AD_CLIENT_ID= - AZURE_AD_CLIENT_SECRET= - AZURE_AD_TENANT_ID= - - # Update your production URL in NEXTAUTH_URL - - NEXTAUTH_SECRET=AZURE-OPENIAI-NEXTAUTH-OWNKEY@1 - NEXTAUTH_URL=http://localhost:3000 - - # Update your Cosmos Environment details here - - AZURE_COSMOSDB_URI=https://.documents.azure.com:443/ - AZURE_COSMOSDB_KEY= - - # Update your Cosmos DB_NAME and CONTAINER_NAME if you want to overwrite the default values - - AZURE_COSMOSDB_DB_NAME=chat - AZURE_COSMOSDB_CONTAINER_NAME=history - - # Azure cognitive search is used for chat over your data - - AZURE_SEARCH_API_KEY= - AZURE_SEARCH_NAME= - AZURE_SEARCH_INDEX_NAME= - AZURE_SEARCH_API_VERSION="2023-07-01-Preview" - - # Azure AI Document Intelligence to extract content from your data - - AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT="https://REGION.api.cognitive.microsoft.com/" - AZURE_DOCUMENT_INTELLIGENCE_KEY= - - # Azure Speech to Text to convert audio to text - - # Enabled must be set to "true" any other value will disable the feature - - PUBLIC_SPEECH_ENABLED=true - AZURE_SPEECH_REGION= - AZURE_SPEECH_KEY= - - ``` -
- - ``` - -4. Install npm packages by running `npm install` -5. Start the app by running `npm run dev` -6. Access the app on [http://localhost:3000](http://localhost:3000) +2. Rename the file `.env.example` to `.env.local` and populate the environment variables based on the deployed resources in Azure. +3. Install npm packages by running `npm install` +4. Start the app by running `npm run dev` +5. Access the app on [http://localhost:3000](http://localhost:3000) You should now be prompted to login with your chosen OAuth provider. -> NOTE: If using Basic Auth (DEV ONLY) any username you enter will create a new user id (hash of username@localhost). You can use this to simulate multiple users. - -![Chat Login (DEV)](/images/chat-login-dev.png) - -Once successfully logged in, you can start creating new conversations. - -![Chat Home](/images/chat-home.png) -![Chat history](/images/chat-history.png) +> [!NOTE] +> If using Basic Auth (DEV ONLY) any username you enter will create a new user id (hash of username@localhost). You can use this to simulate multiple users. Once successfully logged in, you can start creating new conversations. [Next](/docs/4-deploy-to-azure.md) diff --git a/docs/4-deploy-to-azure.md b/docs/4-deploy-to-azure.md index ac0295766..240118633 100644 --- a/docs/4-deploy-to-azure.md +++ b/docs/4-deploy-to-azure.md @@ -35,6 +35,6 @@ Under the same repository secrets add a new variable `AZURE_APP_SERVICE_NAME` to Once the secrets are configured, the GitHub Actions will be triggered for every code push to the repository. Alternatively, you can manually run the workflow by clicking on the "Run Workflow" button in the Actions tab in GitHub. -![Workflow screenshot](/images/runworkflow.png) +![Workflow screenshot](/docs/images/runworkflow.png) [Next](/docs/5-add-identity.md) diff --git a/docs/5-add-identity.md b/docs/5-add-identity.md index bf3d33206..2462779bf 100644 --- a/docs/5-add-identity.md +++ b/docs/5-add-identity.md @@ -2,13 +2,12 @@ Once the deployment is complete, you will need to add an identity provider to authenticate your app. You will also need to configure an admin user. -> **Note** +> [!NOTE] > Only one of the identity providers is required to be configured below. -> **Important** +> [!IMPORTANT] > We **strongly** recommend that you store client secrets in Azure Key Vault and use Kev Vault references in your App config settings. If you have created your environment using the templates in this repo you will already have a Key Vault that is being used to store a range of other secrets, and you will have Key Vault references in your app config. Details on how to configure App Service settings to use Key Vault are [here](https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references?tabs=azure-cli#source-app-settings-from-key-vault). Note that you will also need to give yourself appropriate permissions to create secrets in the Key Vault. - ## GitHub Authentication Provider We'll create two GitHub apps: one for testing locally and another for production. @@ -37,7 +36,7 @@ We'll create two GitHub apps: one for testing locally and another for production Authorization callback URL: https://YOUR-WEBSITE-NAME.azurewebsites.net/api/auth/callback/github ``` -> **Note** +> [!NOTE] > After completing app setup, ensure that both your local environment variables as well as Azure Web App environment variables are up to date. ```bash @@ -74,7 +73,7 @@ We'll create two GitHub apps: one for testing locally and another for production Redirect URI: https://YOUR-WEBSITE-NAME.azurewebsites.net/api/auth/callback/azure-ad ``` -> **Note** +> [!NOTE] > After completing app setup, ensure your environment variables locally and on Azure App Service are up to date. ```bash @@ -87,6 +86,6 @@ AZURE_AD_TENANT_ID= ## Configure an admin user -The reporting pages in the application are only available to an admin user. To configure the admin user create or update the "ADMIN_EMAIL_ADDRESS" config setting locally and on Azure App Service with the email address of the user who will use reports. +The reporting pages in the application are only available to an admin user. To configure the admin user create or update the `ADMIN_EMAIL_ADDRESS` config setting locally and on Azure App Service with the email address of the user who will use reports. [Next](/docs/6-chat-over-file.md) diff --git a/docs/6-chat-over-file.md b/docs/6-chat-over-file.md index 2db0ad1cf..e3da9c3b1 100644 --- a/docs/6-chat-over-file.md +++ b/docs/6-chat-over-file.md @@ -1,17 +1,98 @@ # 📃 Chatting With Your File -Users can utilise this functionality to upload their PDF files through the portal and engage in chat discussions related to the content of those files. +There are multiple ways you can integrate chat with your data. + +# **Upload a file and chat with your file using the chat interface.** + +Users can utilise this functionality to upload their files through the portal and engage in chat discussions related to the content of those files. + +Advantages of using this approach: + +1. Simple and easy to use. +2. File content is indexed and maintained within the chat interface and it is only available for the current chat session. Chat with your data utilises the following Azure Services: -1. [Azure Document Intelligence](https://learn.microsoft.com/en-GB/azure/ai-services/document-intelligence/) for extracting information from documents. -1. [Azure Cognitive Search](https://learn.microsoft.com/en-GB/azure/search/) for indexing and retrieving information. -1. [Azure OpenAI Embeddings](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/embeddings?tabs=console) for embed content extracted from files +Once the file is uploaded, the content is extracted and indexed using Azure AI Search. The content is then used to generate embeddings using Azure OpenAI Embeddings. The embeddings are then used to generate a similarity score between the uploaded file and the chat messages. The chat messages are then filtered based on the similarity score and displayed to the user. + +3. [Azure Document Intelligence](https://learn.microsoft.com/en-GB/azure/ai-services/document-intelligence/) for extracting information from documents. +4. [Azure AI Search ](https://learn.microsoft.com/en-GB/azure/search/) for indexing and retrieving information. +5. [Azure OpenAI Embeddings](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/embeddings?tabs=console) for embed content extracted from files + +![](/docs/images/chatover-file.png) + +# **Bring your own Azure AI Search.** + +With the help of Extensions feature you can bring your own Azure AI Search and integrate it with the chat interface. This will allow you to search and retrieve information from your own data source. + +Advantages of using this approach: + +1. Index and maintain your own data outside of Azure Chat. +2. Re-use the index across multiple chat sessions. +3. As an admin, you can publish the index across organisation. e.g. HR, Finance, IT etc. + +Steps to integrate your own Azure AI Search: + +1. Navigate to the Extensions page and click on the "Azure AI search" button. +2. Fill in the first section with the following details: + +![](/docs/images/extensions/extension-azure-ai-search-1.png) + +- **Name**: Name of the extension e.g. "HR Search" +- **Description**: Description of the extension e.g. "Search HR documents" +- **Detail description**: + +Change the description to match your use case. However, the citation section must remain the same. + +```markdown +You are an expert in searching internal documents using aisearch function. You must always include a citation at the end of your answer and don't include a full stop after the citations. + +Use the format for your citation {% citation items=[{name:\"filename 1\",id:\"file id\"}, {name:\"filename 2\",id:\"file id\"}] /%} +``` + +3. Fill in the Headers section with the following details: + +![](/docs/images/extensions/extension-azure-ai-search-2.png) + +- **vectors**: Comma separated values of the vectors on the index e.g. "title, content" +- **apiKey**: API key for the Azure AI Search +- **searchName**: Name of the Azure AI Search service +- **indexName**: Name of the Azure AI Search index + +4. Update the function definition and publish the extension. + +![](/docs/images/extensions/extension-azure-ai-search-3.png) + +- **Method**: POST +- **URL**: `https://REPLACE_WITH_YOUR_DOMAIN.COM/api/document` +- **Function**: -![](/images/chatover-file.png) +Update the description and parameters to match your use case. -### Things to Consider +```json +{ + "name": "aisearch", + "parameters": { + "type": "object", + "properties": { + "body": { + "type": "object", + "description": "Body of search for relevant information", + "properties": { + "search": { + "type": "string", + "description": "The exact search value from the user" + } + }, + "required": ["search"] + } + }, + "required": ["body"] + }, + "description": "DESCRIBE YOUR SEARCH DESCRIPTION HERE" +} +``` -1. Central place maintain uploaded files (e.g a storage account with blob storage) +Save the function and publish the extension. -[Next](/docs/7-environment-variables.md) +[Next](/docs/6-persona.md) diff --git a/docs/6-persona.md b/docs/6-persona.md new file mode 100644 index 000000000..e3b5878f5 --- /dev/null +++ b/docs/6-persona.md @@ -0,0 +1,50 @@ +# 🎭 Persona + +Persona helps you craft individual personas to bring personality and engagement into your conversations. + +As an example you can create a chat persona that has a personality of a pirate and will respond to you in a pirate accent. + +### Pirate a persona + +1. **Name**: Talk Like a Pirate +2. **Description**: A persona that talks like a pirate +3. **Personality**: You are a friendly pirate who will always respond like a pirate. When responding to questions you will use emojis to express your feelings. + +You can now use this persona in your conversations. + +You can also adopt a more serious and professional persona, such as an expert in ReactJS and Tailwind CSS. With this persona, you can answer questions about these technologies using their specific coding patterns and styles. + +### ReactJS and Tailwind CSS persona + +1. **Name**: ReactJS and Tailwind CSS +2. **Description**: An expert in ReactJS and Tailwind CSS +3. **Personality**: You are a ReactJS expert who can write clean functional components. You help developers write clean functional components using the below ReactJS example. + +```jsx +Example: +import * as React from "react"; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; +``` + +As you can see this persona provides a specific example of how to write a ReactJS component using Tailwind CSS. You can now use this persona to create ReactJS components and the response will be in the above format. + +[Next](/docs/8-extensions.md) diff --git a/docs/7-environment-variables.md b/docs/7-environment-variables.md deleted file mode 100644 index b82c6b064..000000000 --- a/docs/7-environment-variables.md +++ /dev/null @@ -1,30 +0,0 @@ -# 🔑 Environment Variables - -Below are the required environment variables, to be added to the Azure Portal or in the `.env.local` file. - -| App Setting | Value | Note | -| ----------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `OPENAI_API_KEY` | | API keys of your Azure OpenAI resource | -| `AZURE_OPENAI_API_INSTANCE_NAME` | | the name of your Azure OpenAI resource | -| `AZURE_OPENAI_API_DEPLOYMENT_NAME` | | The name of your model deployment | -| `AZURE_OPENAI_API_VERSION` | `2023-03-15-preview` | API version when using gpt chat | -| `AUTH_GITHUB_ID` | | Client ID of your GitHub OAuth application | -| `AUTH_GITHUB_SECRET` | | Client Secret of your GitHub OAuth application | -| `NEXTAUTH_SECRET` | | Used to encrypt the NextAuth.js JWT, and to hash email verification tokens. **This is set by default as part of the deployment template** | -| `NEXTAUTH_URL` | | Current webs hosting domain name with HTTP or HTTPS. **This set by default as part of the deployment template** | -| `AZURE_COSMOSDB_URI` | | URL of the Azure CosmosDB | -| `AZURE_COSMOSDB_KEY` | | API Key for Azure Cosmos DB | -| `AZURE_AD_CLIENT_ID` | | The client id specific to the application | -| `AZURE_AD_CLIENT_SECRET` | | The client secret specific to the application | -| `AZURE_AD_TENANT_ID` | | The organisation Tenant ID | -| `ADMIN_EMAIL_ADDRESS` | | Comma separated list of email addresses of the admin users ID | -| **Azure Cognitive Search is optional. This is only required for chat over file feature.** | -| `AZURE_SEARCH_API_KEY` | | API Key of Azure Cognitive search | -| `AZURE_SEARCH_NAME` | `https://AZURE_SEARCH_NAME.search.windows.net` | The deployment name of your Azure Cognitive Search | -| `AZURE_SEARCH_INDEX_NAME` | | The index name with [vector search](https://learn.microsoft.com/en-us/azure/search/vector-search-overview) enabled | -| `AZURE_SEARCH_API_VERSION` | `2023-07-01-Preview` | API version which supports vector search `2023-07-01-Preview` | -| `AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT` | `https://NAME.api.cognitive.microsoft.com/` | Endpoint url of the Azure document intelligence. The REGION is specific to your Azure resource location | -| `PUBLIC_SPEECH_ENABLED` | Y | Whether speech should be enabled (microphone button appears). Must be "true" to enable, any other value (or blank) will disable. | -| `AZURE_SPEECH_REGION` | australiaeast | Region of your Azure Speech service | -| `AZURE_SPEECH_KEY` | | API Key of Azure Speech service | -| | diff --git a/docs/8-extensions.md b/docs/8-extensions.md new file mode 100644 index 000000000..992a287cd --- /dev/null +++ b/docs/8-extensions.md @@ -0,0 +1,261 @@ +# 💡🔗 Extensions + +With Extensions, you can enhance the functionality of Azure Chat by integrating it with your internal APIs or external resources.Extensions are created using OpenAI Tools, specifically through Function Calling. + +As a user, you have the ability to create extensions that call your own internal APIs or external resources. However, if you are an admin, you can create extensions that can be utilised by all users within your organization. + +Refer to the [OpenAI Tools](https://platform.openai.com/docs/guides/function-calling) documentation for more information on how tools and functions call works. + +Azure Chat expects the following from the function definition: + +```json +{ + "name": "FunctionName", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "object", + "description": "Query parameters", + "properties": { + // Query parameters + }, + "required": [ + // comma separated parameters of the required query parameters + ] + }, + "body": { + "type": "object", + "description": "Body of the...", + "properties": { + // Body parameters + }, + "required": [ + // comma separated parameters of the required body parameters + ] + } + }, + "required": [ + // query or body are optional however at least one of them must be required e.g. ["query"] or ["body"] or ["query", "body"] + ] + }, + "description": "Description of the function" +} +``` + +As an example you can create an extension that calls Bing Search API to search for a specific topic and return the results to the user. + +In the example below only the `query` is required as Bing does not require a body parameter. + +# Bing Search Extension + +1. **Name**: `Bing Search` +2. **Short Description**: `Bring up to date information with Bing Search` +3. **Detail Description**: + + ```markdown + You are an expert in searching the web using BingSearch function. + ``` + + The detail description will be injected into chat as a system message. + +4. **Headers**: A collection of secure header values to be passed to the function. The header values are stored securely in Azure Key Vault and are passed to the function as part of the request. + +5. **Function**: + + - API Endpoint: GET https://api.bing.microsoft.com/v7.0/search?q=BING_SEARCH_QUERY + + BIG_SEARCH_QUERY is a variable that will be replaced with the search query entered by the user. The BIG_SEARCH_QUERY will be automatically passed to the function as part of the request based on the function definition below. + + - Function definition: + + ```json + { + "name": "BingSearch", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "object", + "description": "Ues this as the search query parameters", + "properties": { + "BING_SEARCH_QUERY": { + "type": "string", + "description": "Search query from the user", + "example": "What is the current weather in Sydney, Australia?" + } + }, + "example": { + "BING_SEARCH_QUERY": "What is the current weather in Sydney, Australia?" + }, + "required": ["BING_SEARCH_QUERY"] + } + }, + "required": ["query"] + }, + "description": "Use BingSearch to search for information on the web to bring up to date information" + } + ``` + +6. **Publish**: Publish the extension to make it available to use in your conversations. Publish is an admin only feature. If you are not an admin you will not see the publish button. + +# GitHub Issues Extension + +This example is much more complex as it is capable of invoking multiple APIs to create or update a GitHub Issue depending on the user question. + +In this example you will be able to create and update GitHub Issues using the GitHub API. + +1. **Name**: `GitHub Issues` +2. **Short Description**: `Create and update GitHub Issues` +3. **Detail Description**: + + ```markdown + You are an expert in creating and updating GitHub Issues. + CreateGitHubIssue is used to create GitHub Issues. + UpdateGitHubIssue is used to update GitHub Issues. + + If the user doesn't provide a GitHub Issue ID ensure to use the ID mentioned in the previous chat messages. + ``` + + The detail description is injected into chat as a system message. + +4. **Headers**: Secure header values to be passed to the function. + + ```markdown + Authorization: Bearer GITHUB_TOKEN + Accept: application/vnd.github.v3+json + X-GitHub-Api-Version 2022-11-28 + ``` + +5. **Function** + + 5.1 **CreateGitHubIssue Function** + + - API Endpoint: + + ```markdown + POST https://api.github.com/repos/GITHUB_OWNER/GITHUB_REPO/issues + ``` + + Ensure to replace the GITHUB_OWNER and GITHUB_REPO with the repository you want to create the issue in. + + - The function definition for creating GitHub issue + + The `body` parameter is required by Azure Chat as it uses it to generate request body for the function call. The parameters of the `body` is a representation of the GitHub issues API. You can find the full documentation [here](https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#create-an-issue). + + ```json + { + "name": "CreateGitHubIssue", + "parameters": { + "type": "object", + "properties": { + "body": { + "type": "object", + "description": "Body of the GitHub issue", + "properties": { + "title": { + "type": "string", + "description": "Title of the issue", + "example": "I'm having a problem with this." + }, + "body": { + "type": "string", + "description": "Body of the issue", + "example": "I'm having a problem with this." + }, + "labels": { + "type": "array", + "description": "Labels to add to the issue", + "items": { + "type": "string", + "example": "bug" + } + } + }, + "example": { + "title": "I'm having a problem with this.", + "body": "I'm having a problem with this.", + "labels": ["bug"] + }, + "required": ["title"] + } + }, + "required": ["body"] + }, + "description": "You must use this to only create an existing GitHub issue" + } + ``` + + 5.2 **UpdateGitHubIssue Function** + + - API Endpoint: + + ```markdown + POST https://api.github.com/repos/GITHUB_OWNER/GITHUB_REPO/issues/ISSUE_NUMBER + ``` + + The ISSUE_NUMBER will be automatically passed to the function as part of the request based on the function definition below. + + - The function definition for updating GitHub issue + + The `body` parameter is the same scheme as CreateGitHubIssue function. However you will notice that the `query` parameter is added to the function definition. This is because Azure Chat will automatically pass the query parameters to the function as part of the request. In this case the query parameter is ISSUE_NUMBER.G + + ```json + { + "name": "UpdateGitHubIssue", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "object", + "description": "Query parameters", + "properties": { + "ISSUE_NUMBER": { + "type": "string", + "description": "Github issue number", + "example": "123" + } + }, + "example": { + "ISSUE_NUMBER": "123" + }, + "required": ["ISSUE_NUMBER"] + }, + "body": { + "type": "object", + "description": "Body of the GitHub issue", + "properties": { + "title": { + "type": "string", + "description": "Title of the issue", + "example": "I'm having a problem with this." + }, + "body": { + "type": "string", + "description": "Body of the issue", + "example": "I'm having a problem with this." + }, + "labels": { + "type": "array", + "description": "Labels to add to the issue", + "items": { + "type": "string", + "example": "bug" + } + } + }, + "example": { + "title": "I'm having a problem with this.", + "body": "I'm having a problem with this.", + "labels": ["bug"] + }, + "required": ["title"] + } + }, + "required": ["body", "query"] + }, + "description": "You must use this to only update an existing GitHub issue" + } + ``` + + [Next](/docs/9-environment-variables.md) diff --git a/docs/9-environment-variables.md b/docs/9-environment-variables.md new file mode 100644 index 000000000..206779212 --- /dev/null +++ b/docs/9-environment-variables.md @@ -0,0 +1,3 @@ +# 🔑 Environment Variables + +Refer to the [`.env.example`](../src/.env.example) for the required environment variables diff --git a/images/architecture.png b/docs/images/architecture.png similarity index 100% rename from images/architecture.png rename to docs/images/architecture.png diff --git a/images/chat-history.png b/docs/images/chat-history.png similarity index 100% rename from images/chat-history.png rename to docs/images/chat-history.png diff --git a/images/chat-home.png b/docs/images/chat-home.png similarity index 100% rename from images/chat-home.png rename to docs/images/chat-home.png diff --git a/images/chat-login-dev.png b/docs/images/chat-login-dev.png similarity index 100% rename from images/chat-login-dev.png rename to docs/images/chat-login-dev.png diff --git a/images/chatover-file.png b/docs/images/chatover-file.png similarity index 100% rename from images/chatover-file.png rename to docs/images/chatover-file.png diff --git a/docs/images/extensions/extension-azure-ai-search-1.png b/docs/images/extensions/extension-azure-ai-search-1.png new file mode 100644 index 000000000..3cb48bd44 Binary files /dev/null and b/docs/images/extensions/extension-azure-ai-search-1.png differ diff --git a/docs/images/extensions/extension-azure-ai-search-2.png b/docs/images/extensions/extension-azure-ai-search-2.png new file mode 100644 index 000000000..50bc86f9d Binary files /dev/null and b/docs/images/extensions/extension-azure-ai-search-2.png differ diff --git a/docs/images/extensions/extension-azure-ai-search-3.png b/docs/images/extensions/extension-azure-ai-search-3.png new file mode 100644 index 000000000..194832fd8 Binary files /dev/null and b/docs/images/extensions/extension-azure-ai-search-3.png differ diff --git a/docs/images/intro.png b/docs/images/intro.png new file mode 100644 index 000000000..3bce275ae Binary files /dev/null and b/docs/images/intro.png differ diff --git a/images/personalise-session.png b/docs/images/personalise-session.png similarity index 100% rename from images/personalise-session.png rename to docs/images/personalise-session.png diff --git a/images/runworkflow.png b/docs/images/runworkflow.png similarity index 100% rename from images/runworkflow.png rename to docs/images/runworkflow.png diff --git a/images/set-startup-command.png b/docs/images/set-startup-command.png similarity index 100% rename from images/set-startup-command.png rename to docs/images/set-startup-command.png diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 000000000..3e71e7d2d --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,75 @@ +# Migration + +The following changes and services are required to migrate from the old version to the new version. + +Refer the `.env.example` file for the latest environment variable changes. + +If you previously had Azure Chat running and have pulled the v2 version you will need at minimum to make the following changes: + +* Change the "OPENAI_API_KEY" environment setting to "AZURE_OPENAI_API_KEY" +* Add an additional container to your Cosmos DB database called "config" with a partition key of "/userId" +* Add the "AZURE_KEY_VAULT_NAME" environment setting with the name of your Azure Key Vault +* Add the "New Azure Services" settings below if you wish to use these features + +## New Azure Services + +1. **Azure OpenAI Service**: Create a new Azure OpenAI Service and deploy a DALL-E 3 model. DALL-E is available within the following [regions](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#dall-e-models-preview). + +Once the model is deployed successfully, update the environment variables in the `.env.local` file and on Azure App settings. + +```bash +# DALL-E image creation endpoint config +AZURE_OPENAI_DALLE_API_KEY=222222 +AZURE_OPENAI_DALLE_API_INSTANCE_NAME=azurechat-dall-e +AZURE_OPENAI_DALLE_API_DEPLOYMENT_NAME=dall-e +AZURE_OPENAI_DALLE_API_VERSION=2023-12-01-preview +``` + +2. **Azure Blob Storage**: Create a new Azure Blob Storage account and update the environment variables in the `.env.local` file and on Azure App settings. + +The Azure Blob Storage account is used to store the images created by the DALL-E model. + +```bash +# Azure Storage account to store files +AZURE_STORAGE_ACCOUNT_NAME=azurechat +AZURE_STORAGE_ACCOUNT_KEY=123456 +``` + +3. **Azure OpenAI Service**: Create a new Azure OpenAI Service and deploy a GPT 4 Vision model. The vision model is available within the following [regions](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#gpt-4-and-gpt-4-turbo-preview-model-availability). + +Once the model is deployed successfully, update the environment variables in the `.env.local` file and on Azure App settings. + +```bash +# GPT4 V OpenaAI details +AZURE_OPENAI_VISION_API_KEY=333333 +AZURE_OPENAI_VISION_API_INSTANCE_NAME=azurechat-vision +AZURE_OPENAI_VISION_API_DEPLOYMENT_NAME=gpt-4-vision +AZURE_OPENAI_VISION_API_VERSION=2023-12-01-preview +``` + +## Existing Azure services + +1. **Azure Key Vault**: The Azure Key Vault is already created and used to store the API Keys. + +Update the environment variables in the `.env.local` file and on Azure App settings with the key vault name. The Extension feature uses the key vault to save and retrieve the secure header values. + +```bash +# Azure Key Vault to store secrets +AZURE_KEY_VAULT_NAME= +``` + +2. **Azure Cosmos DB**: The Azure Cosmos DB is already created and used to store the chat data. The new version of the application segregates the data into two collections: `history` and `config`. + +`history`: Stores the chat history data. + +`config`: Stores the configuration data such as the prompt templates, extension details etc. + +Update the environment variables in the `.env.local` file and on Azure App settings with the Cosmos DB account name and the database name. + +```bash +# Update your Cosmos variables if you want to overwrite the default values +AZURE_COSMOSDB_DB_NAME=chat +AZURE_COSMOSDB_CONTAINER_NAME=history +# NOTE: Ensure the container is created within the Cosmos db database +AZURE_COSMOSDB_CONFIG_CONTAINER_NAME=config +``` diff --git a/images/intro.png b/images/intro.png deleted file mode 100644 index ae180eff8..000000000 Binary files a/images/intro.png and /dev/null differ diff --git a/infra/main.bicep b/infra/main.bicep index 6476e3b90..5fe7149ec 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -9,9 +9,9 @@ param name string @description('Primary location for all resources') param location string -// azure open ai +// azure open ai -- only regions supporting gpt-35-turbo v1106 @description('Location for the OpenAI resource group') -@allowed(['canadaeast', 'eastus', 'francecentral', 'japaneast', 'northcentralus', 'australieast']) +@allowed(['australiaeast', 'canadaeast', 'francecentral', 'southindia', 'uksouth', 'swedencentral', 'westus']) @metadata({ azd: { type: 'location' @@ -20,20 +20,44 @@ param location string param openAILocation string param openAISku string = 'S0' -param openAIApiVersion string = '2023-03-15-preview' +param openAIApiVersion string = '2023-12-01-preview' -param chatGptDeploymentCapacity int = 30 +param chatGptDeploymentCapacity int = 120 param chatGptDeploymentName string = 'chat-gpt-35-turbo' param chatGptModelName string = 'gpt-35-turbo' -param chatGptModelVersion string = '0613' +param chatGptModelVersion string = '1106' param embeddingDeploymentName string = 'embedding' -param embeddingDeploymentCapacity int = 10 +param embeddingDeploymentCapacity int = 120 param embeddingModelName string = 'text-embedding-ada-002' +// DALL-E v3 only supported in Sweden Central for now +@description('Location for the OpenAI DALL-E 3 instance resource group') +@allowed(['swedencentral']) +param dalleLocation string + +param dalleDeploymentCapacity int = 1 +param dalleDeploymentName string = 'dall-e-3' +param dalleModelName string = 'dall-e-3' +param dalleApiVersion string = '2023-12-01-preview' + +// DALL-E v3 only supported in Sweden Central for now +@description('Location for the GPT vision instance resource') +@allowed(['swedencentral','westus',]) +param gptvisionLocation string + +param gptvisionDeploymentCapacity int = 1 +param gptvisionDeploymentName string = 'gpt-4-vision' +param gptvisionModelName string = 'gpt-4' +param gptvisionApiVersion string = '2023-12-01-preview' +param gptvisionModelVersion string = 'vision-preview' + param formRecognizerSkuName string = 'S0' param searchServiceIndexName string = 'azure-chat' param searchServiceSkuName string = 'standard' -param searchServiceAPIVersion string = '2023-07-01-Preview' + +// TODO: define good default Sku and settings for storage account +param storageServiceSku object = { name: 'Standard_LRS' } +param storageServiceImageContainerName string = 'images' param resourceGroupName string = 'buhler-alm-chatgpt' @@ -55,7 +79,7 @@ module resources 'resources.bicep' = { resourceToken: resourceToken tags: tags openai_api_version: openAIApiVersion - openAiResourceGroupLocation: openAILocation + openAiLocation: openAILocation openAiSkuName: openAISku chatGptDeploymentCapacity: chatGptDeploymentCapacity chatGptDeploymentName: chatGptDeploymentName @@ -64,10 +88,22 @@ module resources 'resources.bicep' = { embeddingDeploymentName: embeddingDeploymentName embeddingDeploymentCapacity: embeddingDeploymentCapacity embeddingModelName: embeddingModelName + dalleLocation: dalleLocation + dalleDeploymentCapacity: dalleDeploymentCapacity + dalleDeploymentName: dalleDeploymentName + dalleModelName: dalleModelName + dalleApiVersion: dalleApiVersion + gptvisionLocation: gptvisionLocation + gptvisionApiVersion: gptvisionApiVersion + gptvisionDeploymentCapacity: gptvisionDeploymentCapacity + gptvisionDeploymentName: gptvisionDeploymentName + gptvisionModelName: gptvisionModelName + gptvisionModelVersion: gptvisionModelVersion formRecognizerSkuName: formRecognizerSkuName searchServiceIndexName: searchServiceIndexName searchServiceSkuName: searchServiceSkuName - searchServiceAPIVersion: searchServiceAPIVersion + storageServiceSku: storageServiceSku + storageServiceImageContainerName: storageServiceImageContainerName location: location } } diff --git a/infra/main.json b/infra/main.json index 2969c62ab..25d7ee091 100644 --- a/infra/main.json +++ b/infra/main.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.21.1.54444", - "templateHash": "18067428240031110120" + "version": "0.24.24.22086", + "templateHash": "16779160080190232837" } }, "parameters": { @@ -27,11 +27,13 @@ "openAILocation": { "type": "string", "allowedValues": [ + "australiaeast", "canadaeast", - "eastus", "francecentral", - "japaneast", - "northcentralus" + "southindia", + "uksouth", + "swedencentral", + "westus" ], "metadata": { "azd": { @@ -46,11 +48,11 @@ }, "openAIApiVersion": { "type": "string", - "defaultValue": "2023-03-15-preview" + "defaultValue": "2023-12-01-preview" }, "chatGptDeploymentCapacity": { "type": "int", - "defaultValue": 30 + "defaultValue": 120 }, "chatGptDeploymentName": { "type": "string", @@ -62,7 +64,7 @@ }, "chatGptModelVersion": { "type": "string", - "defaultValue": "0613" + "defaultValue": "1106" }, "embeddingDeploymentName": { "type": "string", @@ -70,12 +72,67 @@ }, "embeddingDeploymentCapacity": { "type": "int", - "defaultValue": 10 + "defaultValue": 120 }, "embeddingModelName": { "type": "string", "defaultValue": "text-embedding-ada-002" }, + "dalleLocation": { + "type": "string", + "allowedValues": [ + "swedencentral" + ], + "metadata": { + "description": "Location for the OpenAI DALL-E 3 instance resource group" + } + }, + "dalleDeploymentCapacity": { + "type": "int", + "defaultValue": 1 + }, + "dalleDeploymentName": { + "type": "string", + "defaultValue": "dall-e-3" + }, + "dalleModelName": { + "type": "string", + "defaultValue": "dall-e-3" + }, + "dalleApiVersion": { + "type": "string", + "defaultValue": "2023-12-01-preview" + }, + "gptvisionLocation": { + "type": "string", + "allowedValues": [ + "swedencentral", + "westus" + ], + "metadata": { + "description": "Location for the GPT vision instance resource" + } + }, + "gptvisionDeploymentCapacity": { + "type": "int", + "defaultValue": 1 + }, + "gptvisionDeploymentName": { + "type": "string", + "defaultValue": "gpt-4-vision" + }, + "gptvisionModelName": { + "type": "string", + "defaultValue": "gpt-4" + }, + "gptvisionApiVersion": { + "type": "string", + "defaultValue": "2023-12-01-preview" + }, + "gptvisionModelVersion": { + "type": "string", + "defaultValue": "vision-preview" + }, "formRecognizerSkuName": { "type": "string", "defaultValue": "S0" @@ -88,9 +145,15 @@ "type": "string", "defaultValue": "standard" }, - "searchServiceAPIVersion": { + "storageServiceSku": { + "type": "object", + "defaultValue": { + "name": "Standard_LRS" + } + }, + "storageServiceImageContainerName": { "type": "string", - "defaultValue": "2023-07-01-Preview" + "defaultValue": "images" }, "resourceGroupName": { "type": "string", @@ -134,7 +197,7 @@ "openai_api_version": { "value": "[parameters('openAIApiVersion')]" }, - "openAiResourceGroupLocation": { + "openAiLocation": { "value": "[parameters('openAILocation')]" }, "openAiSkuName": { @@ -161,6 +224,39 @@ "embeddingModelName": { "value": "[parameters('embeddingModelName')]" }, + "dalleLocation": { + "value": "[parameters('dalleLocation')]" + }, + "dalleDeploymentCapacity": { + "value": "[parameters('dalleDeploymentCapacity')]" + }, + "dalleDeploymentName": { + "value": "[parameters('dalleDeploymentName')]" + }, + "dalleModelName": { + "value": "[parameters('dalleModelName')]" + }, + "dalleApiVersion": { + "value": "[parameters('dalleApiVersion')]" + }, + "gptvisionLocation": { + "value": "[parameters('gptvisionLocation')]" + }, + "gptvisionApiVersion": { + "value": "[parameters('gptvisionApiVersion')]" + }, + "gptvisionDeploymentCapacity": { + "value": "[parameters('gptvisionDeploymentCapacity')]" + }, + "gptvisionDeploymentName": { + "value": "[parameters('gptvisionDeploymentName')]" + }, + "gptvisionModelName": { + "value": "[parameters('gptvisionModelName')]" + }, + "gptvisionModelVersion": { + "value": "[parameters('gptvisionModelVersion')]" + }, "formRecognizerSkuName": { "value": "[parameters('formRecognizerSkuName')]" }, @@ -170,8 +266,11 @@ "searchServiceSkuName": { "value": "[parameters('searchServiceSkuName')]" }, - "searchServiceAPIVersion": { - "value": "[parameters('searchServiceAPIVersion')]" + "storageServiceSku": { + "value": "[parameters('storageServiceSku')]" + }, + "storageServiceImageContainerName": { + "value": "[parameters('storageServiceImageContainerName')]" }, "location": { "value": "[parameters('location')]" @@ -183,8 +282,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.21.1.54444", - "templateHash": "3970654296971990539" + "version": "0.24.24.22086", + "templateHash": "14077555141603956691" } }, "parameters": { @@ -198,7 +297,7 @@ "openai_api_version": { "type": "string" }, - "openAiResourceGroupLocation": { + "openAiLocation": { "type": "string" }, "openAiSkuName": { @@ -219,7 +318,7 @@ }, "chatGptModelVersion": { "type": "string", - "defaultValue": "0613" + "defaultValue": "1106" }, "embeddingDeploymentName": { "type": "string", @@ -227,12 +326,54 @@ }, "embeddingDeploymentCapacity": { "type": "int", - "defaultValue": 30 + "defaultValue": 10 }, "embeddingModelName": { "type": "string", "defaultValue": "text-embedding-ada-002" }, + "dalleLocation": { + "type": "string" + }, + "dalleDeploymentCapacity": { + "type": "int" + }, + "dalleDeploymentName": { + "type": "string" + }, + "dalleModelName": { + "type": "string" + }, + "dalleApiVersion": { + "type": "string" + }, + "gptvisionLocation": { + "type": "string" + }, + "gptvisionDeploymentCapacity": { + "type": "int", + "defaultValue": 30 + }, + "gptvisionDeploymentName": { + "type": "string", + "defaultValue": "gpt-4-vision" + }, + "gptvisionModelName": { + "type": "string", + "defaultValue": "gpt-4" + }, + "gptvisionApiVersion": { + "type": "string", + "defaultValue": "2023-12-01-preview" + }, + "gptvisionModelVersion": { + "type": "string", + "defaultValue": "vision-preview" + }, + "speechServiceSkuName": { + "type": "string", + "defaultValue": "S0" + }, "formRecognizerSkuName": { "type": "string", "defaultValue": "S0" @@ -245,9 +386,11 @@ "type": "string", "defaultValue": "azure-chat" }, - "searchServiceAPIVersion": { - "type": "string", - "defaultValue": "2023-07-01-Preview" + "storageServiceSku": { + "type": "object" + }, + "storageServiceImageContainerName": { + "type": "string" }, "location": { "type": "string", @@ -263,18 +406,27 @@ } }, "variables": { - "openai_name": "[toLower(format('{0}ai{1}', parameters('name'), parameters('resourceToken')))]", + "openai_name": "[toLower(format('{0}-aillm-{1}', parameters('name'), parameters('resourceToken')))]", + "openai_dalle_name": "[toLower(format('{0}-aidalle-{1}', parameters('name'), parameters('resourceToken')))]", + "openai_gpt_vision_name": "[toLower(format('{0}-aivision-{1}', parameters('name'), parameters('resourceToken')))]", "form_recognizer_name": "[toLower(format('{0}-form-{1}', parameters('name'), parameters('resourceToken')))]", + "speech_service_name": "[toLower(format('{0}-speech-{1}', parameters('name'), parameters('resourceToken')))]", "cosmos_name": "[toLower(format('{0}-cosmos-{1}', parameters('name'), parameters('resourceToken')))]", "search_name": "[toLower(format('{0}search{1}', parameters('name'), parameters('resourceToken')))]", "webapp_name": "[toLower(format('{0}-webapp-{1}', parameters('name'), parameters('resourceToken')))]", "appservice_name": "[toLower(format('{0}-app-{1}', parameters('name'), parameters('resourceToken')))]", + "storage_prefix": "[take(parameters('name'), 8)]", + "storage_name": "[toLower(format('{0}sto{1}', variables('storage_prefix'), parameters('resourceToken')))]", "kv_prefix": "[take(parameters('name'), 7)]", "keyVaultName": "[toLower(format('{0}-kv-{1}', variables('kv_prefix'), parameters('resourceToken')))]", - "keyVaultSecretsUserRole": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", + "la_workspace_name": "[toLower(format('{0}-la-{1}', parameters('name'), parameters('resourceToken')))]", + "diagnostic_setting_name": "AppServiceConsoleLogs", + "keyVaultSecretsOfficerRole": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7')]", + "validStorageServiceImageContainerName": "[toLower(replace(parameters('storageServiceImageContainerName'), '-', ''))]", "databaseName": "chat", - "containerName": "history", - "deployments": [ + "historyContainerName": "history", + "configContainerName": "config", + "llmDeployments": [ { "name": "[parameters('chatGptDeploymentName')]", "model": { @@ -299,16 +451,44 @@ ] }, "resources": [ + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', variables('webapp_name'), 'logs')]", + "properties": { + "applicationLogs": { + "fileSystem": { + "level": "Verbose" + } + }, + "detailedErrorMessages": { + "enabled": true + }, + "failedRequestsTracing": { + "enabled": true + }, + "httpLogs": { + "fileSystem": { + "enabled": true, + "retentionInDays": 1, + "retentionInMb": 35 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('webapp_name'))]" + ] + }, { "type": "Microsoft.KeyVault/vaults/secrets", "apiVersion": "2021-06-01-preview", - "name": "[format('{0}/{1}', variables('keyVaultName'), 'AZURE-COSMOSDB-KEY')]", + "name": "[format('{0}/{1}', variables('keyVaultName'), 'AZURE-OPENAI-VISION-API-KEY')]", "properties": { "contentType": "text/plain", - "value": "[listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmos_name')), '2023-04-15').secondaryMasterKey]" + "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', variables('openai_gpt_vision_name')), '2023-05-01').key1]" }, "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmos_name'))]", + "[resourceId('Microsoft.CognitiveServices/accounts', variables('openai_gpt_vision_name'))]", "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" ] }, @@ -325,6 +505,44 @@ "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" ] }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2021-06-01-preview", + "name": "[format('{0}/{1}', variables('keyVaultName'), 'AZURE-OPENAI-DALLE-API-KEY')]", + "properties": { + "contentType": "text/plain", + "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', variables('openai_dalle_name')), '2023-05-01').key1]" + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', variables('openai_dalle_name'))]", + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + ] + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2021-06-01-preview", + "name": "[format('{0}/{1}', variables('keyVaultName'), 'NEXTAUTH-SECRET')]", + "properties": { + "contentType": "text/plain", + "value": "[parameters('nextAuthHash')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + ] + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2021-06-01-preview", + "name": "[format('{0}/{1}', variables('keyVaultName'), 'AZURE-COSMOSDB-KEY')]", + "properties": { + "contentType": "text/plain", + "value": "[listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmos_name')), '2023-04-15').secondaryMasterKey]" + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmos_name'))]", + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + ] + }, { "type": "Microsoft.KeyVault/vaults/secrets", "apiVersion": "2021-06-01-preview", @@ -338,6 +556,19 @@ "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" ] }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2021-06-01-preview", + "name": "[format('{0}/{1}', variables('keyVaultName'), 'AZURE-SPEECH-KEY')]", + "properties": { + "contentType": "text/plain", + "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', variables('speech_service_name')), '2023-05-01').key1]" + }, + "dependsOn": [ + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", + "[resourceId('Microsoft.CognitiveServices/accounts', variables('speech_service_name'))]" + ] + }, { "type": "Microsoft.KeyVault/vaults/secrets", "apiVersion": "2021-06-01-preview", @@ -354,13 +585,70 @@ { "type": "Microsoft.KeyVault/vaults/secrets", "apiVersion": "2021-06-01-preview", - "name": "[format('{0}/{1}', variables('keyVaultName'), 'NEXTAUTH-SECRET')]", + "name": "[format('{0}/{1}', variables('keyVaultName'), 'AZURE-STORAGE-ACCOUNT-KEY')]", "properties": { "contentType": "text/plain", - "value": "[parameters('nextAuthHash')]" + "value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storage_name')), '2022-05-01').keys[0].value]" }, "dependsOn": [ - "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" + "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", + "[resourceId('Microsoft.Storage/storageAccounts', variables('storage_name'))]" + ] + }, + { + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2023-05-01", + "name": "[format('{0}/{1}', variables('openai_dalle_name'), parameters('dalleDeploymentName'))]", + "properties": { + "model": { + "format": "OpenAI", + "name": "[parameters('dalleModelName')]" + } + }, + "sku": { + "name": "Standard", + "capacity": "[parameters('dalleDeploymentCapacity')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', variables('openai_dalle_name'))]" + ] + }, + { + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2023-05-01", + "name": "[format('{0}/{1}', variables('openai_gpt_vision_name'), parameters('gptvisionDeploymentName'))]", + "properties": { + "model": { + "format": "OpenAI", + "name": "[parameters('gptvisionModelName')]", + "version": "[parameters('gptvisionModelVersion')]" + } + }, + "sku": { + "name": "Standard", + "capacity": "[parameters('gptvisionDeploymentCapacity')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', variables('openai_gpt_vision_name'))]" + ] + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "apiVersion": "2022-05-01", + "name": "[format('{0}/{1}/{2}', variables('storage_name'), 'default', variables('validStorageServiceImageContainerName'))]", + "properties": { + "publicAccess": "None" + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/blobServices', variables('storage_name'), 'default')]" + ] + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices", + "apiVersion": "2022-05-01", + "name": "[format('{0}/{1}', variables('storage_name'), 'default')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', variables('storage_name'))]" ] }, { @@ -398,44 +686,32 @@ "minTlsVersion": "1.2", "appSettings": [ { - "name": "AZURE_COSMOSDB_KEY", - "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-COSMOSDB-KEY')]" - }, - { - "name": "OPENAI_API_KEY", - "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-OPENAI-API-KEY')]" - }, - { - "name": "AZURE_DOCUMENT_INTELLIGENCE_KEY", - "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-DOCUMENT-INTELLIGENCE-KEY')]" + "name": "AZURE_KEY_VAULT_NAME", + "value": "[variables('keyVaultName')]" }, { - "name": "AZURE_SEARCH_API_KEY", - "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-SEARCH-API-KEY')]" + "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", + "value": "true" }, { - "name": "AZURE_SEARCH_API_VERSION", - "value": "[parameters('searchServiceAPIVersion')]" + "name": "AZURE_OPENAI_VISION_API_KEY", + "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-OPENAI-VISION-API-KEY')]" }, { - "name": "AZURE_SEARCH_NAME", - "value": "[variables('search_name')]" + "name": "AZURE_OPENAI_VISION_API_INSTANCE_NAME", + "value": "[variables('openai_gpt_vision_name')]" }, { - "name": "AZURE_SEARCH_INDEX_NAME", - "value": "[parameters('searchServiceIndexName')]" + "name": "AZURE_OPENAI_VISION_API_DEPLOYMENT_NAME", + "value": "[parameters('gptvisionDeploymentName')]" }, { - "name": "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT", - "value": "[format('https://{0}.api.cognitive.microsoft.com/', parameters('location'))]" + "name": "AZURE_OPENAI_VISION_API_VERSION", + "value": "[parameters('gptvisionApiVersion')]" }, { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - }, - { - "name": "AZURE_COSMOSDB_URI", - "value": "[reference(resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmos_name')), '2023-04-15').documentEndpoint]" + "name": "AZURE_OPENAI_API_KEY", + "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-OPENAI-API-KEY')]" }, { "name": "AZURE_OPENAI_API_INSTANCE_NAME", @@ -453,6 +729,22 @@ "name": "AZURE_OPENAI_API_VERSION", "value": "[parameters('openai_api_version')]" }, + { + "name": "AZURE_OPENAI_DALLE_API_KEY", + "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-OPENAI-DALLE-API-KEY')]" + }, + { + "name": "AZURE_OPENAI_DALLE_API_INSTANCE_NAME", + "value": "[variables('openai_dalle_name')]" + }, + { + "name": "AZURE_OPENAI_DALLE_API_DEPLOYMENT_NAME", + "value": "[parameters('dalleDeploymentName')]" + }, + { + "name": "AZURE_OPENAI_DALLE_API_VERSION", + "value": "[parameters('dalleApiVersion')]" + }, { "name": "NEXTAUTH_SECRET", "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'NEXTAUTH-SECRET')]" @@ -460,6 +752,50 @@ { "name": "NEXTAUTH_URL", "value": "[format('https://{0}.azurewebsites.net', variables('webapp_name'))]" + }, + { + "name": "AZURE_COSMOSDB_URI", + "value": "[reference(resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmos_name')), '2023-04-15').documentEndpoint]" + }, + { + "name": "AZURE_COSMOSDB_KEY", + "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-COSMOSDB-KEY')]" + }, + { + "name": "AZURE_SEARCH_API_KEY", + "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-SEARCH-API-KEY')]" + }, + { + "name": "AZURE_SEARCH_NAME", + "value": "[variables('search_name')]" + }, + { + "name": "AZURE_SEARCH_INDEX_NAME", + "value": "[parameters('searchServiceIndexName')]" + }, + { + "name": "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT", + "value": "[format('https://{0}.cognitiveservices.azure.com/', variables('form_recognizer_name'))]" + }, + { + "name": "AZURE_DOCUMENT_INTELLIGENCE_KEY", + "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-DOCUMENT-INTELLIGENCE-KEY')]" + }, + { + "name": "AZURE_SPEECH_REGION", + "value": "[parameters('location')]" + }, + { + "name": "AZURE_SPEECH_KEY", + "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-SPEECH-KEY')]" + }, + { + "name": "AZURE_STORAGE_ACCOUNT_NAME", + "value": "[variables('storage_name')]" + }, + { + "name": "AZURE_STORAGE_ACCOUNT_KEY", + "value": "[format('@Microsoft.KeyVault(VaultName={0};SecretName={1})', variables('keyVaultName'), 'AZURE-STORAGE-ACCOUNT-KEY')]" } ] } @@ -472,21 +808,51 @@ "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'AZURE-COSMOSDB-KEY')]", "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'AZURE-DOCUMENT-INTELLIGENCE-KEY')]", "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'AZURE-OPENAI-API-KEY')]", + "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'AZURE-OPENAI-DALLE-API-KEY')]", + "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'AZURE-OPENAI-VISION-API-KEY')]", "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'AZURE-SEARCH-API-KEY')]", + "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'AZURE-SPEECH-KEY')]", + "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'AZURE-STORAGE-ACCOUNT-KEY')]", "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmos_name'))]", "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", "[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'NEXTAUTH-SECRET')]" ] }, + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2021-12-01-preview", + "name": "[variables('la_workspace_name')]", + "location": "[parameters('location')]" + }, + { + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.Web/sites/{0}', variables('webapp_name'))]", + "name": "[variables('diagnostic_setting_name')]", + "properties": { + "workspaceId": "[resourceId('Microsoft.OperationalInsights/workspaces', variables('la_workspace_name'))]", + "logs": [ + { + "category": "AppServiceConsoleLogs", + "enabled": true + } + ], + "metrics": [] + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', variables('la_workspace_name'))]", + "[resourceId('Microsoft.Web/sites', variables('webapp_name'))]" + ] + }, { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2020-04-01-preview", "scope": "[format('Microsoft.KeyVault/vaults/{0}', variables('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName')), variables('webapp_name'), variables('keyVaultSecretsUserRole'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName')), variables('webapp_name'), variables('keyVaultSecretsOfficerRole'))]", "properties": { "principalId": "[reference(resourceId('Microsoft.Web/sites', variables('webapp_name')), '2020-06-01', 'full').identity.principalId]", "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('keyVaultSecretsUserRole')]" + "roleDefinitionId": "[variables('keyVaultSecretsOfficerRole')]" }, "dependsOn": [ "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", @@ -544,19 +910,39 @@ { "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", "apiVersion": "2022-05-15", - "name": "[format('{0}/{1}', format('{0}/{1}', variables('cosmos_name'), variables('databaseName')), variables('containerName'))]", + "name": "[format('{0}/{1}/{2}', variables('cosmos_name'), variables('databaseName'), variables('historyContainerName'))]", "properties": { "resource": { - "id": "[variables('containerName')]", + "id": "[variables('historyContainerName')]", "partitionKey": { - "paths": ["/userId"], + "paths": [ + "/userId" + ], "kind": "Hash" - }, - "defaultTtl": 86400 + } } }, "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', split(format('{0}/{1}', variables('cosmos_name'), variables('databaseName')), '/')[0], split(format('{0}/{1}', variables('cosmos_name'), variables('databaseName')), '/')[1])]" + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmos_name'), variables('databaseName'))]" + ] + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2022-05-15", + "name": "[format('{0}/{1}/{2}', variables('cosmos_name'), variables('databaseName'), variables('configContainerName'))]", + "properties": { + "resource": { + "id": "[variables('configContainerName')]", + "partitionKey": { + "paths": [ + "/userId" + ], + "kind": "Hash" + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmos_name'), variables('databaseName'))]" ] }, { @@ -593,7 +979,7 @@ "type": "Microsoft.CognitiveServices/accounts", "apiVersion": "2023-05-01", "name": "[variables('openai_name')]", - "location": "[parameters('openAiResourceGroupLocation')]", + "location": "[parameters('openAiLocation')]", "tags": "[parameters('tags')]", "kind": "OpenAI", "properties": { @@ -606,22 +992,76 @@ }, { "copy": { - "name": "deployment", - "count": "[length(variables('deployments'))]", + "name": "llmdeployment", + "count": "[length(variables('llmDeployments'))]", "mode": "serial", "batchSize": 1 }, "type": "Microsoft.CognitiveServices/accounts/deployments", "apiVersion": "2023-05-01", - "name": "[format('{0}/{1}', variables('openai_name'), variables('deployments')[copyIndex()].name)]", + "name": "[format('{0}/{1}', variables('openai_name'), variables('llmDeployments')[copyIndex()].name)]", "properties": { - "model": "[variables('deployments')[copyIndex()].model]", - "raiPolicyName": "[if(contains(variables('deployments')[copyIndex()], 'raiPolicyName'), variables('deployments')[copyIndex()].raiPolicyName, null())]" + "model": "[variables('llmDeployments')[copyIndex()].model]", + "raiPolicyName": "[if(contains(variables('llmDeployments')[copyIndex()], 'raiPolicyName'), variables('llmDeployments')[copyIndex()].raiPolicyName, null())]" }, - "sku": "[if(contains(variables('deployments')[copyIndex()], 'sku'), variables('deployments')[copyIndex()].sku, createObject('name', 'Standard', 'capacity', variables('deployments')[copyIndex()].capacity))]", + "sku": "[if(contains(variables('llmDeployments')[copyIndex()], 'sku'), variables('llmDeployments')[copyIndex()].sku, createObject('name', 'Standard', 'capacity', variables('llmDeployments')[copyIndex()].capacity))]", "dependsOn": [ "[resourceId('Microsoft.CognitiveServices/accounts', variables('openai_name'))]" ] + }, + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2023-05-01", + "name": "[variables('openai_dalle_name')]", + "location": "[parameters('dalleLocation')]", + "tags": "[parameters('tags')]", + "kind": "OpenAI", + "properties": { + "customSubDomainName": "[variables('openai_dalle_name')]", + "publicNetworkAccess": "Enabled" + }, + "sku": { + "name": "[parameters('openAiSkuName')]" + } + }, + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2023-05-01", + "name": "[variables('openai_gpt_vision_name')]", + "location": "[parameters('gptvisionLocation')]", + "tags": "[parameters('tags')]", + "kind": "OpenAI", + "properties": { + "customSubDomainName": "[variables('openai_gpt_vision_name')]", + "publicNetworkAccess": "Enabled" + }, + "sku": { + "name": "[parameters('openAiSkuName')]" + } + }, + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2023-05-01", + "name": "[variables('speech_service_name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "SpeechServices", + "properties": { + "customSubDomainName": "[variables('speech_service_name')]", + "publicNetworkAccess": "Enabled" + }, + "sku": { + "name": "[parameters('speechServiceSkuName')]" + } + }, + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2022-05-01", + "name": "[variables('storage_name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "StorageV2", + "sku": "[parameters('storageServiceSku')]" } ], "outputs": { @@ -651,4 +1091,4 @@ "value": "[tenant().tenantId]" } } -} +} \ No newline at end of file diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 216f4df13..65d540660 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -1,12 +1,14 @@ { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { "name": { "value": "${AZURE_ENV_NAME=buhlerchatgpt}" }, "location": { - "value": "${AZURE_LOCATION=eastus}" - } + "value": "${AZURE_LOCATION}" + }, + "openAILocation": {}, + "dalleLocation": {} } -} +} \ No newline at end of file diff --git a/infra/resources.bicep b/infra/resources.bicep index 434377a8c..b55cc324f 100644 --- a/infra/resources.bicep +++ b/infra/resources.bicep @@ -3,21 +3,38 @@ param resourceToken string param openai_api_version string -param openAiResourceGroupLocation string +param openAiLocation string param openAiSkuName string = 'S0' param chatGptDeploymentCapacity int = 30 param chatGptDeploymentName string = 'chat-gpt-35-turbo' param chatGptModelName string = 'chat-gpt-35-turbo' -param chatGptModelVersion string = '0613' +param chatGptModelVersion string = '1106' param embeddingDeploymentName string = 'text-embedding-ada-002' -param embeddingDeploymentCapacity int = 30 +param embeddingDeploymentCapacity int = 10 param embeddingModelName string = 'text-embedding-ada-002' +param dalleLocation string +param dalleDeploymentCapacity int +param dalleDeploymentName string +param dalleModelName string +param dalleApiVersion string + +param gptvisionLocation string +param gptvisionDeploymentCapacity int = 30 +param gptvisionDeploymentName string = 'gpt-4-vision' +param gptvisionModelName string = 'gpt-4' +param gptvisionApiVersion string = '2023-12-01-preview' +param gptvisionModelVersion string = 'vision-preview' + param speechServiceSkuName string = 'S0' + param formRecognizerSkuName string = 'S0' + param searchServiceSkuName string = 'standard' param searchServiceIndexName string = 'azure-chat' -param searchServiceAPIVersion string = '2023-07-01-Preview' + +param storageServiceSku object +param storageServiceImageContainerName string param location string = resourceGroup().location @@ -26,26 +43,34 @@ param nextAuthHash string = uniqueString(newGuid()) param tags object = {} -var openai_name = toLower('${name}ai${resourceToken}') +var openai_name = toLower('${name}-aillm-${resourceToken}') +var openai_dalle_name = toLower('${name}-aidalle-${resourceToken}') +var openai_gpt_vision_name = toLower('${name}-aivision-${resourceToken}') + var form_recognizer_name = toLower('${name}-form-${resourceToken}') var speech_service_name = toLower('${name}-speech-${resourceToken}') var cosmos_name = toLower('${name}-cosmos-${resourceToken}') var search_name = toLower('${name}search${resourceToken}') var webapp_name = toLower('${name}-webapp-${resourceToken}') var appservice_name = toLower('${name}-app-${resourceToken}') -var appInsights_name = toLower('${name}-ai-${resourceToken}') +// storage name must be less than 24 chars, alphanumeric only - token is 13 +var storage_prefix = take(name, 8) +var storage_name = toLower('${storage_prefix}sto${resourceToken}') // keyvault name must be less than 24 chars - token is 13 var kv_prefix = take(name, 7) var keyVaultName = toLower('balm-chat-${resourceToken}') var la_workspace_name = toLower('${name}-la-${resourceToken}') var diagnostic_setting_name = 'AppServiceConsoleLogs' -var keyVaultSecretsUserRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') +var keyVaultSecretsOfficerRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') + +var validStorageServiceImageContainerName = toLower(replace(storageServiceImageContainerName, '-', '')) var databaseName = 'chat' -var containerName = 'history' +var historyContainerName = 'history' +var configContainerName = 'config' -var deployments = [ +var llmDeployments = [ { name: chatGptDeploymentName model: { @@ -100,45 +125,33 @@ resource webApp 'Microsoft.Web/sites@2020-06-01' = { ftpsState: 'Disabled' minTlsVersion: '1.2' appSettings: [ - { - name: 'AZURE_COSMOSDB_KEY' - value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_COSMOSDB_KEY.name})' + { + name: 'AZURE_KEY_VAULT_NAME' + value: keyVaultName } - { - name: 'OPENAI_API_KEY' - value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::OPENAI_API_KEY.name})' + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'true' } { - name: 'AZURE_DOCUMENT_INTELLIGENCE_KEY' - value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_DOCUMENT_INTELLIGENCE_KEY.name})' + name: 'AZURE_OPENAI_VISION_API_KEY' + value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_OPENAI_VISION_API_KEY.name})' } { - name: 'AZURE_SEARCH_API_KEY' - value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_SEARCH_API_KEY.name})' + name: 'AZURE_OPENAI_VISION_API_INSTANCE_NAME' + value: openai_gpt_vision_name } - { - name: 'AZURE_SEARCH_API_VERSION' - value: searchServiceAPIVersion - } - { - name: 'AZURE_SEARCH_NAME' - value: search_name - } - { - name: 'AZURE_SEARCH_INDEX_NAME' - value: searchServiceIndexName - } - { - name: 'AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT' - value: 'https://${form_recognizer_name}.cognitiveservices.azure.com/' + { + name: 'AZURE_OPENAI_VISION_API_DEPLOYMENT_NAME' + value: gptvisionDeploymentName } - { - name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' - value: 'true' + { + name: 'AZURE_OPENAI_VISION_API_VERSION' + value: gptvisionApiVersion } { - name: 'AZURE_COSMOSDB_URI' - value: cosmosDbAccount.properties.documentEndpoint + name: 'AZURE_OPENAI_API_KEY' + value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_OPENAI_API_KEY.name})' } { name: 'AZURE_OPENAI_API_INSTANCE_NAME' @@ -156,6 +169,22 @@ resource webApp 'Microsoft.Web/sites@2020-06-01' = { name: 'AZURE_OPENAI_API_VERSION' value: openai_api_version } + { + name: 'AZURE_OPENAI_DALLE_API_KEY' + value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_OPENAI_DALLE_API_KEY.name})' + } + { + name: 'AZURE_OPENAI_DALLE_API_INSTANCE_NAME' + value: openai_dalle_name + } + { + name: 'AZURE_OPENAI_DALLE_API_DEPLOYMENT_NAME' + value: dalleDeploymentName + } + { + name: 'AZURE_OPENAI_DALLE_API_VERSION' + value: dalleApiVersion + } { name: 'NEXTAUTH_SECRET' value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::NEXTAUTH_SECRET.name})' @@ -164,22 +193,64 @@ resource webApp 'Microsoft.Web/sites@2020-06-01' = { name: 'NEXTAUTH_URL' value: 'https://${webapp_name}.azurewebsites.net' } + { + name: 'AZURE_COSMOSDB_URI' + value: cosmosDbAccount.properties.documentEndpoint + } + { + name: 'AZURE_COSMOSDB_KEY' + value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_COSMOSDB_KEY.name})' + } + { + name: 'AZURE_SEARCH_API_KEY' + value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_SEARCH_API_KEY.name})' + } + { + name: 'AZURE_SEARCH_NAME' + value: search_name + } + { + name: 'AZURE_SEARCH_INDEX_NAME' + value: searchServiceIndexName + } + { + name: 'AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT' + value: 'https://${form_recognizer_name}.cognitiveservices.azure.com/' + } + { + name: 'AZURE_DOCUMENT_INTELLIGENCE_KEY' + value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_DOCUMENT_INTELLIGENCE_KEY.name})' + } { name: 'AZURE_SPEECH_REGION' - value: resourceGroup().location + value: location } { name: 'AZURE_SPEECH_KEY' value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_SPEECH_KEY.name})' } { - name: 'NEXT_PUBLIC_APPINSIGHTS_INSTRUMENTATIONKEY' - value: appInsights.properties.InstrumentationKey + name: 'AZURE_STORAGE_ACCOUNT_NAME' + value: storage_name + } + { + name: 'AZURE_STORAGE_ACCOUNT_KEY' + value: '@Microsoft.KeyVault(VaultName=${kv.name};SecretName=${kv::AZURE_STORAGE_ACCOUNT_KEY.name})' } ] } } identity: { type: 'SystemAssigned'} + + resource configLogs 'config' = { + name: 'logs' + properties: { + applicationLogs: { fileSystem: { level: 'Verbose' } } + detailedErrorMessages: { enabled: true } + failedRequestsTracing: { enabled: true } + httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } + } + } } resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { @@ -187,18 +258,6 @@ resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-12 location: location } -resource appInsights 'Microsoft.Insights/components@2020-02-02' = { - name: appInsights_name - kind: 'web' - location: location - tags: tags - properties: { - WorkspaceResourceId: logAnalyticsWorkspace.id - Application_Type: 'web' - Request_Source: 'rest' - } -} - resource webDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { name: diagnostic_setting_name scope: webApp @@ -215,12 +274,12 @@ resource webDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01 } resource kvFunctionAppPermissions 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: guid(kv.id, webApp.name, keyVaultSecretsUserRole) + name: guid(kv.id, webApp.name, keyVaultSecretsOfficerRole) scope: kv properties: { principalId: webApp.identity.principalId principalType: 'ServicePrincipal' - roleDefinitionId: keyVaultSecretsUserRole + roleDefinitionId: keyVaultSecretsOfficerRole } } @@ -239,15 +298,15 @@ resource kv 'Microsoft.KeyVault/vaults@2021-06-01-preview' = { enabledForTemplateDeployment: false } - resource AZURE_COSMOSDB_KEY 'secrets' = { - name: 'AZURE-COSMOSDB-KEY' + resource AZURE_OPENAI_VISION_API_KEY 'secrets' = { + name: 'AZURE-OPENAI-VISION-API-KEY' properties: { contentType: 'text/plain' - value: cosmosDbAccount.listKeys().secondaryMasterKey + value: azureopenaivision.listKeys().key1 } } - resource OPENAI_API_KEY 'secrets' = { + resource AZURE_OPENAI_API_KEY 'secrets' = { name: 'AZURE-OPENAI-API-KEY' properties: { contentType: 'text/plain' @@ -255,6 +314,30 @@ resource kv 'Microsoft.KeyVault/vaults@2021-06-01-preview' = { } } + resource AZURE_OPENAI_DALLE_API_KEY 'secrets' = { + name: 'AZURE-OPENAI-DALLE-API-KEY' + properties: { + contentType: 'text/plain' + value: azureopenaidalle.listKeys().key1 + } + } + + resource NEXTAUTH_SECRET 'secrets' = { + name: 'NEXTAUTH-SECRET' + properties: { + contentType: 'text/plain' + value: nextAuthHash + } + } + + resource AZURE_COSMOSDB_KEY 'secrets' = { + name: 'AZURE-COSMOSDB-KEY' + properties: { + contentType: 'text/plain' + value: cosmosDbAccount.listKeys().secondaryMasterKey + } + } + resource AZURE_DOCUMENT_INTELLIGENCE_KEY 'secrets' = { name: 'AZURE-DOCUMENT-INTELLIGENCE-KEY' properties: { @@ -271,7 +354,6 @@ resource kv 'Microsoft.KeyVault/vaults@2021-06-01-preview' = { } } - resource AZURE_SEARCH_API_KEY 'secrets' = { name: 'AZURE-SEARCH-API-KEY' properties: { @@ -280,11 +362,11 @@ resource kv 'Microsoft.KeyVault/vaults@2021-06-01-preview' = { } } - resource NEXTAUTH_SECRET 'secrets' = { - name: 'NEXTAUTH-SECRET' + resource AZURE_STORAGE_ACCOUNT_KEY 'secrets' = { + name: 'AZURE-STORAGE-ACCOUNT-KEY' properties: { contentType: 'text/plain' - value: nextAuthHash + value: storage.listKeys().keys[0].value } } } @@ -307,7 +389,8 @@ resource cosmosDbAccount 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = { } resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-05-15' = { - name: '${cosmosDbAccount.name}/${databaseName}' + name: databaseName + parent: cosmosDbAccount properties: { resource: { id: databaseName @@ -315,11 +398,28 @@ resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-05-15 } } -resource container 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2022-05-15' = { - name: '${database.name}/${containerName}' +resource historyContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2022-05-15' = { + name: historyContainerName + parent: database properties: { resource: { - id: containerName + id: historyContainerName + partitionKey: { + paths: [ + '/userId' + ] + kind: 'Hash' + } + } + } +} + +resource configContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2022-05-15' = { + name: configContainerName + parent: database + properties: { + resource: { + id: configContainerName partitionKey: { paths: [ '/userId' @@ -360,7 +460,7 @@ resource searchService 'Microsoft.Search/searchServices@2022-09-01' = { resource azureopenai 'Microsoft.CognitiveServices/accounts@2023-05-01' = { name: openai_name - location: openAiResourceGroupLocation + location: openAiLocation tags: tags kind: 'OpenAI' properties: { @@ -373,7 +473,7 @@ resource azureopenai 'Microsoft.CognitiveServices/accounts@2023-05-01' = { } @batchSize(1) -resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in deployments: { +resource llmdeployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in llmDeployments: { parent: azureopenai name: deployment.name properties: { @@ -386,6 +486,65 @@ resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01 } }] +resource azureopenaidalle 'Microsoft.CognitiveServices/accounts@2023-05-01' = { + name: openai_dalle_name + location: dalleLocation + tags: tags + kind: 'OpenAI' + properties: { + customSubDomainName: openai_dalle_name + publicNetworkAccess: 'Enabled' + } + sku: { + name: openAiSkuName + } + + resource dalleDeployment 'deployments' = { + name: dalleDeploymentName + properties: { + model: { + format: 'OpenAI' + name: dalleModelName + } + } + sku: { + name: 'Standard' + capacity: dalleDeploymentCapacity + } + } +} + + + +resource azureopenaivision 'Microsoft.CognitiveServices/accounts@2023-05-01' = { + name: openai_gpt_vision_name + location: gptvisionLocation + tags: tags + kind: 'OpenAI' + properties: { + customSubDomainName: openai_gpt_vision_name + publicNetworkAccess: 'Enabled' + } + sku: { + name: openAiSkuName + } + + resource dalleDeployment 'deployments' = { + name: gptvisionDeploymentName + properties: { + model: { + format: 'OpenAI' + name: gptvisionModelName + version:gptvisionModelVersion + } + } + sku: { + name: 'Standard' + capacity: gptvisionDeploymentCapacity + } + } +} + resource speechService 'Microsoft.CognitiveServices/accounts@2023-05-01' = { name: speech_service_name location: location @@ -400,4 +559,23 @@ resource speechService 'Microsoft.CognitiveServices/accounts@2023-05-01' = { } } +// TODO: define good default Sku and settings for storage account +resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' = { + name: storage_name + location: location + tags: tags + kind: 'StorageV2' + sku: storageServiceSku + + resource blobServices 'blobServices' = { + name: 'default' + resource container 'containers' = { + name: validStorageServiceImageContainerName + properties: { + publicAccess: 'None' + } + } + } +} + output url string = 'https://${webApp.properties.defaultHostName}' diff --git a/src/.env.example b/src/.env.example index 79adc5f87..915f905e7 100644 --- a/src/.env.example +++ b/src/.env.example @@ -6,14 +6,26 @@ # AZURE_OPENAI_API_INSTANCE_NAME should be just the name of azure openai resource and not the full url; # AZURE_OPENAI_API_DEPLOYMENT_NAME should be deployment name from your azure openai studio and not the model name. # AZURE_OPENAI_API_VERSION should be Supported versions checkout docs https://learn.microsoft.com/en-us/azure/ai-services/openai/reference -OPENAI_API_KEY= -AZURE_OPENAI_API_INSTANCE_NAME= -AZURE_OPENAI_API_DEPLOYMENT_NAME= -AZURE_OPENAI_API_VERSION=2023-03-15-preview -AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= +AZURE_OPENAI_API_KEY=111111 +AZURE_OPENAI_API_INSTANCE_NAME=azurechat +AZURE_OPENAI_API_DEPLOYMENT_NAME=gpt-4 +AZURE_OPENAI_API_VERSION=2023-12-01-preview +AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME=embedding + +# DALL-E image creation endpoint config +AZURE_OPENAI_DALLE_API_KEY=222222 +AZURE_OPENAI_DALLE_API_INSTANCE_NAME=azurechat-dall-e +AZURE_OPENAI_DALLE_API_DEPLOYMENT_NAME=dall-e +AZURE_OPENAI_DALLE_API_VERSION=2023-12-01-preview + +# GPT4 V OpenaAI details +AZURE_OPENAI_VISION_API_KEY=333333 +AZURE_OPENAI_VISION_API_INSTANCE_NAME=azurechat-vision +AZURE_OPENAI_VISION_API_DEPLOYMENT_NAME=gpt-4-vision +AZURE_OPENAI_VISION_API_VERSION=2023-12-01-preview # Update your admin email addresses - comma separated -ADMIN_EMAIL_ADDRESS="you@email.com,you2@email.com" +ADMIN_EMAIL_ADDRESS=you@email.com,you2@email.com # Identity provider is optional if you are running in development mode locally (npm run dev) AUTH_GITHUB_ID= @@ -25,29 +37,37 @@ AZURE_AD_TENANT_ID= # Update your production URL in NEXTAUTH_URL NEXTAUTH_SECRET=AZURE-OPENIAI-NEXTAUTH-OWNKEY@1 +# in production, this should be your production URL e.g. https://azurechat.azurewebsites.net NEXTAUTH_URL=http://localhost:3000 # Update your Cosmos Environment details here AZURE_COSMOSDB_URI=https://.documents.azure.com:443/ AZURE_COSMOSDB_KEY= -# Update your Cosmos DB_NAME and CONTAINER_NAME if you want to overwrite the default values +# Update your Cosmos variables if you want to overwrite the default values AZURE_COSMOSDB_DB_NAME=chat AZURE_COSMOSDB_CONTAINER_NAME=history +AZURE_COSMOSDB_CONFIG_CONTAINER_NAME=config -# Azure cognitive search is used for chat over your data +# Azure AI Search is used for chat over your data AZURE_SEARCH_API_KEY= AZURE_SEARCH_NAME= AZURE_SEARCH_INDEX_NAME= -AZURE_SEARCH_API_VERSION="2023-07-01-Preview" # Azure AI Document Intelligence to extract content from your data -AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT="https://NAME.api.cognitive.microsoft.com/" +AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT=https://NAME.api.cognitive.microsoft.com/ AZURE_DOCUMENT_INTELLIGENCE_KEY= +# max upload document size in bytes +MAX_UPLOAD_DOCUMENT_SIZE=20000000 + # Azure Speech to Text to convert audio to text AZURE_SPEECH_REGION= AZURE_SPEECH_KEY= -# Enabled must be set to "true" any other value will disable the feature -PUBLIC_SPEECH_ENABLED=true \ No newline at end of file +# Azure Storage account to store files +AZURE_STORAGE_ACCOUNT_NAME=azurechat +AZURE_STORAGE_ACCOUNT_KEY=123456 + +# Azure Key Vault to store secrets +AZURE_KEY_VAULT_NAME= \ No newline at end of file diff --git a/src/app-global.ts b/src/app-global.ts deleted file mode 100644 index a07852896..000000000 --- a/src/app-global.ts +++ /dev/null @@ -1 +0,0 @@ -export const APP_VERSION = "1.2.0"; diff --git a/src/app/(authenticated)/api/auth/[...nextauth]/route.ts b/src/app/(authenticated)/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..deb89ae00 --- /dev/null +++ b/src/app/(authenticated)/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/features/auth-page/auth-api"; + +export { handlers as GET, handlers as POST }; diff --git a/src/app/(authenticated)/api/chat/route.ts b/src/app/(authenticated)/api/chat/route.ts new file mode 100644 index 000000000..a8b5f231e --- /dev/null +++ b/src/app/(authenticated)/api/chat/route.ts @@ -0,0 +1,15 @@ +import { ChatAPIEntry } from "@/features/chat-page/chat-services/chat-api/chat-api"; +import { UserPrompt } from "@/features/chat-page/chat-services/models"; + +export async function POST(req: Request) { + const formData = await req.formData(); + const content = formData.get("content") as unknown as string; + const multimodalImage = formData.get("image-base64") as unknown as string; + + const userPrompt: UserPrompt = { + ...JSON.parse(content), + multimodalImage, + }; + + return await ChatAPIEntry(userPrompt, req.signal); +} diff --git a/src/app/(authenticated)/api/document/route.ts b/src/app/(authenticated)/api/document/route.ts new file mode 100644 index 000000000..aec7c5e9c --- /dev/null +++ b/src/app/(authenticated)/api/document/route.ts @@ -0,0 +1,5 @@ +import { SearchAzureAISimilarDocuments } from "@/features/chat-page/chat-services/chat-api/chat-api-rag-extension"; + +export async function POST(req: Request) { + return SearchAzureAISimilarDocuments(req); +} diff --git a/src/app/(authenticated)/api/images/route.ts b/src/app/(authenticated)/api/images/route.ts new file mode 100644 index 000000000..70a666423 --- /dev/null +++ b/src/app/(authenticated)/api/images/route.ts @@ -0,0 +1,5 @@ +import { ImageAPIEntry } from "@/features/chat-page/chat-services/images-api"; + +export async function GET(req: Request) { + return await ImageAPIEntry(req); +} \ No newline at end of file diff --git a/src/app/(authenticated)/chat/[id]/loading.tsx b/src/app/(authenticated)/chat/[id]/loading.tsx new file mode 100644 index 000000000..e002c27e9 --- /dev/null +++ b/src/app/(authenticated)/chat/[id]/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoader } from "@/features/ui/page-loader"; + +export default function Loading() { + return ; +} diff --git a/src/app/(authenticated)/chat/[id]/page.tsx b/src/app/(authenticated)/chat/[id]/page.tsx new file mode 100644 index 000000000..42cdebcf3 --- /dev/null +++ b/src/app/(authenticated)/chat/[id]/page.tsx @@ -0,0 +1,54 @@ +import { ChatPage } from "@/features/chat-page/chat-page"; +import { FindAllChatDocuments } from "@/features/chat-page/chat-services/chat-document-service"; +import { FindAllChatMessagesForCurrentUser } from "@/features/chat-page/chat-services/chat-message-service"; +import { FindChatThreadForCurrentUser } from "@/features/chat-page/chat-services/chat-thread-service"; +import { FindAllExtensionForCurrentUser } from "@/features/extensions-page/extension-services/extension-service"; +import { AI_NAME } from "@/features/theme/theme-config"; +import { DisplayError } from "@/features/ui/error/display-error"; + +export const metadata = { + title: AI_NAME, + description: AI_NAME, +}; + +interface HomeParams { + params: { + id: string; + }; +} + +export default async function Home(props: HomeParams) { + const { id } = props.params; + const [chatResponse, chatThreadResponse, docsResponse, extensionResponse] = + await Promise.all([ + FindAllChatMessagesForCurrentUser(id), + FindChatThreadForCurrentUser(id), + FindAllChatDocuments(id), + FindAllExtensionForCurrentUser(), + ]); + + if (docsResponse.status !== "OK") { + return ; + } + + if (chatResponse.status !== "OK") { + return ; + } + + if (extensionResponse.status !== "OK") { + return ; + } + + if (chatThreadResponse.status !== "OK") { + return ; + } + + return ( + + ); +} diff --git a/src/app/(authenticated)/chat/layout.tsx b/src/app/(authenticated)/chat/layout.tsx new file mode 100644 index 000000000..704bff923 --- /dev/null +++ b/src/app/(authenticated)/chat/layout.tsx @@ -0,0 +1,42 @@ +import { ChatMenu } from "@/features/chat-page/chat-menu/chat-menu"; +import { ChatMenuHeader } from "@/features/chat-page/chat-menu/chat-menu-header"; +import { FindAllChatThreadForCurrentUser } from "@/features/chat-page/chat-services/chat-thread-service"; +import { MenuTray } from "@/features/main-menu/menu-tray"; +import { cn } from "@/ui/lib"; + +import { AI_NAME } from "@/features/theme/theme-config"; +import { DisplayError } from "@/features/ui/error/display-error"; +import { ScrollArea } from "@/features/ui/scroll-area"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: AI_NAME, + description: AI_NAME, +}; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const chatHistoryResponse = await FindAllChatThreadForCurrentUser(); + + if (chatHistoryResponse.status !== "OK") { + return ; + } + + return ( +
+
+ + + + + + + {children} +
+
+ ); +} diff --git a/src/app/(authenticated)/chat/loading.tsx b/src/app/(authenticated)/chat/loading.tsx new file mode 100644 index 000000000..e002c27e9 --- /dev/null +++ b/src/app/(authenticated)/chat/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoader } from "@/features/ui/page-loader"; + +export default function Loading() { + return ; +} diff --git a/src/app/(authenticated)/chat/page.tsx b/src/app/(authenticated)/chat/page.tsx new file mode 100644 index 000000000..5cc022cb0 --- /dev/null +++ b/src/app/(authenticated)/chat/page.tsx @@ -0,0 +1,33 @@ +import { ChatHome } from "@/features/chat-home-page/chat-home"; +import { FindAllNewsArticles } from "@/features/common/services/news-service/news-service"; +import { FindAllExtensionForCurrentUser } from "@/features/extensions-page/extension-services/extension-service"; +import { FindAllPersonaForCurrentUser } from "@/features/persona-page/persona-services/persona-service"; +import { DisplayError } from "@/features/ui/error/display-error"; + +export default async function Home() { + const [personaResponse, extensionResponse, newsResponse] = await Promise.all([ + FindAllPersonaForCurrentUser(), + FindAllExtensionForCurrentUser(), + FindAllNewsArticles(), + ]); + + if (personaResponse.status !== "OK") { + return ; + } + + if (extensionResponse.status !== "OK") { + return ; + } + + if (newsResponse.status !== "OK") { + return ; + } + + return ( + + ); +} diff --git a/src/app/(authenticated)/extensions/loading.tsx b/src/app/(authenticated)/extensions/loading.tsx new file mode 100644 index 000000000..e002c27e9 --- /dev/null +++ b/src/app/(authenticated)/extensions/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoader } from "@/features/ui/page-loader"; + +export default function Loading() { + return ; +} diff --git a/src/app/(authenticated)/extensions/page.tsx b/src/app/(authenticated)/extensions/page.tsx new file mode 100644 index 000000000..7a5ce3732 --- /dev/null +++ b/src/app/(authenticated)/extensions/page.tsx @@ -0,0 +1,13 @@ +import { ExtensionPage } from "@/features/extensions-page/extension-page"; +import { FindAllExtensionForCurrentUser } from "@/features/extensions-page/extension-services/extension-service"; +import { DisplayError } from "@/features/ui/error/display-error"; + +export default async function Home() { + const extensionResponse = await FindAllExtensionForCurrentUser(); + + if (extensionResponse.status !== "OK") { + return ; + } + + return ; +} diff --git a/src/app/(authenticated)/layout.tsx b/src/app/(authenticated)/layout.tsx new file mode 100644 index 000000000..fee2a2e77 --- /dev/null +++ b/src/app/(authenticated)/layout.tsx @@ -0,0 +1,33 @@ +import { AuthenticatedProviders } from "@/features/globals/providers"; +import { MainMenu } from "@/features/main-menu/main-menu"; +import { AI_NAME } from "@/features/theme/theme-config"; +import ApplicationInsightsProvider from "../application-insights-provider"; +import { cn } from "@/ui/lib"; + +import { unstable_noStore as noStore } from 'next/cache' + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: AI_NAME, + description: AI_NAME, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + noStore() + const instrumentationKey = process.env.APPINSIGHTS_INSTRUMENTATIONKEY || ""; + return ( + + +
+ +
{children}
+
+
+
+ ); +} diff --git a/src/app/(authenticated)/persona/loading.tsx b/src/app/(authenticated)/persona/loading.tsx new file mode 100644 index 000000000..e002c27e9 --- /dev/null +++ b/src/app/(authenticated)/persona/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoader } from "@/features/ui/page-loader"; + +export default function Loading() { + return ; +} diff --git a/src/app/(authenticated)/persona/page.tsx b/src/app/(authenticated)/persona/page.tsx new file mode 100644 index 000000000..44e79456d --- /dev/null +++ b/src/app/(authenticated)/persona/page.tsx @@ -0,0 +1,11 @@ +import { ChatPersonaPage } from "@/features/persona-page/persona-page"; +import { FindAllPersonaForCurrentUser } from "@/features/persona-page/persona-services/persona-service"; +import { DisplayError } from "@/features/ui/error/display-error"; + +export default async function Home() { + const personasResponse = await FindAllPersonaForCurrentUser(); + if (personasResponse.status !== "OK") { + return ; + } + return ; +} diff --git a/src/app/(authenticated)/prompt/loading.tsx b/src/app/(authenticated)/prompt/loading.tsx new file mode 100644 index 000000000..e002c27e9 --- /dev/null +++ b/src/app/(authenticated)/prompt/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoader } from "@/features/ui/page-loader"; + +export default function Loading() { + return ; +} diff --git a/src/app/(authenticated)/prompt/page.tsx b/src/app/(authenticated)/prompt/page.tsx new file mode 100644 index 000000000..b27fd8031 --- /dev/null +++ b/src/app/(authenticated)/prompt/page.tsx @@ -0,0 +1,5 @@ +import { ChatSamplePromptPage } from "@/features/prompt-page/prompt-page"; + +export default async function Home() { + return ; +} diff --git a/src/app/(authenticated)/reporting/chat/[id]/loading.tsx b/src/app/(authenticated)/reporting/chat/[id]/loading.tsx new file mode 100644 index 000000000..e002c27e9 --- /dev/null +++ b/src/app/(authenticated)/reporting/chat/[id]/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoader } from "@/features/ui/page-loader"; + +export default function Loading() { + return ; +} diff --git a/src/app/(authenticated)/reporting/chat/[id]/page.tsx b/src/app/(authenticated)/reporting/chat/[id]/page.tsx new file mode 100644 index 000000000..82edfbf54 --- /dev/null +++ b/src/app/(authenticated)/reporting/chat/[id]/page.tsx @@ -0,0 +1,23 @@ +import ReportingChatPage from "@/features/reporting-page/reporting-chat-page"; +import { FindAllChatMessagesForAdmin } from "@/features/reporting-page/reporting-services/reporting-service"; +import { DisplayError } from "@/features/ui/error/display-error"; + +interface HomeParams { + params: { + id: string; + }; +} + +export default async function Home(props: HomeParams) { + const [chatResponse] = await Promise.all([ + FindAllChatMessagesForAdmin(props.params.id), + ]); + + if (chatResponse.status !== "OK") { + return ; + } + + return ( + + ); +} diff --git a/src/app/(authenticated)/reporting/loading.tsx b/src/app/(authenticated)/reporting/loading.tsx new file mode 100644 index 000000000..e002c27e9 --- /dev/null +++ b/src/app/(authenticated)/reporting/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoader } from "@/features/ui/page-loader"; + +export default function Loading() { + return ; +} diff --git a/src/app/(authenticated)/reporting/page.tsx b/src/app/(authenticated)/reporting/page.tsx new file mode 100644 index 000000000..cfd649d8d --- /dev/null +++ b/src/app/(authenticated)/reporting/page.tsx @@ -0,0 +1,12 @@ +import { ChatReportingPage } from "@/features/reporting-page/reporting-page"; + +interface Props { + params: {}; + searchParams: { + pageNumber?: string; + }; +} + +export default async function Home(props: Props) { + return ; +} diff --git a/src/app/(authenticated)/unauthorized/page.tsx b/src/app/(authenticated)/unauthorized/page.tsx new file mode 100644 index 000000000..703972bd7 --- /dev/null +++ b/src/app/(authenticated)/unauthorized/page.tsx @@ -0,0 +1,12 @@ +export default async function Home() { + return ( +
+
+

+ You are not authorized to view this page +

+

This page can only be viewed by admin users.

+
+
+ ); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index a07efa66c..000000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from "@/features/auth/auth-api"; - -export { handlers as GET, handlers as POST }; diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts deleted file mode 100644 index bec2f4987..000000000 --- a/src/app/api/chat/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { chatAPIEntry } from "@/features/chat/chat-services/chat-api-entry"; - -export async function POST(req: Request) { - const body = await req.json(); - return await chatAPIEntry(body); -} diff --git a/src/app/change-log/layout.tsx b/src/app/change-log/layout.tsx deleted file mode 100644 index 6f9ff185d..000000000 --- a/src/app/change-log/layout.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { MainMenu } from "@/features/main-menu/menu"; -import { AI_NAME } from "@/features/theme/customise"; - -export const dynamic = "force-dynamic"; - -export const metadata = { - title: AI_NAME, - description: AI_NAME, -}; - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> - -
- {children} -
- - ); -} diff --git a/src/app/change-log/loading.tsx b/src/app/change-log/loading.tsx deleted file mode 100644 index e792fc37b..000000000 --- a/src/app/change-log/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoadingSkeleton } from "@/features/loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/src/app/change-log/page.tsx b/src/app/change-log/page.tsx deleted file mode 100644 index 3c1128997..000000000 --- a/src/app/change-log/page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Markdown } from "@/components/markdown/markdown"; -import { Card } from "@/components/ui/card"; -import { VersionDisplay } from "@/features/change-log/version-display"; -import { promises as fs } from "fs"; -import { Suspense } from "react"; - -export const dynamic = "force-dynamic"; - -export default async function Home() { - const content = await loadContent(); - return ( - -
- - - -
- -
-
-
- ); -} - -const loadContent = async () => { - if (process.env.NODE_ENV === "production") { - const response = await fetch( - "https://raw.githubusercontent.com/microsoft/azurechat/main/src/app/change-log/update.md", - { - cache: "no-cache", - } - ); - return await response.text(); - } else { - return await fs.readFile( - process.cwd() + "/app/change-log/update.md", - "utf8" - ); - } -}; diff --git a/src/app/change-log/update.md b/src/app/change-log/update.md deleted file mode 100644 index 3a695a106..000000000 --- a/src/app/change-log/update.md +++ /dev/null @@ -1,25 +0,0 @@ -# Azure Chat Updates - -Below are the updates for the Azure Chat Solution accelerator - -## 📂 Chat with file - -- In the chat with file feature, you can now see citations within the responses. Simply click on the citation to access the related context. - -- You can now upload files to existing chats, allowing you to chat with multiple files simultaneously. - -## 🎙️ Speech - -Ability to use Azure Speech in conversations. This feature is not enabled by default. To enable this feature, you must set the environment variable `PUBLIC_SPEECH_ENABLED=true` along with the Azure Speech subscription key and region. - -``` -PUBLIC_SPEECH_ENABLED=true -AZURE_SPEECH_REGION="REGION" -AZURE_SPEECH_KEY="1234...." -``` - -## 🔑 Environment variable change - -Please note that the solution has been upgraded to utilise the most recent version of the OpenAI JavaScript SDK, necessitating the use of the `OPENAI_API_KEY` environment variable. - -Ensure that you update the variable name in both your '.env' file and the configuration within Azure App Service or Key Vault, changing it from `AZURE_OPENAI_API_KEY` to `OPENAI_API_KEY`. diff --git a/src/app/chat/[id]/loading.tsx b/src/app/chat/[id]/loading.tsx deleted file mode 100644 index e792fc37b..000000000 --- a/src/app/chat/[id]/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoadingSkeleton } from "@/features/loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/src/app/chat/[id]/not-found.tsx b/src/app/chat/[id]/not-found.tsx deleted file mode 100644 index 6a79fe090..000000000 --- a/src/app/chat/[id]/not-found.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Card } from "@/components/ui/card"; -import { NewChat } from "@/features/chat/chat-menu/new-chat"; - -export default async function NotFound() { - return ( - -
-
-

Uh-oh! 404

-

- How about we start a new chat? -

- -
-
-
- ); -} diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx deleted file mode 100644 index 63f6a734c..000000000 --- a/src/app/chat/[id]/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FindAllChats } from "@/features/chat/chat-services/chat-service"; -import { FindChatThreadByID } from "@/features/chat/chat-services/chat-thread-service"; -import { ChatProvider } from "@/features/chat/chat-ui/chat-context"; -import { ChatUI } from "@/features/chat/chat-ui/chat-ui"; -import { notFound } from "next/navigation"; - -export const dynamic = "force-dynamic"; - -export default async function Home({ params }: { params: { id: string } }) { - const [items, thread] = await Promise.all([ - FindAllChats(params.id), - FindChatThreadByID(params.id), - ]); - - if (thread.length === 0) { - notFound(); - } - - return ( - - - - ); -} diff --git a/src/app/chat/layout.tsx b/src/app/chat/layout.tsx deleted file mode 100644 index 7b784d7d9..000000000 --- a/src/app/chat/layout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ChatMenu } from "@/features/chat/chat-menu/chat-menu"; -import { ChatMenuContainer } from "@/features/chat/chat-menu/chat-menu-container"; -import { MainMenu } from "@/features/main-menu/menu"; -import { AI_NAME } from "@/features/theme/customise"; - -export const dynamic = "force-dynamic"; - -export const metadata = { - title: AI_NAME, - description: AI_NAME, -}; - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> - -
- - - - {children} -
- - ); -} diff --git a/src/app/chat/loading.tsx b/src/app/chat/loading.tsx deleted file mode 100644 index e792fc37b..000000000 --- a/src/app/chat/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoadingSkeleton } from "@/features/loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx deleted file mode 100644 index 5a70139d8..000000000 --- a/src/app/chat/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Card } from "@/components/ui/card"; -import { StartNewChat } from "@/features/chat/chat-ui/chat-empty-state/start-new-chat"; - -export const dynamic = "force-dynamic"; - -export default async function Home() { - return ( - - - - ); -} diff --git a/src/app/globals.css b/src/app/globals.css index 931b99188..5b618cf0e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,47 +5,47 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 240 10% 3.9%; + --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; + --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 170 90% 29%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 176.1 100% 30.4%; + --primary-foreground: 210 40% 98%; + --secondary: 183 47.6% 58.8%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 170 90% 29%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 169 74% 22%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 176.1,100%,30.4%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 183,47.6%,58.8%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 169 74% 22%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5002ec9c6..c167c623d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,15 +1,9 @@ -import { ThemeProvider } from "@/components/theme-provider"; -import { Toaster } from "@/components/ui/toaster"; -import { GlobalConfigProvider } from "@/features/global-config/global-client-config-context"; -import { Providers } from "@/features/providers"; -import { AI_NAME } from "@/features/theme/customise"; -import { cn } from "@/lib/utils"; +import { AI_NAME } from "@/features/theme/theme-config"; +import { ThemeProvider } from "@/features/theme/theme-provider"; +import { Toaster } from "@/features/ui/toaster"; +import { cn } from "@/ui/lib"; import { Inter } from "next/font/google"; import "./globals.css"; -import ApplicationInsightsProvider from "./application-insights-provider"; -import { unstable_noStore as noStore } from 'next/cache' - -export const dynamic = "force-dynamic"; const inter = Inter({ subsets: ["latin"] }); @@ -18,35 +12,29 @@ export const metadata = { description: AI_NAME, }; +export const dynamic = "force-dynamic"; + export default function RootLayout({ children, }: { children: React.ReactNode; }) { - noStore() - const instrumentationKey = process.env.APPINSIGHTS_INSTRUMENTATIONKEY || ""; return ( - - - + + + - - - -
- {children} -
- -
-
-
-
+ {children} + + + ); diff --git a/src/app/loading.tsx b/src/app/loading.tsx index e792fc37b..e002c27e9 100644 --- a/src/app/loading.tsx +++ b/src/app/loading.tsx @@ -1,5 +1,5 @@ -import { LoadingSkeleton } from "@/features/loading-skeleton"; +import { PageLoader } from "@/features/ui/page-loader"; export default function Loading() { - return ; + return ; } diff --git a/src/app/page.tsx b/src/app/page.tsx index e595db1fd..91616adfd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,18 +1,11 @@ -import { LogIn } from "@/components/login/login"; -import { Card } from "@/components/ui/card"; -import { userSession } from "@/features/auth/helpers"; -import { redirect } from "next/navigation"; - -export const dynamic = "force-dynamic"; +import { redirectIfAuthenticated } from "@/features/auth-page/helpers"; +import { LogIn } from "@/features/auth-page/login"; export default async function Home() { - const user = await userSession(); - if (user) { - redirect("/chat"); - } + await redirectIfAuthenticated(); return ( - - - +
+ +
); } diff --git a/src/app/reporting/[chatid]/loading.tsx b/src/app/reporting/[chatid]/loading.tsx deleted file mode 100644 index e792fc37b..000000000 --- a/src/app/reporting/[chatid]/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoadingSkeleton } from "@/features/loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/src/app/reporting/[chatid]/page.tsx b/src/app/reporting/[chatid]/page.tsx deleted file mode 100644 index 5982e7e5e..000000000 --- a/src/app/reporting/[chatid]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ChatReportingUI } from "@/features/reporting/chat-reporting-ui"; - -export default async function Home({ params }: { params: { chatid: string } }) { - return ; -} diff --git a/src/app/reporting/layout.tsx b/src/app/reporting/layout.tsx deleted file mode 100644 index 96dc7aa6e..000000000 --- a/src/app/reporting/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { MainMenu } from "@/features/main-menu/menu"; -import { AI_NAME } from "@/features/theme/customise"; - -export const metadata = { - title: AI_NAME, - description: AI_NAME, -}; - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> - -
{children}
- - ); -} diff --git a/src/app/reporting/loading.tsx b/src/app/reporting/loading.tsx deleted file mode 100644 index e792fc37b..000000000 --- a/src/app/reporting/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoadingSkeleton } from "@/features/loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/src/app/reporting/page.tsx b/src/app/reporting/page.tsx deleted file mode 100644 index 9459c5206..000000000 --- a/src/app/reporting/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Reporting, ReportingProp } from "@/features/reporting/reporting"; - -export default async function Home(props: ReportingProp) { - return ; -} diff --git a/src/app/unauthorized/layout.tsx b/src/app/unauthorized/layout.tsx deleted file mode 100644 index 96dc7aa6e..000000000 --- a/src/app/unauthorized/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { MainMenu } from "@/features/main-menu/menu"; -import { AI_NAME } from "@/features/theme/customise"; - -export const metadata = { - title: AI_NAME, - description: AI_NAME, -}; - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> - -
{children}
- - ); -} diff --git a/src/app/unauthorized/loading.tsx b/src/app/unauthorized/loading.tsx deleted file mode 100644 index e792fc37b..000000000 --- a/src/app/unauthorized/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoadingSkeleton } from "@/features/loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/src/app/unauthorized/page.tsx b/src/app/unauthorized/page.tsx deleted file mode 100644 index 37f6db45f..000000000 --- a/src/app/unauthorized/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Card } from "@/components/ui/card"; - -export default async function Home() { - return ( - -
-

You not authorized to view this page

-

This page can only be viewed by admin users.

-
-
- ) -} diff --git a/src/components.json b/src/components.json index bb6354abc..50e20392a 100644 --- a/src/components.json +++ b/src/components.json @@ -2,6 +2,7 @@ "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": true, + "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "app/globals.css", @@ -9,7 +10,7 @@ "cssVariables": true }, "aliases": { - "components": "@/components", - "utils": "@/lib/utils" + "components": "@/features", + "utils": "@/features/lib/utils" } -} \ No newline at end of file +} diff --git a/src/components/chat/chat-loading.tsx b/src/components/chat/chat-loading.tsx deleted file mode 100644 index f9819cc89..000000000 --- a/src/components/chat/chat-loading.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Loader } from "lucide-react"; -import { FC } from "react"; - -interface Props {} - -const ChatLoading: FC = (props) => { - return ( -
- -
- ); -}; - -export default ChatLoading; diff --git a/src/components/chat/chat-row.tsx b/src/components/chat/chat-row.tsx deleted file mode 100644 index 0e1306d10..000000000 --- a/src/components/chat/chat-row.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; -import { ChatRole } from "@/features/chat/chat-services/models"; -import { isNotNullOrEmpty } from "@/features/chat/chat-services/utils"; -import { cn } from "@/lib/utils"; -import { CheckIcon, ClipboardIcon, UserCircle } from "lucide-react"; -import { FC, useState } from "react"; -import { Markdown } from "../markdown/markdown"; -import Typography from "../typography"; -import { Avatar, AvatarImage } from "../ui/avatar"; -import { Button } from "../ui/button"; - -interface ChatRowProps { - name: string; - profilePicture: string; - message: string; - type: ChatRole; -} - -const ChatRow: FC = (props) => { - const [isIconChecked, setIsIconChecked] = useState(false); - const toggleIcon = () => { - setIsIconChecked((prevState) => !prevState); - }; - - const handleButtonClick = () => { - toggleIcon(); - navigator.clipboard.writeText(props.message); - }; - - return ( -
-
-
-
-
- {isNotNullOrEmpty(props.profilePicture) ? ( - - - - ) : ( - - )} -
- - {props.name} - -
- -
- -
- -
-
-
- ); -}; - -export default ChatRow; diff --git a/src/components/hooks/use-chat-scroll-anchor.tsx b/src/components/hooks/use-chat-scroll-anchor.tsx deleted file mode 100644 index 7b66c48e2..000000000 --- a/src/components/hooks/use-chat-scroll-anchor.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { Message } from "ai"; -import { RefObject, useEffect } from "react"; - -export const useChatScrollAnchor = ( - chats: Message[], - ref: RefObject -) => { - useEffect(() => { - if (ref && ref.current) { - ref.current.scrollTop = ref.current.scrollHeight; - } - }, [chats, ref]); -}; diff --git a/src/components/markdown/code-block.tsx b/src/components/markdown/code-block.tsx deleted file mode 100644 index 0ef554219..000000000 --- a/src/components/markdown/code-block.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { FC, memo } from "react"; -import { Prism } from "react-syntax-highlighter"; -import { atomDark } from "react-syntax-highlighter/dist/esm/styles/prism"; - -export const fence = { - render: "CodeBlock", - attributes: { - language: { - type: String, - }, - value: { - type: String, - }, - }, -}; - -interface Props { - language: string; - children: string; -} - -export const CodeBlock: FC = memo(({ language, children }) => { - console.log(language); - return ( - - {children} - - ); -}); - -CodeBlock.displayName = "CodeBlock"; diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx deleted file mode 100644 index bfd4d3326..000000000 --- a/src/components/markdown/markdown.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import Markdoc from "@markdoc/markdoc"; -import React, { FC } from "react"; -import { Citation } from "../../features/chat/chat-ui/markdown/citation"; -import { CodeBlock } from "./code-block"; -import { citationConfig } from "./config"; -import { Paragraph } from "./paragraph"; - -interface Props { - content: string; -} - -export const Markdown: FC = (props) => { - const ast = Markdoc.parse(props.content); - - const content = Markdoc.transform(ast, { - ...citationConfig, - }); - - return Markdoc.renderers.react(content, React, { - components: { Citation, Paragraph, CodeBlock }, - }); -}; diff --git a/src/components/markdown/paragraph.tsx b/src/components/markdown/paragraph.tsx deleted file mode 100644 index d4904caf6..000000000 --- a/src/components/markdown/paragraph.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const Paragraph = ({ children, className }: any) => { - return
{children}
; -}; - -export const paragraph = { - render: "Paragraph", -}; diff --git a/src/components/menu.tsx b/src/components/menu.tsx deleted file mode 100644 index cd48cc775..000000000 --- a/src/components/menu.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from "react"; - -import { cn } from "@/lib/utils"; -import Link from "next/link"; - -const Menu = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); - -Menu.displayName = "Menu"; - -const MenuHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); -MenuHeader.displayName = "MenuHeader"; - -const MenuContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); -MenuContent.displayName = "MenuContent"; - -interface MenuItemProps extends React.HTMLAttributes { - href: string; - isSelected?: boolean; -} - -const MenuItem: React.FC = (props) => { - return ( - - {props.children} - - ); -}; - -const MenuFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); -MenuFooter.displayName = "MenuFooter"; - -export { Menu, MenuContent, MenuFooter, MenuHeader, MenuItem }; diff --git a/src/components/typography.tsx b/src/components/typography.tsx deleted file mode 100644 index 9b5e19c1a..000000000 --- a/src/components/typography.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { cn } from "@/lib/utils"; -import * as React from "react"; - -type TypographyProps = { - variant: "h1" | "h2" | "h3" | "h4" | "h5" | "p"; -} & React.HTMLAttributes; - -const Typography = React.forwardRef( - function Typography({ variant, className, ...props }, ref) { - const Component = variant; - return ( - - ); - } -); - -export default Typography; diff --git a/src/dockerfile b/src/dockerfile deleted file mode 100644 index 69bd88a53..000000000 --- a/src/dockerfile +++ /dev/null @@ -1,79 +0,0 @@ -FROM node:18-alpine AS base - -# Install dependencies only when needed -FROM base AS deps -# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat -WORKDIR /app - -# Install dependencies based on the preferred package manager -COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ -RUN \ - if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ - elif [ -f package-lock.json ]; then npm ci; \ - elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ - else echo "Lockfile not found." && exit 1; \ - fi - - -# Rebuild the source code only when needed -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . - -# Next.js collects completely anonymous telemetry data about general usage. -# Learn more here: https://nextjs.org/telemetry -# Uncomment the following line in case you want to disable telemetry during the build. -# ENV NEXT_TELEMETRY_DISABLED 1 - -RUN yarn build - -# If using npm comment out above and use below instead -# RUN npm run build - -# Production image, copy all the files and run next -FROM base AS runner -WORKDIR /app - -ENV NODE_ENV production -# Uncomment the following line in case you want to disable telemetry during runtime. -# ENV NEXT_TELEMETRY_DISABLED 1 - -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - -COPY --from=builder /app/public ./public - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -USER nextjs - -ENV PORT 3000 -ENV HOSTNAME localhost - -# set environment variables here to run locally -# ENV OPENAI_API_KEY -# ENV AZURE_OPENAI_API_INSTANCE_NAME -# ENV AZURE_OPENAI_API_DEPLOYMENT_NAME -# ENV AZURE_OPENAI_API_VERSION - -# ENV AUTH_GITHUB_ID -# ENV AUTH_GITHUB_SECRET - -# ENV AZURE_AD_CLIENT_ID -# ENV AZURE_AD_CLIENT_SECRET -# ENV AZURE_AD_TENANT_ID - -# ENV NEXTAUTH_SECRET -# ENV NEXTAUTH_URL - -# ENV AZURE_COSMOSDB_URI -# ENV AZURE_COSMOSDB_KEY - -EXPOSE 3000 - -CMD ["node", "server.js"] \ No newline at end of file diff --git a/src/features/auth/auth-api.ts b/src/features/auth-page/auth-api.ts similarity index 77% rename from src/features/auth/auth-api.ts rename to src/features/auth-page/auth-api.ts index 73fbc0cb6..a70aaca47 100644 --- a/src/features/auth/auth-api.ts +++ b/src/features/auth-page/auth-api.ts @@ -1,14 +1,16 @@ import NextAuth, { NextAuthOptions } from "next-auth"; -import { Provider } from "next-auth/providers"; import AzureADProvider from "next-auth/providers/azure-ad"; -import GitHubProvider from "next-auth/providers/github"; import CredentialsProvider from "next-auth/providers/credentials"; +import GitHubProvider from "next-auth/providers/github"; +import { Provider } from "next-auth/providers/index"; import { hashValue } from "./helpers"; const configureIdentityProvider = () => { const providers: Array = []; - const adminEmails = process.env.ADMIN_EMAIL_ADDRESS?.split(",").map(email => email.toLowerCase().trim()); + const adminEmails = process.env.ADMIN_EMAIL_ADDRESS?.split(",").map((email) => + email.toLowerCase().trim() + ); if (process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET) { providers.push( @@ -18,10 +20,10 @@ const configureIdentityProvider = () => { async profile(profile) { const newProfile = { ...profile, - isAdmin: adminEmails?.includes(profile.email.toLowerCase()) - } + isAdmin: adminEmails?.includes(profile.email.toLowerCase()), + }; return newProfile; - } + }, }) ); } @@ -37,15 +39,16 @@ const configureIdentityProvider = () => { clientSecret: process.env.AZURE_AD_CLIENT_SECRET!, tenantId: process.env.AZURE_AD_TENANT_ID!, async profile(profile) { - const newProfile = { ...profile, // throws error without this - unsure of the root cause (https://stackoverflow.com/questions/76244244/profile-id-is-missing-in-google-oauth-profile-response-nextauth) id: profile.sub, - isAdmin: adminEmails?.includes(profile.email.toLowerCase()) || adminEmails?.includes(profile.preferred_username.toLowerCase()) - } + isAdmin: + adminEmails?.includes(profile.email.toLowerCase()) || + adminEmails?.includes(profile.preferred_username.toLowerCase()), + }; return newProfile; - } + }, }) ); } @@ -61,7 +64,7 @@ const configureIdentityProvider = () => { credentials: { username: { label: "Username", type: "text", placeholder: "dev" }, password: { label: "Password", type: "password" }, - }, + }, async authorize(credentials, req): Promise { // You can put logic here to validate the credentials and return a user. // We're going to take any username and make a new user with it @@ -69,15 +72,18 @@ const configureIdentityProvider = () => { const username = credentials?.username || "dev"; const email = username + "@localhost"; const user = { - id: hashValue(email), - name: username, - email: email, - isAdmin: false, - image: "", - }; - console.log("=== DEV USER LOGGED IN:\n", JSON.stringify(user, null, 2)); + id: hashValue(email), + name: username, + email: email, + isAdmin: false, + image: "", + }; + console.log( + "=== DEV USER LOGGED IN:\n", + JSON.stringify(user, null, 2) + ); return user; - } + }, }) ); } @@ -89,16 +95,16 @@ export const options: NextAuthOptions = { secret: process.env.NEXTAUTH_SECRET, providers: [...configureIdentityProvider()], callbacks: { - async jwt({token, user, account, profile, isNewUser, session}) { + async jwt({ token, user }) { if (user?.isAdmin) { - token.isAdmin = user.isAdmin + token.isAdmin = user.isAdmin; } - return token + return token; + }, + async session({ session, token, user }) { + session.user.isAdmin = token.isAdmin as boolean; + return session; }, - async session({session, token, user }) { - session.user.isAdmin = token.isAdmin as string - return session - } }, session: { strategy: "jwt", diff --git a/src/features/auth/helpers.ts b/src/features/auth-page/helpers.ts similarity index 56% rename from src/features/auth/helpers.ts rename to src/features/auth-page/helpers.ts index 407dd47bb..722308a4a 100644 --- a/src/features/auth/helpers.ts +++ b/src/features/auth-page/helpers.ts @@ -1,16 +1,30 @@ import { createHash } from "crypto"; import { getServerSession } from "next-auth"; +import { RedirectToPage } from "../common/navigation-helpers"; import { options } from "./auth-api"; export const userSession = async (): Promise => { const session = await getServerSession(options); if (session && session.user) { - return session.user as UserModel; + return { + name: session.user.name!, + image: session.user.image!, + email: session.user.email!, + isAdmin: session.user.isAdmin!, + }; } return null; }; +export const getCurrentUser = async (): Promise => { + const user = await userSession(); + if (user) { + return user; + } + throw new Error("User not found"); +}; + export const userHashedId = async (): Promise => { const user = await userSession(); if (user) { @@ -20,14 +34,22 @@ export const userHashedId = async (): Promise => { throw new Error("User not found"); }; -export type UserModel = { - name: string; - image: string; - email: string; -}; - export const hashValue = (value: string): string => { const hash = createHash("sha256"); hash.update(value); return hash.digest("hex"); }; + +export const redirectIfAuthenticated = async () => { + const user = await userSession(); + if (user) { + RedirectToPage("chat"); + } +}; + +export type UserModel = { + name: string; + image: string; + email: string; + isAdmin: boolean; +}; diff --git a/src/components/login/login.tsx b/src/features/auth-page/login.tsx similarity index 65% rename from src/components/login/login.tsx rename to src/features/auth-page/login.tsx index c31dd51c7..b694991cd 100644 --- a/src/components/login/login.tsx +++ b/src/features/auth-page/login.tsx @@ -1,6 +1,7 @@ "use client"; -import { AI_NAME } from "@/features/theme/customise"; +import { AI_NAME } from "@/features/theme/theme-config"; import { signIn } from "next-auth/react"; +import { FC } from "react"; import { Avatar, AvatarImage } from "../ui/avatar"; import { Button } from "../ui/button"; import { @@ -11,7 +12,11 @@ import { CardTitle, } from "../ui/card"; -export const LogIn = () => { +interface LoginProps { + isDevMode: boolean; +} + +export const LogIn: FC = (props) => { return ( @@ -26,11 +31,12 @@ export const LogIn = () => { - {/* */} - - {process.env.NODE_ENV === "development" && ( - - )} + + {props.isDevMode ? ( + + ) : null} ); diff --git a/src/features/change-log/app-version.ts b/src/features/change-log/app-version.ts deleted file mode 100644 index a8d3f8d12..000000000 --- a/src/features/change-log/app-version.ts +++ /dev/null @@ -1,32 +0,0 @@ -"use server"; -import { APP_VERSION } from "@/app-global"; - -export const appVersionDetails = async () => { - const appVersion = await fetch( - "https://raw.githubusercontent.com/microsoft/azurechat/main/src/package.json", - { - cache: "no-cache", - } - ); - - const version = (await appVersion.json()).version as string; - const isOutdated = isVersionGreater(version, APP_VERSION); - - return { version, isOutdated }; -}; - -function isVersionGreater(version1: string, version2: string): boolean { - const v1parts = version1.split("."); - const v2parts = version2.split("."); - - for (let i = 0; i < v1parts.length; ++i) { - if (parseInt(v1parts[i], 10) > parseInt(v2parts[i], 10)) { - return true; - } - if (parseInt(v1parts[i], 10) < parseInt(v2parts[i], 10)) { - return false; - } - } - - return false; -} diff --git a/src/features/change-log/update-indicator.tsx b/src/features/change-log/update-indicator.tsx deleted file mode 100644 index 9736e0700..000000000 --- a/src/features/change-log/update-indicator.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; -import { useEffect } from "react"; -import { useFormState } from "react-dom"; -import { UpdateIndicatorAction } from "./version-action"; - -export const UpdateIndicator = () => { - const [node, formAction] = useFormState(UpdateIndicatorAction, null); - - useEffect(() => { - formAction(); - }, [formAction]); - - return <>{node}; -}; diff --git a/src/features/change-log/version-action.tsx b/src/features/change-log/version-action.tsx deleted file mode 100644 index cbb639cc1..000000000 --- a/src/features/change-log/version-action.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { appVersionDetails } from "./app-version"; - -export const UpdateIndicatorAction = async () => { - const appVersion = await appVersionDetails(); - - if (!appVersion.isOutdated) { - return null; - } else { - return ( -
-
-
- ); - } -}; diff --git a/src/features/change-log/version-display.tsx b/src/features/change-log/version-display.tsx deleted file mode 100644 index 27e8e6fc1..000000000 --- a/src/features/change-log/version-display.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { APP_VERSION } from "@/app-global"; -import { appVersionDetails } from "./app-version"; - -export const VersionDisplay = async () => { - const appVersion = await appVersionDetails(); - - return ( -
-

- Version: {APP_VERSION}{" "} - {appVersion.isOutdated ? ( - - new version available {appVersion.version} - - ) : ( - - You are up to date - - )} -

-
- ); -}; diff --git a/src/features/chat-home-page/chat-home.tsx b/src/features/chat-home-page/chat-home.tsx new file mode 100644 index 000000000..6d111013b --- /dev/null +++ b/src/features/chat-home-page/chat-home.tsx @@ -0,0 +1,82 @@ +import { AddExtension } from "@/features/extensions-page/add-extension/add-new-extension"; +import { ExtensionCard } from "@/features/extensions-page/extension-card/extension-card"; +import { ExtensionModel } from "@/features/extensions-page/extension-services/models"; +import { PersonaCard } from "@/features/persona-page/persona-card/persona-card"; +import { PersonaModel } from "@/features/persona-page/persona-services/models"; +import { AI_DESCRIPTION, AI_NAME } from "@/features/theme/theme-config"; +import { Hero } from "@/features/ui/hero"; +import { ScrollArea } from "@/features/ui/scroll-area"; +import Image from "next/image"; +import { FC } from "react"; +import { NewsArticleModel } from "@/features/common/services/news-service/news-model"; +import { NewsArticle } from "./news-article"; + +interface ChatPersonaProps { + personas: PersonaModel[]; + extensions: ExtensionModel[]; + news: NewsArticleModel[]; +} + +export const ChatHome: FC = (props) => { + return ( + +
+ + ai-icon{" "} + {AI_NAME} + + } + description={AI_DESCRIPTION} + > +
+
+
+

Articles

+
+ {props.news && props.news.length > 0 ? ( + + props.news.map((newsArticle) => { + return (NewsArticle({newsArticle})) + } + ) + ) : ( +

+ No current news +

+ )} +
+
+
+
+

Personas

+ + {props.personas && props.personas.length > 0 ? ( +
+ {props.personas.map((persona) => { + return ( + + ); + })} +
+ ) : +

No personas created

+ } +
+
+ +
+
+ ); +}; diff --git a/src/features/chat-home-page/news-article.tsx b/src/features/chat-home-page/news-article.tsx new file mode 100644 index 000000000..3c5672cc4 --- /dev/null +++ b/src/features/chat-home-page/news-article.tsx @@ -0,0 +1,19 @@ +import { FC } from "react"; +import { NewsArticleModel } from "@/features/common/services/news-service/news-model"; + +interface Props { + newsArticle: NewsArticleModel +} + +export const NewsArticle: FC = (props) => { + const { newsArticle } = props; + return ( +
+

{newsArticle.title}

+

+ {newsArticle.description} +

+ Read more +
+ ); +}; diff --git a/src/features/chat-page/chat-header/chat-header.tsx b/src/features/chat-page/chat-header/chat-header.tsx new file mode 100644 index 000000000..269bc880e --- /dev/null +++ b/src/features/chat-page/chat-header/chat-header.tsx @@ -0,0 +1,45 @@ +import { ExtensionModel } from "@/features/extensions-page/extension-services/models"; +import { CHAT_DEFAULT_PERSONA } from "@/features/theme/theme-config"; +import { VenetianMask } from "lucide-react"; +import { FC } from "react"; +import { ChatDocumentModel, ChatThreadModel } from "../chat-services/models"; +import { DocumentDetail } from "./document-detail"; +import { ExtensionDetail } from "./extension-detail"; +import { PersonaDetail } from "./persona-detail"; + +interface Props { + chatThread: ChatThreadModel; + chatDocuments: Array; + extensions: Array; +} + +export const ChatHeader: FC = (props) => { + const persona = + props.chatThread.personaMessageTitle === "" || + props.chatThread.personaMessageTitle === undefined + ? CHAT_DEFAULT_PERSONA + : props.chatThread.personaMessageTitle; + return ( +
+
+
+ {props.chatThread.name} + + + {persona} + +
+
+ + + +
+
+
+ ); +}; diff --git a/src/features/chat-page/chat-header/document-detail.tsx b/src/features/chat-page/chat-header/document-detail.tsx new file mode 100644 index 000000000..c645fe90a --- /dev/null +++ b/src/features/chat-page/chat-header/document-detail.tsx @@ -0,0 +1,44 @@ +import { Button } from "@/features/ui/button"; +import { ScrollArea } from "@/features/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/features/ui/sheet"; +import { File } from "lucide-react"; +import { FC } from "react"; +import { ChatDocumentModel } from "../chat-services/models"; + +interface Props { + chatDocuments: Array; +} + +export const DocumentDetail: FC = (props) => { + return ( + + + + + + + Documents + + +
+ {props.chatDocuments.map((doc) => { + return ( +
+
{doc.name}
+
+ ); + })} +
+
+
+
+ ); +}; diff --git a/src/features/chat-page/chat-header/extension-detail.tsx b/src/features/chat-page/chat-header/extension-detail.tsx new file mode 100644 index 000000000..670905689 --- /dev/null +++ b/src/features/chat-page/chat-header/extension-detail.tsx @@ -0,0 +1,76 @@ +import { ExtensionModel } from "@/features/extensions-page/extension-services/models"; +import { Button } from "@/features/ui/button"; +import { ScrollArea } from "@/features/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/features/ui/sheet"; +import { Switch } from "@/features/ui/switch"; +import { PocketKnife } from "lucide-react"; +import { FC } from "react"; +import { chatStore } from "../chat-store"; + +interface Props { + extensions: Array; + chatThreadId: string; + installedExtensionIds: Array | undefined; + disabled: boolean; +} + +export const ExtensionDetail: FC = (props) => { + const toggleInstall = async (isChecked: boolean, extensionId: string) => { + if (isChecked) { + await chatStore.AddExtensionToChatThread(extensionId); + } else { + await chatStore.RemoveExtensionFromChatThread(extensionId); + } + }; + + const installedCount = props.installedExtensionIds?.length ?? 0; + const totalCount = props.extensions.length; + + return ( + + + + + + + Extensions + + +
+ {props.extensions.map((extension) => { + const isInstalled = + props.installedExtensionIds?.includes(extension.id) ?? false; + return ( +
+
+
{extension.name}
+
+ {extension.description} +
+
+
+ toggleInstall(e, extension.id)} + /> +
+
+ ); + })} +
+
+
+
+ ); +}; diff --git a/src/features/chat-page/chat-header/persona-detail.tsx b/src/features/chat-page/chat-header/persona-detail.tsx new file mode 100644 index 000000000..57bde9e43 --- /dev/null +++ b/src/features/chat-page/chat-header/persona-detail.tsx @@ -0,0 +1,51 @@ +import { CHAT_DEFAULT_SYSTEM_PROMPT } from "@/features/theme/theme-config"; +import { Button } from "@/features/ui/button"; +import { Label } from "@/features/ui/label"; +import { ScrollArea } from "@/features/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/features/ui/sheet"; +import { VenetianMask } from "lucide-react"; +import { FC } from "react"; +import { ChatThreadModel } from "../chat-services/models"; + +interface Props { + chatThread: ChatThreadModel; +} + +export const PersonaDetail: FC = (props) => { + const persona = props.chatThread.personaMessageTitle; + const personaMessage = props.chatThread.personaMessage; + return ( + + + + + + + Persona + + +
+
+ +
{persona}
+
+ +
+ +
{`${CHAT_DEFAULT_SYSTEM_PROMPT}`}
+
{`${personaMessage}`}
+
+
+
+
+
+ ); +}; diff --git a/src/features/chat-page/chat-input/chat-input.tsx b/src/features/chat-page/chat-input/chat-input.tsx new file mode 100644 index 000000000..1b03092ca --- /dev/null +++ b/src/features/chat-page/chat-input/chat-input.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { + ResetInputRows, + onKeyDown, + onKeyUp, + useChatInputDynamicHeight, +} from "@/features/chat-page/chat-input/use-chat-input-dynamic-height"; + +import { AttachFile } from "@/features/ui/chat/chat-input-area/attach-file"; +import { + ChatInputActionArea, + ChatInputForm, + ChatInputPrimaryActionArea, + ChatInputSecondaryActionArea, +} from "@/features/ui/chat/chat-input-area/chat-input-area"; +import { ChatTextInput } from "@/features/ui/chat/chat-input-area/chat-text-input"; +import { ImageInput } from "@/features/ui/chat/chat-input-area/image-input"; +import { Microphone } from "@/features/ui/chat/chat-input-area/microphone"; +import { StopChat } from "@/features/ui/chat/chat-input-area/stop-chat"; +import { SubmitChat } from "@/features/ui/chat/chat-input-area/submit-chat"; +import React, { useRef } from "react"; +import { chatStore, useChat } from "../chat-store"; +import { fileStore, useFileStore } from "./file/file-store"; +import { PromptSlider } from "./prompt/prompt-slider"; +import { + speechToTextStore, + useSpeechToText, +} from "./speech/use-speech-to-text"; +import { + textToSpeechStore, + useTextToSpeech, +} from "./speech/use-text-to-speech"; + +export const ChatInput = () => { + const { loading, input, chatThreadId } = useChat(); + const { uploadButtonLabel } = useFileStore(); + const { isPlaying } = useTextToSpeech(); + const { isMicrophoneReady } = useSpeechToText(); + const { rows } = useChatInputDynamicHeight(); + + const submitButton = React.useRef(null); + const formRef = useRef(null); + + const submit = () => { + if (formRef.current) { + formRef.current.requestSubmit(); + } + }; + + return ( + { + e.preventDefault(); + chatStore.submitChat(e); + }} + status={uploadButtonLabel} + > + { + if (e.currentTarget.value.replace(/\s/g, "").length === 0) { + ResetInputRows(); + } + }} + onKeyDown={(e) => { + onKeyDown(e, submit); + }} + onKeyUp={(e) => { + onKeyUp(e); + }} + value={input} + rows={rows} + onChange={(e) => { + chatStore.updateInput(e.currentTarget.value); + }} + /> + + + + fileStore.onFileChange({ formData, chatThreadId }) + } + /> + + + + + speechToTextStore.startRecognition()} + stopRecognition={() => speechToTextStore.stopRecognition()} + isPlaying={isPlaying} + stopPlaying={() => textToSpeechStore.stopPlaying()} + isMicrophoneReady={isMicrophoneReady} + /> + {loading === "loading" ? ( + chatStore.stopGeneratingMessages()} /> + ) : ( + + )} + + + + ); +}; diff --git a/src/features/chat-page/chat-input/file/file-store.ts b/src/features/chat-page/chat-input/file/file-store.ts new file mode 100644 index 000000000..cbb96f4bf --- /dev/null +++ b/src/features/chat-page/chat-input/file/file-store.ts @@ -0,0 +1,103 @@ +"use client"; + +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { + showError, + showSuccess, +} from "@/features/globals/global-message-store"; +import { proxy, useSnapshot } from "valtio"; +import { IndexDocuments } from "../../chat-services/azure-ai-search/azure-ai-search"; +import { + CrackDocument, + CreateChatDocument, +} from "../../chat-services/chat-document-service"; +import { chatStore } from "../../chat-store"; + +class FileStore { + public uploadButtonLabel: string = ""; + + public async onFileChange(props: { + formData: FormData; + chatThreadId: string; + }) { + const { formData, chatThreadId } = props; + + try { + chatStore.updateLoading("file upload"); + + formData.append("id", chatThreadId); + const file: File | null = formData.get("file") as unknown as File; + + this.uploadButtonLabel = "Processing document"; + const crackingResponse = await CrackDocument(formData); + + if (crackingResponse.status === "OK") { + let index = 0; + + const documentIndexResponses: Array> = []; + + for (const doc of crackingResponse.response) { + this.uploadButtonLabel = `Indexing document [${index + 1}]/[${ + crackingResponse.response.length + }]`; + + // index one document at a time + const indexResponses = await IndexDocuments( + file.name, + [doc], + chatThreadId + ); + + documentIndexResponses.push(...indexResponses); + index++; + } + + const allDocumentsIndexed = documentIndexResponses.every( + (r) => r.status === "OK" + ); + + if (allDocumentsIndexed) { + // Update state + this.uploadButtonLabel = file.name + " loaded"; + // Update history DB with doc on chat thread + const response = await CreateChatDocument(file.name, chatThreadId); + + if (response.status === "OK") { + showSuccess({ + title: "File upload", + description: `${file.name} uploaded successfully.`, + }); + } else { + showError(response.errors.map((e) => e).join("\n")); + } + } else { + const errors: Array = []; + + documentIndexResponses.forEach((r) => { + if (r.status === "ERROR") { + errors.push(...r.errors.map((e) => e.message)); + } + }); + + showError( + "Looks like not all documents were indexed" + + errors.map((e) => e).join("\n") + ); + } + } else { + showError(crackingResponse.errors.map((e) => e.message).join("\n")); + } + } catch (error) { + showError("" + error); + } finally { + this.uploadButtonLabel = ""; + chatStore.updateLoading("idle"); + } + } +} + +export const fileStore = proxy(new FileStore()); + +export function useFileStore() { + return useSnapshot(fileStore); +} diff --git a/src/features/chat-page/chat-input/prompt/input-prompt-store.ts b/src/features/chat-page/chat-input/prompt/input-prompt-store.ts new file mode 100644 index 000000000..b1215fff0 --- /dev/null +++ b/src/features/chat-page/chat-input/prompt/input-prompt-store.ts @@ -0,0 +1,51 @@ +import { PromptModel } from "@/features/prompt-page/models"; +import { FindAllPrompts } from "@/features/prompt-page/prompt-service"; +import { proxy, useSnapshot } from "valtio"; +import { chatStore } from "../../chat-store"; +import { SetInputRowsToMax } from "../use-chat-input-dynamic-height"; + +class InputPromptState { + public errors: string[] = []; + public prompts: Array = []; + public isOpened: boolean = false; + public isLoading: boolean = false; + + public async openPrompt() { + this.isOpened = true; + this.isLoading = true; + this.errors = []; + + const response = await FindAllPrompts(); + + if (response.status === "OK") { + this.prompts = response.response; + } else { + this.errors = response.errors.map((e) => e.message); + } + + this.isLoading = false; + } + + public updateOpened(value: boolean) { + this.isOpened = value; + } + + public updateErrors(errors: string[]) { + this.errors = errors; + } + + public selectPrompt(prompt: PromptModel) { + chatStore.updateInput(prompt.description); + this.isOpened = false; + this.errors = []; + SetInputRowsToMax(); + } +} + +export const inputPromptStore = proxy(new InputPromptState()); + +export const useInputPromptState = () => { + return useSnapshot(inputPromptStore, { + sync: true, + }); +}; diff --git a/src/features/chat-page/chat-input/prompt/prompt-slider.tsx b/src/features/chat-page/chat-input/prompt/prompt-slider.tsx new file mode 100644 index 000000000..5c2587556 --- /dev/null +++ b/src/features/chat-page/chat-input/prompt/prompt-slider.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/features/ui/card"; +import { LoadingIndicator } from "@/features/ui/loading"; +import { ScrollArea } from "@/features/ui/scroll-area"; +import { Button } from "@/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/ui/sheet"; +import { Book } from "lucide-react"; +import { FC } from "react"; +import { inputPromptStore, useInputPromptState } from "./input-prompt-store"; + +interface SliderProps {} + +export const PromptSlider: FC = (props) => { + const { prompts, isLoading, isOpened } = useInputPromptState(); + return ( + { + inputPromptStore.updateOpened(value); + }} + > + + + + + + + Prompt Library + + +
+ + {!isLoading && prompts.length === 0 ? "There are no prompts" : ""} + + + {prompts.map((prompt) => ( + inputPromptStore.selectPrompt(prompt)} + > + + {prompt.name} + + + {prompt.description} + + + ))} +
+
+
+
+ ); +}; diff --git a/src/features/chat/chat-ui/chat-speech/speech-service.ts b/src/features/chat-page/chat-input/speech/speech-service.ts similarity index 100% rename from src/features/chat/chat-ui/chat-speech/speech-service.ts rename to src/features/chat-page/chat-input/speech/speech-service.ts diff --git a/src/features/chat-page/chat-input/speech/use-speech-to-text.ts b/src/features/chat-page/chat-input/speech/use-speech-to-text.ts new file mode 100644 index 000000000..f0c1c6b91 --- /dev/null +++ b/src/features/chat-page/chat-input/speech/use-speech-to-text.ts @@ -0,0 +1,82 @@ +import { showError } from "@/features/globals/global-message-store"; +import { + AudioConfig, + AutoDetectSourceLanguageConfig, + SpeechConfig, + SpeechRecognizer, +} from "microsoft-cognitiveservices-speech-sdk"; +import { proxy, useSnapshot } from "valtio"; +import { chatStore } from "../../chat-store"; +import { GetSpeechToken } from "./speech-service"; + +let speechRecognizer: SpeechRecognizer | undefined = undefined; + +class SpeechToText { + public isMicrophoneUsed: boolean = false; + public isMicrophoneReady: boolean = false; + + public async startRecognition() { + const token = await GetSpeechToken(); + + if (token.error) { + showError(token.errorMessage); + return; + } + + this.isMicrophoneReady = true; + this.isMicrophoneUsed = true; + + const speechConfig = SpeechConfig.fromAuthorizationToken( + token.token, + token.region + ); + + const audioConfig = AudioConfig.fromDefaultMicrophoneInput(); + + const autoDetectSourceLanguageConfig = + AutoDetectSourceLanguageConfig.fromLanguages([ + "en-US", + "zh-CN", + "it-IT", + "pt-BR", + ]); + + const recognizer = SpeechRecognizer.FromConfig( + speechConfig, + autoDetectSourceLanguageConfig, + audioConfig + ); + + speechRecognizer = recognizer; + + recognizer.recognizing = (s, e) => { + chatStore.updateInput(e.result.text); + }; + + recognizer.canceled = (s, e) => { + showError(e.errorDetails); + }; + + recognizer.startContinuousRecognitionAsync(); + } + + public stopRecognition() { + if (speechRecognizer) { + speechRecognizer.stopContinuousRecognitionAsync(); + this.isMicrophoneReady = false; + } + } + + public userDidUseMicrophone() { + return this.isMicrophoneUsed; + } + + public resetMicrophoneUsed() { + this.isMicrophoneUsed = false; + } +} +export const speechToTextStore = proxy(new SpeechToText()); + +export const useSpeechToText = () => { + return useSnapshot(speechToTextStore); +}; diff --git a/src/features/chat-page/chat-input/speech/use-text-to-speech.ts b/src/features/chat-page/chat-input/speech/use-text-to-speech.ts new file mode 100644 index 000000000..ca44587b2 --- /dev/null +++ b/src/features/chat-page/chat-input/speech/use-text-to-speech.ts @@ -0,0 +1,81 @@ +import { showError } from "@/features/globals/global-message-store"; +import { + AudioConfig, + ResultReason, + SpeakerAudioDestination, + SpeechConfig, + SpeechSynthesizer, +} from "microsoft-cognitiveservices-speech-sdk"; +import { proxy, useSnapshot } from "valtio"; +import { GetSpeechToken } from "./speech-service"; +import { speechToTextStore } from "./use-speech-to-text"; + +let player: SpeakerAudioDestination | undefined = undefined; + +class TextToSpeech { + public isPlaying: boolean = false; + + public stopPlaying() { + this.isPlaying = false; + if (player) { + player.pause(); + } + } + + public async textToSpeech(textToSpeak: string) { + if (this.isPlaying) { + this.stopPlaying(); + } + + const tokenObj = await GetSpeechToken(); + + if (tokenObj.error) { + showError(tokenObj.errorMessage); + return; + } + + const speechConfig = SpeechConfig.fromAuthorizationToken( + tokenObj.token, + tokenObj.region + ); + + player = new SpeakerAudioDestination(); + + var audioConfig = AudioConfig.fromSpeakerOutput(player); + let synthesizer = new SpeechSynthesizer(speechConfig, audioConfig); + + player.onAudioEnd = () => { + this.isPlaying = false; + }; + + synthesizer.speakTextAsync( + textToSpeak, + (result) => { + if (result.reason === ResultReason.SynthesizingAudioCompleted) { + this.isPlaying = true; + } else { + showError(result.errorDetails); + this.isPlaying = false; + } + synthesizer.close(); + }, + function (err) { + console.error("err - " + err); + synthesizer.close(); + } + ); + } + + public speak(value: string) { + if (speechToTextStore.userDidUseMicrophone()) { + textToSpeechStore.textToSpeech(value); + speechToTextStore.resetMicrophoneUsed(); + } + } +} + +export const textToSpeechStore = proxy(new TextToSpeech()); + +export const useTextToSpeech = () => { + return useSnapshot(textToSpeechStore); +}; diff --git a/src/features/chat-page/chat-input/use-chat-input-dynamic-height.tsx b/src/features/chat-page/chat-input/use-chat-input-dynamic-height.tsx new file mode 100644 index 000000000..0053de343 --- /dev/null +++ b/src/features/chat-page/chat-input/use-chat-input-dynamic-height.tsx @@ -0,0 +1,56 @@ +import { proxy, snapshot, useSnapshot } from "valtio"; + +const MAX_ROWS = 6; + +interface ChatInputStoreProps { + rows: number; + keysPressed: Set; +} + +const state = proxy({ + rows: 1, + keysPressed: new Set(), +}); + +export const SetInputRows = (rows: number) => { + if (rows < MAX_ROWS) { + state.rows = rows + 1; + } +}; + +export const SetInputRowsToMax = () => { + state.rows = MAX_ROWS; +}; + +export const ResetInputRows = () => { + state.rows = 1; +}; + +export const onKeyDown = ( + event: React.KeyboardEvent, + submit: () => void +) => { + state.keysPressed.add(event.key); + const snap = snapshot(state); + if (snap.keysPressed.has("Enter") && snap.keysPressed.has("Shift")) { + SetInputRows(state.rows + 1); + } + + if ( + !event.nativeEvent.isComposing && + snap.keysPressed.has("Enter") && + !snap.keysPressed.has("Shift") + ) { + submit(); + ResetInputRows(); + event.preventDefault(); + } +}; + +export const onKeyUp = (event: React.KeyboardEvent) => { + state.keysPressed.delete(event.key); +}; + +export const useChatInputDynamicHeight = () => { + return useSnapshot(state); +}; diff --git a/src/features/chat-page/chat-menu/chat-context-menu.tsx b/src/features/chat-page/chat-menu/chat-context-menu.tsx new file mode 100644 index 000000000..afeb11ed6 --- /dev/null +++ b/src/features/chat-page/chat-menu/chat-context-menu.tsx @@ -0,0 +1,51 @@ +"use client"; +import { RedirectToPage } from "@/features/common/navigation-helpers"; +import { showError } from "@/features/globals/global-message-store"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/features/ui/dropdown-menu"; +import { LoadingIndicator } from "@/features/ui/loading"; +import { MoreVertical, Trash } from "lucide-react"; +import { useState } from "react"; +import { DropdownMenuItemWithIcon } from "./chat-menu-item"; +import { DeleteAllChatThreads } from "./chat-menu-service"; + +export const ChatContextMenu = () => { + const [isLoading, setIsLoading] = useState(false); + + const handleAction = async () => { + if ( + window.confirm("Are you sure you want to delete all the chat threads?") + ) { + setIsLoading(true); + const response = await DeleteAllChatThreads(); + + if (response.status === "OK") { + setIsLoading(false); + RedirectToPage("chat"); + } else { + showError(response.errors.map((e) => e.message).join(", ")); + } + } + }; + + return ( + + + {isLoading ? ( + + ) : ( + + )} + + + await handleAction()}> + + Delete all + + + + ); +}; diff --git a/src/features/chat-page/chat-menu/chat-group.tsx b/src/features/chat-page/chat-menu/chat-group.tsx new file mode 100644 index 000000000..c79224edb --- /dev/null +++ b/src/features/chat-page/chat-menu/chat-group.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from "react"; + +interface Props extends PropsWithChildren { + title: string; +} + +export const ChatGroup = (props: Props) => { + return ( +
+
{props.title}
+
{props.children}
+
+ ); +}; diff --git a/src/features/chat-page/chat-menu/chat-menu-header.tsx b/src/features/chat-page/chat-menu/chat-menu-header.tsx new file mode 100644 index 000000000..67ce793f7 --- /dev/null +++ b/src/features/chat-page/chat-menu/chat-menu-header.tsx @@ -0,0 +1,14 @@ +import { CreateChatAndRedirect } from "../chat-services/chat-thread-service"; +import { ChatContextMenu } from "./chat-context-menu"; +import { NewChat } from "./new-chat"; + +export const ChatMenuHeader = () => { + return ( +
+
+ + + +
+ ); +}; diff --git a/src/features/chat-page/chat-menu/chat-menu-item.tsx b/src/features/chat-page/chat-menu/chat-menu-item.tsx new file mode 100644 index 000000000..2846cdb20 --- /dev/null +++ b/src/features/chat-page/chat-menu/chat-menu-item.tsx @@ -0,0 +1,127 @@ +"use client"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/features/ui/dropdown-menu"; +import { LoadingIndicator } from "@/features/ui/loading"; +import { cn } from "@/ui/lib"; +import { BookmarkCheck, MoreVertical, Pencil, Trash } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { FC, useState } from "react"; +import { ChatThreadModel } from "../chat-services/models"; +import { + BookmarkChatThread, + DeleteChatThreadByID, + UpdateChatThreadTitle, +} from "./chat-menu-service"; + +interface ChatMenuItemProps { + href: string; + chatThread: ChatThreadModel; + children?: React.ReactNode; +} + +export const ChatMenuItem: FC = (props) => { + const path = usePathname(); + const { isLoading, handleAction } = useDropdownAction({ + chatThread: props.chatThread, + }); + + return ( +
+ + {props.children} + + + + {isLoading ? ( + + ) : ( + + )} + + + await handleAction("bookmark")} + > + + + {props.chatThread.bookmarked ? "Remove bookmark" : "Bookmark"} + + + await handleAction("rename")} + > + + Rename + + + await handleAction("delete")} + > + + Delete + + + +
+ ); +}; + +type DropdownAction = "bookmark" | "rename" | "delete"; + +const useDropdownAction = (props: { chatThread: ChatThreadModel }) => { + const { chatThread } = props; + const [isLoading, setIsLoading] = useState(false); + + const handleAction = async (action: DropdownAction) => { + setIsLoading(true); + switch (action) { + case "bookmark": + await BookmarkChatThread({ chatThread }); + break; + case "rename": + const name = window.prompt("Enter the new name for the chat thread:"); + if (name !== null) { + await UpdateChatThreadTitle({ chatThread, name }); + } + break; + case "delete": + if ( + window.confirm("Are you sure you want to delete this chat thread?") + ) { + await DeleteChatThreadByID(chatThread.id); + } + break; + } + setIsLoading(false); + }; + + return { + isLoading, + handleAction, + }; +}; + +export const DropdownMenuItemWithIcon: FC<{ + children?: React.ReactNode; + onClick?: () => void; +}> = (props) => { + return ( + + {props.children} + + ); +}; diff --git a/src/features/chat-page/chat-menu/chat-menu-service.ts b/src/features/chat-page/chat-menu/chat-menu-service.ts new file mode 100644 index 000000000..9014c101a --- /dev/null +++ b/src/features/chat-page/chat-menu/chat-menu-service.ts @@ -0,0 +1,72 @@ +"use server"; + +import { + RedirectToPage, + RevalidateCache, +} from "@/features/common/navigation-helpers"; +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { + FindAllChatThreadForCurrentUser, + SoftDeleteChatThreadForCurrentUser, + UpsertChatThread, +} from "../chat-services/chat-thread-service"; +import { ChatThreadModel } from "../chat-services/models"; + +export const DeleteChatThreadByID = async (chatThreadID: string) => { + await SoftDeleteChatThreadForCurrentUser(chatThreadID); + RedirectToPage("chat"); +}; + +export const DeleteAllChatThreads = async (): Promise< + ServerActionResponse +> => { + const chatThreadResponse = await FindAllChatThreadForCurrentUser(); + + if (chatThreadResponse.status === "OK") { + const chatThreads = chatThreadResponse.response; + const promise = chatThreads.map(async (chatThread) => { + return SoftDeleteChatThreadForCurrentUser(chatThread.id); + }); + + await Promise.all(promise); + RevalidateCache({ + page: "chat", + type: "layout", + }); + return { + status: "OK", + response: true, + }; + } + + return chatThreadResponse; +}; + +export const UpdateChatThreadTitle = async (props: { + chatThread: ChatThreadModel; + name: string; +}) => { + await UpsertChatThread({ + ...props.chatThread, + name: props.name, + }); + + RevalidateCache({ + page: "chat", + type: "layout", + }); +}; + +export const BookmarkChatThread = async (props: { + chatThread: ChatThreadModel; +}) => { + await UpsertChatThread({ + ...props.chatThread, + bookmarked: !props.chatThread.bookmarked, + }); + + RevalidateCache({ + page: "chat", + type: "layout", + }); +}; diff --git a/src/features/chat-page/chat-menu/chat-menu.tsx b/src/features/chat-page/chat-menu/chat-menu.tsx new file mode 100644 index 000000000..2dcb99dc8 --- /dev/null +++ b/src/features/chat-page/chat-menu/chat-menu.tsx @@ -0,0 +1,81 @@ +import { sortByTimestamp } from "@/features/common/util"; +import { FC } from "react"; +import { + ChatThreadModel, + MenuItemsGroup, + MenuItemsGroupName, +} from "../chat-services/models"; +import { ChatGroup } from "./chat-group"; +import { ChatMenuItem } from "./chat-menu-item"; + +interface ChatMenuProps { + menuItems: Array; +} + +export const ChatMenu: FC = (props) => { + const menuItemsGrouped = GroupChatThreadByType(props.menuItems); + return ( +
+ {Object.entries(menuItemsGrouped).map( + ([groupName, groupItems], index) => ( + + {groupItems?.map((item) => ( + + {item.name.replace("\n", "")} + + ))} + + ) + )} +
+ ); +}; + +export const GroupChatThreadByType = (menuItems: Array) => { + const groupedMenuItems: Array = []; + + // todays date + const today = new Date(); + // 7 days ago + const sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + menuItems.sort(sortByTimestamp).forEach((el) => { + if (el.bookmarked) { + groupedMenuItems.push({ + ...el, + groupName: "Bookmarked", + }); + } else if (new Date(el.lastMessageAt) > sevenDaysAgo) { + groupedMenuItems.push({ + ...el, + groupName: "Past 7 days", + }); + } else { + groupedMenuItems.push({ + ...el, + groupName: "Previous", + }); + } + }); + const menuItemsGrouped = groupedMenuItems.reduce((acc, el) => { + const key = el.groupName; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(el); + return acc; + }, {} as Record>); + + const records: Record> = { + Bookmarked: menuItemsGrouped["Bookmarked"]?.sort(sortByTimestamp), + "Past 7 days": menuItemsGrouped["Past 7 days"]?.sort(sortByTimestamp), + Previous: menuItemsGrouped["Previous"]?.sort(sortByTimestamp), + }; + + return records; +}; diff --git a/src/features/chat-page/chat-menu/new-chat.tsx b/src/features/chat-page/chat-menu/new-chat.tsx new file mode 100644 index 000000000..b31af0d7f --- /dev/null +++ b/src/features/chat-page/chat-menu/new-chat.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Button } from "@/features/ui/button"; +import { LoadingIndicator } from "@/features/ui/loading"; +import { Plus } from "lucide-react"; +import { useFormStatus } from "react-dom"; + +export const NewChat = () => { + const { pending } = useFormStatus(); + + return ( + + ); +}; diff --git a/src/features/chat-page/chat-page.tsx b/src/features/chat-page/chat-page.tsx new file mode 100644 index 000000000..bee93f07a --- /dev/null +++ b/src/features/chat-page/chat-page.tsx @@ -0,0 +1,78 @@ +"use client"; +import { ChatInput } from "@/features/chat-page/chat-input/chat-input"; +import { chatStore, useChat } from "@/features/chat-page/chat-store"; +import { ChatLoading } from "@/features/ui/chat/chat-message-area/chat-loading"; +import { ChatMessageArea } from "@/features/ui/chat/chat-message-area/chat-message-area"; +import ChatMessageContainer from "@/features/ui/chat/chat-message-area/chat-message-container"; +import ChatMessageContentArea from "@/features/ui/chat/chat-message-area/chat-message-content"; +import { useChatScrollAnchor } from "@/features/ui/chat/chat-message-area/use-chat-scroll-anchor"; +import { useSession } from "next-auth/react"; +import { FC, useEffect, useRef } from "react"; +import { ExtensionModel } from "../extensions-page/extension-services/models"; +import { ChatHeader } from "./chat-header/chat-header"; +import { + ChatDocumentModel, + ChatMessageModel, + ChatThreadModel, +} from "./chat-services/models"; +import MessageContent from "./message-content"; + +interface ChatPageProps { + messages: Array; + chatThread: ChatThreadModel; + chatDocuments: Array; + extensions: Array; +} + +export const ChatPage: FC = (props) => { + const { data: session } = useSession(); + + useEffect(() => { + chatStore.initChatSession({ + chatThread: props.chatThread, + messages: props.messages, + userName: session?.user?.name!, + }); + }, [props.messages, session?.user?.name, props.chatThread]); + + const { messages, loading } = useChat(); + + const current = useRef(null); + + useChatScrollAnchor({ ref: current }); + + return ( +
+ + + + {messages.map((message) => { + return ( + { + navigator.clipboard.writeText(message.content); + }} + profilePicture={ + message.role === "assistant" + ? "/ai-icon.png" + : session?.user?.image + } + > + + + ); + })} + {loading === "loading" && } + + + +
+ ); +}; diff --git a/src/features/chat-page/chat-services/azure-ai-search/azure-ai-search.ts b/src/features/chat-page/chat-services/azure-ai-search/azure-ai-search.ts new file mode 100644 index 000000000..d2b3c6398 --- /dev/null +++ b/src/features/chat-page/chat-services/azure-ai-search/azure-ai-search.ts @@ -0,0 +1,449 @@ +"use server"; +import "server-only"; + +import { userHashedId } from "@/features/auth-page/helpers"; +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { + AzureAISearchIndexClientInstance, + AzureAISearchInstance, +} from "@/features/common/services/ai-search"; +import { OpenAIEmbeddingInstance } from "@/features/common/services/openai"; +import { uniqueId } from "@/features/common/util"; +import { + AzureKeyCredential, + SearchClient, + SearchIndex, +} from "@azure/search-documents"; + +export interface AzureSearchDocumentIndex { + id: string; + pageContent: string; + embedding?: number[]; + user: string; + chatThreadId: string; + metadata: string; +} + +export type DocumentSearchResponse = { + score: number; + document: AzureSearchDocumentIndex; +}; + +export const SimpleSearch = async ( + searchText?: string, + filter?: string +): Promise>> => { + try { + const instance = AzureAISearchInstance(); + const searchResults = await instance.search(searchText, { filter: filter }); + + const results: Array = []; + for await (const result of searchResults.results) { + results.push({ + score: result.score, + document: result.document, + }); + } + + return { + status: "OK", + response: results, + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export const SimilaritySearch = async ( + searchText: string, + k: number, + filter?: string +): Promise>> => { + try { + const openai = OpenAIEmbeddingInstance(); + const embeddings = await openai.embeddings.create({ + input: searchText, + model: "", + }); + + const searchClient = AzureAISearchInstance(); + + const searchResults = await searchClient.search(searchText, { + top: k, + filter: filter, + vectorSearchOptions: { + queries: [ + { + vector: embeddings.data[0].embedding, + fields: ["embedding"], + kind: "vector", + kNearestNeighborsCount: 10, + }, + ], + }, + }); + + const results: Array = []; + for await (const result of searchResults.results) { + results.push({ + score: result.score, + document: result.document, + }); + } + + return { + status: "OK", + response: results, + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export const ExtensionSimilaritySearch = async (props: { + searchText: string; + vectors: string[]; + apiKey: string; + searchName: string; + indexName: string; +}): Promise>> => { + try { + const openai = OpenAIEmbeddingInstance(); + const { searchText, vectors, apiKey, searchName, indexName } = props; + + const embeddings = await openai.embeddings.create({ + input: searchText, + model: "", + }); + + const endpoint = `https://${searchName}.search.windows.net`; + + const searchClient = new SearchClient( + endpoint, + indexName, + new AzureKeyCredential(apiKey) + ); + + const searchResults = await searchClient.search(searchText, { + top: 3, + + // filter: filter, + vectorSearchOptions: { + queries: [ + { + vector: embeddings.data[0].embedding, + fields: vectors, + kind: "vector", + kNearestNeighborsCount: 10, + }, + ], + }, + }); + + const results: Array = []; + for await (const result of searchResults.results) { + const item = { + score: result.score, + document: result.document, + }; + + // exclude the all the fields that are not in the fields array + const document = item.document as any; + const newDocument: any = {}; + + // iterate over the object entries in document + // and only include the fields that are in the fields array + + for (const key in document) { + const hasKey = vectors.includes(key); + if (!hasKey) { + newDocument[key] = document[key]; + } + } + + results.push({ + score: result.score, + document: newDocument, // Use the newDocument object instead of the original document + }); + } + + return { + status: "OK", + response: results, + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export const IndexDocuments = async ( + fileName: string, + docs: string[], + chatThreadId: string +): Promise>> => { + try { + const documentsToIndex: AzureSearchDocumentIndex[] = []; + + for (const doc of docs) { + const docToAdd: AzureSearchDocumentIndex = { + id: uniqueId(), + chatThreadId, + user: await userHashedId(), + pageContent: doc, + metadata: fileName, + embedding: [], + }; + + documentsToIndex.push(docToAdd); + } + + const instance = AzureAISearchInstance(); + const embeddingsResponse = await EmbedDocuments(documentsToIndex); + + if (embeddingsResponse.status === "OK") { + const uploadResponse = await instance.uploadDocuments( + embeddingsResponse.response + ); + + const response: Array> = []; + uploadResponse.results.forEach((r) => { + if (r.succeeded) { + response.push({ + status: "OK", + response: r.succeeded, + }); + } else { + response.push({ + status: "ERROR", + errors: [ + { + message: `${r.errorMessage}`, + }, + ], + }); + } + }); + + return response; + } + + return [embeddingsResponse]; + } catch (e) { + return [ + { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }, + ]; + } +}; + +export const DeleteDocuments = async ( + chatThreadId: string +): Promise>> => { + try { + // find all documents for chat thread + const documentsInChatResponse = await SimpleSearch( + undefined, + `chatThreadId eq '${chatThreadId}'` + ); + + if (documentsInChatResponse.status === "OK") { + const instance = AzureAISearchInstance(); + const deletedResponse = await instance.deleteDocuments( + documentsInChatResponse.response.map((r) => r.document) + ); + const response: Array> = []; + deletedResponse.results.forEach((r) => { + if (r.succeeded) { + response.push({ + status: "OK", + response: r.succeeded, + }); + } else { + response.push({ + status: "ERROR", + errors: [ + { + message: `${r.errorMessage}`, + }, + ], + }); + } + }); + + return response; + } + + return [documentsInChatResponse]; + } catch (e) { + return [ + { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }, + ]; + } +}; + +export const EmbedDocuments = async ( + documents: Array +): Promise>> => { + try { + const openai = OpenAIEmbeddingInstance(); + + const contentsToEmbed = documents.map((d) => d.pageContent); + + const embeddings = await openai.embeddings.create({ + input: contentsToEmbed, + model: process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME, + }); + + embeddings.data.forEach((embedding, index) => { + documents[index].embedding = embedding.embedding; + }); + + return { + status: "OK", + response: documents, + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export const EnsureIndexIsCreated = async (): Promise< + ServerActionResponse +> => { + try { + const client = AzureAISearchIndexClientInstance(); + const result = await client.getIndex(process.env.AZURE_SEARCH_INDEX_NAME); + return { + status: "OK", + response: result, + }; + } catch (e) { + return await CreateSearchIndex(); + } +}; + +const CreateSearchIndex = async (): Promise< + ServerActionResponse +> => { + try { + const client = AzureAISearchIndexClientInstance(); + const result = await client.createIndex({ + name: process.env.AZURE_SEARCH_INDEX_NAME, + vectorSearch: { + algorithms: [ + { + name: "hnsw-vector", + kind: "hnsw", + parameters: { + m: 4, + efConstruction: 200, + efSearch: 200, + metric: "cosine", + }, + }, + ], + profiles: [ + { + name: "hnsw-vector", + algorithmConfigurationName: "hnsw-vector", + }, + ], + }, + + fields: [ + { + name: "id", + type: "Edm.String", + key: true, + filterable: true, + }, + { + name: "user", + type: "Edm.String", + searchable: true, + filterable: true, + }, + { + name: "chatThreadId", + type: "Edm.String", + searchable: true, + filterable: true, + }, + { + name: "pageContent", + searchable: true, + type: "Edm.String", + }, + { + name: "metadata", + type: "Edm.String", + }, + { + name: "embedding", + type: "Collection(Edm.Single)", + searchable: true, + filterable: false, + sortable: false, + facetable: false, + vectorSearchDimensions: 1536, + vectorSearchProfileName: "hnsw-vector", + }, + ], + }); + + return { + status: "OK", + response: result, + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; diff --git a/src/features/chat-page/chat-services/chat-api/chat-api-default-extensions.ts b/src/features/chat-page/chat-services/chat-api/chat-api-default-extensions.ts new file mode 100644 index 000000000..5e5cd2908 --- /dev/null +++ b/src/features/chat-page/chat-services/chat-api/chat-api-default-extensions.ts @@ -0,0 +1,125 @@ +"use server"; +import "server-only"; + +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { OpenAIDALLEInstance } from "@/features/common/services/openai"; +import { uniqueId } from "@/features/common/util"; +import { GetImageUrl, UploadImageToStore } from "../chat-image-service"; +import { ChatThreadModel } from "../models"; + +export const GetDefaultExtensions = async (props: { + chatThread: ChatThreadModel; + userMessage: string; + signal: AbortSignal; +}): Promise>> => { + const defaultExtensions: Array = []; + + // Add image creation Extension + defaultExtensions.push({ + type: "function", + function: { + function: async (args: any) => + await executeCreateImage( + args, + props.chatThread.id, + props.userMessage, + props.signal + ), + parse: (input: string) => JSON.parse(input), + parameters: { + type: "object", + properties: { + prompt: { type: "string" }, + }, + }, + description: + "You must only use this tool if the user asks you to create an image. You must only use this tool once per message.", + name: "create_img", + }, + }); + + // Add any other default Extension here + + return { + status: "OK", + response: defaultExtensions, + }; +}; + +// Extension for image creation using DALL-E +async function executeCreateImage( + args: { prompt: string }, + threadId: string, + userMessage: string, + signal: AbortSignal +) { + console.log("createImage called with prompt:", args.prompt); + + if (!args.prompt) { + return "No prompt provided"; + } + + // Check the prompt is < 4000 characters (DALL-E 3) + if (args.prompt.length >= 4000) { + return "Prompt is too long, it must be less than 4000 characters"; + } + + const openAI = OpenAIDALLEInstance(); + + let response; + + try { + response = await openAI.images.generate( + { + model: "dall-e-3", + prompt: userMessage, + response_format: "b64_json", + }, + { + signal, + } + ); + } catch (error) { + console.error("🔴 error:\n", error); + return { + error: + "There was an error creating the image: " + + error + + "Return this message to the user and halt execution.", + }; + } + + // Check the response is valid + if (response.data[0].b64_json === undefined) { + return { + error: + "There was an error creating the image: Invalid API response received. Return this message to the user and halt execution.", + }; + } + + // upload image to blob storage + const imageName = `${uniqueId()}.png`; + + try { + await UploadImageToStore( + threadId, + imageName, + Buffer.from(response.data[0].b64_json, "base64") + ); + + const updated_response = { + revised_prompt: response.data[0].revised_prompt, + url: GetImageUrl(threadId, imageName), + }; + + return updated_response; + } catch (error) { + console.error("🔴 error:\n", error); + return { + error: + "There was an error storing the image: " + + error + + "Return this message to the user and halt execution.", + }; + } +} diff --git a/src/features/chat-page/chat-services/chat-api/chat-api-dynamic-extensions.ts b/src/features/chat-page/chat-services/chat-api/chat-api-dynamic-extensions.ts new file mode 100644 index 000000000..032eae6ff --- /dev/null +++ b/src/features/chat-page/chat-services/chat-api/chat-api-dynamic-extensions.ts @@ -0,0 +1,139 @@ +"use server"; +import "server-only"; + +import { ServerActionResponse } from "@/features/common/server-action-response"; + +import { userHashedId } from "@/features/auth-page/helpers"; +import { + FindAllExtensionForCurrentUser, + FindSecureHeaderValue, +} from "@/features/extensions-page/extension-services/extension-service"; +import { + ExtensionFunctionModel, + ExtensionModel, +} from "@/features/extensions-page/extension-services/models"; +import { RunnableToolFunction } from "openai/lib/RunnableFunction"; +import { ToolsInterface } from "../models"; +export const GetDynamicExtensions = async (props: { + extensionIds: string[]; +}): Promise>> => { + const extensionResponse = await FindAllExtensionForCurrentUser(); + + if (extensionResponse.status === "OK") { + const extensionToReturn = extensionResponse.response.filter((e) => + props.extensionIds.includes(e.id) + ); + + const dynamicExtensions: Array> = []; + + extensionToReturn.forEach((e) => { + e.functions.forEach((f) => { + const extension = JSON.parse(f.code) as ToolsInterface; + dynamicExtensions.push({ + type: "function", + function: { + function: (args: any) => + executeFunction({ + functionModel: f, + extensionModel: e, + args, + }), + parse: JSON.parse, + parameters: extension.parameters, + description: extension.description, + name: extension.name, + }, + }); + }); + }); + + return { + status: "OK", + response: dynamicExtensions, + }; + } + + return extensionResponse; +}; + +async function executeFunction(props: { + functionModel: ExtensionFunctionModel; + extensionModel: ExtensionModel; + args: any; +}) { + try { + const { functionModel, args, extensionModel } = props; + + // get the secure headers + const headerPromise = extensionModel.headers.map(async (h) => { + const headerValue = await FindSecureHeaderValue(h.id); + + if (headerValue.status === "OK") { + return { + id: h.id, + key: h.key, + value: headerValue.response, + }; + } + + return { + id: h.id, + key: h.key, + value: "***", + }; + }); + + const headerItems = await Promise.all(headerPromise); + + // we need to add the user id to the headers as this is expected by the function and does not have context of the user + headerItems.push({ + id: "authorization", + key: "authorization", + value: await userHashedId(), + }); + // map the headers to a dictionary + const headers: { [key: string]: string } = headerItems.reduce( + (acc: { [key: string]: string }, header) => { + acc[header.key] = header.value; + return acc; + }, + {} + ); + + // replace the query parameters + if (args.query) { + for (const key in args.query) { + const value = args.query[key]; + functionModel.endpoint = functionModel.endpoint.replace( + `${key}`, + value + ); + } + } + + const requestInit: RequestInit = { + method: functionModel.endpointType, + headers: headers, + cache: "no-store", + }; + + if (args.body) { + requestInit.body = JSON.stringify(args.body); + } + + const response = await fetch(functionModel.endpoint, requestInit); + + if (!response.ok) { + return `There was an error calling the api: ${response.statusText}`; + } + const result = await response.json(); + + return { + id: functionModel.id, + result: result, + }; + } catch (e) { + console.error("🔴", e); + return `There was an error calling the api: ${e}`; + } +} diff --git a/src/features/chat-page/chat-services/chat-api/chat-api-extension.ts b/src/features/chat-page/chat-services/chat-api/chat-api-extension.ts new file mode 100644 index 000000000..a5339ea75 --- /dev/null +++ b/src/features/chat-page/chat-services/chat-api/chat-api-extension.ts @@ -0,0 +1,76 @@ +"use server"; +import "server-only"; + +import { OpenAIInstance } from "@/features/common/services/openai"; +import { FindExtensionByID } from "@/features/extensions-page/extension-services/extension-service"; +import { RunnableToolFunction } from "openai/lib/RunnableFunction"; +import { ChatCompletionStreamingRunner } from "openai/resources/beta/chat/completions"; +import { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import { ChatThreadModel } from "../models"; +import { ChatTokenService } from "@/features/common/services/chat-token-service"; +import { reportPromptTokens } from "@/features/common/services/chat-metrics-service"; +export const ChatApiExtensions = async (props: { + chatThread: ChatThreadModel; + userMessage: string; + history: ChatCompletionMessageParam[]; + extensions: RunnableToolFunction[]; + signal: AbortSignal; +}): Promise => { + const { userMessage, history, signal, chatThread, extensions } = props; + + const openAI = OpenAIInstance(); + const systemMessage = await extensionsSystemMessage(chatThread); + + const messages: ChatCompletionMessageParam[] = [ + { + role: "system", + content: chatThread.personaMessage + "\n" + systemMessage, + }, + ...history, + { + role: "user", + content: userMessage, + }, + ]; + + const tokenService = new ChatTokenService(); + let promptTokens = tokenService.getTokenCountFromHistory(messages); + + for (const tokens of promptTokens) { + reportPromptTokens(tokens.tokens, "gpt-4", tokens.role, {personaMessageTitle: chatThread.personaMessageTitle}); + } + + for (const e of extensions) { + + let toolsText = ""; + toolsText += `${e.function.description} \n`; + toolsText += `${JSON.stringify(e.function.name)} \n`; + toolsText += `${JSON.stringify(e.function.parameters)} \n`; + + let toolsTokens = tokenService.getTokenCount(toolsText); + reportPromptTokens(toolsTokens, "gpt-4", "tools", {"functionName": e.function.name || "", "personaMessageTitle": chatThread.personaMessageTitle}); + } + + return openAI.beta.chat.completions.runTools( + { + model: "", + stream: true, + messages: messages, + tools: extensions, + }, + { signal: signal } + ); +}; + +const extensionsSystemMessage = async (chatThread: ChatThreadModel) => { + let message = ""; + + for (const e of chatThread.extension) { + const extension = await FindExtensionByID(e); + if (extension.status === "OK") { + message += ` ${extension.response.executionSteps} \n`; + } + } + + return message; +}; diff --git a/src/features/chat-page/chat-services/chat-api/chat-api-multimodal.tsx b/src/features/chat-page/chat-services/chat-api/chat-api-multimodal.tsx new file mode 100644 index 000000000..c47d1f0da --- /dev/null +++ b/src/features/chat-page/chat-services/chat-api/chat-api-multimodal.tsx @@ -0,0 +1,45 @@ +"use server"; +import "server-only"; + +import { OpenAIVisionInstance } from "@/features/common/services/openai"; +import { ChatCompletionStreamingRunner } from "openai/resources/beta/chat/completions"; +import { ChatThreadModel } from "../models"; +export const ChatApiMultimodal = (props: { + chatThread: ChatThreadModel; + userMessage: string; + file: string; + signal: AbortSignal; +}): ChatCompletionStreamingRunner => { + const { chatThread, userMessage, signal, file } = props; + + const openAI = OpenAIVisionInstance(); + + return openAI.beta.chat.completions.stream( + { + model: "", + stream: true, + max_tokens: 4096, + messages: [ + { + role: "system", + content: + chatThread.personaMessage + + "\n You are an expert in extracting insights from images that are uploaded to the chat. \n You will answer questions about the image that is provided.", + }, + { + role: "user", + content: [ + { type: "text", text: userMessage }, + { + type: "image_url", + image_url: { + url: file, + }, + }, + ], + }, + ], + }, + { signal } + ); +}; diff --git a/src/features/chat-page/chat-services/chat-api/chat-api-rag-extension.ts b/src/features/chat-page/chat-services/chat-api/chat-api-rag-extension.ts new file mode 100644 index 000000000..35c61e7f4 --- /dev/null +++ b/src/features/chat-page/chat-services/chat-api/chat-api-rag-extension.ts @@ -0,0 +1,47 @@ +import { ExtensionSimilaritySearch } from "../azure-ai-search/azure-ai-search"; +import { CreateCitations, FormatCitations } from "../citation-service"; + +export const SearchAzureAISimilarDocuments = async (req: Request) => { + try { + const body = await req.json(); + const search = body.search as string; + + const vectors = req.headers.get("vectors") as string; + const apiKey = req.headers.get("apiKey") as string; + const searchName = req.headers.get("searchName") as string; + const indexName = req.headers.get("indexName") as string; + const userId = req.headers.get("authorization") as string; + + const result = await ExtensionSimilaritySearch({ + apiKey, + searchName, + indexName, + vectors: vectors.split(","), + searchText: search, + }); + + if (result.status !== "OK") { + console.error("🔴 Retrieving documents", result.errors); + return new Response(JSON.stringify(result)); + } + + const withoutEmbedding = FormatCitations(result.response); + const citationResponse = await CreateCitations(withoutEmbedding, userId); + + // only get the citations that are ok + const allCitations = []; + for (const citation of citationResponse) { + if (citation.status === "OK") { + allCitations.push({ + id: citation.response.id, + content: citation.response.content, + }); + } + } + + return new Response(JSON.stringify(allCitations)); + } catch (e) { + console.error("🔴 Retrieving documents", e); + return new Response(JSON.stringify(e)); + } +}; diff --git a/src/features/chat-page/chat-services/chat-api/chat-api-rag.ts b/src/features/chat-page/chat-services/chat-api/chat-api-rag.ts new file mode 100644 index 000000000..a663a284b --- /dev/null +++ b/src/features/chat-page/chat-services/chat-api/chat-api-rag.ts @@ -0,0 +1,93 @@ +"use server"; +import "server-only"; + +import { userHashedId } from "@/features/auth-page/helpers"; +import { OpenAIInstance } from "@/features/common/services/openai"; +import { + ChatCompletionStreamingRunner, + ChatCompletionStreamParams, +} from "openai/resources/beta/chat/completions"; +import { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import { SimilaritySearch } from "../azure-ai-search/azure-ai-search"; +import { CreateCitations, FormatCitations } from "../citation-service"; +import { ChatCitationModel, ChatThreadModel } from "../models"; +import { reportPromptTokens } from "@/features/common/services/chat-metrics-service"; +import { ChatTokenService } from "@/features/common/services/chat-token-service"; + +export const ChatApiRAG = async (props: { + chatThread: ChatThreadModel; + userMessage: string; + history: ChatCompletionMessageParam[]; + signal: AbortSignal; +}): Promise => { + const { chatThread, userMessage, history, signal } = props; + + const openAI = OpenAIInstance(); + + const documentResponse = await SimilaritySearch( + userMessage, + 10, + `user eq '${await userHashedId()}' and chatThreadId eq '${chatThread.id}'` + ); + + const documents: ChatCitationModel[] = []; + + if (documentResponse.status === "OK") { + const withoutEmbedding = FormatCitations(documentResponse.response); + const citationResponse = await CreateCitations(withoutEmbedding); + + citationResponse.forEach((c) => { + if (c.status === "OK") { + documents.push(c.response); + } + }); + } + + const content = documents + .map((result, index) => { + const content = result.content.document.pageContent; + const context = `[${index}]. file name: ${result.content.document.metadata} \n file id: ${result.id} \n ${content}`; + return context; + }) + .join("\n------\n"); + // Augment the user prompt + const _userMessage = `\n +- Review the following content from documents uploaded by the user and create a final answer. +- If you don't know the answer, just say that you don't know. Don't try to make up an answer. +- You must always include a citation at the end of your answer and don't include full stop after the citations. +- Use the format for your citation {% citation items=[{name:"filename 1",id:"file id"}, {name:"filename 2",id:"file id"}] /%} +---------------- +content: +${content} +\n +---------------- \n +question: +${userMessage} +`; + + const stream: ChatCompletionStreamParams = { + model: "", + stream: true, + messages: [ + { + role: "system", + content: chatThread.personaMessage, + }, + ...history, + { + role: "user", + content: _userMessage, + }, + ] + }; + + let chatTokenService = new ChatTokenService(); + + let promptTokens = chatTokenService.getTokenCountFromHistory(stream.messages); + + for (let tokens of promptTokens) { + reportPromptTokens(tokens.tokens, "gpt-4", tokens.role, { personaMessageTitle: chatThread.personaMessageTitle }); + } + + return openAI.beta.chat.completions.stream(stream, { signal }); +}; diff --git a/src/features/chat-page/chat-services/chat-api/chat-api.ts b/src/features/chat-page/chat-services/chat-api/chat-api.ts new file mode 100644 index 000000000..e31584b6b --- /dev/null +++ b/src/features/chat-page/chat-services/chat-api/chat-api.ts @@ -0,0 +1,173 @@ +"use server"; +import "server-only"; + +import { getCurrentUser } from "@/features/auth-page/helpers"; +import { CHAT_DEFAULT_SYSTEM_PROMPT } from "@/features/theme/theme-config"; +import { ChatCompletionStreamingRunner } from "openai/resources/beta/chat/completions"; +import { ChatApiRAG } from "../chat-api/chat-api-rag"; +import { FindAllChatDocuments } from "../chat-document-service"; +import { + CreateChatMessage, + FindTopChatMessagesForCurrentUser, +} from "../chat-message-service"; +import { EnsureChatThreadOperation } from "../chat-thread-service"; +import { ChatThreadModel, UserPrompt } from "../models"; +import { mapOpenAIChatMessages } from "../utils"; +import { GetDefaultExtensions } from "./chat-api-default-extensions"; +import { GetDynamicExtensions } from "./chat-api-dynamic-extensions"; +import { ChatApiExtensions } from "./chat-api-extension"; +import { ChatApiMultimodal } from "./chat-api-multimodal"; +import { OpenAIStream } from "./open-ai-stream"; +import { reportCompletionTokens, reportPromptTokens, reportUserChatMessage } from "../../../common/services/chat-metrics-service"; +import { ChatTokenService } from "@/features/common/services/chat-token-service"; +import { isRunningInBrowser } from "openai/core.mjs"; +type ChatTypes = "extensions" | "chat-with-file" | "multimodal"; + +export const ChatAPIEntry = async (props: UserPrompt, signal: AbortSignal) => { + const currentChatThreadResponse = await EnsureChatThreadOperation(props.id); + + if (currentChatThreadResponse.status !== "OK") { + return new Response("", { status: 401 }); + } + + const currentChatThread = currentChatThreadResponse.response; + + // promise all to get user, history and docs + const [user, history, docs, extension] = await Promise.all([ + getCurrentUser(), + _getHistory(currentChatThread), + _getDocuments(currentChatThread), + _getExtensions({ + chatThread: currentChatThread, + userMessage: props.message, + signal, + }), + ]); + // Starting values for system and user prompt + // Note that the system message will also get prepended with the extension execution steps. Please see ChatApiExtensions method. + currentChatThread.personaMessage = `${CHAT_DEFAULT_SYSTEM_PROMPT} \n\n ${currentChatThread.personaMessage}`; + + let chatType: ChatTypes = "extensions"; + + if (props.multimodalImage && props.multimodalImage.length > 0) { + chatType = "multimodal"; + } else if (docs.length > 0) { + chatType = "chat-with-file"; + } else if (extension.length > 0) { + chatType = "extensions"; + } + + // save the user message + await CreateChatMessage({ + name: user.name, + content: props.message, + role: "user", + chatThreadId: currentChatThread.id, + multiModalImage: props.multimodalImage, + }); + + let runner: ChatCompletionStreamingRunner; + + switch (chatType) { + case "chat-with-file": + runner = await ChatApiRAG({ + chatThread: currentChatThread, + userMessage: props.message, + history: history, + signal: signal, + }); + break; + case "multimodal": + runner = ChatApiMultimodal({ + chatThread: currentChatThread, + userMessage: props.message, + file: props.multimodalImage, + signal: signal, + }); + break; + case "extensions": + runner = await ChatApiExtensions({ + chatThread: currentChatThread, + userMessage: props.message, + history: history, + extensions: extension, + signal: signal, + }); + break; + } + + reportUserChatMessage("gpt-4", { personaMessageTitle: currentChatThread.personaMessageTitle }); + + const readableStream = OpenAIStream({ + runner: runner, + chatThread: currentChatThread, + }); + + runner.on("finalContent", async (finalContent) => { + const chatTokenService = new ChatTokenService(); + const tokens = chatTokenService.getTokenCount(finalContent); + reportCompletionTokens(tokens, "gpt-4", {personaMessageTitle: currentChatThread.personaMessageTitle}); + }); + + return new Response(readableStream, { + headers: { + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +}; + +const _getHistory = async (chatThread: ChatThreadModel) => { + const historyResponse = await FindTopChatMessagesForCurrentUser( + chatThread.id + ); + + if (historyResponse.status === "OK") { + const historyResults = historyResponse.response; + return mapOpenAIChatMessages(historyResults).reverse(); + } + + console.error("🔴 Error on getting history:", historyResponse.errors); + + return []; +}; + +const _getDocuments = async (chatThread: ChatThreadModel) => { + const docsResponse = await FindAllChatDocuments(chatThread.id); + + if (docsResponse.status === "OK") { + return docsResponse.response; + } + + console.error("🔴 Error on AI search:", docsResponse.errors); + return []; +}; + +const _getExtensions = async (props: { + chatThread: ChatThreadModel; + userMessage: string; + signal: AbortSignal; +}) => { + const extension: Array = []; + + const response = await GetDefaultExtensions({ + chatThread: props.chatThread, + userMessage: props.userMessage, + signal: props.signal, + }); + if (response.status === "OK" && response.response.length > 0) { + extension.push(...response.response); + } + + const dynamicExtensionsResponse = await GetDynamicExtensions({ + extensionIds: props.chatThread.extension, + }); + if ( + dynamicExtensionsResponse.status === "OK" && + dynamicExtensionsResponse.response.length > 0 + ) { + extension.push(...dynamicExtensionsResponse.response); + } + + return extension; +}; diff --git a/src/features/chat-page/chat-services/chat-api/open-ai-stream.ts b/src/features/chat-page/chat-services/chat-api/open-ai-stream.ts new file mode 100644 index 000000000..55f2805f6 --- /dev/null +++ b/src/features/chat-page/chat-services/chat-api/open-ai-stream.ts @@ -0,0 +1,112 @@ +import { AI_NAME } from "@/features/theme/theme-config"; +import { ChatCompletionStreamingRunner } from "openai/resources/beta/chat/completions"; +import { CreateChatMessage } from "../chat-message-service"; +import { + AzureChatCompletion, + AzureChatCompletionAbort, + ChatThreadModel, +} from "../models"; + +export const OpenAIStream = (props: { + runner: ChatCompletionStreamingRunner; + chatThread: ChatThreadModel; +}) => { + const encoder = new TextEncoder(); + + const { runner, chatThread } = props; + + const readableStream = new ReadableStream({ + async start(controller) { + const streamResponse = (event: string, value: string) => { + controller.enqueue(encoder.encode(`event: ${event} \n`)); + controller.enqueue(encoder.encode(`data: ${value} \n\n`)); + }; + + let lastMessage = ""; + + runner + .on("content", (content) => { + const completion = runner.currentChatCompletionSnapshot; + + if (completion) { + const response: AzureChatCompletion = { + type: "content", + response: completion, + }; + lastMessage = completion.choices[0].message.content ?? ""; + streamResponse(response.type, JSON.stringify(response)); + } + }) + .on("functionCall", async (functionCall) => { + await CreateChatMessage({ + name: functionCall.name, + content: functionCall.arguments, + role: "function", + chatThreadId: chatThread.id, + }); + + const response: AzureChatCompletion = { + type: "functionCall", + response: functionCall, + }; + streamResponse(response.type, JSON.stringify(response)); + }) + .on("functionCallResult", async (functionCallResult) => { + const response: AzureChatCompletion = { + type: "functionCallResult", + response: functionCallResult, + }; + await CreateChatMessage({ + name: "tool", + content: functionCallResult, + role: "function", + chatThreadId: chatThread.id, + }); + streamResponse(response.type, JSON.stringify(response)); + }) + .on("abort", (error) => { + const response: AzureChatCompletionAbort = { + type: "abort", + response: "Chat aborted", + }; + streamResponse(response.type, JSON.stringify(response)); + controller.close(); + }) + .on("error", async (error) => { + console.log("🔴 error", error); + const response: AzureChatCompletion = { + type: "error", + response: error.message, + }; + + // if there is an error still save the last message even though it is not complete + await CreateChatMessage({ + name: AI_NAME, + content: lastMessage, + role: "assistant", + chatThreadId: props.chatThread.id, + }); + + streamResponse(response.type, JSON.stringify(response)); + controller.close(); + }) + .on("finalContent", async (content: string) => { + await CreateChatMessage({ + name: AI_NAME, + content: content, + role: "assistant", + chatThreadId: props.chatThread.id, + }); + + const response: AzureChatCompletion = { + type: "finalContent", + response: content, + }; + streamResponse(response.type, JSON.stringify(response)); + controller.close(); + }); + }, + }); + + return readableStream; +}; diff --git a/src/features/chat-page/chat-services/chat-document-service.ts b/src/features/chat-page/chat-services/chat-document-service.ts new file mode 100644 index 000000000..58b9defec --- /dev/null +++ b/src/features/chat-page/chat-services/chat-document-service.ts @@ -0,0 +1,234 @@ +"use server"; +import "server-only"; + +import { userHashedId } from "@/features/auth-page/helpers"; +import { HistoryContainer } from "@/features/common/services/cosmos"; + +import { RevalidateCache } from "@/features/common/navigation-helpers"; +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { DocumentIntelligenceInstance } from "@/features/common/services/document-intelligence"; +import { uniqueId } from "@/features/common/util"; +import { SqlQuerySpec } from "@azure/cosmos"; +import { EnsureIndexIsCreated } from "./azure-ai-search/azure-ai-search"; +import { CHAT_DOCUMENT_ATTRIBUTE, ChatDocumentModel } from "./models"; + +const MAX_UPLOAD_DOCUMENT_SIZE: number = 20000000; +const CHUNK_SIZE = 2300; +// 25% overlap +const CHUNK_OVERLAP = CHUNK_SIZE * 0.25; + +export const CrackDocument = async ( + formData: FormData +): Promise> => { + try { + const response = await EnsureIndexIsCreated(); + if (response.status === "OK") { + const fileResponse = await LoadFile(formData); + if (fileResponse.status === "OK") { + const splitDocuments = await ChunkDocumentWithOverlap( + fileResponse.response.join("\n") + ); + + return { + status: "OK", + response: splitDocuments, + }; + } + + return fileResponse; + } + + return response; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +const LoadFile = async ( + formData: FormData +): Promise> => { + try { + const file: File | null = formData.get("file") as unknown as File; + + const fileSize = process.env.MAX_UPLOAD_DOCUMENT_SIZE + ? Number(process.env.MAX_UPLOAD_DOCUMENT_SIZE) + : MAX_UPLOAD_DOCUMENT_SIZE; + + if (file && file.size < fileSize) { + const client = DocumentIntelligenceInstance(); + + const blob = new Blob([file], { type: file.type }); + + const poller = await client.beginAnalyzeDocument( + "prebuilt-read", + await blob.arrayBuffer() + ); + const { paragraphs } = await poller.pollUntilDone(); + + const docs: Array = []; + + if (paragraphs) { + for (const paragraph of paragraphs) { + docs.push(paragraph.content); + } + } + + return { + status: "OK", + response: docs, + }; + } else { + return { + status: "ERROR", + errors: [ + { + message: `File is too large and must be less than ${MAX_UPLOAD_DOCUMENT_SIZE} bytes.`, + }, + ], + }; + } + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export const FindAllChatDocuments = async ( + chatThreadID: string +): Promise> => { + try { + const querySpec: SqlQuerySpec = { + query: + "SELECT * FROM root r WHERE r.type=@type AND r.chatThreadId = @threadId AND r.isDeleted=@isDeleted", + parameters: [ + { + name: "@type", + value: CHAT_DOCUMENT_ATTRIBUTE, + }, + { + name: "@threadId", + value: chatThreadID, + }, + { + name: "@isDeleted", + value: false, + }, + ], + }; + + const { resources } = await HistoryContainer() + .items.query(querySpec) + .fetchAll(); + + if (resources) { + return { + status: "OK", + response: resources, + }; + } else { + return { + status: "ERROR", + errors: [ + { + message: "No documents found", + }, + ], + }; + } + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export const CreateChatDocument = async ( + fileName: string, + chatThreadID: string +): Promise> => { + try { + const modelToSave: ChatDocumentModel = { + chatThreadId: chatThreadID, + id: uniqueId(), + userId: await userHashedId(), + createdAt: new Date(), + type: CHAT_DOCUMENT_ATTRIBUTE, + isDeleted: false, + name: fileName, + }; + + const { resource } = + await HistoryContainer().items.upsert(modelToSave); + RevalidateCache({ + page: "chat", + params: chatThreadID, + }); + + if (resource) { + return { + status: "OK", + response: resource, + }; + } + + return { + status: "ERROR", + errors: [ + { + message: "Unable to save chat document", + }, + ], + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export async function ChunkDocumentWithOverlap( + document: string +): Promise { + const chunks: string[] = []; + + if (document.length <= CHUNK_SIZE) { + // If the document is smaller than the desired chunk size, return it as a single chunk. + chunks.push(document); + return chunks; + } + + let startIndex = 0; + + // Split the document into chunks of the desired size, with overlap. + while (startIndex < document.length) { + const endIndex = startIndex + CHUNK_SIZE; + const chunk = document.substring(startIndex, endIndex); + chunks.push(chunk); + startIndex = endIndex - CHUNK_OVERLAP; + } + + return chunks; +} diff --git a/src/features/chat-page/chat-services/chat-image-service.ts b/src/features/chat-page/chat-services/chat-image-service.ts new file mode 100644 index 000000000..76d8ea2ba --- /dev/null +++ b/src/features/chat-page/chat-services/chat-image-service.ts @@ -0,0 +1,69 @@ +"use server"; +import "server-only"; + +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { GetBlob, UploadBlob } from "../../common/services/azure-storage"; + +const IMAGE_CONTAINER_NAME = "images"; +const IMAGE_API_PATH = process.env.NEXTAUTH_URL + "/api/images"; + +export const GetBlobPath = (threadId: string, blobName: string): string => { + return `${threadId}/${blobName}`; +}; + +export const UploadImageToStore = async ( + threadId: string, + fileName: string, + imageData: Buffer +): Promise> => { + return await UploadBlob( + IMAGE_CONTAINER_NAME, + `${threadId}/${fileName}`, + imageData + ); +}; + +export const GetImageFromStore = async ( + threadId: string, + fileName: string +): Promise> => { + const blobPath = GetBlobPath(threadId, fileName); + return await GetBlob(IMAGE_CONTAINER_NAME, blobPath); +}; + +export const GetImageUrl = (threadId: string, fileName: string): string => { + // add threadId and fileName as query parameters t and img respectively + const params = `?t=${threadId}&img=${fileName}`; + + return `${IMAGE_API_PATH}/${params}`; +}; + +export const GetThreadAndImageFromUrl = ( + urlString: string +): ServerActionResponse<{ threadId: string; imgName: string }> => { + // Get threadId and img from query parameters t and img + const url = new URL(urlString); + const threadId = url.searchParams.get("t"); + const imgName = url.searchParams.get("img"); + + // Check if threadId and img are valid + if (!threadId || !imgName) { + return { + status: "ERROR", + errors: [ + { + message: + "Invalid URL, threadId and/or imgName not formatted correctly.", + }, + ], + }; + } + + return { + status: "OK", + response: { + threadId, + imgName, + }, + }; +}; diff --git a/src/features/chat-page/chat-services/chat-message-service.ts b/src/features/chat-page/chat-services/chat-message-service.ts new file mode 100644 index 000000000..8b21e2d12 --- /dev/null +++ b/src/features/chat-page/chat-services/chat-message-service.ts @@ -0,0 +1,179 @@ +"use server"; +import "server-only"; + +import { userHashedId } from "@/features/auth-page/helpers"; +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { uniqueId } from "@/features/common/util"; +import { SqlQuerySpec } from "@azure/cosmos"; +import { HistoryContainer } from "../../common/services/cosmos"; +import { ChatMessageModel, ChatRole, MESSAGE_ATTRIBUTE } from "./models"; + +export const FindTopChatMessagesForCurrentUser = async ( + chatThreadID: string, + top: number = 30 +): Promise>> => { + try { + const querySpec: SqlQuerySpec = { + query: + "SELECT TOP @top * FROM root r WHERE r.type=@type AND r.threadId = @threadId AND r.userId=@userId AND r.isDeleted=@isDeleted ORDER BY r.createdAt DESC", + parameters: [ + { + name: "@type", + value: MESSAGE_ATTRIBUTE, + }, + { + name: "@threadId", + value: chatThreadID, + }, + { + name: "@userId", + value: await userHashedId(), + }, + { + name: "@isDeleted", + value: false, + }, + { + name: "@top", + value: top, + }, + ], + }; + + const { resources } = await HistoryContainer() + .items.query(querySpec) + .fetchAll(); + + return { + status: "OK", + response: resources, + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export const FindAllChatMessagesForCurrentUser = async ( + chatThreadID: string +): Promise>> => { + try { + const querySpec: SqlQuerySpec = { + query: + "SELECT * FROM root r WHERE r.type=@type AND r.threadId = @threadId AND r.userId=@userId AND r.isDeleted=@isDeleted ORDER BY r.createdAt ASC", + parameters: [ + { + name: "@type", + value: MESSAGE_ATTRIBUTE, + }, + { + name: "@threadId", + value: chatThreadID, + }, + { + name: "@userId", + value: await userHashedId(), + }, + { + name: "@isDeleted", + value: false, + }, + ], + }; + + const { resources } = await HistoryContainer() + .items.query(querySpec) + .fetchAll(); + + return { + status: "OK", + response: resources, + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; + +export const CreateChatMessage = async ({ + name, + content, + role, + chatThreadId, + multiModalImage, +}: { + name: string; + role: ChatRole; + content: string; + chatThreadId: string; + multiModalImage?: string; +}): Promise> => { + const userId = await userHashedId(); + const modelToSave: ChatMessageModel = { + id: uniqueId(), + createdAt: new Date(), + type: MESSAGE_ATTRIBUTE, + isDeleted: false, + content: content, + name: name, + role: role, + threadId: chatThreadId, + userId: userId, + multiModalImage: multiModalImage, + }; + return await UpsertChatMessage(modelToSave); +}; + +export const UpsertChatMessage = async ( + chatModel: ChatMessageModel +): Promise> => { + try { + const modelToSave: ChatMessageModel = { + ...chatModel, + id: uniqueId(), + createdAt: new Date(), + type: MESSAGE_ATTRIBUTE, + isDeleted: false, + }; + + const { resource } = + await HistoryContainer().items.upsert(modelToSave); + + if (resource) { + return { + status: "OK", + response: resource, + }; + } + + return { + status: "ERROR", + errors: [ + { + message: `Chat message not found`, + }, + ], + }; + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; diff --git a/src/features/chat-page/chat-services/chat-thread-service.ts b/src/features/chat-page/chat-services/chat-thread-service.ts new file mode 100644 index 000000000..8f42b27d7 --- /dev/null +++ b/src/features/chat-page/chat-services/chat-thread-service.ts @@ -0,0 +1,342 @@ +"use server"; +import "server-only"; + +import { + getCurrentUser, + userHashedId, + userSession, +} from "@/features/auth-page/helpers"; +import { RedirectToChatThread } from "@/features/common/navigation-helpers"; +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { uniqueId } from "@/features/common/util"; +import { + CHAT_DEFAULT_PERSONA, + NEW_CHAT_NAME, +} from "@/features/theme/theme-config"; +import { SqlQuerySpec } from "@azure/cosmos"; +import { HistoryContainer } from "../../common/services/cosmos"; +import { DeleteDocuments } from "./azure-ai-search/azure-ai-search"; +import { FindAllChatDocuments } from "./chat-document-service"; +import { FindAllChatMessagesForCurrentUser } from "./chat-message-service"; +import { + CHAT_THREAD_ATTRIBUTE, + ChatDocumentModel, + ChatThreadModel, +} from "./models"; + +export const FindAllChatThreadForCurrentUser = async (): Promise< + ServerActionResponse> +> => { + try { + const querySpec: SqlQuerySpec = { + query: + "SELECT * FROM root r WHERE r.type=@type AND r.userId=@userId AND r.isDeleted=@isDeleted ORDER BY r.createdAt DESC", + parameters: [ + { + name: "@type", + value: CHAT_THREAD_ATTRIBUTE, + }, + { + name: "@userId", + value: await userHashedId(), + }, + { + name: "@isDeleted", + value: false, + }, + ], + }; + + const { resources } = await HistoryContainer() + .items.query(querySpec, { + partitionKey: await userHashedId(), + }) + .fetchAll(); + return { + status: "OK", + response: resources, + }; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +export const FindChatThreadForCurrentUser = async ( + id: string +): Promise> => { + try { + const querySpec: SqlQuerySpec = { + query: + "SELECT * FROM root r WHERE r.type=@type AND r.userId=@userId AND r.id=@id AND r.isDeleted=@isDeleted", + parameters: [ + { + name: "@type", + value: CHAT_THREAD_ATTRIBUTE, + }, + { + name: "@userId", + value: await userHashedId(), + }, + { + name: "@id", + value: id, + }, + { + name: "@isDeleted", + value: false, + }, + ], + }; + + const { resources } = await HistoryContainer() + .items.query(querySpec) + .fetchAll(); + + if (resources.length === 0) { + return { + status: "NOT_FOUND", + errors: [{ message: `Chat thread not found` }], + }; + } + + return { + status: "OK", + response: resources[0], + }; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +export const SoftDeleteChatThreadForCurrentUser = async ( + chatThreadID: string +): Promise> => { + try { + const chatThreadResponse = await FindChatThreadForCurrentUser(chatThreadID); + + if (chatThreadResponse.status === "OK") { + const chatResponse = await FindAllChatMessagesForCurrentUser( + chatThreadID + ); + + if (chatResponse.status !== "OK") { + return chatResponse; + } + const chats = chatResponse.response; + + chats.forEach(async (chat) => { + const itemToUpdate = { + ...chat, + }; + itemToUpdate.isDeleted = true; + await HistoryContainer().items.upsert(itemToUpdate); + }); + + const chatDocumentsResponse = await FindAllChatDocuments(chatThreadID); + + if (chatDocumentsResponse.status !== "OK") { + return chatDocumentsResponse; + } + + const chatDocuments = chatDocumentsResponse.response; + + if (chatDocuments.length !== 0) { + await DeleteDocuments(chatThreadID); + } + + chatDocuments.forEach(async (chatDocument: ChatDocumentModel) => { + const itemToUpdate = { + ...chatDocument, + }; + itemToUpdate.isDeleted = true; + await HistoryContainer().items.upsert(itemToUpdate); + }); + + chatThreadResponse.response.isDeleted = true; + await HistoryContainer().items.upsert(chatThreadResponse.response); + } + + return chatThreadResponse; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +export const EnsureChatThreadOperation = async ( + chatThreadID: string +): Promise> => { + const response = await FindChatThreadForCurrentUser(chatThreadID); + const currentUser = await getCurrentUser(); + const hashedId = await userHashedId(); + + if (response.status === "OK") { + if (currentUser.isAdmin || response.response.userId === hashedId) { + return response; + } + } + + return response; +}; + +export const AddExtensionToChatThread = async (props: { + chatThreadId: string; + extensionId: string; +}): Promise> => { + try { + const response = await FindChatThreadForCurrentUser(props.chatThreadId); + if (response.status === "OK") { + const chatThread = response.response; + + const existingExtension = chatThread.extension.find( + (e) => e === props.extensionId + ); + + if (existingExtension === undefined) { + chatThread.extension.push(props.extensionId); + return await UpsertChatThread(chatThread); + } + + return { + status: "OK", + response: chatThread, + }; + } + + return response; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +export const RemoveExtensionFromChatThread = async (props: { + chatThreadId: string; + extensionId: string; +}): Promise> => { + const response = await FindChatThreadForCurrentUser(props.chatThreadId); + if (response.status === "OK") { + const chatThread = response.response; + chatThread.extension = chatThread.extension.filter( + (e) => e !== props.extensionId + ); + + return await UpsertChatThread(chatThread); + } + + return response; +}; + +export const UpsertChatThread = async ( + chatThread: ChatThreadModel +): Promise> => { + try { + if (chatThread.id) { + const response = await EnsureChatThreadOperation(chatThread.id); + if (response.status !== "OK") { + return response; + } + } + + chatThread.lastMessageAt = new Date(); + const { resource } = await HistoryContainer().items.upsert( + chatThread + ); + + if (resource) { + return { + status: "OK", + response: resource, + }; + } + + return { + status: "ERROR", + errors: [{ message: `Chat thread not found` }], + }; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +export const CreateChatThread = async (): Promise< + ServerActionResponse +> => { + try { + const modelToSave: ChatThreadModel = { + name: NEW_CHAT_NAME, + useName: (await userSession())!.name, + userId: await userHashedId(), + id: uniqueId(), + createdAt: new Date(), + lastMessageAt: new Date(), + bookmarked: false, + isDeleted: false, + type: CHAT_THREAD_ATTRIBUTE, + personaMessage: "", + personaMessageTitle: CHAT_DEFAULT_PERSONA, + extension: [], + }; + + const { resource } = await HistoryContainer().items.create( + modelToSave + ); + if (resource) { + return { + status: "OK", + response: resource, + }; + } + + return { + status: "ERROR", + errors: [{ message: `Chat thread not found` }], + }; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +export const UpdateChatTitle = async ( + chatThreadId: string, + title: string +): Promise> => { + try { + const response = await FindChatThreadForCurrentUser(chatThreadId); + if (response.status === "OK") { + const chatThread = response.response; + // take the first 30 characters + chatThread.name = title.substring(0, 30); + return await UpsertChatThread(chatThread); + } + return response; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +export const CreateChatAndRedirect = async () => { + const response = await CreateChatThread(); + if (response.status === "OK") { + RedirectToChatThread(response.response.id); + } +}; diff --git a/src/features/chat-page/chat-services/citation-service.ts b/src/features/chat-page/chat-services/citation-service.ts new file mode 100644 index 000000000..d6163d24b --- /dev/null +++ b/src/features/chat-page/chat-services/citation-service.ts @@ -0,0 +1,119 @@ +import { userHashedId } from "@/features/auth-page/helpers"; +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { HistoryContainer } from "@/features/common/services/cosmos"; +import { uniqueId } from "@/features/common/util"; +import { SqlQuerySpec } from "@azure/cosmos"; +import { DocumentSearchResponse } from "./azure-ai-search/azure-ai-search"; +import { CHAT_CITATION_ATTRIBUTE, ChatCitationModel } from "./models"; + +export const CreateCitation = async ( + model: ChatCitationModel +): Promise> => { + try { + const { resource } = + await HistoryContainer().items.create(model); + + if (!resource) { + return { + status: "ERROR", + errors: [{ message: "Citation not created" }], + }; + } + + return { + status: "OK", + response: resource, + }; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +// Create citations for the documents with a user as optional parameter +// when calling this method from the extension, you must provide the user as the REST API does not have access to the user +export const CreateCitations = async ( + models: DocumentSearchResponse[], + userId?: string +): Promise>> => { + const items: Array>> = []; + + for (const model of models) { + const res = CreateCitation({ + content: model, + id: uniqueId(), + type: CHAT_CITATION_ATTRIBUTE, + userId: userId || (await userHashedId()), + }); + + items.push(res); + } + + return await Promise.all(items); +}; + +export const FindCitationByID = async ( + id: string +): Promise> => { + try { + const querySpec: SqlQuerySpec = { + query: + "SELECT * FROM root r WHERE r.type=@type AND r.id=@id AND r.userId=@userId ", + parameters: [ + { + name: "@type", + value: CHAT_CITATION_ATTRIBUTE, + }, + { + name: "@id", + value: id, + }, + { + name: "@userId", + value: await userHashedId(), + }, + ], + }; + + const { resources } = await HistoryContainer() + .items.query(querySpec) + .fetchAll(); + + if (resources.length === 0) { + return { + status: "ERROR", + errors: [{ message: "Citation not found" }], + }; + } + + return { + status: "OK", + response: resources[0], + }; + } catch (error) { + return { + status: "ERROR", + errors: [{ message: `${error}` }], + }; + } +}; + +export const FormatCitations = (citation: DocumentSearchResponse[]) => { + const withoutEmbedding: DocumentSearchResponse[] = []; + citation.forEach((d) => { + withoutEmbedding.push({ + score: d.score, + document: { + metadata: d.document.metadata, + pageContent: d.document.pageContent, + chatThreadId: d.document.chatThreadId, + id: "", + user: "", + }, + }); + }); + + return withoutEmbedding; +}; diff --git a/src/features/chat-page/chat-services/images-api.ts b/src/features/chat-page/chat-services/images-api.ts new file mode 100644 index 000000000..ab5f5b8aa --- /dev/null +++ b/src/features/chat-page/chat-services/images-api.ts @@ -0,0 +1,25 @@ +import { + GetImageFromStore, + GetThreadAndImageFromUrl, +} from "./chat-image-service"; + +export const ImageAPIEntry = async (request: Request): Promise => { + const urlPath = request.url; + + const response = GetThreadAndImageFromUrl(urlPath); + + if (response.status !== "OK") { + return new Response(response.errors[0].message, { status: 404 }); + } + + const { threadId, imgName } = response.response; + const imageData = await GetImageFromStore(threadId, imgName); + + if (imageData.status === "OK") { + return new Response(imageData.response, { + headers: { "content-type": "image/png" }, + }); + } else { + return new Response(imageData.errors[0].message, { status: 404 }); + } +}; diff --git a/src/features/chat-page/chat-services/models.ts b/src/features/chat-page/chat-services/models.ts new file mode 100644 index 000000000..c8e02d208 --- /dev/null +++ b/src/features/chat-page/chat-services/models.ts @@ -0,0 +1,110 @@ +import { ChatCompletionSnapshot } from "openai/lib/ChatCompletionStream"; +import { ChatCompletionMessage } from "openai/resources/chat/completions"; + +export const CHAT_DOCUMENT_ATTRIBUTE = "CHAT_DOCUMENT"; +export const CHAT_THREAD_ATTRIBUTE = "CHAT_THREAD"; +export const MESSAGE_ATTRIBUTE = "CHAT_MESSAGE"; +export const CHAT_CITATION_ATTRIBUTE = "CHAT_CITATION"; + +export interface ChatMessageModel { + id: string; + createdAt: Date; + isDeleted: boolean; + threadId: string; + userId: string; + content: string; + role: ChatRole; + name: string; + multiModalImage?: string; + type: typeof MESSAGE_ATTRIBUTE; +} + +export type ChatRole = "system" | "user" | "assistant" | "function" | "tool"; + +export interface ChatThreadModel { + id: string; + name: string; + createdAt: Date; + lastMessageAt: Date; + userId: string; + useName: string; + isDeleted: boolean; + bookmarked: boolean; + personaMessage: string; + personaMessageTitle: string; + extension: string[]; + type: typeof CHAT_THREAD_ATTRIBUTE; +} + +export interface UserPrompt { + id: string; // thread id + message: string; + multimodalImage: string; +} + +export interface ChatDocumentModel { + id: string; + name: string; + chatThreadId: string; + userId: string; + isDeleted: boolean; + createdAt: Date; + type: typeof CHAT_DOCUMENT_ATTRIBUTE; +} + +export interface ToolsInterface { + name: string; + description: string; + parameters: any; +} + +export type MenuItemsGroupName = "Bookmarked" | "Past 7 days" | "Previous"; + +export type MenuItemsGroup = { + groupName: MenuItemsGroupName; +} & ChatThreadModel; + +export type ChatCitationModel = { + id: string; + content: any; + userId: string; + type: typeof CHAT_CITATION_ATTRIBUTE; +}; + +export type AzureChatCompletionFunctionCall = { + type: "functionCall"; + response: ChatCompletionMessage.FunctionCall; +}; + +export type AzureChatCompletionFunctionCallResult = { + type: "functionCallResult"; + response: string; +}; + +export type AzureChatCompletionContent = { + type: "content"; + response: ChatCompletionSnapshot; +}; + +export type AzureChatCompletionFinalContent = { + type: "finalContent"; + response: string; +}; + +export type AzureChatCompletionError = { + type: "error"; + response: string; +}; + +export type AzureChatCompletionAbort = { + type: "abort"; + response: string; +}; + +export type AzureChatCompletion = + | AzureChatCompletionError + | AzureChatCompletionFunctionCall + | AzureChatCompletionFunctionCallResult + | AzureChatCompletionContent + | AzureChatCompletionFinalContent + | AzureChatCompletionAbort; diff --git a/src/features/chat-page/chat-services/utils.ts b/src/features/chat-page/chat-services/utils.ts new file mode 100644 index 000000000..776699c96 --- /dev/null +++ b/src/features/chat-page/chat-services/utils.ts @@ -0,0 +1,31 @@ +import { + ChatCompletionAssistantMessageParam, + ChatCompletionFunctionMessageParam, + ChatCompletionMessageParam, +} from "openai/resources/chat/completions"; +import { ChatMessageModel } from "./models"; + +export const mapOpenAIChatMessages = ( + messages: ChatMessageModel[] +): ChatCompletionMessageParam[] => { + return messages.map((message) => { + switch (message.role) { + case "function": + return { + role: message.role, + name: message.name, + content: message.content, + } as ChatCompletionFunctionMessageParam; + case "assistant": + return { + role: message.role, + content: message.content, + } as ChatCompletionAssistantMessageParam; + default: + return { + role: message.role, + content: message.content, + } as ChatCompletionMessageParam; + } + }); +}; diff --git a/src/features/chat-page/chat-store.tsx b/src/features/chat-page/chat-store.tsx new file mode 100644 index 000000000..4c9124be0 --- /dev/null +++ b/src/features/chat-page/chat-store.tsx @@ -0,0 +1,298 @@ +"use client"; +import { uniqueId } from "@/features/common/util"; +import { showError } from "@/features/globals/global-message-store"; +import { AI_NAME, NEW_CHAT_NAME } from "@/features/theme/theme-config"; +import { + ParsedEvent, + ReconnectInterval, + createParser, +} from "eventsource-parser"; +import { FormEvent } from "react"; +import { proxy, useSnapshot } from "valtio"; +import { RevalidateCache } from "../common/navigation-helpers"; +import { InputImageStore } from "../ui/chat/chat-input-area/input-image-store"; +import { textToSpeechStore } from "./chat-input/speech/use-text-to-speech"; +import { ResetInputRows } from "./chat-input/use-chat-input-dynamic-height"; +import { + AddExtensionToChatThread, + RemoveExtensionFromChatThread, + UpdateChatTitle, +} from "./chat-services/chat-thread-service"; +import { + AzureChatCompletion, + ChatMessageModel, + ChatThreadModel, +} from "./chat-services/models"; +let abortController: AbortController = new AbortController(); + +type chatStatus = "idle" | "loading" | "file upload"; + +class ChatState { + public messages: Array = []; + public loading: chatStatus = "idle"; + public input: string = ""; + public lastMessage: string = ""; + public autoScroll: boolean = false; + public userName: string = ""; + public chatThreadId: string = ""; + + private chatThread: ChatThreadModel | undefined; + + private addToMessages(message: ChatMessageModel) { + const currentMessage = this.messages.find((el) => el.id === message.id); + if (currentMessage) { + currentMessage.content = message.content; + } else { + this.messages.push(message); + } + } + + private removeMessage(id: string) { + const index = this.messages.findIndex((el) => el.id === id); + if (index > -1) { + this.messages.splice(index, 1); + } + } + + public updateLoading(value: chatStatus) { + this.loading = value; + } + + public initChatSession({ + userName, + messages, + chatThread, + }: { + chatThread: ChatThreadModel; + userName: string; + messages: Array; + }) { + this.chatThread = chatThread; + this.chatThreadId = chatThread.id; + this.messages = messages; + this.userName = userName; + } + + public async AddExtensionToChatThread(extensionId: string) { + this.loading = "loading"; + + const response = await AddExtensionToChatThread({ + extensionId: extensionId, + chatThreadId: this.chatThreadId, + }); + RevalidateCache({ + page: "chat", + type: "layout", + }); + + if (response.status !== "OK") { + showError(response.errors[0].message); + } + + this.loading = "idle"; + } + + public async RemoveExtensionFromChatThread(extensionId: string) { + this.loading = "loading"; + + const response = await RemoveExtensionFromChatThread({ + extensionId: extensionId, + chatThreadId: this.chatThreadId, + }); + + RevalidateCache({ + page: "chat", + }); + + if (response.status !== "OK") { + showError(response.errors[0].message); + } + + this.loading = "idle"; + } + + public updateInput(value: string) { + this.input = value; + } + + public stopGeneratingMessages() { + abortController.abort(); + } + + public updateAutoScroll(value: boolean) { + this.autoScroll = value; + } + + private reset() { + this.input = ""; + ResetInputRows(); + InputImageStore.Reset(); + } + + private async chat(formData: FormData) { + this.updateAutoScroll(true); + this.loading = "loading"; + + const multimodalImage = formData.get("image-base64") as unknown as string; + + const newUserMessage: ChatMessageModel = { + id: uniqueId(), + role: "user", + content: this.input, + name: this.userName, + multiModalImage: multimodalImage, + createdAt: new Date(), + isDeleted: false, + threadId: this.chatThreadId, + type: "CHAT_MESSAGE", + userId: "", + }; + + this.messages.push(newUserMessage); + this.reset(); + + const controller = new AbortController(); + abortController = controller; + + try { + if (this.chatThreadId === "" || this.chatThreadId === undefined) { + showError("Chat thread ID is empty"); + return; + } + + const response = await fetch("/api/chat", { + method: "POST", + body: formData, + signal: controller.signal, + }); + + const onParse = (event: ParsedEvent | ReconnectInterval) => { + if (event.type === "event") { + const responseType = JSON.parse(event.data) as AzureChatCompletion; + switch (responseType.type) { + case "functionCall": + const mappedFunction: ChatMessageModel = { + id: uniqueId(), + content: responseType.response.arguments, + name: responseType.response.name, + role: "function", + createdAt: new Date(), + isDeleted: false, + threadId: this.chatThreadId, + type: "CHAT_MESSAGE", + userId: "", + multiModalImage: "", + }; + this.addToMessages(mappedFunction); + break; + case "functionCallResult": + const mappedFunctionResult: ChatMessageModel = { + id: uniqueId(), + content: responseType.response, + name: "tool", + role: "tool", + createdAt: new Date(), + isDeleted: false, + threadId: this.chatThreadId, + type: "CHAT_MESSAGE", + userId: "", + multiModalImage: "", + }; + this.addToMessages(mappedFunctionResult); + break; + case "content": + const mappedContent: ChatMessageModel = { + id: responseType.response.id, + content: responseType.response.choices[0].message.content || "", + name: AI_NAME, + role: "assistant", + createdAt: new Date(), + isDeleted: false, + threadId: this.chatThreadId, + type: "CHAT_MESSAGE", + userId: "", + multiModalImage: "", + }; + + this.addToMessages(mappedContent); + this.lastMessage = mappedContent.content; + + break; + case "abort": + this.removeMessage(newUserMessage.id); + this.loading = "idle"; + break; + case "error": + showError(responseType.response); + this.loading = "idle"; + break; + case "finalContent": + this.loading = "idle"; + this.completed(this.lastMessage); + this.updateTitle(); + break; + default: + break; + } + } + }; + + if (response.body) { + const parser = createParser(onParse); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let done = false; + while (!done) { + const { value, done: doneReading } = await reader.read(); + done = doneReading; + + const chunkValue = decoder.decode(value); + parser.feed(chunkValue); + } + this.loading = "idle"; + } + } catch (error) { + showError("" + error); + this.loading = "idle"; + } + } + + private async updateTitle() { + if (this.chatThread && this.chatThread.name === NEW_CHAT_NAME) { + await UpdateChatTitle(this.chatThreadId, this.messages[0].content); + RevalidateCache({ + page: "chat", + type: "layout", + }); + } + } + + private completed(message: string) { + textToSpeechStore.speak(message); + } + + public async submitChat(e: FormEvent) { + e.preventDefault(); + if (this.input === "" || this.loading !== "idle") { + return; + } + + // get form data from e + const formData = new FormData(e.currentTarget); + + const body = JSON.stringify({ + id: this.chatThreadId, + message: this.input, + }); + formData.append("content", body); + + this.chat(formData); + } +} + +export const chatStore = proxy(new ChatState()); + +export const useChat = () => { + return useSnapshot(chatStore, { sync: true }); +}; diff --git a/src/features/chat-page/citation/citation-action.tsx b/src/features/chat-page/citation/citation-action.tsx new file mode 100644 index 000000000..bc0e80d6b --- /dev/null +++ b/src/features/chat-page/citation/citation-action.tsx @@ -0,0 +1,32 @@ +"use server"; + +import { DisplayError } from "@/features/ui/error/display-error"; +import { RecursiveUI } from "@/features/ui/recursive-ui"; +import { FindCitationByID } from "../chat-services/citation-service"; + +export const CitationAction = async ( + previousState: any, + formData: FormData +) => { + const searchResponse = await FindCitationByID(formData.get("id") as string); + + if (searchResponse.status !== "OK") { + return ; + } + + if (searchResponse.status === "OK") { + const document = searchResponse.response; + + return ( +
+ +
+ ); + } + + return
Not found
; +}; + +interface Prop { + documentField: any; +} diff --git a/src/features/chat-page/message-content.tsx b/src/features/chat-page/message-content.tsx new file mode 100644 index 000000000..757613a4c --- /dev/null +++ b/src/features/chat-page/message-content.tsx @@ -0,0 +1,74 @@ +import { Markdown } from "@/features/ui/markdown/markdown"; +import { FunctionSquare } from "lucide-react"; +import React from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../ui/accordion"; +import { RecursiveUI } from "../ui/recursive-ui"; +import { CitationAction } from "./citation/citation-action"; + +interface MessageContentProps { + message: { + role: string; + content: string; + name: string; + multiModalImage?: string; + }; +} + +const MessageContent: React.FC = ({ message }) => { + if (message.role === "assistant" || message.role === "user") { + return ( + <> + + {message.multiModalImage && } + + ); + } + + if (message.role === "tool" || message.role === "function") { + return ( +
+ + + +
+ {" "} + Show {message.name}{" "} + {message.name === "tool" ? "output" : "function"} +
+
+ + + +
+
+
+ ); + } + + return null; +}; + +const toJson = (value: string) => { + try { + return JSON.parse(value); + } catch (e) { + return value; + } +}; + +export default MessageContent; diff --git a/src/features/chat/chat-menu/chat-menu-container.tsx b/src/features/chat/chat-menu/chat-menu-container.tsx deleted file mode 100644 index 93fbeacb3..000000000 --- a/src/features/chat/chat-menu/chat-menu-container.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { useMenuContext } from "@/features/main-menu/menu-context"; - -export const ChatMenuContainer = ({ - children, -}: { - children: React.ReactNode; -}) => { - const { isMenuOpen } = useMenuContext(); - return <>{isMenuOpen ? children : null}; -}; diff --git a/src/features/chat/chat-menu/chat-menu.tsx b/src/features/chat/chat-menu/chat-menu.tsx deleted file mode 100644 index 58221fb10..000000000 --- a/src/features/chat/chat-menu/chat-menu.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Menu, MenuContent, MenuHeader } from "@/components/menu"; -import { FindAllChatThreadForCurrentUser } from "@/features/chat/chat-services/chat-thread-service"; -import { MenuItems } from "./menu-items"; -import { NewChat } from "./new-chat"; - -export const ChatMenu = async () => { - const items = await FindAllChatThreadForCurrentUser(); - - return ( - - - - - - - - - ); -}; diff --git a/src/features/chat/chat-menu/menu-items.tsx b/src/features/chat/chat-menu/menu-items.tsx deleted file mode 100644 index a924a7064..000000000 --- a/src/features/chat/chat-menu/menu-items.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; -import { MenuItem } from "@/components/menu"; -import { Button } from "@/components/ui/button"; -import { SoftDeleteChatThreadByID } from "@/features/chat/chat-services/chat-thread-service"; -import { useGlobalMessageContext } from "@/features/global-message/global-message-context"; -import { FileText, MessageCircle, Trash } from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { FC } from "react"; -import { ChatThreadModel } from "../chat-services/models"; - -interface Prop { - menuItems: Array; -} - -export const MenuItems: FC = (props) => { - const { id } = useParams(); - const router = useRouter(); - const { showError } = useGlobalMessageContext(); - - const sendData = async (threadID: string) => { - try { - await SoftDeleteChatThreadByID(threadID); - router.refresh(); - router.replace("/chat"); - } catch (e) { - console.log(e); - showError("" + e); - } - }; - - return ( - <> - {props.menuItems.map((thread, index) => ( - - {thread.chatType === "data" ? ( - - ) : ( - - )} - - - {thread.name} - - - - ))} - - ); -}; diff --git a/src/features/chat/chat-menu/new-chat.tsx b/src/features/chat/chat-menu/new-chat.tsx deleted file mode 100644 index 73fd6aa5e..000000000 --- a/src/features/chat/chat-menu/new-chat.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { PlusCircle } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { CreateChatThread } from "../chat-services/chat-thread-service"; - -export const NewChat = () => { - const router = useRouter(); - const startNewChat = async () => { - try { - const newChatThread = await CreateChatThread(); - if (newChatThread) { - router.push("/chat/" + newChatThread.id); - router.refresh(); - } - } catch (e) { - console.log(e); - } - }; - - return ( - - ); -}; diff --git a/src/features/chat/chat-services/azure-cog-search/azure-cog-vector-store.ts b/src/features/chat/chat-services/azure-cog-search/azure-cog-vector-store.ts deleted file mode 100644 index 9f0934ea3..000000000 --- a/src/features/chat/chat-services/azure-cog-search/azure-cog-vector-store.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { OpenAIEmbeddingInstance } from "@/features/common/openai"; - -export interface AzureCogDocumentIndex { - id: string; - pageContent: string; - embedding?: number[]; - user: string; - chatThreadId: string; - metadata: string; -} - -interface DocumentSearchResponseModel { - value: TModel[]; -} - -type DocumentSearchModel = { - "@search.score": number; -}; - -type DocumentDeleteModel = { - id: string; - "@search.action": "delete"; -}; - -export interface AzureCogDocument {} - -type AzureCogVectorField = { - value: number[]; - fields: string; - k: number; -}; - -type AzureCogFilter = { - search?: string; - facets?: string[]; - filter?: string; - top?: number; -}; - -type AzureCogRequestObject = { - search: string; - facets: string[]; - filter: string; - vectors: AzureCogVectorField[]; - top: number; -}; - -export const simpleSearch = async ( - filter?: AzureCogFilter -): Promise> => { - const url = `${baseIndexUrl()}/docs/search?api-version=${ - process.env.AZURE_SEARCH_API_VERSION - }`; - - const searchBody: AzureCogRequestObject = { - search: filter?.search || "*", - facets: filter?.facets || [], - filter: filter?.filter || "", - vectors: [], - top: filter?.top || 10, - }; - - const resultDocuments = (await fetcher(url, { - method: "POST", - body: JSON.stringify(searchBody), - })) as DocumentSearchResponseModel< - AzureCogDocumentIndex & DocumentSearchModel - >; - - return resultDocuments.value; -}; - -export const similaritySearchVectorWithScore = async ( - query: string, - k: number, - filter?: AzureCogFilter -): Promise> => { - const openai = OpenAIEmbeddingInstance(); - - const embeddings = await openai.embeddings.create({ - input: query, - model: process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME, - }); - - const url = `${baseIndexUrl()}/docs/search?api-version=${ - process.env.AZURE_SEARCH_API_VERSION - }`; - - const searchBody: AzureCogRequestObject = { - search: filter?.search || "*", - facets: filter?.facets || [], - filter: filter?.filter || "", - vectors: [ - { value: embeddings.data[0].embedding, fields: "embedding", k: k }, - ], - top: filter?.top || k, - }; - - const resultDocuments = (await fetcher(url, { - method: "POST", - body: JSON.stringify(searchBody), - })) as DocumentSearchResponseModel< - AzureCogDocumentIndex & DocumentSearchModel - >; - - return resultDocuments.value; -}; - -export const indexDocuments = async ( - documents: Array -): Promise => { - const url = `${baseIndexUrl()}/docs/index?api-version=${ - process.env.AZURE_SEARCH_API_VERSION - }`; - - await embedDocuments(documents); - const documentIndexRequest: DocumentSearchResponseModel = - { - value: documents, - }; - - await fetcher(url, { - method: "POST", - body: JSON.stringify(documentIndexRequest), - }); -}; - -export const deleteDocuments = async (chatThreadId: string): Promise => { - // find all documents for chat thread - - const documentsInChat = await simpleSearch({ - filter: `chatThreadId eq '${chatThreadId}'`, - }); - - const documentsToDelete: DocumentDeleteModel[] = []; - - documentsInChat.forEach(async (document: { id: string }) => { - const doc: DocumentDeleteModel = { - "@search.action": "delete", - id: document.id, - }; - documentsToDelete.push(doc); - }); - - // delete the documents - await fetcher( - `${baseIndexUrl()}/docs/index?api-version=${ - process.env.AZURE_SEARCH_API_VERSION - }`, - { - method: "POST", - body: JSON.stringify({ value: documentsToDelete }), - } - ); -}; - -export const embedDocuments = async ( - documents: Array -) => { - const openai = OpenAIEmbeddingInstance(); - - try { - const contentsToEmbed = documents.map((d) => d.pageContent); - - const embeddings = await openai.embeddings.create({ - input: contentsToEmbed, - model: process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME, - }); - - embeddings.data.forEach((embedding, index) => { - documents[index].embedding = embedding.embedding; - }); - } catch (e) { - console.log(e); - const error = e as any; - throw new Error(`${e} with code ${error.status}`); - } -}; - -const baseUrl = (): string => { - return `https://${process.env.AZURE_SEARCH_NAME}.search.windows.net/indexes`; -}; - -const baseIndexUrl = (): string => { - return `https://${process.env.AZURE_SEARCH_NAME}.search.windows.net/indexes/${process.env.AZURE_SEARCH_INDEX_NAME}`; -}; - -const fetcher = async (url: string, init?: RequestInit) => { - const response = await fetch(url, { - ...init, - cache: "no-store", - headers: { - "Content-Type": "application/json", - "api-key": process.env.AZURE_SEARCH_API_KEY, - }, - }); - - if (!response.ok) { - if (response.status === 400) { - const err = await response.json(); - throw new Error(err.error.message); - } else { - throw new Error(`Azure Cog Search Error: ${response.statusText}`); - } - } - - return await response.json(); -}; - -export const ensureIndexIsCreated = async (): Promise => { - const url = `${baseIndexUrl()}?api-version=${ - process.env.AZURE_SEARCH_API_VERSION - }`; - - try { - await fetcher(url); - } catch (e) { - await createCogSearchIndex(); - } -}; - -const createCogSearchIndex = async (): Promise => { - const url = `${baseUrl()}?api-version=${ - process.env.AZURE_SEARCH_API_VERSION - }`; - - await fetcher(url, { - method: "POST", - body: JSON.stringify(AZURE_SEARCH_INDEX), - }); -}; - -const AZURE_SEARCH_INDEX = { - name: process.env.AZURE_SEARCH_INDEX_NAME, - fields: [ - { - name: "id", - type: "Edm.String", - key: true, - filterable: true, - }, - { - name: "user", - type: "Edm.String", - searchable: true, - filterable: true, - }, - { - name: "chatThreadId", - type: "Edm.String", - searchable: true, - filterable: true, - }, - { - name: "pageContent", - searchable: true, - type: "Edm.String", - }, - { - name: "metadata", - type: "Edm.String", - }, - { - name: "embedding", - type: "Collection(Edm.Single)", - searchable: true, - filterable: false, - sortable: false, - facetable: false, - retrievable: true, - dimensions: 1536, - vectorSearchConfiguration: "vectorConfig", - }, - ], - vectorSearch: { - algorithmConfigurations: [ - { - name: "vectorConfig", - kind: "hnsw", - }, - ], - }, -}; diff --git a/src/features/chat/chat-services/chat-api-data.ts b/src/features/chat/chat-services/chat-api-data.ts deleted file mode 100644 index a54130907..000000000 --- a/src/features/chat/chat-services/chat-api-data.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { userHashedId } from "@/features/auth/helpers"; -import { OpenAIInstance } from "@/features/common/openai"; -import { AI_NAME } from "@/features/theme/customise"; -import { OpenAIStream, StreamingTextResponse } from "ai"; -import { similaritySearchVectorWithScore } from "./azure-cog-search/azure-cog-vector-store"; -import { initAndGuardChatSession } from "./chat-thread-service"; -import { CosmosDBChatMessageHistory } from "./cosmosdb/cosmosdb"; -import { PromptGPTProps } from "./models"; -import { ChatTokenService } from "./chat-token-service"; -import { reportCompletionTokens, reportPromptTokens, reportUserChatMessage } from "./chat-metrics-service"; - -const SYSTEM_PROMPT = `You are ${AI_NAME} who is a helpful AI Assistant.`; - -const CONTEXT_PROMPT = ({ - context, - userQuestion, -}: { - context: string; - userQuestion: string; -}) => { - return ` -- Given the following extracted parts of a long document, create a final answer. \n -- If you don't know the answer, just say that you don't know. Don't try to make up an answer.\n -- You must always include a citation at the end of your answer and don't include full stop.\n -- Use the format for your citation {% citation items=[{name:"filename 1",id:"file id"}, {name:"filename 2",id:"file id"}] /%}\n -----------------\n -context:\n -${context} -----------------\n -question: ${userQuestion}`; -}; - -export const ChatAPIData = async (props: PromptGPTProps) => { - const { lastHumanMessage, id, chatThread } = await initAndGuardChatSession( - props - ); - - const openAI = OpenAIInstance(); - - const chatModel = "gpt-4"; - - const userId = await userHashedId(); - - const chatHistory = new CosmosDBChatMessageHistory({ - sessionId: chatThread.id, - userId: userId, - }); - - const history = await chatHistory.getMessages(); - const topHistory = history.slice(history.length - 30, history.length); - - const tokenService = new ChatTokenService(); - - const relevantDocuments = await findRelevantDocuments( - lastHumanMessage.content, - id - ); - - const context = relevantDocuments - .map((result, index) => { - const content = result.pageContent.replace(/(\r\n|\n|\r)/gm, ""); - const context = `[${index}]. file name: ${result.metadata} \n file id: ${result.id} \n ${content}`; - return context; - }) - .join("\n------\n"); - - const contextTokens = tokenService.getTokenCount(context); - - let promptTokens = contextTokens + 122; // 122 is static system prompt tokens. - - promptTokens += tokenService.getTokenCountFromHistory(topHistory, 0); - - try { - - reportPromptTokens(promptTokens, chatModel); - - const response = await openAI.chat.completions.create({ - messages: [ - { - role: "system", - content: SYSTEM_PROMPT, - }, - ...topHistory, - { - role: "user", - content: CONTEXT_PROMPT({ - context, - userQuestion: lastHumanMessage.content, - }), - }, - ], - model: process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME, - stream: true, - }); - - let completionTokens = 0; - - const stream = OpenAIStream(response, { - async onCompletion(completion) { - await chatHistory.addMessage({ - content: lastHumanMessage.content, - role: "user", - }); - - await chatHistory.addMessage( - { - content: completion, - role: "assistant", - }, - context - ); - - reportCompletionTokens(completionTokens, chatModel); - reportUserChatMessage(chatModel); - }, - onToken(token) { - completionTokens += tokenService.getTokenCount(token); - } - }); - - return new StreamingTextResponse(stream); - } catch (e: unknown) { - if (e instanceof Error) { - return new Response(e.message, { - status: 500, - statusText: e.toString(), - }); - } else { - return new Response("An unknown error occurred.", { - status: 500, - statusText: "Unknown Error", - }); - } - } -}; - -const findRelevantDocuments = async (query: string, chatThreadId: string) => { - const relevantDocuments = await similaritySearchVectorWithScore(query, 10, { - filter: `user eq '${await userHashedId()}' and chatThreadId eq '${chatThreadId}'`, - }); - - return relevantDocuments; -}; diff --git a/src/features/chat/chat-services/chat-api-entry.ts b/src/features/chat/chat-services/chat-api-entry.ts deleted file mode 100644 index 76e97b57d..000000000 --- a/src/features/chat/chat-services/chat-api-entry.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ChatAPIData } from "./chat-api-data"; -import { ChatAPISimple } from "./chat-api-simple"; -import { PromptGPTProps } from "./models"; - -export const chatAPIEntry = async (props: PromptGPTProps) => { - if (props.chatType === "simple") { - return await ChatAPISimple(props); - } else if (props.chatType === "data") { - return await ChatAPIData(props); - } else if (props.chatType === "mssql") { - return await ChatAPIData(props); - } else { - return await ChatAPISimple(props); - } -}; diff --git a/src/features/chat/chat-services/chat-api-simple.ts b/src/features/chat/chat-services/chat-api-simple.ts deleted file mode 100644 index 725ddfcf2..000000000 --- a/src/features/chat/chat-services/chat-api-simple.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { userHashedId, userSession } from "@/features/auth/helpers"; -import { OpenAIInstance } from "@/features/common/openai"; -import { AI_NAME } from "@/features/theme/customise"; -import { OpenAIStream, StreamingTextResponse } from "ai"; -import { initAndGuardChatSession } from "./chat-thread-service"; -import { CosmosDBChatMessageHistory } from "./cosmosdb/cosmosdb"; -import { PromptGPTProps } from "./models"; -import { encodingForModel, TiktokenModel} from "js-tiktoken" -import { reportCompletionTokens, reportPromptTokens, reportUserChatMessage } from "./chat-metrics-service"; -import { ChatTokenService } from "./chat-token-service"; - -export const ChatAPISimple = async (props: PromptGPTProps) => { - const { lastHumanMessage, chatThread } = await initAndGuardChatSession(props); - - const openAI = OpenAIInstance(); - - const userId = await userHashedId(); - - const chatHistory = new CosmosDBChatMessageHistory({ - sessionId: chatThread.id, - userId: userId, - }); - - await chatHistory.addMessage({ - content: lastHumanMessage.content, - role: "user", - }); - - const history = await chatHistory.getMessages(); - const topHistory = history.slice(history.length - 30, history.length); - - const tokenService = new ChatTokenService(); - - try { - const promptTokens = tokenService.getTokenCountFromHistory(topHistory, 45); - - const model = "gpt-4"; - - reportPromptTokens(promptTokens, model); - - const response = await openAI.chat.completions.create({ - messages: [ - { - role: "system", - content: `-You are ${AI_NAME} who is a helpful AI Assistant. - - You will provide clear and concise queries, and you will respond with polite and professional answers. - - You will answer questions truthfully and accurately.`, - }, - ...topHistory, - ], - model: process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME, - stream: true, - }); - - let completionTokens = 0; - - const stream = OpenAIStream(response, { - async onToken(token) { - completionTokens += tokenService.getTokenCount(token); - }, - async onCompletion(completion) { - await chatHistory.addMessage({ - content: completion, - role: "assistant", - }); - - reportUserChatMessage(model); - reportCompletionTokens(completionTokens, model); - }, - }); - - return new StreamingTextResponse(stream); - } catch (e: unknown) { - if (e instanceof Error) { - return new Response(e.message, { - status: 500, - statusText: e.toString(), - }); - } else { - return new Response("An unknown error occurred.", { - status: 500, - statusText: "Unknown Error", - }); - } - } -}; diff --git a/src/features/chat/chat-services/chat-document-service.ts b/src/features/chat/chat-services/chat-document-service.ts deleted file mode 100644 index 415e77388..000000000 --- a/src/features/chat/chat-services/chat-document-service.ts +++ /dev/null @@ -1,217 +0,0 @@ -"use server"; - -import { userHashedId } from "@/features/auth/helpers"; -import { CosmosDBContainer } from "@/features/common/cosmos"; - -import { uniqueId } from "@/features/common/util"; -import { - AzureKeyCredential, - DocumentAnalysisClient, -} from "@azure/ai-form-recognizer"; -import { SqlQuerySpec } from "@azure/cosmos"; -import { - AzureCogDocumentIndex, - ensureIndexIsCreated, - indexDocuments, -} from "./azure-cog-search/azure-cog-vector-store"; -import { - CHAT_DOCUMENT_ATTRIBUTE, - ChatDocumentModel, - ServerActionResponse, -} from "./models"; -import { chunkDocumentWithOverlap } from "./text-chunk"; -import { isNotNullOrEmpty } from "./utils"; - -const MAX_DOCUMENT_SIZE = 20000000; - -export const UploadDocument = async ( - formData: FormData -): Promise> => { - try { - await ensureSearchIsConfigured(); - - const { docs } = await LoadFile(formData); - const splitDocuments = chunkDocumentWithOverlap(docs.join("\n")); - - return { - success: true, - error: "", - response: splitDocuments, - }; - } catch (e) { - return { - success: false, - error: (e as Error).message, - response: [], - }; - } -}; - -const LoadFile = async (formData: FormData) => { - try { - const file: File | null = formData.get("file") as unknown as File; - - if (file && file.size < MAX_DOCUMENT_SIZE) { - const client = initDocumentIntelligence(); - - const blob = new Blob([file], { type: file.type }); - - const poller = await client.beginAnalyzeDocument( - "prebuilt-read", - await blob.arrayBuffer() - ); - const { paragraphs } = await poller.pollUntilDone(); - - const docs: Array = []; - - if (paragraphs) { - for (const paragraph of paragraphs) { - docs.push(paragraph.content); - } - } - - return { docs }; - } - } catch (e) { - const error = e as any; - - if (error.details) { - if (error.details.length > 0) { - throw new Error(error.details[0].message); - } else { - throw new Error(error.details.error.innererror.message); - } - } - - throw new Error(error.message); - } - - throw new Error("Invalid file format or size. Only PDF files are supported."); -}; - -export const IndexDocuments = async ( - fileName: string, - docs: string[], - chatThreadId: string -): Promise> => { - try { - const documentsToIndex: AzureCogDocumentIndex[] = []; - - for (const doc of docs) { - const docToAdd: AzureCogDocumentIndex = { - id: uniqueId(), - chatThreadId, - user: await userHashedId(), - pageContent: doc, - metadata: fileName, - embedding: [], - }; - - documentsToIndex.push(docToAdd); - } - - await indexDocuments(documentsToIndex); - - await UpsertChatDocument(fileName, chatThreadId); - return { - success: true, - error: "", - response: documentsToIndex, - }; - } catch (e) { - console.log(e); - return { - success: false, - error: (e as Error).message, - response: [], - }; - } -}; - -export const initDocumentIntelligence = () => { - const client = new DocumentAnalysisClient( - process.env.AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT, - new AzureKeyCredential(process.env.AZURE_DOCUMENT_INTELLIGENCE_KEY) - ); - - return client; -}; - -export const FindAllChatDocuments = async (chatThreadID: string) => { - const container = await CosmosDBContainer.getInstance().getContainer(); - - const querySpec: SqlQuerySpec = { - query: - "SELECT * FROM root r WHERE r.type=@type AND r.chatThreadId = @threadId AND r.isDeleted=@isDeleted", - parameters: [ - { - name: "@type", - value: CHAT_DOCUMENT_ATTRIBUTE, - }, - { - name: "@threadId", - value: chatThreadID, - }, - { - name: "@isDeleted", - value: false, - }, - ], - }; - - const { resources } = await container.items - .query(querySpec) - .fetchAll(); - - return resources; -}; - -export const UpsertChatDocument = async ( - fileName: string, - chatThreadID: string -) => { - const modelToSave: ChatDocumentModel = { - chatThreadId: chatThreadID, - id: uniqueId(), - userId: await userHashedId(), - createdAt: new Date(), - type: CHAT_DOCUMENT_ATTRIBUTE, - isDeleted: false, - name: fileName, - }; - - const container = await CosmosDBContainer.getInstance().getContainer(); - await container.items.upsert(modelToSave); -}; - -export const ensureSearchIsConfigured = async () => { - var isSearchConfigured = - isNotNullOrEmpty(process.env.AZURE_SEARCH_NAME) && - isNotNullOrEmpty(process.env.AZURE_SEARCH_API_KEY) && - isNotNullOrEmpty(process.env.AZURE_SEARCH_INDEX_NAME) && - isNotNullOrEmpty(process.env.AZURE_SEARCH_API_VERSION); - - if (!isSearchConfigured) { - throw new Error("Azure search environment variables are not configured."); - } - - var isDocumentIntelligenceConfigured = - isNotNullOrEmpty(process.env.AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT) && - isNotNullOrEmpty(process.env.AZURE_DOCUMENT_INTELLIGENCE_KEY); - - if (!isDocumentIntelligenceConfigured) { - throw new Error( - "Azure document intelligence environment variables are not configured." - ); - } - - var isEmbeddingsConfigured = isNotNullOrEmpty( - process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME - ); - - if (!isEmbeddingsConfigured) { - throw new Error("Azure openai embedding variables are not configured."); - } - - await ensureIndexIsCreated(); -}; diff --git a/src/features/chat/chat-services/chat-service.ts b/src/features/chat/chat-services/chat-service.ts deleted file mode 100644 index 94729e55b..000000000 --- a/src/features/chat/chat-services/chat-service.ts +++ /dev/null @@ -1,82 +0,0 @@ -"use server"; -import "server-only"; - -import { uniqueId } from "@/features/common/util"; -import { SqlQuerySpec } from "@azure/cosmos"; -import { CosmosDBContainer } from "../../common/cosmos"; -import { ChatMessageModel, MESSAGE_ATTRIBUTE } from "./models"; - -export const FindAllChats = async (chatThreadID: string) => { - const container = await CosmosDBContainer.getInstance().getContainer(); - - const querySpec: SqlQuerySpec = { - query: - "SELECT * FROM root r WHERE r.type=@type AND r.threadId = @threadId AND r.isDeleted=@isDeleted", - parameters: [ - { - name: "@type", - value: MESSAGE_ATTRIBUTE, - }, - { - name: "@threadId", - value: chatThreadID, - }, - { - name: "@isDeleted", - value: false, - }, - ], - }; - - const { resources } = await container.items - .query(querySpec) - .fetchAll(); - - return resources; -}; - -export const UpsertChat = async (chatModel: ChatMessageModel) => { - const modelToSave: ChatMessageModel = { - ...chatModel, - id: uniqueId(), - createdAt: new Date(), - type: MESSAGE_ATTRIBUTE, - isDeleted: false, - }; - - const container = await CosmosDBContainer.getInstance().getContainer(); - await container.items.upsert(modelToSave); -}; - -export const insertPromptAndResponse = async ( - threadID: string, - userQuestion: string, - assistantResponse: string -) => { - await UpsertChat({ - ...newChatModel(), - content: userQuestion, - threadId: threadID, - role: "user", - }); - await UpsertChat({ - ...newChatModel(), - content: assistantResponse, - threadId: threadID, - role: "assistant", - }); -}; - -export const newChatModel = (): ChatMessageModel => { - return { - content: "", - threadId: "", - role: "user", - userId: "", - id: uniqueId(), - createdAt: new Date(), - type: MESSAGE_ATTRIBUTE, - isDeleted: false, - context: "", - }; -}; diff --git a/src/features/chat/chat-services/chat-thread-service.ts b/src/features/chat/chat-services/chat-thread-service.ts deleted file mode 100644 index eb52c2eb1..000000000 --- a/src/features/chat/chat-services/chat-thread-service.ts +++ /dev/null @@ -1,212 +0,0 @@ -"use server"; -import "server-only"; - -import { userHashedId, userSession } from "@/features/auth/helpers"; -import { FindAllChats } from "@/features/chat/chat-services/chat-service"; -import { uniqueId } from "@/features/common/util"; -import { SqlQuerySpec } from "@azure/cosmos"; -import { CosmosDBContainer } from "../../common/cosmos"; -import { deleteDocuments } from "./azure-cog-search/azure-cog-vector-store"; -import { FindAllChatDocuments } from "./chat-document-service"; -import { - CHAT_THREAD_ATTRIBUTE, - ChatMessageModel, - ChatThreadModel, - ChatType, - ConversationStyle, - PromptGPTProps, -} from "./models"; - -export const FindAllChatThreadForCurrentUser = async () => { - const container = await CosmosDBContainer.getInstance().getContainer(); - - const querySpec: SqlQuerySpec = { - query: - "SELECT * FROM root r WHERE r.type=@type AND r.userId=@userId AND r.isDeleted=@isDeleted ORDER BY r.createdAt DESC", - parameters: [ - { - name: "@type", - value: CHAT_THREAD_ATTRIBUTE, - }, - { - name: "@userId", - value: await userHashedId(), - }, - { - name: "@isDeleted", - value: false, - }, - ], - }; - - const { resources } = await container.items - .query(querySpec, { - partitionKey: await userHashedId(), - }) - .fetchAll(); - return resources; -}; - -export const FindChatThreadByID = async (id: string) => { - const container = await CosmosDBContainer.getInstance().getContainer(); - - const querySpec: SqlQuerySpec = { - query: - "SELECT * FROM root r WHERE r.type=@type AND r.userId=@userId AND r.id=@id AND r.isDeleted=@isDeleted", - parameters: [ - { - name: "@type", - value: CHAT_THREAD_ATTRIBUTE, - }, - { - name: "@userId", - value: await userHashedId(), - }, - { - name: "@id", - value: id, - }, - { - name: "@isDeleted", - value: false, - }, - ], - }; - - const { resources } = await container.items - .query(querySpec) - .fetchAll(); - - return resources; -}; - -export const SoftDeleteChatThreadByID = async (chatThreadID: string) => { - const container = await CosmosDBContainer.getInstance().getContainer(); - const threads = await FindChatThreadByID(chatThreadID); - - if (threads.length !== 0) { - const chats = await FindAllChats(chatThreadID); - - chats.forEach(async (chat) => { - const itemToUpdate = { - ...chat, - }; - itemToUpdate.isDeleted = true; - await container.items.upsert(itemToUpdate); - }); - - const chatDocuments = await FindAllChatDocuments(chatThreadID); - - if (chatDocuments.length !== 0) { - await deleteDocuments(chatThreadID); - } - - chatDocuments.forEach(async (chatDocument) => { - const itemToUpdate = { - ...chatDocument, - }; - itemToUpdate.isDeleted = true; - await container.items.upsert(itemToUpdate); - }); - - threads.forEach(async (thread) => { - const itemToUpdate = { - ...thread, - }; - itemToUpdate.isDeleted = true; - await container.items.upsert(itemToUpdate); - }); - } -}; - -export const EnsureChatThreadIsForCurrentUser = async ( - chatThreadID: string -) => { - const modelToSave = await FindChatThreadByID(chatThreadID); - if (modelToSave.length === 0) { - throw new Error("Chat thread not found"); - } - - return modelToSave[0]; -}; - -export const UpsertChatThread = async (chatThread: ChatThreadModel) => { - const container = await CosmosDBContainer.getInstance().getContainer(); - const updatedChatThread = await container.items.upsert( - chatThread - ); - - if (updatedChatThread === undefined) { - throw new Error("Chat thread not found"); - } - - return updatedChatThread; -}; - -export const updateChatThreadTitle = async ( - chatThread: ChatThreadModel, - messages: ChatMessageModel[], - chatType: ChatType, - conversationStyle: ConversationStyle, - chatOverFileName: string, - userMessage: string -) => { - if (messages.length === 0) { - const updatedChatThread = await UpsertChatThread({ - ...chatThread, - chatType: chatType, - chatOverFileName: chatOverFileName, - conversationStyle: conversationStyle, - name: userMessage.substring(0, 30), - }); - - return updatedChatThread.resource!; - } - - return chatThread; -}; - -export const CreateChatThread = async () => { - const modelToSave: ChatThreadModel = { - name: "new chat", - useName: (await userSession())!.name, - userId: await userHashedId(), - id: uniqueId(), - createdAt: new Date(), - isDeleted: false, - chatType: "simple", - conversationStyle: "precise", - type: CHAT_THREAD_ATTRIBUTE, - chatOverFileName: "", - }; - - const container = await CosmosDBContainer.getInstance().getContainer(); - const response = await container.items.create(modelToSave); - return response.resource; -}; - -export const initAndGuardChatSession = async (props: PromptGPTProps) => { - const { messages, id, chatType, conversationStyle, chatOverFileName } = props; - - //last message - const lastHumanMessage = messages[messages.length - 1]; - - const currentChatThread = await EnsureChatThreadIsForCurrentUser(id); - const chats = await FindAllChats(id); - - const chatThread = await updateChatThreadTitle( - currentChatThread, - chats, - chatType, - conversationStyle, - chatOverFileName, - lastHumanMessage.content - ); - - return { - id, - lastHumanMessage, - chats, - chatThread, - }; -}; diff --git a/src/features/chat/chat-services/cosmosdb/cosmosdb.ts b/src/features/chat/chat-services/cosmosdb/cosmosdb.ts deleted file mode 100644 index 543ff80b4..000000000 --- a/src/features/chat/chat-services/cosmosdb/cosmosdb.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - FindAllChats, - UpsertChat, -} from "@/features/chat/chat-services/chat-service"; -import { - ChatMessageModel, - MESSAGE_ATTRIBUTE, -} from "@/features/chat/chat-services/models"; -import { CosmosDBContainer } from "@/features/common/cosmos"; -import { uniqueId } from "@/features/common/util"; -import { ChatCompletionMessage } from "openai/resources"; - -export interface CosmosDBChatMessageHistoryFields { - sessionId: string; - userId: string; -} - -export class CosmosDBChatMessageHistory { - private sessionId: string; - private userId: string; - - constructor({ sessionId, userId }: CosmosDBChatMessageHistoryFields) { - this.sessionId = sessionId; - this.userId = userId; - } - - async getMessages(): Promise { - const chats = await FindAllChats(this.sessionId); - return mapOpenAIChatMessages(chats); - } - - async clear(): Promise { - const container = await CosmosDBContainer.getInstance().getContainer(); - await container.delete(); - } - - async addMessage(message: ChatCompletionMessage, citations: string = "") { - const modelToSave: ChatMessageModel = { - id: uniqueId(), - createdAt: new Date(), - type: MESSAGE_ATTRIBUTE, - isDeleted: false, - content: message.content ?? "", - role: message.role, - threadId: this.sessionId, - userId: this.userId, - context: citations, - }; - - await UpsertChat(modelToSave); - } -} - -function mapOpenAIChatMessages( - messages: ChatMessageModel[] -): ChatCompletionMessage[] { - return messages.map((message) => { - return { - role: message.role, - content: message.content, - }; - }); -} diff --git a/src/features/chat/chat-services/models.ts b/src/features/chat/chat-services/models.ts deleted file mode 100644 index 8f9483286..000000000 --- a/src/features/chat/chat-services/models.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Message } from "ai"; - -export const CHAT_DOCUMENT_ATTRIBUTE = "CHAT_DOCUMENT"; -export const CHAT_THREAD_ATTRIBUTE = "CHAT_THREAD"; -export const MESSAGE_ATTRIBUTE = "CHAT_MESSAGE"; - -export interface ChatMessageModel { - id: string; - createdAt: Date; - isDeleted: boolean; - threadId: string; - userId: string; - content: string; - role: ChatRole; - context: string; - type: "CHAT_MESSAGE"; -} - -export type ConversationStyle = "creative" | "balanced" | "precise"; -export type ChatType = "simple" | "data" | "mssql"; - -export type ChatRole = "system" | "user" | "assistant" | "function"; - -export interface ChatThreadModel { - id: string; - name: string; - createdAt: Date; - userId: string; - useName: string; - isDeleted: boolean; - chatType: ChatType; - conversationStyle: ConversationStyle; - chatOverFileName: string; - type: "CHAT_THREAD"; -} - -export interface PromptGPTBody { - id: string; // thread id - chatType: ChatType; - conversationStyle: ConversationStyle; - chatOverFileName: string; -} - -export interface PromptGPTProps extends PromptGPTBody { - messages: Message[]; -} - -export interface ChatDocumentModel { - id: string; - name: string; - chatThreadId: string; - userId: string; - isDeleted: boolean; - createdAt: Date; - type: "CHAT_DOCUMENT"; -} - -export interface ServerActionResponse { - success: boolean; - error: string; - response: T; -} diff --git a/src/features/chat/chat-services/text-chunk.ts b/src/features/chat/chat-services/text-chunk.ts deleted file mode 100644 index 8220e4f51..000000000 --- a/src/features/chat/chat-services/text-chunk.ts +++ /dev/null @@ -1,24 +0,0 @@ -const chunkSize = 1000; -const chunkOverlap = 200; - -export function chunkDocumentWithOverlap(document: string): string[] { - const chunks: string[] = []; - - if (document.length <= chunkSize) { - // If the document is smaller than the desired chunk size, return it as a single chunk. - chunks.push(document); - return chunks; - } - - let startIndex = 0; - - // Split the document into chunks of the desired size, with overlap. - while (startIndex < document.length) { - const endIndex = startIndex + chunkSize; - const chunk = document.substring(startIndex, endIndex); - chunks.push(chunk); - startIndex = endIndex - chunkOverlap; - } - - return chunks; -} diff --git a/src/features/chat/chat-services/utils.ts b/src/features/chat/chat-services/utils.ts deleted file mode 100644 index 4f15c62f1..000000000 --- a/src/features/chat/chat-services/utils.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Message } from "ai"; -import { ChatMessageModel, ConversationStyle } from "./models"; - -export const transformCosmosToAIModel = ( - chats: Array -): Array => { - return chats.map((chat) => { - return { - role: chat.role, - content: chat.content, - id: chat.id, - createdAt: chat.createdAt, - }; - }); -}; - -export const transformConversationStyleToTemperature = ( - conversationStyle: ConversationStyle -) => { - switch (conversationStyle) { - case "precise": - return 0.1; - case "balanced": - return 0.5; - case "creative": - return 1; - default: - return 0.5; - } -}; - -export const isNotNullOrEmpty = (value?: string) => { - return value !== null && value !== undefined && value !== ""; -}; diff --git a/src/features/chat/chat-ui/chat-context.tsx b/src/features/chat/chat-ui/chat-context.tsx deleted file mode 100644 index 4b369a0ce..000000000 --- a/src/features/chat/chat-ui/chat-context.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -import { useGlobalMessageContext } from "@/features/global-message/global-message-context"; -import { Message } from "ai"; -import { UseChatHelpers, useChat } from "ai/react"; -import React, { FC, createContext, useContext, useState } from "react"; -import { - ChatMessageModel, - ChatThreadModel, - ChatType, - ConversationStyle, - PromptGPTBody, -} from "../chat-services/models"; -import { transformCosmosToAIModel } from "../chat-services/utils"; -import { FileState, useFileState } from "./chat-file/use-file-state"; -import { - SpeechToTextProps, - useSpeechToText, -} from "./chat-speech/use-speech-to-text"; -import { - TextToSpeechProps, - useTextToSpeech, -} from "./chat-speech/use-text-to-speech"; - -interface ChatContextProps extends UseChatHelpers { - id: string; - setChatBody: (body: PromptGPTBody) => void; - chatBody: PromptGPTBody; - fileState: FileState; - onChatTypeChange: (value: ChatType) => void; - onConversationStyleChange: (value: ConversationStyle) => void; - speech: TextToSpeechProps & SpeechToTextProps; -} - -const ChatContext = createContext(null); - -interface Prop { - children: React.ReactNode; - id: string; - chats: Array; - chatThread: ChatThreadModel; -} - -export const ChatProvider: FC = (props) => { - const { showError } = useGlobalMessageContext(); - - const speechSynthesizer = useTextToSpeech(); - const speechRecognizer = useSpeechToText({ - onSpeech(value) { - response.setInput(value); - }, - }); - - const fileState = useFileState(); - - const [chatBody, setBody] = useState({ - id: props.chatThread.id, - chatType: props.chatThread.chatType, - conversationStyle: props.chatThread.conversationStyle, - chatOverFileName: props.chatThread.chatOverFileName, - }); - - const { textToSpeech } = speechSynthesizer; - const { isMicrophoneUsed, resetMicrophoneUsed } = speechRecognizer; - - const response = useChat({ - onError, - id: props.id, - body: chatBody, - - initialMessages: transformCosmosToAIModel(props.chats), - onFinish: async (lastMessage: Message) => { - if (isMicrophoneUsed) { - await textToSpeech(lastMessage.content); - resetMicrophoneUsed(); - } - }, - }); - - const setChatBody = (body: PromptGPTBody) => { - setBody(body); - }; - - const onChatTypeChange = (value: ChatType) => { - fileState.setShowFileUpload(value); - fileState.setIsFileNull(true); - setChatBody({ ...chatBody, chatType: value }); - }; - - const onConversationStyleChange = (value: ConversationStyle) => { - setChatBody({ ...chatBody, conversationStyle: value }); - }; - - function onError(error: Error) { - showError(error.message, response.reload); - } - - return ( - - {props.children} - - ); -}; - -export const useChatContext = () => { - const context = useContext(ChatContext); - if (!context) { - throw new Error("ChatContext is null"); - } - - return context; -}; diff --git a/src/features/chat/chat-ui/chat-empty-state/chat-message-empty-state.tsx b/src/features/chat/chat-ui/chat-empty-state/chat-message-empty-state.tsx deleted file mode 100644 index e376a7360..000000000 --- a/src/features/chat/chat-ui/chat-empty-state/chat-message-empty-state.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import Typography from "@/components/typography"; -import { Card } from "@/components/ui/card"; -import { FC } from "react"; -import { useChatContext } from "../chat-context"; -import { ChatFileUI } from "../chat-file/chat-file-ui"; -import { ChatStyleSelector } from "./chat-style-selector"; -import { ChatTypeSelector } from "./chat-type-selector"; - -interface Prop {} - -export const ChatMessageEmptyState: FC = (props) => { - const { fileState } = useChatContext(); - - const { showFileUpload } = fileState; - - return ( -
-
- -

- Start by just typing your message in the box below. You can also - personalise the chat by making changes to the settings on the right. -

-
- - - Personalise - - -
-

- Choose a conversation style -

- -
-
-

- How would you like to chat? -

- -
- {showFileUpload === "data" && } -
-
- ); -}; diff --git a/src/features/chat/chat-ui/chat-empty-state/chat-style-selector.tsx b/src/features/chat/chat-ui/chat-empty-state/chat-style-selector.tsx deleted file mode 100644 index 8067d32d8..000000000 --- a/src/features/chat/chat-ui/chat-empty-state/chat-style-selector.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Brush, CircleDot, Scale } from "lucide-react"; -import { FC } from "react"; -import { ConversationStyle } from "../../chat-services/models"; -import { useChatContext } from "../chat-context"; - -interface Prop { - disable: boolean; -} - -export const ChatStyleSelector: FC = (props) => { - const { onConversationStyleChange, chatBody } = useChatContext(); - - return ( - - onConversationStyleChange(value as ConversationStyle) - } - > - - - Creative - - - Balanced - - - Precise - - - - ); -}; diff --git a/src/features/chat/chat-ui/chat-empty-state/chat-type-selector.tsx b/src/features/chat/chat-ui/chat-empty-state/chat-type-selector.tsx deleted file mode 100644 index 66f047e6c..000000000 --- a/src/features/chat/chat-ui/chat-empty-state/chat-type-selector.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { FileText, MessageCircle } from "lucide-react"; -import { FC } from "react"; -import { ChatType } from "../../chat-services/models"; -import { useChatContext } from "../chat-context"; - -interface Prop { - disable: boolean; -} - -export const ChatTypeSelector: FC = (props) => { - const { chatBody, onChatTypeChange } = useChatContext(); - - return ( - onChatTypeChange(value as ChatType)} - > - - - General - - - File - - - - ); -}; diff --git a/src/features/chat/chat-ui/chat-empty-state/start-new-chat.tsx b/src/features/chat/chat-ui/chat-empty-state/start-new-chat.tsx deleted file mode 100644 index f8b80037e..000000000 --- a/src/features/chat/chat-ui/chat-empty-state/start-new-chat.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Typography from "@/components/typography"; -import { Card } from "@/components/ui/card"; -import { AI_NAME } from "@/features/theme/customise"; -import { FC } from "react"; -import { NewChat } from "../../chat-menu/new-chat"; - -interface Prop {} - -export const StartNewChat: FC = (props) => { - return ( -
-
- -
- - - {AI_NAME} - -
-

- Welcome to {AI_NAME}. You should interact in a friendly manner with - the AI assistant and refrain from participating in any harmful - activities. -

-

You can start a new chat with me by clicking the button below.

-
-
- -
-
-
- ); -}; diff --git a/src/features/chat/chat-ui/chat-file/chat-file-slider.tsx b/src/features/chat/chat-ui/chat-file/chat-file-slider.tsx deleted file mode 100644 index 3bd0f67ba..000000000 --- a/src/features/chat/chat-ui/chat-file/chat-file-slider.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { FileText } from "lucide-react"; -import { ChatFileUI } from "./chat-file-ui"; - -export const ChatFileSlider = () => { - return ( -
- - - - - - - Upload File - -
- -
-
-
-
- ); -}; diff --git a/src/features/chat/chat-ui/chat-file/chat-file-ui.tsx b/src/features/chat/chat-ui/chat-file/chat-file-ui.tsx deleted file mode 100644 index bd6de5dcf..000000000 --- a/src/features/chat/chat-ui/chat-file/chat-file-ui.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ArrowUpCircle, Loader2 } from "lucide-react"; -import { FC } from "react"; -import { useChatContext } from "../chat-context"; -import { useFileSelection } from "./use-file-selection"; - -export const ChatFileUI: FC = () => { - const { id, fileState } = useChatContext(); - - const { isFileNull, setIsFileNull, uploadButtonLabel, isUploadingFile } = - fileState; - - const { onSubmit } = useFileSelection({ id }); - - return ( -
-
- { - setIsFileNull(e.currentTarget.value === null); - }} - /> - - -
-

{uploadButtonLabel}

-
- ); -}; diff --git a/src/features/chat/chat-ui/chat-file/use-file-selection.ts b/src/features/chat/chat-ui/chat-file/use-file-selection.ts deleted file mode 100644 index 5ecdefeae..000000000 --- a/src/features/chat/chat-ui/chat-file/use-file-selection.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useGlobalMessageContext } from "@/features/global-message/global-message-context"; -import { - IndexDocuments, - UploadDocument, -} from "../../chat-services/chat-document-service"; -import { useChatContext } from "../chat-context"; - -interface Props { - id: string; -} - -export const useFileSelection = (props: Props) => { - const { setChatBody, chatBody, fileState } = useChatContext(); - const { setIsUploadingFile, setUploadButtonLabel } = fileState; - - const { showError, showSuccess } = useGlobalMessageContext(); - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - onFileChange(formData); - }; - - const onFileChange = async (formData: FormData) => { - try { - setIsUploadingFile(true); - setUploadButtonLabel("Uploading document..."); - formData.append("id", props.id); - const file: File | null = formData.get("file") as unknown as File; - const uploadResponse = await UploadDocument(formData); - - if (uploadResponse.success) { - let index = 0; - - for (const doc of uploadResponse.response) { - setUploadButtonLabel( - `Indexing document [${index + 1}]/[${ - uploadResponse.response.length - }]` - ); - try { - const indexResponse = await IndexDocuments( - file.name, - [doc], - props.id - ); - - if (!indexResponse.success) { - showError(indexResponse.error); - break; - } - } catch (e) { - alert(e); - } - - index++; - } - - if (index === uploadResponse.response.length) { - showSuccess({ - title: "File upload", - description: `${file.name} uploaded successfully.`, - }); - setUploadButtonLabel(""); - setChatBody({ ...chatBody, chatOverFileName: file.name }); - } else { - showError( - "Looks like not all documents were indexed. Please try again." - ); - } - } else { - showError(uploadResponse.error); - } - } catch (error) { - showError("" + error); - } finally { - setIsUploadingFile(false); - setUploadButtonLabel(""); - } - }; - - return { onSubmit }; -}; diff --git a/src/features/chat/chat-ui/chat-file/use-file-state.ts b/src/features/chat/chat-ui/chat-file/use-file-state.ts deleted file mode 100644 index ab315f80f..000000000 --- a/src/features/chat/chat-ui/chat-file/use-file-state.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useState } from "react"; -import { ChatType } from "../../chat-services/models"; - -export interface FileState { - showFileUpload: ChatType; - setShowFileUpload: (value: ChatType) => void; - isFileNull: boolean; - setIsFileNull: (value: boolean) => void; - isUploadingFile: boolean; - setIsUploadingFile: (value: boolean) => void; - uploadButtonLabel: string; - setUploadButtonLabel: (value: string) => void; -} - -export const useFileState = (): FileState => { - const [showFileUpload, _setShowFileUpload] = useState("simple"); - const [isFileNull, _setIsFileNull] = useState(true); - const [isUploadingFile, _setIsUploadingFile] = useState(false); - const [uploadButtonLabel, _setUploadButtonLabel] = useState(""); - - return { - showFileUpload, - setShowFileUpload: (value: ChatType) => { - _setShowFileUpload(value); - }, - isFileNull, - setIsFileNull: (value: boolean) => { - _setIsFileNull(value); - }, - isUploadingFile, - setIsUploadingFile: (value: boolean) => { - _setIsUploadingFile(value); - }, - uploadButtonLabel, - setUploadButtonLabel: (value: string) => { - _setUploadButtonLabel(value); - }, - }; -}; diff --git a/src/features/chat/chat-ui/chat-header.tsx b/src/features/chat/chat-ui/chat-header.tsx deleted file mode 100644 index 07f5af1eb..000000000 --- a/src/features/chat/chat-ui/chat-header.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { FC } from "react"; -import { useChatContext } from "./chat-context"; -import { ChatStyleSelector } from "./chat-empty-state/chat-style-selector"; -import { ChatTypeSelector } from "./chat-empty-state/chat-type-selector"; - -interface Prop {} - -export const ChatHeader: FC = (props) => { - const { chatBody } = useChatContext(); - return ( -
-
- - -
-
-

{chatBody.chatOverFileName}

-
-
- ); -}; diff --git a/src/features/chat/chat-ui/chat-input/chat-input.tsx b/src/features/chat/chat-ui/chat-input/chat-input.tsx deleted file mode 100644 index 6206a7b64..000000000 --- a/src/features/chat/chat-ui/chat-input/chat-input.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { useChatContext } from "@/features/chat/chat-ui/chat-context"; -import { useGlobalConfigContext } from "@/features/global-config/global-client-config-context"; -import { Loader, Send } from "lucide-react"; -import { FC, FormEvent, useRef } from "react"; -import { ChatFileSlider } from "../chat-file/chat-file-slider"; -import { Microphone } from "../chat-speech/microphone"; -import { useChatInputDynamicHeight } from "./use-chat-input-dynamic-height"; - -interface Props {} - -const ChatInput: FC = (props) => { - const { setInput, handleSubmit, isLoading, input, chatBody } = - useChatContext(); - - const { speechEnabled } = useGlobalConfigContext(); - - const buttonRef = useRef(null); - const { rows, resetRows, onKeyDown, onKeyUp } = useChatInputDynamicHeight({ - buttonRef, - }); - - const fileCHatVisible = - chatBody.chatType === "data" && chatBody.chatOverFileName; - - const submit = (e: FormEvent) => { - e.preventDefault(); - handleSubmit(e); - resetRows(); - setInput(""); - }; - - const onChange = (event: React.ChangeEvent) => { - setInput(event.target.value); - }; - - return ( -
-
- {fileCHatVisible && } - -
- {speechEnabled && } - -
-
-
- ); -}; - -export default ChatInput; diff --git a/src/features/chat/chat-ui/chat-input/use-chat-input-dynamic-height.tsx b/src/features/chat/chat-ui/chat-input/use-chat-input-dynamic-height.tsx deleted file mode 100644 index 5f04e4e98..000000000 --- a/src/features/chat/chat-ui/chat-input/use-chat-input-dynamic-height.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from "react"; - -interface Props { - buttonRef: React.RefObject; -} - -export const useChatInputDynamicHeight = (props: Props) => { - const maxRows = 6; - const [rows, setRows] = useState(1); - - const [keysPressed, setKeysPressed] = useState(new Set()); - - const onKeyUp = (event: React.KeyboardEvent) => { - keysPressed.delete(event.key); - setKeysPressed(keysPressed); - }; - - const setRowsToMax = (rows: number) => { - if (rows < maxRows) { - setRows(rows + 1); - } - }; - - const resetRows = () => { - setRows(1); - }; - - const onChange = (event: React.ChangeEvent) => { - setRowsToMax(event.target.value.split("\n").length - 1); - }; - - const onKeyDown = (event: React.KeyboardEvent) => { - setKeysPressed(keysPressed.add(event.key)); - - if (keysPressed.has("Enter") && keysPressed.has("Shift")) { - setRowsToMax(rows + 1); - } - - if ( - !event.nativeEvent.isComposing && - keysPressed.has("Enter") && - !keysPressed.has("Shift") && - props.buttonRef.current - ) { - props.buttonRef.current.click(); - event.preventDefault(); - } - }; - - return { - rows, - resetRows, - onChange, - onKeyDown, - onKeyUp, - }; -}; diff --git a/src/features/chat/chat-ui/chat-message-container.tsx b/src/features/chat/chat-ui/chat-message-container.tsx deleted file mode 100644 index b2e686bd0..000000000 --- a/src/features/chat/chat-ui/chat-message-container.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import ChatLoading from "@/components/chat/chat-loading"; -import ChatRow from "@/components/chat/chat-row"; -import { useChatScrollAnchor } from "@/components/hooks/use-chat-scroll-anchor"; -import { AI_NAME } from "@/features/theme/customise"; -import { useSession } from "next-auth/react"; -import { useRef } from "react"; -import { useChatContext } from "./chat-context"; -import { ChatHeader } from "./chat-header"; - -export const ChatMessageContainer = () => { - const { data: session } = useSession(); - const scrollRef = useRef(null); - - const { messages, isLoading } = useChatContext(); - - useChatScrollAnchor(messages, scrollRef); - - return ( -
-
- -
-
- {messages.map((message, index) => ( - - ))} - {isLoading && } -
-
- ); -}; diff --git a/src/features/chat/chat-ui/chat-speech/microphone.tsx b/src/features/chat/chat-ui/chat-speech/microphone.tsx deleted file mode 100644 index d7e12b019..000000000 --- a/src/features/chat/chat-ui/chat-speech/microphone.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { FC } from "react"; -import { useChatContext } from "../chat-context"; -import { RecordSpeech } from "./record-speech"; -import { StopSpeech } from "./stop-speech"; - -interface MicrophoneProps { - disabled: boolean; -} - -export const Microphone: FC = (props) => { - const { speech } = useChatContext(); - return ( - <> - {speech.isPlaying ? ( - - ) : ( - - )} - - ); -}; diff --git a/src/features/chat/chat-ui/chat-speech/record-speech.tsx b/src/features/chat/chat-ui/chat-speech/record-speech.tsx deleted file mode 100644 index 4ed9a82b1..000000000 --- a/src/features/chat/chat-ui/chat-speech/record-speech.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Mic } from "lucide-react"; -import { FC } from "react"; -import { useChatContext } from "../chat-context"; - -interface Prop { - disabled: boolean; -} - -export const RecordSpeech: FC = (props) => { - const { speech } = useChatContext(); - const { startRecognition, stopRecognition, isMicrophonePressed } = speech; - - const handleMouseDown = async () => { - await startRecognition(); - }; - - const handleMouseUp = () => { - stopRecognition(); - }; - - return ( - - ); -}; diff --git a/src/features/chat/chat-ui/chat-speech/stop-speech.tsx b/src/features/chat/chat-ui/chat-speech/stop-speech.tsx deleted file mode 100644 index 62bdbf7dd..000000000 --- a/src/features/chat/chat-ui/chat-speech/stop-speech.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Square } from "lucide-react"; -import { FC } from "react"; -import { useChatContext } from "../chat-context"; - -interface StopButtonProps { - disabled: boolean; -} - -export const StopSpeech: FC = (props) => { - const { speech } = useChatContext(); - return ( - - ); -}; diff --git a/src/features/chat/chat-ui/chat-speech/use-speech-to-text.ts b/src/features/chat/chat-ui/chat-speech/use-speech-to-text.ts deleted file mode 100644 index 1c89f7bd0..000000000 --- a/src/features/chat/chat-ui/chat-speech/use-speech-to-text.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useGlobalMessageContext } from "@/features/global-message/global-message-context"; -import { - AudioConfig, - AutoDetectSourceLanguageConfig, - SpeechConfig, - SpeechRecognizer, -} from "microsoft-cognitiveservices-speech-sdk"; -import { useRef, useState } from "react"; -import { GetSpeechToken } from "./speech-service"; - -export interface SpeechToTextProps { - startRecognition: () => void; - stopRecognition: () => void; - isMicrophoneUsed: boolean; - resetMicrophoneUsed: () => void; - isMicrophonePressed: boolean; -} - -interface Props { - onSpeech: (value: string) => void; -} - -export const useSpeechToText = (props: Props): SpeechToTextProps => { - const recognizerRef = useRef(); - - const [isMicrophoneUsed, setIsMicrophoneUsed] = useState(false); - const [isMicrophonePressed, setIsMicrophonePressed] = useState(false); - - const { showError } = useGlobalMessageContext(); - - const startRecognition = async () => { - const token = await GetSpeechToken(); - - if (token.error) { - showError(token.errorMessage); - return; - } - - setIsMicrophoneUsed(true); - setIsMicrophonePressed(true); - const speechConfig = SpeechConfig.fromAuthorizationToken( - token.token, - token.region - ); - - const audioConfig = AudioConfig.fromDefaultMicrophoneInput(); - - const autoDetectSourceLanguageConfig = - AutoDetectSourceLanguageConfig.fromLanguages([ - "en-US", - "zh-CN", - "it-IT", - "pt-BR", - ]); - - const recognizer = SpeechRecognizer.FromConfig( - speechConfig, - autoDetectSourceLanguageConfig, - audioConfig - ); - - recognizerRef.current = recognizer; - - recognizer.recognizing = (s, e) => { - props.onSpeech(e.result.text); - }; - - recognizer.canceled = (s, e) => { - showError(e.errorDetails); - }; - - recognizer.startContinuousRecognitionAsync(); - }; - - const stopRecognition = () => { - recognizerRef.current?.stopContinuousRecognitionAsync(); - setIsMicrophonePressed(false); - }; - - const resetMicrophoneUsed = () => { - setIsMicrophoneUsed(false); - }; - - return { - startRecognition, - stopRecognition, - isMicrophoneUsed, - resetMicrophoneUsed, - isMicrophonePressed, - }; -}; diff --git a/src/features/chat/chat-ui/chat-speech/use-text-to-speech.ts b/src/features/chat/chat-ui/chat-speech/use-text-to-speech.ts deleted file mode 100644 index 4c58778d5..000000000 --- a/src/features/chat/chat-ui/chat-speech/use-text-to-speech.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useGlobalMessageContext } from "@/features/global-message/global-message-context"; -import { - AudioConfig, - ResultReason, - SpeakerAudioDestination, - SpeechConfig, - SpeechSynthesizer, -} from "microsoft-cognitiveservices-speech-sdk"; -import { useRef, useState } from "react"; -import { GetSpeechToken } from "./speech-service"; - -export interface TextToSpeechProps { - stopPlaying: () => void; - textToSpeech: (textToSpeak: string) => void; - isPlaying: boolean; -} - -export const useTextToSpeech = (): TextToSpeechProps => { - const [isPlaying, setIsPlaying] = useState(false); - const playerRef = useRef(); - - const { showError } = useGlobalMessageContext(); - - const stopPlaying = () => { - setIsPlaying(false); - if (playerRef.current) { - playerRef.current.pause(); - } - }; - - const textToSpeech = async (textToSpeak: string) => { - if (isPlaying) { - stopPlaying(); - } - - const tokenObj = await GetSpeechToken(); - - if (tokenObj.error) { - showError(tokenObj.errorMessage); - return; - } - - const speechConfig = SpeechConfig.fromAuthorizationToken( - tokenObj.token, - tokenObj.region - ); - playerRef.current = new SpeakerAudioDestination(); - - var audioConfig = AudioConfig.fromSpeakerOutput(playerRef.current); - let synthesizer = new SpeechSynthesizer(speechConfig, audioConfig); - - playerRef.current.onAudioEnd = () => { - setIsPlaying(false); - }; - - synthesizer.speakTextAsync( - textToSpeak, - (result) => { - if (result.reason === ResultReason.SynthesizingAudioCompleted) { - setIsPlaying(true); - } else { - showError(result.errorDetails); - setIsPlaying(false); - } - synthesizer.close(); - }, - function (err) { - console.log("err - " + err); - synthesizer.close(); - } - ); - }; - - return { stopPlaying, textToSpeech, isPlaying }; -}; diff --git a/src/features/chat/chat-ui/chat-ui.tsx b/src/features/chat/chat-ui/chat-ui.tsx deleted file mode 100644 index 54e5e7a05..000000000 --- a/src/features/chat/chat-ui/chat-ui.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { useChatContext } from "./chat-context"; -import { ChatMessageEmptyState } from "./chat-empty-state/chat-message-empty-state"; -import ChatInput from "./chat-input/chat-input"; -import { ChatMessageContainer } from "./chat-message-container"; - -interface Prop {} - -export const ChatUI: FC = () => { - const { messages } = useChatContext(); - - return ( -
- {messages.length !== 0 ? ( - - ) : ( - - )} - - -
- ); -}; diff --git a/src/features/chat/chat-ui/markdown/citation-action.tsx b/src/features/chat/chat-ui/markdown/citation-action.tsx deleted file mode 100644 index 278441672..000000000 --- a/src/features/chat/chat-ui/markdown/citation-action.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use server"; - -import { simpleSearch } from "@/features/chat/chat-services/azure-cog-search/azure-cog-vector-store"; - -export const CitationAction = async ( - previousState: any, - formData: FormData -) => { - const result = await simpleSearch({ - filter: `id eq '${formData.get("id")}'`, - }); - - if (result.length === 0) return
Not found
; - - const firstResult = result[0]; - - return ( -
-
-
Idd
-
{firstResult.id}
-
-
-
File name
-
{firstResult.metadata}
-
-

{firstResult.pageContent}

-
- ); -}; diff --git a/src/features/common/cosmos.ts b/src/features/common/cosmos.ts deleted file mode 100644 index 8389e28ec..000000000 --- a/src/features/common/cosmos.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Container, CosmosClient } from "@azure/cosmos"; - -// Read Cosmos DB_NAME and CONTAINER_NAME from .env -const DB_NAME = process.env.AZURE_COSMOSDB_DB_NAME || "chat"; -const CONTAINER_NAME = process.env.AZURE_COSMOSDB_CONTAINER_NAME || "history"; - -export const initDBContainer = async () => { - const endpoint = process.env.AZURE_COSMOSDB_URI; - const key = process.env.AZURE_COSMOSDB_KEY; - - const client = new CosmosClient({ endpoint, key }); - - const databaseResponse = await client.databases.createIfNotExists({ - id: DB_NAME, - }); - - const containerResponse = - await databaseResponse.database.containers.createIfNotExists({ - id: CONTAINER_NAME, - partitionKey: { - paths: ["/userId"], - }, - }); - - return containerResponse.container; -}; - -export class CosmosDBContainer { - private static instance: CosmosDBContainer; - private container: Promise; - - private constructor() { - const endpoint = process.env.AZURE_COSMOSDB_URI; - const key = process.env.AZURE_COSMOSDB_KEY; - - const client = new CosmosClient({ endpoint, key }); - - this.container = new Promise((resolve, reject) => { - client.databases - .createIfNotExists({ - id: DB_NAME, - }) - .then((databaseResponse) => { - databaseResponse.database.containers - .createIfNotExists({ - id: CONTAINER_NAME, - partitionKey: { - paths: ["/userId"], - }, - }) - .then((containerResponse) => { - resolve(containerResponse.container); - }); - }) - .catch((err) => { - reject(err); - }); - }); - } - - public static getInstance(): CosmosDBContainer { - if (!CosmosDBContainer.instance) { - CosmosDBContainer.instance = new CosmosDBContainer(); - } - - return CosmosDBContainer.instance; - } - - public async getContainer(): Promise { - return await this.container; - } -} diff --git a/src/features/common/navigation-helpers.ts b/src/features/common/navigation-helpers.ts new file mode 100644 index 000000000..46a045618 --- /dev/null +++ b/src/features/common/navigation-helpers.ts @@ -0,0 +1,26 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +type Page = "extensions" | "persona" | "prompt" | "chat" | "settings"; + +export const RevalidateCache = (props: { + page: Page; + params?: string | undefined; + type?: "layout" | "page" | undefined; +}) => { + const { page, params, type } = props; + if (params) { + revalidatePath(`/${page}/${params}`, type); + } else { + revalidatePath(`/${page}`, type); + } +}; + +export const RedirectToPage = (path: Page) => { + redirect(`/${path}`); +}; + +export const RedirectToChatThread = (chatThreadId: string) => { + redirect(`/chat/${chatThreadId}`); +}; diff --git a/src/features/common/openai.ts b/src/features/common/openai.ts deleted file mode 100644 index 7ad2a7bc7..000000000 --- a/src/features/common/openai.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { OpenAI } from "openai"; - -export const OpenAIInstance = () => { - const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, - baseURL: `https://${process.env.AZURE_OPENAI_API_INSTANCE_NAME}.openai.azure.com/openai/deployments/${process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME}`, - defaultQuery: { "api-version": process.env.AZURE_OPENAI_API_VERSION }, - defaultHeaders: { "api-key": process.env.OPENAI_API_KEY }, - }); - return openai; -}; - -export const OpenAIEmbeddingInstance = () => { - const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, - baseURL: `https://${process.env.AZURE_OPENAI_API_INSTANCE_NAME}.openai.azure.com/openai/deployments/${process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME}`, - defaultQuery: { "api-version": process.env.AZURE_OPENAI_API_VERSION }, - defaultHeaders: { "api-key": process.env.OPENAI_API_KEY }, - }); - return openai; -}; diff --git a/src/features/common/schema-validation.ts b/src/features/common/schema-validation.ts new file mode 100644 index 000000000..8e8bde1d5 --- /dev/null +++ b/src/features/common/schema-validation.ts @@ -0,0 +1,6 @@ +export const refineFromEmpty = (value: string) => { + if (value.length === 0) { + return true; + } + return value.trim() !== ""; +}; diff --git a/src/features/common/server-action-response.ts b/src/features/common/server-action-response.ts new file mode 100644 index 000000000..a9d429730 --- /dev/null +++ b/src/features/common/server-action-response.ts @@ -0,0 +1,27 @@ +import { ZodIssue } from "zod"; + +export type ServerActionError = { + message: string; +}; + +type ServerActionValidationError = { + status: "ERROR" | "NOT_FOUND" | "UNAUTHORIZED"; + errors: ServerActionError[]; +}; + +type ServerActionSuccess = { + status: "OK"; + response: T; +}; + +export type ServerActionResponse = + | ServerActionValidationError + | ServerActionSuccess; + +export const zodErrorsToServerActionErrors = (errors: ZodIssue[]) => { + return errors.map((error) => { + return { + message: error.message, + }; + }); +}; diff --git a/src/features/common/services/ai-search.ts b/src/features/common/services/ai-search.ts new file mode 100644 index 000000000..187cac299 --- /dev/null +++ b/src/features/common/services/ai-search.ts @@ -0,0 +1,59 @@ +import { + AzureKeyCredential, + SearchClient, + SearchIndexClient, + SearchIndexerClient, +} from "@azure/search-documents"; + +export const AzureAISearchCredentials = () => { + const apiKey = process.env.AZURE_SEARCH_API_KEY; + const searchName = process.env.AZURE_SEARCH_NAME; + const indexName = process.env.AZURE_SEARCH_INDEX_NAME; + + if (!apiKey || !searchName || !indexName) { + throw new Error( + "One or more Azure AI Search environment variables are not set" + ); + } + + const endpoint = `https://${searchName}.search.windows.net`; + return { + apiKey, + endpoint, + indexName, + }; +}; + +export const AzureAISearchInstance = () => { + const { apiKey, endpoint, indexName } = AzureAISearchCredentials(); + + const searchClient = new SearchClient( + endpoint, + indexName, + new AzureKeyCredential(apiKey) + ); + + return searchClient; +}; + +export const AzureAISearchIndexClientInstance = () => { + const { apiKey, endpoint } = AzureAISearchCredentials(); + + const searchClient = new SearchIndexClient( + endpoint, + new AzureKeyCredential(apiKey) + ); + + return searchClient; +}; + +export const AzureAISearchIndexerClientInstance = () => { + const { apiKey, endpoint } = AzureAISearchCredentials(); + + const client = new SearchIndexerClient( + endpoint, + new AzureKeyCredential(apiKey) + ); + + return client; +}; diff --git a/src/features/common/services/azure-storage.ts b/src/features/common/services/azure-storage.ts new file mode 100644 index 000000000..17c3c77ab --- /dev/null +++ b/src/features/common/services/azure-storage.ts @@ -0,0 +1,113 @@ +import { BlobServiceClient, RestError } from "@azure/storage-blob"; +import { ServerActionResponse } from "../server-action-response"; + +// initialize the blobServiceClient +const InitBlobServiceClient = () => { + const acc = process.env.AZURE_STORAGE_ACCOUNT_NAME; + const key = process.env.AZURE_STORAGE_ACCOUNT_KEY; + + if (!acc || !key) + throw new Error( + "Azure Storage Account not configured correctly, check environment variables." + ); + + const connectionString = `DefaultEndpointsProtocol=https;AccountName=${acc};AccountKey=${key};EndpointSuffix=core.windows.net`; + + const blobServiceClient = + BlobServiceClient.fromConnectionString(connectionString); + return blobServiceClient; +}; + +export const UploadBlob = async ( + containerName: string, + blobName: string, + blobData: Buffer +): Promise> => { + const blobServiceClient = InitBlobServiceClient(); + + const containerClient = blobServiceClient.getContainerClient(containerName); + const blockBlobClient = containerClient.getBlockBlobClient(blobName); + + try{ + + const response = await blockBlobClient.uploadData(blobData); + + // Check for upload success + if (response.errorCode !== undefined) { + console.error(response); + return { + status: "ERROR", + errors: [ + { + message: `Error uploading blob to storage: ${response.errorCode}`, + }, + ], + }; + } + + console.log("Upload of generated image was successfull"); + + return { + status: "OK", + response: blockBlobClient.url, + }; + + } catch (error){ + console.error(error); + throw error; + } +}; + +export const GetBlob = async ( + containerName: string, + blobPath: string +): Promise>> => { + const blobServiceClient = InitBlobServiceClient(); + + const containerClient = blobServiceClient.getContainerClient(containerName); + const blockBlobClient = containerClient.getBlockBlobClient(blobPath); + + try { + const downloadBlockBlobResponse = await blockBlobClient.download(0); + + // Passes stream to caller to decide what to do with + if (!downloadBlockBlobResponse.readableStreamBody) { + return { + status: "ERROR", + errors: [ + { + message: `Error downloading blob: ${blobPath}`, + }, + ], + }; + } + + return { + status: "OK", + response: + downloadBlockBlobResponse.readableStreamBody as unknown as ReadableStream, + }; + } catch (error) { + if (error instanceof RestError) { + if (error.statusCode === 404) { + return { + status: "NOT_FOUND", + errors: [ + { + message: `Blob not found: ${blobPath}`, + }, + ], + }; + } + } + + return { + status: "ERROR", + errors: [ + { + message: `Error downloading blob: ${blobPath}`, + }, + ], + }; + } +}; diff --git a/src/features/chat/chat-services/chat-metrics-service.ts b/src/features/common/services/chat-metrics-service.ts similarity index 55% rename from src/features/chat/chat-services/chat-metrics-service.ts rename to src/features/common/services/chat-metrics-service.ts index 942bbcb9a..3c1633ca5 100644 --- a/src/features/chat/chat-services/chat-metrics-service.ts +++ b/src/features/common/services/chat-metrics-service.ts @@ -1,19 +1,23 @@ +'use server'; +import "server-only"; + import { metrics } from "@opentelemetry/api"; -import { userHashedId, userSession } from "@/features/auth/helpers"; +import { userHashedId, userSession } from "@/features/auth-page/helpers"; function getChatMeter(){ const meter = metrics.getMeter("chat"); + //console.log("Meter: ", meter); return meter; } async function getAttributes(chatModel: string){ const user = await userSession(); const userId = await userHashedId(); - const attributes = { "email": user?.email, "name": user?.name, "userHashedId": userId, "chatModel": chatModel || "unknown", "userId": userId }; + const attributes = { "email": user?.email, "name": user?.name, "userHashedId": userId, "chatModel": chatModel }; return attributes; } -export async function reportPromptTokens(tokenCount: number, model: string) { +export async function reportPromptTokens(tokenCount: number, model: string, role: string, attributes: any = {}) { const meter = getChatMeter(); @@ -22,10 +26,15 @@ export async function reportPromptTokens(tokenCount: number, model: string) { unit: "tokens", }); - promptTokensUsed.record(tokenCount, await getAttributes(model)); + let defaultAttributes = await getAttributes(model); + attributes["role"] = role; + + let compbinedAttributes = { ...defaultAttributes, ...attributes }; + + promptTokensUsed.record(tokenCount, compbinedAttributes); } -export async function reportCompletionTokens(tokenCount: number, model: string) { +export async function reportCompletionTokens(tokenCount: number, model: string, attributes: any = {}) { const meter = getChatMeter(); @@ -34,10 +43,12 @@ export async function reportCompletionTokens(tokenCount: number, model: string) unit: "tokens", }); - completionsTokensUsed.record(tokenCount, await getAttributes(model)); + let combinedAttributes = { ...attributes, ...await getAttributes(model) }; + + completionsTokensUsed.record(tokenCount, combinedAttributes); } -export async function reportUserChatMessage(model: string) { +export async function reportUserChatMessage(model: string, attributes: any = {}) { const meter = getChatMeter(); @@ -46,5 +57,7 @@ export async function reportUserChatMessage(model: string) { unit: "messages", }); - userChatMessage.add(1, await getAttributes(model)); + let combinedAttributes = { ...attributes, ...await getAttributes(model) }; + + userChatMessage.add(1, combinedAttributes); } diff --git a/src/features/chat/chat-services/chat-token-service.ts b/src/features/common/services/chat-token-service.ts similarity index 71% rename from src/features/chat/chat-services/chat-token-service.ts rename to src/features/common/services/chat-token-service.ts index a7627d9b1..4516a1808 100644 --- a/src/features/chat/chat-services/chat-token-service.ts +++ b/src/features/common/services/chat-token-service.ts @@ -2,6 +2,7 @@ import { Tiktoken, TiktokenModel, encodingForModel } from "js-tiktoken"; export class ChatTokenService{ + private encoder: Tiktoken; constructor(model = "gpt-4") { @@ -16,12 +17,17 @@ export class ChatTokenService{ } } - public getTokenCountFromHistory(topHistory: any, systemPromptStatic: number = 45) { - let promptTokens = systemPromptStatic; + public getTokenCountFromMessage(message: any){ + const tokenList = this.encoder.encode(message.content || ""); + return tokenList.length; + } + + public getTokenCountFromHistory(topHistory: any): { role: string, tokens: number }[] { + let promptTokens = []; for (const message of topHistory) { const tokenList = this.encoder.encode(message.content || ""); - promptTokens += tokenList.length; + promptTokens.push({ role: message.role, tokens: tokenList.length }); } return promptTokens; diff --git a/src/features/common/services/cosmos.ts b/src/features/common/services/cosmos.ts new file mode 100644 index 000000000..82f4933ea --- /dev/null +++ b/src/features/common/services/cosmos.ts @@ -0,0 +1,34 @@ +import { CosmosClient } from "@azure/cosmos"; + +// Read Cosmos DB_NAME and CONTAINER_NAME from .env +const DB_NAME = process.env.AZURE_COSMOSDB_DB_NAME || "chat"; +const CONTAINER_NAME = process.env.AZURE_COSMOSDB_CONTAINER_NAME || "history"; +const CONFIG_CONTAINER_NAME = + process.env.AZURE_COSMOSDB_CONFIG_CONTAINER_NAME || "config"; + +export const CosmosInstance = () => { + const endpoint = process.env.AZURE_COSMOSDB_URI; + const key = process.env.AZURE_COSMOSDB_KEY; + + if (!endpoint || !key) { + throw new Error( + "Azure Cosmos DB is not configured. Please configure it in the .env file." + ); + } + + return new CosmosClient({ endpoint, key }); +}; + +export const ConfigContainer = () => { + const client = CosmosInstance(); + const database = client.database(DB_NAME); + const container = database.container(CONFIG_CONTAINER_NAME); + return container; +}; + +export const HistoryContainer = () => { + const client = CosmosInstance(); + const database = client.database(DB_NAME); + const container = database.container(CONTAINER_NAME); + return container; +}; diff --git a/src/features/common/services/document-intelligence.ts b/src/features/common/services/document-intelligence.ts new file mode 100644 index 000000000..163f3e1a4 --- /dev/null +++ b/src/features/common/services/document-intelligence.ts @@ -0,0 +1,22 @@ +import { + AzureKeyCredential, + DocumentAnalysisClient, +} from "@azure/ai-form-recognizer"; + +export const DocumentIntelligenceInstance = () => { + const endpoint = process.env.AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT; + const key = process.env.AZURE_DOCUMENT_INTELLIGENCE_KEY; + + if (!endpoint || !key) { + throw new Error( + "One or more Document Intelligence environment variables are not set" + ); + } + + const client = new DocumentAnalysisClient( + endpoint, + new AzureKeyCredential(key) + ); + + return client; +}; diff --git a/src/features/common/services/key-vault.ts b/src/features/common/services/key-vault.ts new file mode 100644 index 000000000..0a0b09580 --- /dev/null +++ b/src/features/common/services/key-vault.ts @@ -0,0 +1,16 @@ +import { DefaultAzureCredential } from "@azure/identity"; +import { SecretClient } from "@azure/keyvault-secrets"; + +export const AzureKeyVaultInstance = () => { + const credential = new DefaultAzureCredential(); + const keyVaultName = process.env.AZURE_KEY_VAULT_NAME; + + if (!keyVaultName) { + throw new Error( + "Azure Key vault is not configured correctly, check environment variables." + ); + } + const url = `https://${keyVaultName}.vault.azure.net`; + + return new SecretClient(url, credential); +}; diff --git a/src/features/common/services/news-service/news-model.ts b/src/features/common/services/news-service/news-model.ts new file mode 100644 index 000000000..1d9d52c64 --- /dev/null +++ b/src/features/common/services/news-service/news-model.ts @@ -0,0 +1,28 @@ +import { refineFromEmpty } from "@/features/common/schema-validation"; +import { z } from "zod"; + +export const NEWS_ARTICLE = "NEWS_ARTICLE"; +export type NewsArticleModel = z.infer; + +export const NewsArticleModelSchema = z.object({ + id: z.string(), + title: z + .string({ + invalid_type_error: "Invalid title", + }) + .min(1, "Title cannot be empty") // Ensuring title is not empty + .refine(refineFromEmpty, "Title cannot be empty"), + description: z + .string({ + invalid_type_error: "Invalid description", + }) + .min(1, "Description cannot be empty") // Ensuring description is not empty + .refine(refineFromEmpty, "Description cannot be empty"), + link: z + .string({ + invalid_type_error: "Invalid link", + }) + .url("Link must be a valid URL"), // Ensuring link is a valid URL + createdAt: z.date(), + type: z.literal(NEWS_ARTICLE), +}); \ No newline at end of file diff --git a/src/features/common/services/news-service/news-service.ts b/src/features/common/services/news-service/news-service.ts new file mode 100644 index 000000000..0d7846ddf --- /dev/null +++ b/src/features/common/services/news-service/news-service.ts @@ -0,0 +1,53 @@ +"use server"; +import "server-only"; + +import { ConfigContainer } from "@/features/common/services/cosmos"; + +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { SqlQuerySpec } from "@azure/cosmos"; +import { NEWS_ARTICLE, NewsArticleModel } from "@/features/common/services/news-service/news-model"; + +export const FindAllNewsArticles = async ( +): Promise>> => { + try { + const querySpec: SqlQuerySpec = { + query: + "SELECT * FROM root r WHERE r.type=@type", + parameters: [ + { + name: "@type", + value: NEWS_ARTICLE, + } + ], + }; + + const { resources } = await ConfigContainer() + .items.query(querySpec) + .fetchAll(); + + if (resources) { + return { + status: "OK", + response: resources, + }; + } else { + return { + status: "ERROR", + errors: [ + { + message: "No news found", + }, + ], + }; + } + } catch (e) { + return { + status: "ERROR", + errors: [ + { + message: `${e}`, + }, + ], + }; + } +}; diff --git a/src/features/common/services/openai.ts b/src/features/common/services/openai.ts new file mode 100644 index 000000000..41c9ed0d6 --- /dev/null +++ b/src/features/common/services/openai.ts @@ -0,0 +1,80 @@ +import { OpenAI } from "openai"; + +export const OpenAIInstance = () => { + const openai = new OpenAI({ + apiKey: process.env.AZURE_OPENAI_API_KEY, + baseURL: `https://${process.env.AZURE_OPENAI_API_INSTANCE_NAME}.openai.azure.com/openai/deployments/${process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME}`, + defaultQuery: { "api-version": process.env.AZURE_OPENAI_API_VERSION }, + defaultHeaders: { "api-key": process.env.AZURE_OPENAI_API_KEY }, + }); + return openai; +}; + +export const OpenAIEmbeddingInstance = () => { + if ( + !process.env.AZURE_OPENAI_API_KEY || + !process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME || + !process.env.AZURE_OPENAI_API_INSTANCE_NAME + ) { + throw new Error( + "Azure OpenAI Embeddings endpoint config is not set, check environment variables." + ); + } + + const openai = new OpenAI({ + apiKey: process.env.AZURE_OPENAI_API_KEY, + baseURL: `https://${process.env.AZURE_OPENAI_API_INSTANCE_NAME}.openai.azure.com/openai/deployments/${process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME}`, + defaultQuery: { "api-version": process.env.AZURE_OPENAI_API_VERSION }, + defaultHeaders: { "api-key": process.env.AZURE_OPENAI_API_KEY }, + }); + return openai; +}; + +// a new instance definition for DALL-E image generation +export const OpenAIDALLEInstance = () => { + if ( + !process.env.AZURE_OPENAI_DALLE_API_KEY || + !process.env.AZURE_OPENAI_DALLE_API_DEPLOYMENT_NAME || + !process.env.AZURE_OPENAI_DALLE_API_INSTANCE_NAME + ) { + throw new Error( + "Azure OpenAI DALLE endpoint config is not set, check environment variables." + ); + } + + const openai = new OpenAI({ + apiKey: process.env.AZURE_OPENAI_DALLE_API_KEY, + baseURL: `https://${process.env.AZURE_OPENAI_DALLE_API_INSTANCE_NAME}.openai.azure.com/openai/deployments/${process.env.AZURE_OPENAI_DALLE_API_DEPLOYMENT_NAME}`, + defaultQuery: { + "api-version": + process.env.AZURE_OPENAI_DALLE_API_VERSION || "2023-12-01-preview", + }, + defaultHeaders: { + "api-key": process.env.AZURE_OPENAI_DALLE_API_KEY, + }, + }); + return openai; +}; + +export const OpenAIVisionInstance = () => { + if ( + !process.env.AZURE_OPENAI_VISION_API_KEY || + !process.env.AZURE_OPENAI_VISION_API_DEPLOYMENT_NAME || + !process.env.AZURE_OPENAI_VISION_API_INSTANCE_NAME || + !process.env.AZURE_OPENAI_VISION_API_VERSION + ) { + throw new Error( + "Azure OpenAI Vision environment config is not set, check environment variables." + ); + } + + const openai = new OpenAI({ + apiKey: process.env.AZURE_OPENAI_VISION_API_KEY, + baseURL: `https://${process.env.AZURE_OPENAI_VISION_API_INSTANCE_NAME}.openai.azure.com/openai/deployments/${process.env.AZURE_OPENAI_VISION_API_DEPLOYMENT_NAME}`, + defaultQuery: { + "api-version": process.env.AZURE_OPENAI_VISION_API_VERSION, + }, + defaultHeaders: { "api-key": process.env.AZURE_OPENAI_VISION_API_KEY }, + }); + return openai; +}; diff --git a/src/features/common/util.ts b/src/features/common/util.ts index fcc084b2a..4afad35c8 100644 --- a/src/features/common/util.ts +++ b/src/features/common/util.ts @@ -1,8 +1,16 @@ import { customAlphabet } from "nanoid"; +import { ChatThreadModel } from "../chat-page/chat-services/models"; + export const uniqueId = () => { const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; const nanoid = customAlphabet(alphabet, 36); return nanoid(); }; + +export const sortByTimestamp = (a: ChatThreadModel, b: ChatThreadModel) => { + return ( + new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime() + ); +}; diff --git a/src/features/extensions-page/add-extension/add-function.tsx b/src/features/extensions-page/add-extension/add-function.tsx new file mode 100644 index 000000000..cddbeb274 --- /dev/null +++ b/src/features/extensions-page/add-extension/add-function.tsx @@ -0,0 +1,134 @@ +import { Button } from "@/features/ui/button"; +import { Input } from "@/features/ui/input"; +import { Label } from "@/features/ui/label"; +import { cn } from "@/features/ui/lib"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/features/ui/select"; +import { SheetTitle } from "@/features/ui/sheet"; +import { javascript } from "@codemirror/lang-javascript"; +import CodeMirror from "@uiw/react-codemirror"; +import { ChevronDown, ChevronUp, Copy, Plus, Trash } from "lucide-react"; +import { useTheme } from "next-themes"; +import { extensionStore, useExtensionState } from "../extension-store"; + +export const AddFunction = () => { + const { extension } = useExtensionState(); + + const { theme } = useTheme(); + return ( +
+
+ Functions + +
+ {extension.functions.map((func, index) => ( +
+
extensionStore.toggleFunction(func.id)} + > + {`${index + 1}. ${getName(func.code)}`} + {func.isOpen ? : } +
+
+
+
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ + { + extensionStore.updateFunctionCode(func.id, value); + }} + extensions={[javascript()]} + theme={theme === "dark" ? "dark" : ("light" as const)} + /> +
+
+
+ ))} +
+ ); +}; + +const getName = (value: string) => { + try { + const val = JSON.parse(value); + return val.name; + } catch (e) { + return "Unknown"; + } +}; diff --git a/src/features/extensions-page/add-extension/add-new-extension.tsx b/src/features/extensions-page/add-extension/add-new-extension.tsx new file mode 100644 index 000000000..5d133059d --- /dev/null +++ b/src/features/extensions-page/add-extension/add-new-extension.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { ServerActionResponse } from "@/features/common/server-action-response"; +import { LoadingIndicator } from "@/features/ui/loading"; +import { Textarea } from "@/features/ui/textarea"; +import { useSession } from "next-auth/react"; +import { FC } from "react"; +import { useFormState } from "react-dom"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { Label } from "../../ui/label"; +import { ScrollArea } from "../../ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from "../../ui/sheet"; +import { Switch } from "../../ui/switch"; +import { + AddOrUpdateExtension, + extensionStore, + useExtensionState, +} from "../extension-store"; +import { AddFunction } from "./add-function"; +import { EndpointHeader } from "./endpoint-header"; +import { ErrorMessages } from "./error-messages"; + +interface Props {} + +export const AddExtension: FC = (props) => { + const { isOpened, extension } = useExtensionState(); + + const { data } = useSession(); + const initialState: ServerActionResponse | undefined = undefined; + + const [formState, formAction] = useFormState( + AddOrUpdateExtension, + initialState + ); + + const PublicSwitch = () => { + if (data === undefined || data === null) return null; + + if (data?.user?.isAdmin) { + return ( +
+ + +
+ ); + } + }; + + return ( + { + extensionStore.updateOpened(value); + }} + > + + + Extension + +
+ +
+ + +
+ + +
+
+ + +
+
+ +