diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e002d7ebc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +**/node_modules +**/bin +**/obj \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 1a05f3eb4..6021d364e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,10 @@ root = true indent_style = space end_of_line = lf +# Bash scripts +[*.sh] +indent_size = 2 + # XML project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] indent_size = 2 diff --git a/.github/_typos.toml b/.github/_typos.toml index 8298df765..779f5a254 100644 --- a/.github/_typos.toml +++ b/.github/_typos.toml @@ -15,6 +15,7 @@ extend-exclude = [ "GPT3TokenizerTests.cs", "CodeTokenizerTests.cs", "test_code_tokenizer.py", + "CopilotChat.sln.DotSettings" ] [default.extend-words] diff --git a/.github/labeler.yml b/.github/labeler.yml index c430e2da5..c60b4a01c 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,3 +1,29 @@ -# Add 'documentation' label to any '.md' files +# Add 'webapp' label to any change within the 'webapp' directory +webapp: + - changed-files: + - any-glob-to-any-file: ["webapp/**"] + +# Add 'webapi' label to any change within the 'webapi' directory +webapi: + - changed-files: + - any-glob-to-any-file: ["webapi/**"] + +# Add 'dependencies' label to any change of the 'webapp/yarn.lock' file +dependencies: + - changed-files: + - any-glob-to-any-file: ["webapp/yarn.lock"] + +# Add 'deployment' label to any change within the 'deploy' directory +deployment: + - changed-files: + - any-glob-to-any-file: ["scripts/deploy/**"] + +# Add 'documentation' label to any change of '.md' files documentation: - - '**/*.md' \ No newline at end of file + - changed-files: + - any-glob-to-any-file: ["**/*.md"] + +# Add 'github actions' label to any change within the '.github/workflows' directory +"github actions": + - changed-files: + - any-glob-to-any-file: [".github/workflows/**"] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f5b95b7cf..b083a7b3c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ### Motivation and Context - - [ ] The code builds clean without any errors or warnings -- [ ] The PR follows the [Contribution Guidelines](https://github.com/microsoft/copilot-chat/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/copilot-chat/blob/main/CONTRIBUTING.md#dev-scripts) raises no violations +- [ ] The PR follows the [Contribution Guidelines](https://github.com/microsoft/chat-copilot/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/chat-copilot/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6ae3a7a79..3e96860c2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -6,9 +6,9 @@ name: "CodeQL" on: push: - branches: [ "main", "experimental*", "feature*" ] + branches: ["main", "experimental*", "feature*"] schedule: - - cron: '17 11 * * 2' + - cron: "17 11 * * 2" jobs: analyze: @@ -22,45 +22,44 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'csharp', 'python' ] + language: ["csharp", "javascript"] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/copilot-build-backend.yml b/.github/workflows/copilot-build-backend.yml new file mode 100644 index 000000000..50b2c0961 --- /dev/null +++ b/.github/workflows/copilot-build-backend.yml @@ -0,0 +1,70 @@ +name: copilot-build-backend + +on: + workflow_dispatch: + pull_request: + branches: ["main"] + paths: + - "webapi/**" + workflow_call: + outputs: + artifact: + description: "The name of the uploaded web api artifact." + value: ${{jobs.webapi.outputs.artifact}} + +permissions: + contents: read + +jobs: + webapi: + strategy: + fail-fast: false + matrix: + include: + - { dotnet: "8.0", configuration: Release, os: windows-latest } + + runs-on: ${{ matrix.os }} + + env: + NUGET_CERT_REVOCATION_MODE: offline + + outputs: + artifact: ${{steps.artifactoutput.outputs.artifactname}} + + steps: + - uses: actions/checkout@v4 + with: + clean: true + fetch-depth: 0 + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v2 + with: + versionSpec: "5.x" + + - name: Determine version + id: gitversion + uses: gittools/actions/gitversion/execute@v2 + + - name: Set version tag + id: versiontag + run: | + $VERSION_TAG = "${{ steps.gitversion.outputs.Major }}." + $VERSION_TAG += "${{ steps.gitversion.outputs.Minor }}." + $VERSION_TAG += "${{ steps.gitversion.outputs.CommitsSinceVersionSource }}" + echo $VERSION_TAG + Write-Output "versiontag=$VERSION_TAG" >> $env:GITHUB_OUTPUT + + - name: Package Copilot Chat WebAPI + run: | + scripts\deploy\package-webapi.ps1 -Configuration Release -DotnetFramework net8.0 -TargetRuntime win-x64 -OutputDirectory ${{ github.workspace }}\scripts\deploy -Version ${{ steps.versiontag.outputs.versiontag }} -InformationalVersion "Built from commit ${{ steps.gitversion.outputs.ShortSha }} on $(Get-Date -Format 'yyyy-MM-dd')" -SkipFrontendFiles ('${{ github.event_name == 'pull_request' }}' -eq 'true') + + - name: Upload package to artifacts + uses: actions/upload-artifact@v4 + with: + name: copilotchat-webapi-${{ steps.versiontag.outputs.versiontag }} + path: ${{ github.workspace }}\scripts\deploy\out\webapi.zip + + - name: "Set outputs" + id: artifactoutput + run: Write-Output "artifactname=copilotchat-webapi-${{ steps.versiontag.outputs.versiontag }}" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/copilot-build-frontend.yml b/.github/workflows/copilot-build-frontend.yml new file mode 100644 index 000000000..9a5b5b577 --- /dev/null +++ b/.github/workflows/copilot-build-frontend.yml @@ -0,0 +1,35 @@ +name: copilot-build-frontend + +on: + workflow_dispatch: + pull_request: + branches: ["main"] + paths: + - "webapp/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + webapp: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 18 + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: "yarn" + cache-dependency-path: "webapp/yarn.lock" + + - name: Run yarn install, yarn build, & yarn format + run: | + #!/usr/bin/env bash + set -e # exit with nonzero exit code if anything fails + echo "Running yarn install, yarn build, & yarn format" + yarn install --frozen-lockfile # install dependencies and ensure lockfile is unchanged. + yarn build # run build script + yarn format # run format script + working-directory: webapp diff --git a/.github/workflows/copilot-build-images.yml b/.github/workflows/copilot-build-images.yml new file mode 100644 index 000000000..698fb346d --- /dev/null +++ b/.github/workflows/copilot-build-images.yml @@ -0,0 +1,80 @@ +name: copilot-build-images + +on: + workflow_call: + inputs: + REACT_APP_BACKEND_URI: + required: true + type: string + AZURE_FUNCTION_MASTER_KEY: + required: true + type: string +env: + REGISTRY: ghcr.io + +jobs: + build-and-push-image: + name: Build and push images + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - file: ./docker/webapi/Dockerfile + image: ${{ github.repository }}-webapi + build-args: | + + - file: ./docker/webapp/Dockerfile + image: ${{ github.repository }}-webapp + build-args: | + + - file: ./docker/webapp/Dockerfile.nginx + image: ${{ github.repository }}-webapp-nginx + build-args: | + REACT_APP_BACKEND_URI=${{ inputs.REACT_APP_BACKEND_URI }} + + - file: ./docker/plugins/web-searcher/Dockerfile + image: ${{ github.repository }}-web-searcher + build-args: | + AZURE_FUNCTION_MASTER_KEY=${{ inputs.AZURE_FUNCTION_MASTER_KEY }} + + - file: ./docker/memorypipeline/Dockerfile + image: ${{ github.repository }}-memorypipeline + build-args: | + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ matrix.image }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.file }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: ${{ matrix.build-args }} diff --git a/.github/workflows/copilot-build-memorypipeline.yml b/.github/workflows/copilot-build-memorypipeline.yml new file mode 100644 index 000000000..69efd66bc --- /dev/null +++ b/.github/workflows/copilot-build-memorypipeline.yml @@ -0,0 +1,69 @@ +name: copilot-build-memorypipeline + +on: + workflow_dispatch: + pull_request: + branches: ["main"] + paths: + - "memorypipeline/**" + workflow_call: + outputs: + artifact: + description: "The name of the uploaded memory pipeline artifact." + value: ${{jobs.memory-pipeline.outputs.artifact}} + +permissions: + contents: read + +jobs: + memory-pipeline: + runs-on: windows-latest + + env: + NUGET_CERT_REVOCATION_MODE: offline + + outputs: + artifact: ${{steps.artifactoutput.outputs.artifactname}} + + steps: + - uses: actions/checkout@v4 + with: + clean: true + fetch-depth: 0 + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v2 + with: + versionSpec: "5.x" + + - name: Determine version + id: gitversion + uses: gittools/actions/gitversion/execute@v2 + + - name: Set version tag + id: versiontag + run: | + $VERSION_TAG = "${{ steps.gitversion.outputs.Major }}." + $VERSION_TAG += "${{ steps.gitversion.outputs.Minor }}." + $VERSION_TAG += "${{ steps.gitversion.outputs.CommitsSinceVersionSource }}" + echo $VERSION_TAG + Write-Output "versiontag=$VERSION_TAG" >> $env:GITHUB_OUTPUT + + - name: Set .Net Core version + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Package Copilot Chat Memory Pipeline + run: | + scripts\deploy\package-memorypipeline.ps1 -Configuration Release -DotnetFramework net8.0 -TargetRuntime win-x64 -OutputDirectory ${{ github.workspace }}\scripts\deploy -Version ${{ steps.versiontag.outputs.versiontag }} -InformationalVersion "Built from commit ${{ steps.gitversion.outputs.ShortSha }} on $(Get-Date -Format "yyyy-MM-dd")" + + - name: Upload package to artifacts + uses: actions/upload-artifact@v4 + with: + name: copilotchat-memorypipeline-${{ steps.versiontag.outputs.versiontag }} + path: ${{ github.workspace }}\scripts\deploy\out\memorypipeline.zip + + - name: "Set outputs" + id: artifactoutput + run: Write-Output "artifactname=copilotchat-memorypipeline-${{ steps.versiontag.outputs.versiontag }}" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/copilot-build-plugins.yml b/.github/workflows/copilot-build-plugins.yml new file mode 100644 index 000000000..c7027f9e1 --- /dev/null +++ b/.github/workflows/copilot-build-plugins.yml @@ -0,0 +1,74 @@ +name: copilot-build-plugins + +on: + workflow_dispatch: + pull_request: + branches: ["main"] + paths: + - "plugins/**" + workflow_call: + outputs: + artifact: + description: "The name of the uploaded plugin artifacts." + value: ${{jobs.plugins.outputs.artifact}} + +permissions: + contents: read + +jobs: + plugins: + runs-on: windows-latest + + env: + NUGET_CERT_REVOCATION_MODE: offline + + outputs: + artifact: ${{steps.artifactoutput.outputs.artifactname}} + + steps: + - uses: actions/checkout@v4 + with: + clean: true + fetch-depth: 0 + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v2 + with: + versionSpec: "5.x" + + - name: Determine version + id: gitversion + uses: gittools/actions/gitversion/execute@v2 + + - name: Set version tag + id: versiontag + run: | + $VERSION_TAG = "${{ steps.gitversion.outputs.Major }}." + $VERSION_TAG += "${{ steps.gitversion.outputs.Minor }}." + $VERSION_TAG += "${{ steps.gitversion.outputs.CommitsSinceVersionSource }}" + echo $VERSION_TAG + Write-Output "versiontag=$VERSION_TAG" >> $env:GITHUB_OUTPUT + + - name: Set .Net Core version + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Package Copilot Chat Plugins + run: | + scripts\deploy\package-plugins.ps1 ` + -BuildConfiguration Release ` + -DotNetFramework net8.0 ` + -OutputDirectory ${{ github.workspace }}\scripts\deploy ` + -Version ${{ steps.versiontag.outputs.versiontag }} ` + -InformationalVersion "Built from commit ${{ steps.gitversion.outputs.ShortSha }} on $(Get-Date -Format "yyyy-MM-dd")" + + - name: Upload packages to artifacts + uses: actions/upload-artifact@v4 + with: + name: copilotchat-plugins-${{ steps.versiontag.outputs.versiontag }} + path: ${{ github.workspace }}\scripts\deploy\out\plugins + + - name: "Set outputs" + id: artifactoutput + run: Write-Output "artifactname=copilotchat-plugins-${{ steps.versiontag.outputs.versiontag }}" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/copilot-chat-package.yml b/.github/workflows/copilot-chat-package.yml deleted file mode 100644 index 5b999b089..000000000 --- a/.github/workflows/copilot-chat-package.yml +++ /dev/null @@ -1,55 +0,0 @@ -# -# This workflow will package the Copilot Chat application for deployment. -# - -name: copilot-chat-package - -on: - pull_request: - branches: [ "main" ] - merge_group: - branches: [ "main" ] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - copilot-chat-package: - strategy: - fail-fast: false - matrix: - include: - - { dotnet: '6.0', configuration: Release, os: ubuntu-latest } - - runs-on: ${{ matrix.os }} - env: - NUGET_CERT_REVOCATION_MODE: offline - steps: - - uses: actions/checkout@v3 - with: - clean: true - - - name: Pull container dotnet/sdk:${{ matrix.dotnet }} - run: docker pull mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} - - - name: Package Copilot Chat WebAPI - run: | - chmod +x $(pwd)/deploy/package-webapi.sh; - docker run --rm -v $(pwd):/app -w /app -e GITHUB_ACTIONS='true' mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "/app/deploy/package-webapi.sh --no-zip"; - - - name: Set version tag - id: versiontag - run: | - VERSION_TAG="$(date +'%Y%m%d').${{ github.run_number }}.${{ github.run_attempt }}" - echo $VERSION_TAG - echo "versiontag=$VERSION_TAG" >> $GITHUB_OUTPUT - - - name: Upload package to artifacts - uses: actions/upload-artifact@v3 - with: - name: copilotchat-webapi-${{ steps.versiontag.outputs.versiontag }} - path: ./deploy/publish diff --git a/.github/workflows/copilot-run-integration-tests.yml b/.github/workflows/copilot-run-integration-tests.yml new file mode 100644 index 000000000..9b00c4f87 --- /dev/null +++ b/.github/workflows/copilot-run-integration-tests.yml @@ -0,0 +1,47 @@ +name: copilot-run-integration-tests + +on: + workflow_dispatch: + inputs: + BACKEND_HOST: + required: true + type: string + ENVIRONMENT: + required: true + type: string + workflow_call: + inputs: + BACKEND_HOST: + required: true + type: string + ENVIRONMENT: + required: true + type: string + +permissions: + contents: read + +jobs: + tests: + environment: ${{inputs.ENVIRONMENT}} + name: Integration Testing + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Configure test environment + working-directory: integration-tests + env: + TestUsername: ${{secrets.COPILOT_CHAT_TEST_USER_ACCOUNT1}} + TestPassword: ${{secrets.COPILOT_CHAT_TEST_USER_PASSWORD1}} + run: | + dotnet user-secrets set "BaseServerUrl" "https://${{inputs.BACKEND_HOST}}.azurewebsites.net/" + dotnet user-secrets set "Authority" "https://login.microsoftonline.com/${{vars.APPLICATION_TENANT_ID}}" + dotnet user-secrets set "ClientID" ${{vars.APPLICATION_CLIENT_ID}} + dotnet user-secrets set "Scopes" "openid, offline_access, profile, api://${{vars.BACKEND_CLIENT_ID}}/access_as_user" + # dotnet user-secrets set "TestUsername" "$env:TestUsername" + # dotnet user-secrets set "TestPassword" "$env:TestPassword" + + - name: Run integration tests + run: dotnet test --logger trx diff --git a/.github/workflows/copilot-chat-tests.yml b/.github/workflows/copilot-test-e2e.yml similarity index 58% rename from .github/workflows/copilot-chat-tests.yml rename to .github/workflows/copilot-test-e2e.yml index a30324e4d..14751b39d 100644 --- a/.github/workflows/copilot-chat-tests.yml +++ b/.github/workflows/copilot-test-e2e.yml @@ -1,32 +1,36 @@ name: Copilot Chat Tests on: workflow_dispatch: + inputs: + ENVIRONMENT: + required: true + type: string merge_group: - branches: ["main"] + types: [checks_requested] permissions: contents: read jobs: - test: + e2e: + environment: ${{inputs.ENVIRONMENT}} defaults: run: working-directory: webapp runs-on: ubuntu-latest - timeout-minutes: 30 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 18 cache-dependency-path: webapp/yarn.lock cache: "yarn" - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Install dependencies run: yarn install @@ -38,38 +42,51 @@ jobs: working-directory: webapi env: AzureOpenAI__ApiKey: ${{ secrets.AZUREOPENAI__APIKEY }} - AzureOpenAI__Endpoint: ${{ secrets.AZUREOPENAI__ENDPOINT }} run: | dotnet dev-certs https - dotnet user-secrets set "AIService:Key" "$AzureOpenAI__ApiKey" - dotnet user-secrets set "AIService:Endpoint" "$AzureOpenAI__Endpoint" + dotnet user-secrets set "KernelMemory:Services:AzureOpenAIText:APIKey" "$AzureOpenAI__ApiKey" + dotnet user-secrets set "KernelMemory:Services:AzureOpenAIText:Endpoint" ${{vars.AZURE_OPENAI_ENDPOINT}} + dotnet user-secrets set "KernelMemory:Services:AzureOpenAIEmbedding:APIKey" "$AzureOpenAI__ApiKey" + dotnet user-secrets set "KernelMemory:Services:AzureOpenAIEmbedding:Endpoint" ${{vars.AZURE_OPENAI_ENDPOINT}} + dotnet user-secrets set "Authentication:Type" "AzureAd" + dotnet user-secrets set "Authentication:AzureAd:TenantId" ${{vars.APPLICATION_TENANT_ID}} + dotnet user-secrets set "Authentication:AzureAd:ClientId" ${{vars.BACKEND_CLIENT_ID}} + dotnet user-secrets set "Frontend:AadClientId" ${{vars.APPLICATION_CLIENT_ID}} - name: Start service in background working-directory: webapi run: | dotnet run > service-log.txt 2>&1 & + + STARTED=false; + for attempt in {0..20}; do jobs echo 'Waiting for service to start...'; if curl -k https://localhost:40443/healthz; then echo; echo 'Service started'; + STARTED=true; break; fi; sleep 5; done + if [ "$STARTED" = false ]; then + echo 'Service failed to start'; + exit 1; + fi + - name: Run Playwright tests + timeout-minutes: 5 env: REACT_APP_BACKEND_URI: https://localhost:40443/ - REACT_APP_AAD_CLIENT_ID: ${{ secrets.COPILOT_CHAT_REACT_APP_AAD_CLIENT_ID }} - REACT_APP_AAD_AUTHORITY: https://login.microsoftonline.com/common REACT_APP_TEST_USER_ACCOUNT1: ${{ secrets.COPILOT_CHAT_TEST_USER_ACCOUNT1 }} - REACT_APP_TEST_USER_ACCOUNT1_INITIALS: ${{ secrets.COPILOT_CHAT_TEST_USER_ACCOUNT1_INITIALS }} REACT_APP_TEST_USER_ACCOUNT2: ${{ secrets.COPILOT_CHAT_TEST_USER_ACCOUNT2 }} - REACT_APP_TEST_USER_PASSWORD: ${{ secrets.COPILOT_CHAT_TEST_USER_PASSWORD }} + REACT_APP_TEST_USER_PASSWORD1: ${{ secrets.COPILOT_CHAT_TEST_USER_PASSWORD1 }} + REACT_APP_TEST_USER_PASSWORD2: ${{ secrets.COPILOT_CHAT_TEST_USER_PASSWORD2 }} REACT_APP_TEST_JIRA_EMAIL: ${{ secrets.COPILOT_CHAT_TEST_JIRA_EMAIL }} REACT_APP_TEST_JIRA_ACCESS_TOKEN: ${{ secrets.COPILOT_CHAT_TEST_JIRA_ACCESS_TOKEN }} @@ -80,14 +97,14 @@ jobs: REACT_APP_TEST_GITHUB_REPOSITORY_NAME: ${{ secrets.COPILOT_CHAT_TEST_GITHUB_REPOSITORY_NAME }} run: yarn playwright test - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: webapp/playwright-report/ retention-days: 30 - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: service-log diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml new file mode 100644 index 000000000..2f56019f2 --- /dev/null +++ b/.github/workflows/dotnet-format.yml @@ -0,0 +1,75 @@ +name: dotnet-format + +on: + pull_request: + branches: ["main", "feature*"] + paths: + - "webapi/**" + - "memorypipeline/**" + - "tools/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check-format: + runs-on: ubuntu-latest + env: + NUGET_CERT_REVOCATION_MODE: offline + + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + clean: true + + - name: Get changed files + id: changed-files + uses: jitterbit/get-changed-files@v1 + continue-on-error: true + + - name: No C# files changed + id: no-csharp + if: steps.changed-files.outputs.added_modified == '' + run: echo "No C# files changed" + + # This step will loop over the changed files and find the nearest .csproj file for each one, then store the unique csproj files in a variable + - name: Find csproj files + id: find-csproj + run: | + csproj_files=() + if [[ ${{ steps.changed-files.outcome }} == 'success' ]]; then + for file in ${{ steps.changed-files.outputs.added_modified }}; do + echo "$file was changed" + dir="./$file" + while [[ $dir != "." && $dir != "/" && $dir != $GITHUB_WORKSPACE ]]; do + if find "$dir" -maxdepth 1 -name "*.csproj" -print -quit | grep -q .; then + csproj_files+=("$(find "$dir" -maxdepth 1 -name "*.csproj" -print -quit)") + break + fi + + dir=$(echo ${dir%/*}) + done + done + else + # if the changed-files step failed, run dotnet on the whole sln instead of specific projects + echo "Running dotnet format on whole solution" + csproj_files=$(find ./ -type f -name "*.sln" | tr '\n' ' '); + fi + csproj_files=($(printf "%s\n" "${csproj_files[@]}" | sort -u)) + echo "Found ${#csproj_files[@]} unique csproj/sln files: ${csproj_files[*]}" + echo "csproj_files=${csproj_files[*]}" >> $GITHUB_OUTPUT + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Check formatting + if: steps.find-csproj.outputs.csproj_files != '' + run: | + for csproj in ${{ steps.find-csproj.outputs.csproj_files }}; do + echo "Checking formatting for $csproj" + dotnet format $csproj --verify-no-changes --verbosity diagnostic + done diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml new file mode 100644 index 000000000..dd5f87a14 --- /dev/null +++ b/.github/workflows/label-pr.yml @@ -0,0 +1,22 @@ +# This workflow will triage pull requests and apply a label based on the +# paths that are modified in the pull request. +# +# To use this workflow, you will need to set up a .github/labeler.yml +# file with configuration. For more information, see: +# https://github.com/actions/labeler + +name: Label pull request +on: [pull_request_target] + +jobs: + add_label: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/labeler@v5 + with: + dot: true # allows for easier matching of paths + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/markdown-link-check-config.json b/.github/workflows/markdown-link-check-config.json index 777edfe3f..1044cc6e5 100644 --- a/.github/workflows/markdown-link-check-config.json +++ b/.github/workflows/markdown-link-check-config.json @@ -25,18 +25,15 @@ "pattern": "^https://localhost" }, { - "pattern": "https://github.com/microsoft/copilot-chat/releases" + "pattern": "^https://platform.openai.com" + }, + { + "pattern": "^https://stackoverflow.com" } ], "timeout": "20s", "retryOn429": true, "retryCount": 3, "fallbackRetryDelay": "30s", - "aliveStatusCodes": [ - 200, - 206, - 429, - 500, - 503 - ] + "aliveStatusCodes": [200, 206, 429, 500, 503] } diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index 00826fe75..4e02c7256 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest # check out the latest version of the code steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # Checks the status of hyperlinks in .md files in verbose mode - name: Check links diff --git a/.github/workflows/node-pr.yml b/.github/workflows/node-pr.yml deleted file mode 100644 index 9b4066c26..000000000 --- a/.github/workflows/node-pr.yml +++ /dev/null @@ -1,70 +0,0 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - -name: node-pr - -on: - workflow_dispatch: - pull_request: - branches: ["main"] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - find-yarn-projects: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-yarn-folders.outputs.matrix }} - - steps: - - uses: actions/checkout@v3 - - - name: Find yarn projects - id: set-yarn-folders - # This step uses a bash script to find all subfolders that contain a yarn.lock file - run: | - #!/bin/bash - set -e # exit with nonzero exit code if anything fails - shopt -s globstar # enable globstar option to use ** for recursive matching - yarndirs=() - for lockfile in **/yarn.lock; do # loop over all yarn.lock files - dir=$(dirname "$lockfile") # get the directory of the lock file - echo "Found yarn project in $dir" - yarndirs+=("$dir") # add the directory to the yarndirs array - done - - echo "All yarn projects found: '${yarndirs[*]}'" - yarndirs_json=$(echo -n "${yarndirs[*]%\n}" | jq -R -s -j --compact-output 'split(" ")') - matrix_json="{\"node_version\":[18], \"yarn_folder\":$yarndirs_json}" - echo "Setting output matrix to $matrix_json" - echo "matrix=$matrix_json" >> $GITHUB_OUTPUT - - build: - runs-on: ubuntu-latest - needs: find-yarn-projects - - strategy: - matrix: ${{ fromJson(needs.find-yarn-projects.outputs.matrix) }} - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node_version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node_version }} - cache: "yarn" - cache-dependency-path: "**/yarn.lock" - - - name: Run yarn install & yarn build - # This step runs yarn install and yarn build for each project. - # The --frozen-lockfile option ensures that the dependencies are installed exactly as specified in the lock file. - # The -cwd option sets the current working directory to the folder where the yarn.lock file is located. - run: | - #!/bin/bash - set -e # exit with nonzero exit code if anything fails - dir=${{ matrix.yarn_folder }} # get the directory of the lock file - echo "Running yarn install and yarn build for $dir" - yarn --cwd "$dir" install --frozen-lockfile # install dependencies - yarn --cwd "$dir" build # run build script diff --git a/.github/workflows/typos.yaml b/.github/workflows/typos.yaml index 1d2b0df54..94931d48b 100644 --- a/.github/workflows/typos.yaml +++ b/.github/workflows/typos.yaml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Use custom config file uses: crate-ci/typos@master diff --git a/.github/workflows/update-version.sh b/.github/workflows/update-version.sh deleted file mode 100644 index 5db3622f6..000000000 --- a/.github/workflows/update-version.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/bin/bash - -POSITIONAL_ARGS=() - -while [[ $# -gt 0 ]]; do - case $1 in - -f|--file) - file="$2" - shift # past argument - shift # past value - ;; - -p|--propsFile) - propsFile="$2" - shift # past argument - shift # past value - ;; - -b|--buildAndRevisionNumber) - buildAndRevisionNumber="$2" - shift # past argument - shift # past value - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - POSITIONAL_ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters - -if [ -z "$file" ]; then - echo "ERROR: Parameter file (-f|--file) not provided" - exit 1; -elif [ ! -f "$file" ]; then - echo "ERROR: file ${file} not found" - exit 1; -fi - -if [ -n "$(cat $file | grep -i "false")" ]; then - echo "Project is marked as NOT packable - skipping." - exit 0; -fi - -if [ -z "$propsFile" ]; then - echo "ERROR: Parameter propsFile (-f|--file) not provided" - exit 1; -elif [ ! -f "$propsFile" ]; then - echo "ERROR: propsFile ${file} not found" - exit 1; -fi - -if [ -z "$buildAndRevisionNumber" ]; then - echo "ERROR: Parameter buildAndRevisionNumber (-b|--buildAndRevisionNumber) not provided" - exit 1; -fi - -propsVersionString=$(cat $propsFile | grep -i ""); -regex="([0-9.]*)<\/Version>" -if [[ $propsVersionString =~ $regex ]]; then - propsVersion=${BASH_REMATCH[1]} -else - echo "ERROR: Version tag not found in propsFile" - exit 1; -fi - -if [ -z "$propsVersion" ]; then - echo "ERROR: Version tag not found in propsFile" - exit 1; -elif [[ ! "$propsVersion" =~ ^0.* ]]; then - echo "ERROR: Version expected to start with 0. Actual: ${propsVersion}" - exit 1; -fi - -fullVersionString="${propsVersion}.${buildAndRevisionNumber}-preview" - -if [[ ! "$fullVersionString" =~ ^0.* ]]; then - echo "ERROR: Version expected to start with 0. Actual: ${fullVersionString}" - exit 1; -fi - -echo "==== Project: ${file} ===="; -echo "propsFile = ${propsFile}" -echo "buildAndRevisionNumber = ${buildAndRevisionNumber}" -echo "version prefix from propsFile = ${propsVersion}" -echo "full version string: ${fullVersionString}" - -versionInProj=$(cat $file | grep -i ""); -if [ -n "$versionInProj" ]; then - # Version tag already exists in the csproj. Let's replace it. - echo "Updating version tag..." - content=$(cat $file | sed --expression="s/\([0-9]*.[0-9]*\)<\/Version>/$fullVersionString<\/Version>/g"); -else - # Version tag not found in the csproj. Let's add it. - echo "Project is packable - adding version tag..." - content=$(cat $file | sed --expression="s/<\/Project>/$fullVersionString<\/Version><\/PropertyGroup><\/Project>/g"); -fi - -if [ $? -ne 0 ]; then exit 1; fi -echo "$content" && echo "$content" > $file; -if [ $? -ne 0 ]; then exit 1; fi - -echo "DONE"; -echo ""; diff --git a/.gitignore b/.gitignore index 530b14105..bba535498 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ dotnet/.config *.user *.userosscache *.sln.docstates +webapi/data/*.json +__dev/ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -410,6 +412,7 @@ FodyWeavers.xsd *.pem .env +.env.production certs/ launchSettings.json config.development.yaml @@ -476,5 +479,24 @@ playwright-report/ # Static Web App deployment config swa-cli.config.json -**/copilot-chat-app/webapp/build -**/copilot-chat-app/webapp/node_modules \ No newline at end of file +webapp/build/ +webapp/node_modules/ + +# Custom plugin files used in webapp for testing +webapp/public/.well-known* + +# Auto-generated solution file from Visual Studio +webapi/CopilotChatWebApi.sln + +# Files created for deployments +/deploy/ + +# Tesseract OCR language data files +*.traineddata + +# Semantic Memory local storage +tmp/* + +# Custom plugins in default directories +*/Skills/NativePlugins +*/Skills/SemanticPlugins diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..abd8d87a0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "ms-dotnettools.csharp", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ms-semantic-kernel.semantic-kernel", + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..8495ea143 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + + { + "name": "Debug CopilotChatWebApi", + "type": "coreclr", + "preLaunchTask": "build (CopilotChatWebApi)", + "request": "launch", + "program": "${workspaceFolder}/webapi/bin/Debug/net6.0/CopilotChatWebApi.dll", + "cwd": "${workspaceFolder}/webapi", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..00085e410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,54 @@ +{ + "prettier.enable": true, + "editor.formatOnType": true, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "[csharp]": { + "editor.defaultFormatter": "ms-dotnettools.csharp", + "editor.codeActionsOnSave": { + "source.fixAll": true + } + }, + "editor.bracketPairColorization.enabled": true, + "editor.guides.bracketPairs": "active", + "javascript.updateImportsOnFileMove.enabled": "always", + "search.exclude": { + "**/node_modules": true, + "**/build": true + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.organizeImports": true, + "source.fixAll": true + } + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.organizeImports": true, + "source.fixAll": true + } + }, + "typescript.updateImportsOnFileMove.enabled": "always", + "eslint.enable": true, + "eslint.lintTask.enable": true, + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ], + "files.associations": { + "*.json": "jsonc" + }, + "files.exclude": { + "**/.git": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**.lock": true, + }, + "dotnet.defaultSolution": "CopilotChat.sln", + "editor.tabSize": 2, + } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e5940b7ea..e7c9534aa 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -83,7 +83,7 @@ "type": "process", "args": [ "build", - "${workspaceFolder}/CopilotChatWebApi.csproj", + "${workspaceFolder}/webapi/CopilotChatWebApi.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary", "/property:DebugType=portable" @@ -131,4 +131,4 @@ "password": true } ] -} +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db9b7d214..090b63e4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ tips on how you can make reporting your issue as effective as possible. ### Where to Report -New issues can be reported in our [list of issues](https://github.com/microsoft/copilot-chat/issues). +New issues can be reported in our [list of issues](https://github.com/microsoft/chat-copilot/issues). Before filing a new issue, please search the list of issues to make sure it does not already exist. @@ -91,7 +91,7 @@ We use and recommend the following workflow: "issue-123" or "githubhandle-issue". 4. Make and commit your changes to your branch. 5. Add new tests corresponding to your change, if applicable. -6. Run the relevant scripts in [the section below](https://github.com/microsoft/copilot-chat/blob/main/CONTRIBUTING.md#dev-scripts) to ensure that your build is clean and all tests are passing. +6. Run the relevant scripts in [the section below](https://github.com/microsoft/chat-copilot/blob/main/CONTRIBUTING.md#dev-scripts) to ensure that your build is clean and all tests are passing. 7. Create a PR against the repository's **main** branch. - State in the description what issue or improvement your change is addressing. - Verify that all the Continuous Integration checks are passing. diff --git a/CopilotChat.sln b/CopilotChat.sln index 1135cca43..e79eb97a4 100644 --- a/CopilotChat.sln +++ b/CopilotChat.sln @@ -1,10 +1,21 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.6.33706.43 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CopilotChatWebApi", "webapi\CopilotChatWebApi.csproj", "{5252E68F-B653-44CE-9A32-360A75C54E0E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CopilotChatMemoryPipeline", "memorypipeline\CopilotChatMemoryPipeline.csproj", "{EE1C907E-E90A-4AB5-9094-6CCE5F0DEDF8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImportDocument", "tools\importdocument\ImportDocument.csproj", "{F67EF16F-9A8A-4DC6-A9A1-E64CDFE4F285}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ChatCopilotIntegrationTests", "integration-tests\ChatCopilotIntegrationTests.csproj", "{0CD2CD95-536B-455F-B74A-772A455FA607}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CopilotChatShared", "shared\CopilotChatShared.csproj", "{94F12185-FAF9-43E3-B153-28A1708AC918}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebSearcher", "plugins\web-searcher\WebSearcher.csproj", "{F83C857D-3080-4DEA-B3D1-978E2BC64BFB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PluginShared", "plugins\shared\PluginShared.csproj", "{9D03913A-21FF-4D0A-9883-95C4B3D6F65A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +26,30 @@ Global {5252E68F-B653-44CE-9A32-360A75C54E0E}.Debug|Any CPU.Build.0 = Debug|Any CPU {5252E68F-B653-44CE-9A32-360A75C54E0E}.Release|Any CPU.ActiveCfg = Release|Any CPU {5252E68F-B653-44CE-9A32-360A75C54E0E}.Release|Any CPU.Build.0 = Release|Any CPU + {EE1C907E-E90A-4AB5-9094-6CCE5F0DEDF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE1C907E-E90A-4AB5-9094-6CCE5F0DEDF8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE1C907E-E90A-4AB5-9094-6CCE5F0DEDF8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE1C907E-E90A-4AB5-9094-6CCE5F0DEDF8}.Release|Any CPU.Build.0 = Release|Any CPU + {F67EF16F-9A8A-4DC6-A9A1-E64CDFE4F285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F67EF16F-9A8A-4DC6-A9A1-E64CDFE4F285}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F67EF16F-9A8A-4DC6-A9A1-E64CDFE4F285}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F67EF16F-9A8A-4DC6-A9A1-E64CDFE4F285}.Release|Any CPU.Build.0 = Release|Any CPU + {0CD2CD95-536B-455F-B74A-772A455FA607}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0CD2CD95-536B-455F-B74A-772A455FA607}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0CD2CD95-536B-455F-B74A-772A455FA607}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0CD2CD95-536B-455F-B74A-772A455FA607}.Release|Any CPU.Build.0 = Release|Any CPU + {94F12185-FAF9-43E3-B153-28A1708AC918}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94F12185-FAF9-43E3-B153-28A1708AC918}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94F12185-FAF9-43E3-B153-28A1708AC918}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94F12185-FAF9-43E3-B153-28A1708AC918}.Release|Any CPU.Build.0 = Release|Any CPU + {F83C857D-3080-4DEA-B3D1-978E2BC64BFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F83C857D-3080-4DEA-B3D1-978E2BC64BFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F83C857D-3080-4DEA-B3D1-978E2BC64BFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F83C857D-3080-4DEA-B3D1-978E2BC64BFB}.Release|Any CPU.Build.0 = Release|Any CPU + {9D03913A-21FF-4D0A-9883-95C4B3D6F65A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D03913A-21FF-4D0A-9883-95C4B3D6F65A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D03913A-21FF-4D0A-9883-95C4B3D6F65A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D03913A-21FF-4D0A-9883-95C4B3D6F65A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CopilotChat.sln.DotSettings b/CopilotChat.sln.DotSettings new file mode 100644 index 000000000..d1a28aa42 --- /dev/null +++ b/CopilotChat.sln.DotSettings @@ -0,0 +1,305 @@ + + False + False + True + True + FullFormat + True + True + True + True + True + SOLUTION + False + SUGGESTION + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + SUGGESTION + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + ERROR + True + Field, Property, Event, Method + True + True + True + NEXT_LINE + True + True + True + True + True + True + 1 + 1 + True + True + True + ALWAYS + True + True + False + 512 + True + Copyright (c) Microsoft. All rights reserved. + ACS + AI + AIGPT + AMQP + API + BOM + CORS + DB + DI + GPT + GRPC + HMAC + HTTP + IM + IO + IOS + JSON + JWT + MQ + MQTT + MS + MSAL + OCR + OID + OK + OS + PR + QA + SHA + SK + SKHTTP + SSL + TTL + UI + UID + URL + XML + YAML + False + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB"><ExtraRule Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="Async" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="" Suffix="Async" Style="aaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="Async" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="" Suffix="Async" Style="aaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="s_" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="Async" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Private" Description="Constant fields (private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local variables"><ElementKinds><Kind Name="LOCAL_VARIABLE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="" Suffix="Async" Style="aaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Constant fields (not private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local functions"><ElementKinds><Kind Name="LOCAL_FUNCTION" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="Async" Style="AaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Methods"><ElementKinds><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="Async" Style="AaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Parameters"><ElementKinds><Kind Name="PARAMETER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="" Suffix="Async" Style="aaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"><ElementKinds><Kind Name="LOCAL_CONSTANT" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB"><ExtraRule Prefix="" Suffix="" Style="aaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="Async" Style="AaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="s_" Suffix="" Style="aaBb" /></Policy> + + 2 + False + True + Console + PushToShowHints + True + True + True + True + True + True + True + True + True + True + True + True + False + TRACE + 8201 + Automatic + True + True + True + True + True + 2.0 + InCSharpFile + pragma + True + #pragma warning disable CA0000 // reason + +#pragma warning restore CA0000 + True + True + False + True + guid() + 0 + True + True + False + False + True + 2.0 + InCSharpFile + aaa + True + [Fact] +public void It$SOMENAME$() +{ + // Arrange + + // Act + + // Assert + +} + True + True + MSFT copyright + True + 2.0 + InCSharpFile + copy + // Copyright (c) Microsoft. All rights reserved. + + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + DO_NOT_SHOW + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 000000000..197770b35 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,51 @@ + + + + 12 + + + LatestMajor + + + enable + + + enable + + false + All + CA1812,CA2234,CS1570,CS1572,CS1573,CS1574,CA1056,CA1716,CA1724,SKEXP0003,SKEXP0011,SKEXP0021,SKEXP0026,SKEXP0042,SKEXP0050,SKEXP0052,SKEXP0053,SKEXP0060,KMEXP02,SKEXP0040 + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 000000000..f4b7b2d84 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,102 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + \ No newline at end of file diff --git a/README.md b/README.md index 7d60cd93d..f6746fb6f 100644 --- a/README.md +++ b/README.md @@ -1,172 +1,411 @@ -# Copilot Chat Sample Application - -> This sample is for educational purposes only and is not recommended for production deployments. - -# About Copilot Chat - -This sample allows you to build your own integrated large language model chat copilot. -This is an enriched intelligence app, with multiple dynamic components including -command messages, user intent, and memories. - -The chat prompt and response will evolve as the conversation between the user and the application proceeds. -This chat experience is orchestrated with Semantic Kernel and a Copilot Chat skill containing numerous -functions that work together to construct each response. - -![UI Sample](images/UI-Sample.png) - -# Automated Setup and Local Deployment - -Refer to [./scripts/README.md](./scripts/README.md) for local configuration and deployment. - -Refer to [./deploy/README.md](./deploy/README.md) for Azure configuration and deployment. - -# Manual Setup and Local Deployment - -## Configure your environment - -Before you get started, make sure you have the following requirements in place: - -- [.NET 6.0 SDK](https://dotnet.microsoft.com/download/dotnet/6.0) -- [Node.js](https://nodejs.org/) -- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) - After installation, run `yarn --version` in a terminal window to ensure you are running v1.22.19. -- [Azure OpenAI](https://aka.ms/oai/access) resource or an account with [OpenAI](https://platform.openai.com). -- [Visual Studio Code](https://code.visualstudio.com/Download) **(Optional)** - -## Start the WebApi Backend Server - -The sample uses two applications, a front-end web UI, and a back-end API server. -First, let’s set up and verify the back-end API server is running. - -1. Generate and trust a localhost developer certificate. Open a terminal and run: - - For Windows and Mac run `dotnet dev-certs https --trust` and select `Yes` when asked if you want to install this certificate. - - For Linux run `dotnet dev-certs https` - > **Note:** It is recommended you close all instances of your web browser after installing the developer certificates. - -2. Navigate to `webapi/` and open `appsettings.json` - - Update the `AIService` configuration section: - - Update `Type` to the AI service you will be using (i.e., `AzureOpenAI` or `OpenAI`). - - If your are using Azure OpenAI, update `Endpoint` to your Azure OpenAI resource Endpoint address (e.g., - `http://contoso.openai.azure.com`). - > If you are using OpenAI, this property will be ignored. - - Set your Azure OpenAI or OpenAI key by opening a terminal in the webapi project directory and using `dotnet user-secrets` - ```bash - cd webapi - dotnet user-secrets set "AIService:Key" "MY_AZUREOPENAI_OR_OPENAI_KEY" - ``` - - **(Optional)** Update `Models` to the Azure OpenAI deployment or OpenAI models you want to use. - - For `Completion` and `Planner`, CopilotChat is optimized for Chat completion models, such as gpt-3.5-turbo and gpt-4. - > **Important:** gpt-3.5-turbo is normally labelled as "`gpt-35-turbo`" (no period) in Azure OpenAI and "`gpt-3.5-turbo`" (with a period) in OpenAI. - - For `Embedding`, `text-embedding-ada-002` is sufficient and cost-effective for generating embeddings. - > **Important:** If you are using Azure OpenAI, please use [deployment names](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource). If you are using OpenAI, please use [model names](https://platform.openai.com/docs/models). - - - **(Optional)** To enable speech-to-text for chat input, update the `AzureSpeech` configuration section: - > If you have not already, you will need to [create an Azure Speech resource](https://ms.portal.azure.com/#create/Microsoft.CognitiveServicesSpeechServices) - (see [./webapi/appsettings.json](webapi/appsettings.json) for more details). - - Update `Region` to whichever region is appropriate for your speech sdk instance. - - Set your Azure speech key by opening a terminal in the webapi project directory and setting - a dotnet user-secrets value for `AzureSpeech:Key` - ```bash - dotnet user-secrets set "AzureSpeech:Key" "MY_AZURE_SPEECH_KEY" - ``` - -3. Build and run the back-end API server - 1. Open a terminal and navigate to `webapi/` - - 2. Run `dotnet build` to build the project. - - 3. Run `dotnet run` to start the server. - - 4. Verify the back-end server is responding, open a web browser and navigate to `https://localhost:40443/healthz` - > The first time accessing the probe you may get a warning saying that there is a problem with website's certificate. - Select the option to accept/continue - this is expected when running a service on `localhost` - It is important to do this, as your browser may need to accept the certificate before allowing the frontend to communicate with the backend. - - > You may also need to acknowledge the Windows Defender Firewall, and allow the app to communicate over private or public networks as appropriate. - -## Start the WebApp FrontEnd application - -1. Build and start the front-end application - 1. You will need an Azure Active Directory (AAD) application registration. - > For more details on creating an application registration, go [here](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app). - - Select `Single-page application (SPA)` as platform type, and set the Web redirect URI to `http://localhost:3000` - - Select `Accounts in any organizational directory and personal Microsoft Accounts` as supported account types for this sample. - - Make a note of the `Application (client) ID` from the Azure Portal, we will use of it later. - - 2. Open a terminal and navigate to `webapp/` Copy `.env.example` into a new - file `.env` and update the `REACT_APP_AAD_CLIENT_ID` with the AAD application (Client) ID created above. - For example: +**NOTE**: This is a **sample** application to help you understand how Semantic Kernel and AI can work in Web Applications. This sample is **NOT FOR PRODUCTION** deployments. + +# Chat Copilot Sample Application + +This sample allows you to build your own integrated large language model (LLM) chat copilot. The sample is built on Microsoft [Semantic Kernel](https://github.com/microsoft/semantic-kernel) and has three components: + +1. A frontend application [React web app](./webapp/) +2. A backend REST API [.NET web API service](./webapi/) +3. A [.NET worker service](./memorypipeline/) for processing semantic memory. + +These quick-start instructions run the sample locally. They can also be found on the official Chat Copilot Microsoft Learn documentation page for [getting started](https://learn.microsoft.com/semantic-kernel/chat-copilot/getting-started). + +To deploy the sample to Azure, please view [Deploying Chat Copilot](./scripts/deploy/README.md) after meeting the [requirements](#requirements) described below. + +> **IMPORTANT:** This sample is for educational purposes only and is not recommended for production deployments. + +> **IMPORTANT:** Each chat interaction will call Azure OpenAI/OpenAI which will use tokens that you may be billed for. + +![Chat Copilot answering a question](https://learn.microsoft.com/en-us/semantic-kernel/media/chat-copilot-in-action.gif) + +# Requirements + +You will need the following items to run the sample: + +- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) _(via Setup install.\* script)_ +- [Node.js](https://nodejs.org/en/download) _(via Setup install.\* script)_ +- [Yarn](https://classic.yarnpkg.com/docs/install) _(via Setup install.\* script)_ +- [Git](https://www.git-scm.com/downloads) +- AI Service (one of the following is required) + +| AI Service | Requirement | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Azure OpenAI | - [Access](https://aka.ms/oai/access)
- [Resource](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource)
- [Deployed models](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model) (`gpt-4o` and `text-embedding-ada-002`)
- [Endpoint](https://learn.microsoft.com/azure/ai-services/openai/tutorials/embeddings?tabs=command-line#retrieve-key-and-endpoint)
- [API key](https://learn.microsoft.com/azure/ai-services/openai/tutorials/embeddings?tabs=command-line#retrieve-key-and-endpoint) | +| OpenAI | - [Account](https://platform.openai.com/docs/overview)
- [API key](https://platform.openai.com/api-keys) | + +# Instructions + +## Windows + +1. Open PowerShell as an administrator. + > NOTE: Ensure that you have [PowerShell Core 6+](https://github.com/PowerShell/PowerShell) installed. This is different from the default PowerShell installed on Windows. +1. Clone this repository + ```powershell + git clone https://github.com/microsoft/chat-copilot + ``` +1. Setup your environment. + + The following is a script to help you install the dependencies required. Feel free to install `dotnet`, `nodejs`, and `yarn` with your method of choice or use this script. + + ```powershell + cd \scripts\ + .\Install.ps1 + ``` + + > NOTE: This script will install `Chocolatey`, `dotnet-8.0-sdk`, `nodejs`, and `yarn`. + + > NOTE: If you receive an error that the script is not digitally signed or cannot execute on the system, you may need to [change the execution policy](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.3#change-the-execution-policy) (see list of [policies](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.3#powershell-execution-policies) and [scopes](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.3#execution-policy-scope)) or [unblock the script](https://learn.microsoft.com/powershell/module/microsoft.powershell.security/get-executionpolicy?view=powershell-7.3#example-4-unblock-a-script-to-run-it-without-changing-the-execution-policy). + +1. Configure Chat Copilot. + + ```powershell + .\Configure.ps1 -AIService {AI_SERVICE} -APIKey {API_KEY} -Endpoint {AZURE_OPENAI_ENDPOINT} + ``` + + - `AI_SERVICE`: `AzureOpenAI` or `OpenAI`. + - `API_KEY`: The `API key` for Azure OpenAI or for OpenAI. + - `AZURE_OPENAI_ENDPOINT`: The Azure OpenAI resource `Endpoint` address. This is only required when using Azure OpenAI, omit `-Endpoint` if using OpenAI. + + - > **IMPORTANT:** For `AzureOpenAI`, if you deployed models `gpt-4o` and `text-embedding-ada-002` with custom names (instead of the default names), also use the parameters: + + ```powershell + -CompletionModel {DEPLOYMENT_NAME} -EmbeddingModel {DEPLOYMENT_NAME} + ``` + + Open the `.\Configure.ps1` script to see all of the available parameters. + +1. Run Chat Copilot locally. This step starts both the backend API and frontend application. + + ```powershell + .\Start.ps1 + ``` + + It may take a few minutes for Yarn packages to install on the first run. + + > NOTE: Confirm pop-ups are not blocked and you are logged in with the same account used to register the application. + + - (Optional) To start ONLY the backend: + + ```powershell + .\Start-Backend.ps1 + ``` + +## Linux/macOS + +1. Open Bash as an Administrator. +1. Clone this repository + ```bash + git clone https://github.com/microsoft/chat-copilot + ``` +1. Configure environment. + + The following is a script to help you install the dependencies required. Feel free to install `dotnet`, `nodejs`, and `yarn` with your method of choice or use this script. + + ```bash + cd /scripts/ + ``` + + **Ubuntu/Debian Linux** + + ```bash + ./install-apt.sh + ``` + + > NOTE: This script uses `apt` to install `dotnet-sdk-8.0`, `nodejs`, and `yarn`. + + **macOS** + + ```bash + ./install-brew.sh + ``` + + > NOTE: This script uses `homebrew` to install `dotnet-sdk`, `nodejs`, and `yarn`. + +1. Configure Chat Copilot. + + 1. For OpenAI + ```bash - REACT_APP_BACKEND_URI=https://localhost:40443/ - REACT_APP_AAD_CLIENT_ID={Your Application (client) ID} - REACT_APP_AAD_AUTHORITY=https://login.microsoftonline.com/common + ./configure.sh --aiservice OpenAI --apikey {API_KEY} ``` - > For more detail on AAD authorities, see [Client Application Configuration Authorities](https://learn.microsoft.com/en-us/azure/active-directory/develop/msal-client-application-configuration#authority). - > `REACT_APP_SK_API_KEY` is only required if you're using an Semantic Kernel service deployed to Azure. See the [Authorization section of Deploying Semantic Kernel to Azure in a web app service](./deploy/README.md#authorization) for more details and instruction on how to find your API key. + - `API_KEY`: The `API key` for OpenAI. + + 2. For Azure OpenAI + ```bash - REACT_APP_SK_API_KEY={Your API Key, should be the same as Authorization:ApiKey from appsettings.json} + ./configure.sh --aiservice AzureOpenAI \ + --endpoint {AZURE_OPENAI_ENDPOINT} \ + --apikey {API_KEY} ``` - 3. To build and run the front-end application, open a terminal and navigate to `webapp/` if not already, then run: + - `AZURE_OPENAI_ENDPOINT`: The Azure OpenAI resource `Endpoint` address. + - `API_KEY`: The `API key` for Azure OpenAI. + + **IMPORTANT:** If you deployed models `gpt-4o` and `text-embedding-ada-002` + with custom names (instead of the default names), you need to specify + the deployment names with three additional parameters: + ```bash - yarn install - yarn start + ./configure.sh --aiservice AzureOpenAI \ + --endpoint {AZURE_OPENAI_ENDPOINT} \ + --apikey {API_KEY} \ + --completionmodel {DEPLOYMENT_NAME} \ + --embeddingmodel {DEPLOYMENT_NAME} ``` - > To run the WebApp with HTTPs, see [How to use HTTPS for local development](./webapp/README.md#how-to-use-https-for-local-development). - 4. With the back end and front end running, your web browser should automatically launch and navigate to `http://localhost:3000` - > The first time running the front-end application may take a minute or so to start. - - 5. Sign in with a Microsoft personal account or a "Work or School" account. - - 6. Consent permission for the application to read your profile information (i.e., your name). - - If you you experience any errors or issues, consult the troubleshooting section below. +1. Run Chat Copilot locally. This step starts both the backend API and frontend application. + + ```bash + ./start.sh + ``` + + It may take a few minutes for Yarn packages to install on the first run. + + > NOTE: Confirm pop-ups are not blocked and you are logged in with the same account used to register the application. + + - (Optional) To start ONLY the backend: + + ```powershell + ./start-backend.sh + ``` + +## (Optional) Run the [memory pipeline](./memorypipeline/README.md) + +By default, the webapi is configured to work without the memory pipeline for synchronous processing documents. To enable asynchronous document processing, you need to configure the webapi and the memory pipeline. Please refer to the [webapi README](./webapi/README.md) and the [memory pipeline README](./memorypipeline/README.md) for more information. + +## (Optional) Enable backend authentication via Azure AD + +By default, Chat Copilot runs locally without authentication, using a guest user profile. If you want to enable authentication with Azure Active Directory, follow the steps below. + +### Requirements + +- [Azure account](https://azure.microsoft.com/free) +- [Azure AD Tenant](https://learn.microsoft.com/azure/active-directory/develop/quickstart-create-new-tenant) + +### Instructions + +1. Create an [application registration](https://learn.microsoft.com/azure/active-directory/develop/quickstart-register-app) for the frontend web app, using the values below + + - `Supported account types`: "_Accounts in this organizational directory only ({YOUR TENANT} only - Single tenant)_" + - `Redirect URI (optional)`: _Single-page application (SPA)_ and use _http://localhost:3000_. + +2. Create a second [application registration](https://learn.microsoft.com/azure/active-directory/develop/quickstart-register-app) for the backend web api, using the values below: + - `Supported account types`: "_Accounts in this organizational directory only ({YOUR TENANT} only - Single tenant)_" + - Do **not** configure a `Redirect URI (optional)` + +> NOTE: Other account types can be used to allow multitenant and personal Microsoft accounts to use your application if you desire. Doing so may result in more users and therefore higher costs. + +> Take note of the `Application (client) ID` for both app registrations as you will need them in future steps. + +3. Expose an API within the second app registration -2. Have fun! - > **Note:** Each chat interaction will call Azure OpenAI/OpenAI which will use tokens that you may be billed for. + 1. Select _Expose an API_ from the menu + + 2. Add an _Application ID URI_ + + 1. This will generate an `api://` URI + + 2. Click _Save_ to store the generated URI + + 3. Add a scope for `access_as_user` + + 1. Click _Add scope_ + + 2. Set _Scope name_ to `access_as_user` + + 3. Set _Who can consent_ to _Admins and users_ + + 4. Set _Admin consent display name_ and _User consent display name_ to `Access copilot chat as a user` + + 5. Set _Admin consent description_ and _User consent description_ to `Allows the accesses to the Copilot chat web API as a user` + + 4. Add the web app frontend as an authorized client application + + 1. Click _Add a client application_ + + 2. For _Client ID_, enter the frontend's application (client) ID + + 3. Check the checkbox under _Authorized scopes_ + + 4. Click _Add application_ + +4. Add permissions to web app frontend to access web api as user + + 1. Open app registration for web app frontend + + 2. Go to _API Permissions_ + + 3. Click _Add a permission_ + + 4. Select the tab _APIs my organization uses_ + + 5. Choose the app registration representing the web api backend + + 6. Select permissions `access_as_user` + + 7. Click _Add permissions_ + +5. Run the Configure script with additional parameters to set up authentication. + + **Powershell** + + ```powershell + .\Configure.ps1 -AiService {AI_SERVICE} -APIKey {API_KEY} -Endpoint {AZURE_OPENAI_ENDPOINT} -FrontendClientId {FRONTEND_APPLICATION_ID} -BackendClientId {BACKEND_APPLICATION_ID} -TenantId {TENANT_ID} -Instance {AZURE_AD_INSTANCE} + ``` + + **Bash** + + ```bash + ./configure.sh --aiservice {AI_SERVICE} --apikey {API_KEY} --endpoint {AZURE_OPENAI_ENDPOINT} --frontend-clientid {FRONTEND_APPLICATION_ID} --backend-clientid {BACKEND_APPLICATION_ID} --tenantid {TENANT_ID} --instance {AZURE_AD_INSTANCE} + ``` + + - `AI_SERVICE`: `AzureOpenAI` or `OpenAI`. + - `API_KEY`: The `API key` for Azure OpenAI or for OpenAI. + - `AZURE_OPENAI_ENDPOINT`: The Azure OpenAI resource `Endpoint` address. This is only required when using Azure OpenAI, omit `-Endpoint` if using OpenAI. + - `FRONTEND_APPLICATION_ID`: The `Application (client) ID` associated with the application registration for the frontend. + - `BACKEND_APPLICATION_ID`: The `Application (client) ID` associated with the application registration for the backend. + - `TENANT_ID` : Your Azure AD tenant ID + - `AZURE_AD_INSTANCE` _(optional)_: The Azure AD cloud instance for the authenticating users. Defaults to `https://login.microsoftonline.com`. + +6. Run Chat Copilot locally. This step starts both the backend API and frontend application. + + **Powershell** + + ```powershell + .\Start.ps1 + ``` + + **Bash** + + ```bash + ./start.sh + ``` + +## Optional Configuration: [Ms Graph API Plugin with On-Behalf-Of Flow](./plugins/OBO/README.md) + +This native plugin enables the execution of Microsoft Graph APIs using the On-Behalf-Of (OBO) flow with delegated permissions. + +The OBO flows is used to ensure that the backend APIs are consumed with the identity of the user, not the managed identity or service principal of the middle-tier application (in this case the WebApi). + +Also, this ensures that consent is given, so that the client app (WebApp) can call the middle-tier app (WebApi), and the middle-tier app has permission to call the back-end resource (MSGraph). + +This sample does not implement incremental consent in the UI so all the Graph scopes to be used need to have "Administrator Consent" given in the middle-tier app registration. + +More information in the [OBO readme.md](./plugins/OBO/README.md). + +### Requirements + +Backend authentication via Azure AD must be enabled. Detailed instructions for enabling backend authentication are provided below. + +### Limitations + +- Currently, the plugin only supports GET operations. Future updates may add support for other types of operations. +- Graph queries that return large results, may reach the token limit for the AI model, producing an error. +- Incremental consent is not implemented in this sample. # Troubleshooting -## 1. Unable to load chats. Details: interaction_in_progress: Interaction is currently in progress. +1. **_Issue:_** Unable to load chats. + + _Details_: interaction*in_progress: Interaction is currently in progress.* + + _Explanation_: The WebApp can display this error when the application is configured for a different AAD tenant from the browser, (e.g., personal/MSA account vs work/school account). + + _Solution_: Either use a private/incognito browser tab or clear your browser credentials/cookies. Confirm you are logged in with the same account used to register the application. + +2. **_Issue:_**: Challenges using text completion models, such as `text-davinci-003` + + _Solution_: For OpenAI, see [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility) for + the complete list of current models supporting chat completions. For Azure OpenAI, see [model summary table and region availability](https://learn.microsoft.com/azure/ai-services/openai/concepts/models#model-summary-table-and-region-availability). + +3. **_Issue:_** Localhost SSL certificate errors / CORS errors + + ![Cert-Issue](https://github.com/microsoft/chat-copilot/assets/64985898/e9072af1-e43c-472d-bebc-d0082d0c9180) + + _Explanation_: Your browser may be blocking the frontend access to the backend while waiting for your permission to connect. + + _Solution_: + + 1. Confirm the backend service is running. Open a web browser and navigate to `https://localhost:40443/healthz` + - You should see a confirmation message: `Healthy` + - If your browser asks you to acknowledge the risks of visiting an insecure website, you must acknowledge this before the frontend can connect to the backend server. + 2. Navigate to `http://localhost:3000` or refresh the page to use the Chat Copilot application. + +4. **_Issue:_** Yarn is not working. + + _Explanation_: You may have the wrong Yarn version installed such as v2.x+. + + _Solution_: Use the classic version. + + ```bash + npm install -g yarn + yarn set version classic + ``` + +5. **_Issue:_** Missing `/usr/share/dotnet/host/fxr` folder. + + _Details_: "A fatal error occurred. The folder [/usr/share/dotnet/host/fxr] does not exist" when running dotnet commands on Linux. + + _Explanation_: When .NET (Core) was first released for Linux, it was not yet available in the official Ubuntu repo. So instead, many of us added the Microsoft APT repo in order to install it. Now, the packages are part of the Ubuntu repo, and they are conflicting with the Microsoft packages. This error is a result of mixed packages. ([Source: StackOverflow](https://stackoverflow.com/questions/73753672/a-fatal-error-occurred-the-folder-usr-share-dotnet-host-fxr-does-not-exist)) + + _Solution_: + + ```bash + # Remove all existing packages to get to a clean state: + sudo apt remove --assume-yes dotnet*; + sudo apt remove --assume-yes aspnetcore*; + sudo apt remove --assume-yes netstandard*; + + # Set the Microsoft package provider priority + echo -e "Package: *\nPin: origin \"packages.microsoft.com\"\nPin-Priority: 1001" | sudo tee /etc/apt/preferences.d/99microsoft-dotnet.pref; + + # Update and install dotnet + sudo apt update; + sudo apt install --assume-yes dotnet-sdk-8.0; + ``` + +# A note on branches -The WebApp can display this error when the application is configured for an active directory tenant, -(e.g., personal/MSA accounts) and the browser attempts to use single sign-on with an account from -another tenant (e.g., work or school account). Either user a private/incognito browser tab or clear -your browser credentials/cookies. +Every release is associated with a release branch. For instance, release [v0.9](https://github.com/microsoft/chat-copilot/releases/tag/v0.9) is on a branch called [0.9](https://github.com/microsoft/chat-copilot/tree/0.9). +Once a release is out, its branch will no longer be updated. The exception to this is the latest release branch, which will only receive bug fixes. +This is to provide some stability to those for whom breaking changes and being on the bleeding edge (with the bugs it can entail) is not a desirable option. -## 2. Issues using text completion models, such as `text-davinci-003` +# Check out our other repos! -CopilotChat supports chat completion models, such as `gpt-3.5-*` and `gpt-4-*`. -See [OpenAI's model compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility) for -the complete list of current models supporting chat completions. +If you would like to learn more about Semantic Kernel and AI, you may also be interested in other repos the Semantic Kernel team supports: -## 3. Localhost SSL certificate errors / CORS errors +| Repo | Description | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| [Semantic Kernel](https://github.com/microsoft/semantic-kernel) | A lightweight SDK that integrates cutting-edge LLM technology quickly and easily into your apps. | +| [Semantic Kernel Docs](https://github.com/MicrosoftDocs/semantic-kernel-docs) | The home for Semantic Kernel documentation that appears on the Microsoft learn site. | +| [Semantic Kernel Starters](https://github.com/microsoft/semantic-kernel-starters) | Starter projects for Semantic Kernel to make it easier to get started. | +| [Semantic Memory](https://github.com/microsoft/semantic-memory) | A service that allows you to create pipelines for ingesting, storing, and querying knowledge. | -![](images/Cert-Issue.png) +## Join the community -If you are stopped at an error message similar to the one above, your browser may be blocking the front-end access -to the back end while waiting for your permission to connect. To resolve this, try the following: +We welcome your contributions and suggestions to the Chat Copilot Sample App! One of the easiest +ways to participate is to engage in discussions in the GitHub repository. +Bug reports and fixes are welcome! -1. Confirm the backend service is running by opening a web browser, and navigating to `https://localhost:40443/healthz` - - You should see a confirmation message: `Healthy` -2. If your browser asks you to acknowledge the risks of visiting an insecure website, you must acknowledge the - message before the front end will be allowed to connect to the back-end server. - - Acknowledge, continue, and navigate until you see the message `Healthy`. -3. Navigate to `http://localhost:3000` or refresh the page to use the Copilot Chat application. +To learn more and get started: -## 4. Have Yarn version 2.x or 3.x +- Read the [documentation](https://learn.microsoft.com/semantic-kernel/chat-copilot/) +- Join the [Discord community](https://aka.ms/SKDiscord) +- [Contribute](CONTRIBUTING.md) to the project +- Follow the team on our [blog](https://aka.ms/sk/blog) -The webapp uses packages that are only supported by classic Yarn (v1.x). If you have Yarn v2.x+, run -the following commands in your preferred shell to flip Yarn to the classic version. +## Code of Conduct -```shell -npm install -g yarn -yarn set version classic -``` +This project has adopted the +[Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the +[Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) +with any additional questions or comments. -You can confirm the active Yarn version by running `yarn --version`. +## License -# Additional resources +Copyright (c) Microsoft Corporation. All rights reserved. -1. [Import Document Application](./importdocument/README.md): Import a document to the memory store. +Licensed under the [MIT](LICENSE) license. diff --git a/SUPPORT.md b/SUPPORT.md index eaf439aec..fe7dc1820 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,13 +1,3 @@ -# TODO: The maintainer of this repo has not yet edited this file - -**REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project? - -- **No CSS support:** Fill out this template with information about how to file issues and get help. -- **Yes CSS support:** Fill out an intake form at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). CSS will work with/help you to determine next steps. -- **Not sure?** Fill out an intake as though the answer were "Yes". CSS will help you decide. - -*Then remove this first heading from this SUPPORT.MD file before publishing your repo.* - # Support ## How to file issues and get help @@ -16,10 +6,6 @@ This project uses GitHub Issues to track bugs and feature requests. Please searc issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new Issue. -For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE -FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER -CHANNEL. WHERE WILL YOU HELP PEOPLE?**. - ## Microsoft Support Policy Support for this **PROJECT or PRODUCT** is limited to the resources listed above. diff --git a/deploy/README.md b/deploy/README.md deleted file mode 100644 index b58b25263..000000000 --- a/deploy/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# Deploying Copilot Chat -This document details how to deploy CopilotChat's required resources to your Azure subscription. - -## Things to know -- Access to Azure OpenAI is currently limited as we navigate high demand, upcoming product improvements, and Microsoft’s commitment to responsible AI. - For more details and information on applying for access, go [here](https://learn.microsoft.com/azure/cognitive-services/openai/overview?ocid=AID3051475#how-do-i-get-access-to-azure-openai). - For regional availability of Azure OpenAI, see the [availability map](https://azure.microsoft.com/explore/global-infrastructure/products-by-region/?products=cognitive-services). - -- With the limited availability of Azure OpenAI, consider sharing an Azure OpenAI instance across multiple resources. - -- `F1` and `D1` SKUs for the App Service Plans are not currently supported for this deployment in order to support private networking. - - -# Configure your environment -Before you get started, make sure you have the following requirements in place: -- Azure CLI (i.e., az) (if you already installed Azure CLI, make sure to update your installation to the latest version) - - Windows, go to https://aka.ms/installazurecliwindows - - Linux, run "`curl -L https://aka.ms/InstallAzureCli | bash`" -- Azure Static Web App CLI (i.e., swa) can be installed by running "`npm install -g @azure/static-web-apps-cli`" -- (Linux only) `zip` can be installed by running "`sudo apt install zip`" - - -# Deploy Azure Infrastructure -The examples below assume you are using an existing Azure OpenAI resource. See the notes following each command for using OpenAI or creating a new Azure OpenAI resource. - -## PowerShell -```powershell -./deploy-azure.ps1 -Subscription {YOUR_SUBSCRIPTION_ID} -DeploymentName {YOUR_DEPLOYMENT_NAME} -AIService {AzureOpenAI or OpenAI} -AIApiKey {YOUR_AI_KEY} -AIEndpoint {YOUR_AZURE_OPENAI_ENDPOINT} -``` - - To use an existing Azure OpenAI resource, set `-AIService` to `AzureOpenAI` and include `-AIApiKey` and `-AIEndpoint`. - - To deploy a new Azure OpenAI resource, set `-AIService` to `AzureOpenAI` and omit `-AIApiKey` and `-AIEndpoint`. - - To use an an OpenAI account, set `-AIService` to `OpenAI` and include `-AIApiKey`. - -## Bash -```bash -chmod +x ./deploy-azure.sh -./deploy-azure.sh --subscription {YOUR_SUBSCRIPTION_ID} --deployment-name {YOUR_DEPLOYMENT_NAME} --ai-service {AzureOpenAI or OpenAI} --ai-service-key {YOUR_AI_KEY} --ai-endpoint {YOUR_AZURE_OPENAI_ENDPOINT} -``` - - To use an existing Azure OpenAI resource, set `--ai-service` to `AzureOpenAI` and include `--ai-service-key` and `--ai-endpoint`. - - To deploy a new Azure OpenAI resource, set `--ai-service` to `AzureOpenAI` and omit `--ai-service-key` and `--ai-endpoint`. - - To use an an OpenAI account, set `--ai-service` to `OpenAI` and include `--ai-service-key`. - -## Azure Portal -You can also deploy the infrastructure directly from the Azure Portal by clicking the button below: - -[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoft%2Fcopilot-chat%2Fmain%2Fdeploy%2Fmain.json) - -> This will automatically deploy the most recent release of CopilotChat backend binaries ([link](https://github.com/microsoft/copilot-chat/releases)). - -> To find the deployment name when using `Deploy to Azure`, look for a deployment in your resource group that starts with `Microsoft.Template`. - - -# Deploy Backend (WebAPI) -To deploy the backend, build the deployment package first and deploy it to the Azure resources created above. - -## PowerShell -```powershell -./package-webapi.ps1 - -./deploy-webapi.ps1 -Subscription {YOUR_SUBSCRIPTION_ID} -ResourceGroupName rg-{YOUR_DEPLOYMENT_NAME} -DeploymentName {YOUR_DEPLOYMENT_NAME} -``` - -## Bash -```bash -chmod +x ./package-webapi.sh -./package-webapi.sh - -chmod +x ./deploy-webapi.sh -./deploy-webapi.sh --subscription {YOUR_SUBSCRIPTION_ID} --resource-group rg-{YOUR_DEPLOYMENT_NAME} --deployment-name {YOUR_DEPLOYMENT_NAME} -``` - - -# Deploy Frontend (WebApp) - -## Prerequisites -### App registration (identity) -You will need an Azure Active Directory (AAD) application registration. -> For details on creating an application registration, go [here](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app). -- Select `Single-page application (SPA)` as platform type, and set the redirect URI to `http://localhost:3000` -- Select `Accounts in any organizational directory and personal Microsoft Accounts` as supported account types for this sample. -- Make a note of the `Application (client) ID` from the Azure Portal for use in the `Deploy` below. - -### Install Azure's Static Web Apps CLI -```bash -npm install -g @azure/static-web-apps-cli -``` - -## PowerShell - -```powershell - -./deploy-webapp.ps1 -Subscription {YOUR_SUBSCRIPTION_ID} -ResourceGroupName rg-{YOUR_DEPLOYMENT_NAME} -DeploymentName {YOUR_DEPLOYMENT_NAME} -ApplicationClientId {YOUR_APPLICATION_ID} -``` - -## Bash - -```bash -./deploy-webapp.sh --subscription {YOUR_SUBSCRIPTION_ID} --resource-group rg-{YOUR_DEPLOYMENT_NAME} --deployment-name {YOUR_DEPLOYMENT_NAME} --application-id {YOUR_APPLICATION_ID} -``` - -Your CopilotChat application is now deployed! - - -# Appendix -## Using custom web frontends to access your deployment -Make sure to include your frontend's URL as an allowed origin in your deployment's CORS settings. Otherwise, web browsers will refuse to let JavaScript make calls to your deployment. - -To do this, go on the Azure portal, select your Semantic Kernel App Service, then click on "CORS" under the "API" section of the resource menu on the left of the page. -This will get you to the CORS page where you can add your allowed hosts. - -## Authorization -All of endpoints (except `/healthz`) require authorization to access. -By default, an API key is required for access which can be found in the `Authorization:ApiKey` configuration setting. -To authorize requests with the API key, add the API key value to a `x-sk-api-key` header in your requests. - -To view your CopilotChat API key: -### PowerShell -```powershell -$webApiName = $(az deployment group show --name {DEPLOYMENT_NAME} --resource-group rg-{DEPLOYMENT_NAME} --output json | ConvertFrom-Json).properties.outputs.webapiName.value - -($(az webapp config appsettings list --name $webapiName --resource-group rg-{YOUR_DEPLOYMENT_NAME} | ConvertFrom-JSON) | Where-Object -Property name -EQ -Value Authorization:ApiKey).value -``` - -### Bash -```bash -eval WEB_API_NAME=$(az deployment group show --name $DEPLOYMENT_NAME --resource-group $RESOURCE_GROUP --output json) | jq -r '.properties.outputs.webapiName.value' - -$(az webapp config appsettings list --name $WEB_API_NAME --resource-group rg-{YOUR_DEPLOYMENT_NAME} | jq '.[] | select(.name=="Authorization:ApiKey").value') -``` - diff --git a/deploy/deploy-webapp.ps1 b/deploy/deploy-webapp.ps1 deleted file mode 100644 index e9997946b..000000000 --- a/deploy/deploy-webapp.ps1 +++ /dev/null @@ -1,122 +0,0 @@ -<# -.SYNOPSIS -Deploy CopilotChat's WebApp to Azure -#> - -param( - [Parameter(Mandatory)] - [string] - # Subscription to which to make the deployment - $Subscription, - - [Parameter(Mandatory)] - [string] - # Resource group to which to make the deployment - $ResourceGroupName, - - [Parameter(Mandatory)] - [string] - # Name of the previously deployed Azure deployment - $DeploymentName, - - [Parameter(Mandatory)] - [string] - # Client application id - $ApplicationClientId -) - -Write-Host "Setting up Azure credentials..." -az account show --output none -if ($LASTEXITCODE -ne 0) { - Write-Host "Log into your Azure account" - az login --output none -} - -Write-Host "Setting subscription to '$Subscription'..." -az account set -s $Subscription -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE -} - -Write-Host "Getting deployment outputs..." -$deployment = $(az deployment group show --name $DeploymentName --resource-group $ResourceGroupName --output json | ConvertFrom-Json) -$webappUrl = $deployment.properties.outputs.webappUrl.value -$webappName = $deployment.properties.outputs.webappName.value -$webapiUrl = $deployment.properties.outputs.webapiUrl.value -$webapiName = $deployment.properties.outputs.webapiName.value -$webapiApiKey = ($(az webapp config appsettings list --name $webapiName --resource-group $ResourceGroupName | ConvertFrom-JSON) | Where-Object -Property name -EQ -Value Authorization:ApiKey).value -Write-Host "webappUrl: $webappUrl" -Write-Host "webappName: $webappName" -Write-Host "webapiName: $webapiName" -Write-Host "webapiUrl: $webapiUrl" - -# Set UTF8 as default encoding for Out-File -$PSDefaultParameterValues['Out-File:Encoding'] = 'ascii' - -$envFilePath = "$PSScriptRoot/../webapp/.env" -Write-Host "Writing environment variables to '$envFilePath'..." -"REACT_APP_BACKEND_URI=https://$webapiUrl/" | Out-File -FilePath $envFilePath -"REACT_APP_AAD_AUTHORITY=https://login.microsoftonline.com/common" | Out-File -FilePath $envFilePath -Append -"REACT_APP_AAD_CLIENT_ID=$ApplicationClientId" | Out-File -FilePath $envFilePath -Append -"REACT_APP_SK_API_KEY=$webapiApiKey" | Out-File -FilePath $envFilePath -Append - -Write-Host "Generating SWA config..." -$swaConfig = $(Get-Content "$PSScriptRoot/../webapp/template.swa-cli.config.json" -Raw) -$swaConfig = $swaConfig.Replace("{{appDevserverUrl}}", "https://$webappUrl") -$swaConfig = $swaConfig.Replace("{{appName}}", "$webappName") -$swaConfig = $swaConfig.Replace("{{resourceGroup}}", "$ResourceGroupName") -$swaConfig = $swaConfig.Replace("{{subscription-id}}", "$Subscription") - -$swaConfig | Out-File -FilePath "$PSScriptRoot/../webapp/swa-cli.config.json" -Write-Host $(Get-Content "$PSScriptRoot/../webapp/swa-cli.config.json" -Raw) - -Push-Location -Path "$PSScriptRoot/../webapp" -Write-Host "Installing yarn dependencies..." -yarn install -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE -} - -Write-Host "Building webapp..." -swa build -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE -} - -Write-Host "Deploying webapp..." -swa deploy -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE -} - -$origin = "https://$webappUrl" -Write-Host "Ensuring CORS origin '$origin' to webapi '$webapiName'..." -if (-not ((az webapp cors show --name $webapiName --resource-group $ResourceGroupName --subscription $Subscription | ConvertFrom-Json).allowedOrigins -contains $origin)) { - az webapp cors add --name $webapiName --resource-group $ResourceGroupName --subscription $Subscription --allowed-origins $origin -} - -Write-Host "Ensuring '$origin' is included in AAD app registration's redirect URIs..." -$objectId = (az ad app show --id $ApplicationClientId | ConvertFrom-Json).id -$redirectUris = (az rest --method GET --uri "https://graph.microsoft.com/v1.0/applications/$objectId" --headers 'Content-Type=application/json' | ConvertFrom-Json).spa.redirectUris -if ($redirectUris -notcontains "$origin") { - $redirectUris += "$origin" - - $body = "{spa:{redirectUris:[" - foreach ($uri in $redirectUris) { - $body += "'$uri'," - } - $body += "]}}" - - az rest ` - --method PATCH ` - --uri "https://graph.microsoft.com/v1.0/applications/$objectId" ` - --headers 'Content-Type=application/json' ` - --body $body -} -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE -} - -Pop-Location - -Write-Host "To verify your deployment, go to 'https://$webappUrl' in your browser." diff --git a/deploy/deploy-webapp.sh b/deploy/deploy-webapp.sh deleted file mode 100644 index 13b49c6e4..000000000 --- a/deploy/deploy-webapp.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/bin/bash - -# Deploy CopilotChat's WebApp to Azure - -set -e - -SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -usage() { - echo "Usage: $0 -d DEPLOYMENT_NAME -s SUBSCRIPTION --ai AI_SERVICE_TYPE -aikey AI_SERVICE_KEY [OPTIONS]" - echo "" - echo "Arguments:" - echo " -s, --subscription SUBSCRIPTION Subscription to which to make the deployment (mandatory)" - echo " -rg, --resource-group RESOURCE_GROUP Resource group name from a 'deploy-azure.sh' deployment (mandatory)" - echo " -d, --deployment-name DEPLOYMENT_NAME Name of the deployment from a 'deploy-azure.sh' deployment (mandatory)" - echo " -a, --application-id Client application ID (mandatory)" -} - -# Parse arguments -while [[ $# -gt 0 ]]; do - key="$1" - case $key in - -d|--deployment-name) - DEPLOYMENT_NAME="$2" - shift - shift - ;; - -s|--subscription) - SUBSCRIPTION="$2" - shift - shift - ;; - -rg|--resource-group) - RESOURCE_GROUP="$2" - shift - shift - ;; - -a|--application-id) - APPLICATION_ID="$2" - shift - shift - ;; - *) - echo "Unknown option $1" - usage - exit 1 - ;; - esac -done - -# Check mandatory arguments -if [[ -z "$DEPLOYMENT_NAME" ]] || [[ -z "$SUBSCRIPTION" ]] || [[ -z "$RESOURCE_GROUP" ]] || [[ -z "$APPLICATION_ID" ]]; then - usage - exit 1 -fi - -az account show --output none -if [ $? -ne 0 ]; then - echo "Log into your Azure account" - az login --use-device-code -fi - -az account set -s "$SUBSCRIPTION" - -echo "Getting deployment outputs..." -DEPLOYMENT_JSON=$(az deployment group show --name $DEPLOYMENT_NAME --resource-group $RESOURCE_GROUP --output json) -# get the webapiUrl from the deployment outputs -eval WEB_APP_URL=$(echo $DEPLOYMENT_JSON | jq -r '.properties.outputs.webappUrl.value') -echo "WEB_APP_URL: $WEB_APP_URL" -eval WEB_APP_NAME=$(echo $DEPLOYMENT_JSON | jq -r '.properties.outputs.webappName.value') -echo "WEB_APP_NAME: $WEB_APP_NAME" -eval WEB_API_URL=$(echo $DEPLOYMENT_JSON | jq -r '.properties.outputs.webapiUrl.value') -echo "WEB_API_URL: $WEB_API_URL" -eval WEB_API_NAME=$(echo $DEPLOYMENT_JSON | jq -r '.properties.outputs.webapiName.value') -echo "WEB_API_NAME: $WEB_API_NAME" -echo "Getting webapi key..." -eval WEB_API_KEY=$(az webapp config appsettings list --name $WEB_API_NAME --resource-group $RESOURCE_GROUP | jq '.[] | select(.name=="Authorization:ApiKey").value') - -ENV_FILE_PATH="$SCRIPT_ROOT/../webapp/.env" -echo "Writing environment variables to '$ENV_FILE_PATH'..." -echo "REACT_APP_BACKEND_URI=https://$WEB_API_URL/" > $ENV_FILE_PATH -echo "REACT_APP_AAD_AUTHORITY=https://login.microsoftonline.com/common" >> $ENV_FILE_PATH -echo "REACT_APP_AAD_CLIENT_ID=$APPLICATION_ID" >> $ENV_FILE_PATH -echo "REACT_APP_SK_API_KEY=$WEB_API_KEY" >> $ENV_FILE_PATH - -echo "Writing swa-cli.config.json..." -SWA_CONFIG_FILE_PATH="$SCRIPT_ROOT/../webapp/swa-cli.config.json" -sed "s/{{appDevserverUrl}}/https:\/\/${WEB_APP_URL}/g" $SCRIPT_ROOT/../webapp/template.swa-cli.config.json > $SWA_CONFIG_FILE_PATH -cat $SWA_CONFIG_FILE_PATH - -pushd "$SCRIPT_ROOT/../webapp" - -echo "Installing yarn dependencies..." -yarn install -if [ $? -ne 0 ]; then - echo "Failed to install yarn dependencies" - exit 1 -fi - -echo "Building webapp..." -swa build -if [ $? -ne 0 ]; then - echo "Failed to build webapp" - exit 1 -fi - -echo "Deploying webapp..." -swa deploy --subscription-id $SUBSCRIPTION --app-name $WEB_APP_NAME --env production -if [ $? -ne 0 ]; then - echo "Failed to deploy webapp" - exit 1 -fi - -ORIGIN="https://$WEB_APP_URL" -echo "Ensuring CORS origin '$ORIGIN' to webapi '$WEB_API_NAME'..." -CORS_RESULT=$(az webapp cors show --name $WEB_API_NAME --resource-group $RESOURCE_GROUP --subscription $SUBSCRIPTION | jq '.allowedOrigins | index("$ORIGIN")') -if [[ "$CORS_RESULT" == "null" ]]; then - echo "Adding CORS origin '$ORIGIN' to webapi '$WEB_API_NAME'..." - az webapp cors add --name $WEB_API_NAME --resource-group $RESOURCE_GROUP --subscription $SUBSCRIPTION --allowed-origins $ORIGIN -fi - -echo "Ensuring '$ORIGIN' is included in AAD app registration's redirect URIs..." -eval OBJECT_ID=$(az ad app show --id $APPLICATION_ID | jq -r '.id') - -REDIRECT_URIS=$(az rest --method GET --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" --headers 'Content-Type=application/json' | jq -r '.spa.redirectUris') -if [[ ! "$REDIRECT_URIS" =~ "$ORIGIN" ]]; then - BODY="{spa:{redirectUris:['" - eval BODY+=$(echo $REDIRECT_URIS | jq $'join("\',\'")') - BODY+="','$ORIGIN']}}" - - az rest \ - --method PATCH \ - --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ - --headers 'Content-Type=application/json' \ - --body $BODY -fi -if [ $? -ne 0 ]; then - echo "Failed to update app registration" - exit 1 -fi - -popd - -echo "To verify your deployment, go to 'https://$WEB_APP_URL' in your browser." \ No newline at end of file diff --git a/deploy/main.bicep b/deploy/main.bicep deleted file mode 100644 index 7307dea84..000000000 --- a/deploy/main.bicep +++ /dev/null @@ -1,708 +0,0 @@ -/* -Copyright (c) Microsoft. All rights reserved. -Licensed under the MIT license. See LICENSE file in the project root for full license information. - -Bicep template for deploying CopilotChat Azure resources. -*/ - -@description('Name for the deployment consisting of alphanumeric characters or dashes (\'-\')') -param name string = 'copichat' - -@description('SKU for the Azure App Service plan') -@allowed(['B1', 'S1', 'S2', 'S3', 'P1V3', 'P2V3', 'I1V2', 'I2V2' ]) -param webAppServiceSku string = 'B1' - -@description('Location of package to deploy as the web service') -#disable-next-line no-hardcoded-env-urls -param packageUri string = 'https://aka.ms/copilotchat/webapi/latest' - -@description('Underlying AI service') -@allowed([ - 'AzureOpenAI' - 'OpenAI' -]) -param aiService string = 'AzureOpenAI' - -@description('Model to use for chat completions') -param completionModel string = 'gpt-35-turbo' - -@description('Model to use for text embeddings') -param embeddingModel string = 'text-embedding-ada-002' - -@description('Completion model the task planner should use') -param plannerModel string = 'gpt-35-turbo' - -@description('Azure OpenAI endpoint to use (Azure OpenAI only)') -param aiEndpoint string = '' - -@secure() -@description('Azure OpenAI or OpenAI API key') -param aiApiKey string = '' - -@secure() -@description('WebAPI key to use for authorization') -param webApiKey string = newGuid() - -@description('Whether to deploy a new Azure OpenAI instance') -param deployNewAzureOpenAI bool = false - -@description('Whether to deploy Cosmos DB for persistent chat storage') -param deployCosmosDB bool = true - -@description('Whether to deploy Qdrant (in a container) for persistent memory storage') -param deployQdrant bool = true - -@description('Whether to deploy Azure Speech Services to enable input by voice') -param deploySpeechServices bool = true - -@description('Region for the resources') -param location string = resourceGroup().location - -@description('Region for the webapp frontend') -param webappLocation string = 'westus2' - -@description('Hash of the resource group ID') -var rgIdHash = uniqueString(resourceGroup().id) - -@description('Deployment name unique to resource group') -var uniqueName = '${name}-${rgIdHash}' - -@description('Name of the Azure Storage file share to create') -var storageFileShareName = 'aciqdrantshare' - - -resource openAI 'Microsoft.CognitiveServices/accounts@2022-12-01' = if(deployNewAzureOpenAI) { - name: 'ai-${uniqueName}' - location: location - kind: 'OpenAI' - sku: { - name: 'S0' - } - properties: { - customSubDomainName: toLower(uniqueName) - } -} - -resource openAI_completionModel 'Microsoft.CognitiveServices/accounts/deployments@2022-12-01' = if(deployNewAzureOpenAI) { - parent: openAI - name: completionModel - properties: { - model: { - format: 'OpenAI' - name: completionModel - } - scaleSettings: { - scaleType: 'Standard' - } - } -} - -resource openAI_embeddingModel 'Microsoft.CognitiveServices/accounts/deployments@2022-12-01' = if(deployNewAzureOpenAI) { - parent: openAI - name: embeddingModel - properties: { - model: { - format: 'OpenAI' - name: embeddingModel - } - scaleSettings: { - scaleType: 'Standard' - } - } - dependsOn: [ // This "dependency" is to create models sequentially because the resource - openAI_completionModel // provider does not support parallel creation of models properly. - ] -} - -resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = { - name: 'asp-${uniqueName}-webapi' - location: location - kind: 'app' - sku: { - name: webAppServiceSku - } -} - -resource appServiceWeb 'Microsoft.Web/sites@2022-09-01' = { - name: 'app-${uniqueName}-webapi' - location: location - kind: 'app' - tags: { - skweb: '1' - } - properties: { - serverFarmId: appServicePlan.id - httpsOnly: true - virtualNetworkSubnetId: virtualNetwork.properties.subnets[0].id - } -} - -resource appServiceWebConfig 'Microsoft.Web/sites/config@2022-09-01' = { - parent: appServiceWeb - name: 'web' - properties: { - alwaysOn: true - cors: { - allowedOrigins: [ - 'http://localhost:3000' - 'https://localhost:3000' - ] - supportCredentials: true - } - detailedErrorLoggingEnabled: true - minTlsVersion: '1.2' - netFrameworkVersion: 'v6.0' - use32BitWorkerProcess: false - vnetRouteAllEnabled: true - webSocketsEnabled: true - appSettings: [ - { - name: 'AIService:Type' - value: aiService - } - { - name: 'AIService:Endpoint' - value: deployNewAzureOpenAI ? openAI.properties.endpoint : aiEndpoint - } - { - name: 'AIService:Key' - value: deployNewAzureOpenAI ? openAI.listKeys().key1 : aiApiKey - } - { - name: 'AIService:Models:Completion' - value: completionModel - } - { - name: 'AIService:Models:Embedding' - value: embeddingModel - } - { - name: 'AIService:Models:Planner' - value: plannerModel - } - { - name: 'Authorization:Type' - value: empty(webApiKey) ? 'None' : 'ApiKey' - } - { - name: 'Authorization:ApiKey' - value: webApiKey - } - { - name: 'ChatStore:Type' - value: deployCosmosDB ? 'cosmos' : 'volatile' - } - { - name: 'ChatStore:Cosmos:Database' - value: 'CopilotChat' - } - { - name: 'ChatStore:Cosmos:ChatSessionsContainer' - value: 'chatsessions' - } - { - name: 'ChatStore:Cosmos:ChatMessagesContainer' - value: 'chatmessages' - } - { - name: 'ChatStore:Cosmos:ChatMemorySourcesContainer' - value: 'chatmemorysources' - } - { - name: 'ChatStore:Cosmos:ChatParticipantsContainer' - value: 'chatparticipants' - } - { - name: 'ChatStore:Cosmos:ConnectionString' - value: deployCosmosDB ? cosmosAccount.listConnectionStrings().connectionStrings[0].connectionString : '' - } - { - name: 'MemoriesStore:Type' - value: deployQdrant ? 'Qdrant' : 'Volatile' - } - { - name: 'MemoriesStore:Qdrant:Host' - value: deployQdrant ? 'https://${appServiceQdrant.properties.defaultHostName}' : '' - } - { - name: 'MemoriesStore:Qdrant:Port' - value: '443' - } - { - name: 'AzureSpeech:Region' - value: location - } - { - name: 'AzureSpeech:Key' - value: deploySpeechServices ? speechAccount.listKeys().key1 : '' - } - { - name: 'AllowedOrigins' - value: '[*]' // Defer list of allowed origins to the Azure service app's CORS configuration - } - { - name: 'Kestrel:Endpoints:Https:Url' - value: 'https://localhost:443' - } - { - name: 'Logging:LogLevel:Default' - value: 'Warning' - } - { - name: 'Logging:LogLevel:SemanticKernel.Service' - value: 'Warning' - } - { - name: 'Logging:LogLevel:Microsoft.SemanticKernel' - value: 'Warning' - } - { - name: 'Logging:LogLevel:Microsoft.AspNetCore.Hosting' - value: 'Warning' - } - { - name: 'Logging:LogLevel:Microsoft.Hosting.Lifetimel' - value: 'Warning' - } - { - name: 'ApplicationInsights:ConnectionString' - value: appInsights.properties.ConnectionString - } - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: appInsights.properties.ConnectionString - } - { - name: 'ApplicationInsightsAgent_EXTENSION_VERSION' - value: '~2' - } - ] - } -} - -resource appServiceWebDeploy 'Microsoft.Web/sites/extensions@2022-09-01' = { - name: 'MSDeploy' - kind: 'string' - parent: appServiceWeb - properties: { - packageUri: packageUri - } - dependsOn: [ - appServiceWebConfig - ] -} - -resource appInsights 'Microsoft.Insights/components@2020-02-02' = { - name: 'appins-${uniqueName}' - location: location - kind: 'string' - tags: { - displayName: 'AppInsight' - } - properties: { - Application_Type: 'web' - WorkspaceResourceId: logAnalyticsWorkspace.id - } -} - -resource appInsightExtension 'Microsoft.Web/sites/siteextensions@2022-09-01' = { - parent: appServiceWeb - name: 'Microsoft.ApplicationInsights.AzureWebSites' - dependsOn: [ - appServiceWebDeploy - ] -} - -resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { - name: 'la-${uniqueName}' - location: location - tags: { - displayName: 'Log Analytics' - } - properties: { - sku: { - name: 'PerGB2018' - } - retentionInDays: 90 - features: { - searchVersion: 1 - legacy: 0 - enableLogAccessUsingOnlyResourcePermissions: true - } - } -} - -resource storage 'Microsoft.Storage/storageAccounts@2022-09-01' = if (deployQdrant) { - name: 'st${rgIdHash}' // Not using full unique name to avoid hitting 24 char limit - location: location - kind: 'StorageV2' - sku: { - name: 'Standard_LRS' - } - properties: { - supportsHttpsTrafficOnly: true - allowBlobPublicAccess: false - } - resource fileservices 'fileServices' = { - name: 'default' - resource share 'shares' = { - name: storageFileShareName - } - } -} - -resource appServicePlanQdrant 'Microsoft.Web/serverfarms@2022-03-01' = if (deployQdrant) { - name: 'asp-${uniqueName}-qdrant' - location: location - kind: 'linux' - sku: { - name: 'P1v3' - } - properties: { - reserved: true - } -} - -resource appServiceQdrant 'Microsoft.Web/sites@2022-09-01' = if (deployQdrant) { - name: 'app-${uniqueName}-qdrant' - location: location - kind: 'app,linux,container' - properties: { - serverFarmId: appServicePlanQdrant.id - httpsOnly: true - reserved: true - clientCertMode: 'Required' - virtualNetworkSubnetId: virtualNetwork.properties.subnets[1].id - siteConfig: { - numberOfWorkers: 1 - linuxFxVersion: 'DOCKER|qdrant/qdrant:latest' - alwaysOn: true - vnetRouteAllEnabled: true - ipSecurityRestrictions: [ - { - vnetSubnetResourceId: virtualNetwork.properties.subnets[0].id - action: 'Allow' - priority: 300 - name: 'Allow front vnet' - } - { - ipAddress: 'Any' - action: 'Deny' - priority: 2147483647 - name: 'Deny all' - } - ] - azureStorageAccounts: { - aciqdrantshare: { - type: 'AzureFiles' - accountName: deployQdrant ? storage.name : 'notdeployed' - shareName: storageFileShareName - mountPath: '/qdrant/storage' - accessKey: deployQdrant ? storage.listKeys().keys[0].value : '' - } - } - } - } -} - -resource virtualNetwork 'Microsoft.Network/virtualNetworks@2021-05-01' = { - name: 'vnet-${uniqueName}' - location: location - properties: { - addressSpace: { - addressPrefixes: [ - '10.0.0.0/16' - ] - } - subnets: [ - { - name: 'webSubnet' - properties: { - addressPrefix: '10.0.1.0/24' - networkSecurityGroup: { - id: webNsg.id - } - serviceEndpoints: [ - { - service: 'Microsoft.Web' - locations: [ - '*' - ] - } - ] - delegations: [ - { - name: 'delegation' - properties: { - serviceName: 'Microsoft.Web/serverfarms' - } - } - ] - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - } - { - name: 'qdrantSubnet' - properties: { - addressPrefix: '10.0.2.0/24' - networkSecurityGroup: { - id: qdrantNsg.id - } - serviceEndpoints: [ - { - service: 'Microsoft.Web' - locations: [ - '*' - ] - } - ] - delegations: [ - { - name: 'delegation' - properties: { - serviceName: 'Microsoft.Web/serverfarms' - } - } - ] - privateEndpointNetworkPolicies: 'Disabled' - privateLinkServiceNetworkPolicies: 'Enabled' - } - } - ] - } -} - -resource webNsg 'Microsoft.Network/networkSecurityGroups@2022-11-01' = { - name: 'nsg-${uniqueName}-webapi' - location: location - properties: { - securityRules: [ - { - name: 'AllowAnyHTTPSInbound' - properties: { - protocol: 'TCP' - sourcePortRange: '*' - destinationPortRange: '443' - sourceAddressPrefix: '*' - destinationAddressPrefix: '*' - access: 'Allow' - priority: 100 - direction: 'Inbound' - } - } - ] - } -} - -resource qdrantNsg 'Microsoft.Network/networkSecurityGroups@2022-11-01' = { - name: 'nsg-${uniqueName}-qdrant' - location: location - properties: { - securityRules: [] - } -} - -resource webSubnetConnection 'Microsoft.Web/sites/virtualNetworkConnections@2022-09-01' = { - parent: appServiceWeb - name: 'webSubnetConnection' - properties: { - vnetResourceId: virtualNetwork.properties.subnets[0].id - isSwift: true - } -} - -resource qdrantSubnetConnection 'Microsoft.Web/sites/virtualNetworkConnections@2022-09-01' = if (deployQdrant) { - parent: appServiceQdrant - name: 'qdrantSubnetConnection' - properties: { - vnetResourceId: virtualNetwork.properties.subnets[1].id - isSwift: true - } -} - -resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = if (deployCosmosDB) { - name: toLower('cosmos-${uniqueName}') - location: location - kind: 'GlobalDocumentDB' - properties: { - consistencyPolicy: { defaultConsistencyLevel: 'Session' } - locations: [ { - locationName: location - failoverPriority: 0 - isZoneRedundant: false - } - ] - databaseAccountOfferType: 'Standard' - } -} - -resource cosmosDatabase 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2023-04-15' = if (deployCosmosDB) { - parent: cosmosAccount - name: 'CopilotChat' - properties: { - resource: { - id: 'CopilotChat' - } - } -} - -resource messageContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = if (deployCosmosDB) { - parent: cosmosDatabase - name: 'chatmessages' - properties: { - resource: { - id: 'chatmessages' - indexingPolicy: { - indexingMode: 'consistent' - automatic: true - includedPaths: [ - { - path: '/*' - } - ] - excludedPaths: [ - { - path: '/"_etag"/?' - } - ] - } - partitionKey: { - paths: [ - '/id' - ] - kind: 'Hash' - version: 2 - } - } - } -} - -resource sessionContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = if (deployCosmosDB) { - parent: cosmosDatabase - name: 'chatsessions' - properties: { - resource: { - id: 'chatsessions' - indexingPolicy: { - indexingMode: 'consistent' - automatic: true - includedPaths: [ - { - path: '/*' - } - ] - excludedPaths: [ - { - path: '/"_etag"/?' - } - ] - } - partitionKey: { - paths: [ - '/id' - ] - kind: 'Hash' - version: 2 - } - } - } -} - -resource participantContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = if (deployCosmosDB) { - parent: cosmosDatabase - name: 'chatparticipants' - properties: { - resource: { - id: 'chatparticipants' - indexingPolicy: { - indexingMode: 'consistent' - automatic: true - includedPaths: [ - { - path: '/*' - } - ] - excludedPaths: [ - { - path: '/"_etag"/?' - } - ] - } - partitionKey: { - paths: [ - '/id' - ] - kind: 'Hash' - version: 2 - } - } - } -} - -resource memorySourcesContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = if (deployCosmosDB) { - parent: cosmosDatabase - name: 'chatmemorysources' - properties: { - resource: { - id: 'chatmemorysources' - indexingPolicy: { - indexingMode: 'consistent' - automatic: true - includedPaths: [ - { - path: '/*' - } - ] - excludedPaths: [ - { - path: '/"_etag"/?' - } - ] - } - partitionKey: { - paths: [ - '/id' - ] - kind: 'Hash' - version: 2 - } - } - } -} - -resource speechAccount 'Microsoft.CognitiveServices/accounts@2022-12-01' = if (deploySpeechServices) { - name: 'cog-${uniqueName}' - location: location - sku: { - name: 'S0' - } - kind: 'SpeechServices' - identity: { - type: 'None' - } - properties: { - customSubDomainName: 'cog-${uniqueName}' - networkAcls: { - defaultAction: 'Allow' - } - publicNetworkAccess: 'Enabled' - } -} - -resource staticWebApp 'Microsoft.Web/staticSites@2022-09-01' = { - name: 'swa-${uniqueName}' - location: webappLocation - properties: { - provider: 'None' - } - sku: { - name: 'Free' - tier: 'Free' - } -} - -output webappUrl string = staticWebApp.properties.defaultHostname -output webappName string = staticWebApp.name -output webapiUrl string = appServiceWeb.properties.defaultHostName -output webapiName string = appServiceWeb.name diff --git a/deploy/main.json b/deploy/main.json deleted file mode 100644 index 1f7539631..000000000 --- a/deploy/main.json +++ /dev/null @@ -1,891 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "bicep", - "version": "0.16.2.56959", - "templateHash": "18037485528098010448" - } - }, - "parameters": { - "name": { - "type": "string", - "defaultValue": "copichat", - "metadata": { - "description": "Name for the deployment consisting of alphanumeric characters or dashes ('-')" - } - }, - "webAppServiceSku": { - "type": "string", - "defaultValue": "B1", - "allowedValues": [ - "B1", - "S1", - "S2", - "S3", - "P1V3", - "P2V3", - "I1V2", - "I2V2" - ], - "metadata": { - "description": "SKU for the Azure App Service plan" - } - }, - "packageUri": { - "type": "string", - "defaultValue": "https://aka.ms/copilotchat/webapi/latest", - "metadata": { - "description": "Location of package to deploy as the web service" - } - }, - "aiService": { - "type": "string", - "defaultValue": "AzureOpenAI", - "allowedValues": [ - "AzureOpenAI", - "OpenAI" - ], - "metadata": { - "description": "Underlying AI service" - } - }, - "completionModel": { - "type": "string", - "defaultValue": "gpt-35-turbo", - "metadata": { - "description": "Model to use for chat completions" - } - }, - "embeddingModel": { - "type": "string", - "defaultValue": "text-embedding-ada-002", - "metadata": { - "description": "Model to use for text embeddings" - } - }, - "plannerModel": { - "type": "string", - "defaultValue": "gpt-35-turbo", - "metadata": { - "description": "Completion model the task planner should use" - } - }, - "aiEndpoint": { - "type": "string", - "defaultValue": "", - "metadata": { - "description": "Azure OpenAI endpoint to use (Azure OpenAI only)" - } - }, - "aiApiKey": { - "type": "securestring", - "defaultValue": "", - "metadata": { - "description": "Azure OpenAI or OpenAI API key" - } - }, - "webApiKey": { - "type": "securestring", - "defaultValue": "[newGuid()]", - "metadata": { - "description": "WebAPI key to use for authorization" - } - }, - "deployNewAzureOpenAI": { - "type": "bool", - "defaultValue": false, - "metadata": { - "description": "Whether to deploy a new Azure OpenAI instance" - } - }, - "deployCosmosDB": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Whether to deploy Cosmos DB for persistent chat storage" - } - }, - "deployQdrant": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Whether to deploy Qdrant (in a container) for persistent memory storage" - } - }, - "deploySpeechServices": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Whether to deploy Azure Speech Services to enable input by voice" - } - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Region for the resources" - } - }, - "webappLocation": { - "type": "string", - "defaultValue": "westus2", - "metadata": { - "description": "Region for the webapp frontend" - } - } - }, - "variables": { - "rgIdHash": "[uniqueString(resourceGroup().id)]", - "uniqueName": "[format('{0}-{1}', parameters('name'), variables('rgIdHash'))]", - "storageFileShareName": "aciqdrantshare" - }, - "resources": [ - { - "condition": "[parameters('deployQdrant')]", - "type": "Microsoft.Storage/storageAccounts/fileServices/shares", - "apiVersion": "2022-09-01", - "name": "[format('{0}/{1}/{2}', format('st{0}', variables('rgIdHash')), 'default', variables('storageFileShareName'))]", - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts/fileServices', format('st{0}', variables('rgIdHash')), 'default')]" - ] - }, - { - "condition": "[parameters('deployQdrant')]", - "type": "Microsoft.Storage/storageAccounts/fileServices", - "apiVersion": "2022-09-01", - "name": "[format('{0}/{1}', format('st{0}', variables('rgIdHash')), 'default')]", - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash')))]" - ] - }, - { - "condition": "[parameters('deployNewAzureOpenAI')]", - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2022-12-01", - "name": "[format('ai-{0}', variables('uniqueName'))]", - "location": "[parameters('location')]", - "kind": "OpenAI", - "sku": { - "name": "S0" - }, - "properties": { - "customSubDomainName": "[toLower(variables('uniqueName'))]" - } - }, - { - "condition": "[parameters('deployNewAzureOpenAI')]", - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2022-12-01", - "name": "[format('{0}/{1}', format('ai-{0}', variables('uniqueName')), parameters('completionModel'))]", - "properties": { - "model": { - "format": "OpenAI", - "name": "[parameters('completionModel')]" - }, - "scaleSettings": { - "scaleType": "Standard" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName')))]" - ] - }, - { - "condition": "[parameters('deployNewAzureOpenAI')]", - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2022-12-01", - "name": "[format('{0}/{1}', format('ai-{0}', variables('uniqueName')), parameters('embeddingModel'))]", - "properties": { - "model": { - "format": "OpenAI", - "name": "[parameters('embeddingModel')]" - }, - "scaleSettings": { - "scaleType": "Standard" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName')))]", - "[resourceId('Microsoft.CognitiveServices/accounts/deployments', format('ai-{0}', variables('uniqueName')), parameters('completionModel'))]" - ] - }, - { - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2022-03-01", - "name": "[format('asp-{0}-webapi', variables('uniqueName'))]", - "location": "[parameters('location')]", - "kind": "app", - "sku": { - "name": "[parameters('webAppServiceSku')]" - } - }, - { - "type": "Microsoft.Web/sites", - "apiVersion": "2022-09-01", - "name": "[format('app-{0}-webapi', variables('uniqueName'))]", - "location": "[parameters('location')]", - "kind": "app", - "tags": { - "skweb": "1" - }, - "properties": { - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-webapi', variables('uniqueName')))]", - "httpsOnly": true, - "virtualNetworkSubnetId": "[reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[0].id]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-webapi', variables('uniqueName')))]", - "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" - ] - }, - { - "type": "Microsoft.Web/sites/config", - "apiVersion": "2022-09-01", - "name": "[format('{0}/{1}', format('app-{0}-webapi', variables('uniqueName')), 'web')]", - "properties": { - "alwaysOn": true, - "cors": { - "allowedOrigins": [ - "http://localhost:3000", - "https://localhost:3000" - ], - "supportCredentials": true - }, - "detailedErrorLoggingEnabled": true, - "minTlsVersion": "1.2", - "netFrameworkVersion": "v6.0", - "use32BitWorkerProcess": false, - "vnetRouteAllEnabled": true, - "webSocketsEnabled": true, - "appSettings": [ - { - "name": "AIService:Type", - "value": "[parameters('aiService')]" - }, - { - "name": "AIService:Endpoint", - "value": "[if(parameters('deployNewAzureOpenAI'), reference(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2022-12-01').endpoint, parameters('aiEndpoint'))]" - }, - { - "name": "AIService:Key", - "value": "[if(parameters('deployNewAzureOpenAI'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2022-12-01').key1, parameters('aiApiKey'))]" - }, - { - "name": "AIService:Models:Completion", - "value": "[parameters('completionModel')]" - }, - { - "name": "AIService:Models:Embedding", - "value": "[parameters('embeddingModel')]" - }, - { - "name": "AIService:Models:Planner", - "value": "[parameters('plannerModel')]" - }, - { - "name": "Authorization:Type", - "value": "[if(empty(parameters('webApiKey')), 'None', 'ApiKey')]" - }, - { - "name": "Authorization:ApiKey", - "value": "[parameters('webApiKey')]" - }, - { - "name": "ChatStore:Type", - "value": "[if(parameters('deployCosmosDB'), 'cosmos', 'volatile')]" - }, - { - "name": "ChatStore:Cosmos:Database", - "value": "CopilotChat" - }, - { - "name": "ChatStore:Cosmos:ChatSessionsContainer", - "value": "chatsessions" - }, - { - "name": "ChatStore:Cosmos:ChatMessagesContainer", - "value": "chatmessages" - }, - { - "name": "ChatStore:Cosmos:ChatMemorySourcesContainer", - "value": "chatmemorysources" - }, - { - "name": "ChatStore:Cosmos:ChatParticipantsContainer", - "value": "chatparticipants" - }, - { - "name": "ChatStore:Cosmos:ConnectionString", - "value": "[if(parameters('deployCosmosDB'), listConnectionStrings(resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('cosmos-{0}', variables('uniqueName')))), '2023-04-15').connectionStrings[0].connectionString, '')]" - }, - { - "name": "MemoriesStore:Type", - "value": "[if(parameters('deployQdrant'), 'Qdrant', 'Volatile')]" - }, - { - "name": "MemoriesStore:Qdrant:Host", - "value": "[if(parameters('deployQdrant'), format('https://{0}', reference(resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName'))), '2022-09-01').defaultHostName), '')]" - }, - { - "name": "MemoriesStore:Qdrant:Port", - "value": "443" - }, - { - "name": "AzureSpeech:Region", - "value": "[parameters('location')]" - }, - { - "name": "AzureSpeech:Key", - "value": "[if(parameters('deploySpeechServices'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', format('cog-{0}', variables('uniqueName'))), '2022-12-01').key1, '')]" - }, - { - "name": "AllowedOrigins", - "value": "[[*]" - }, - { - "name": "Kestrel:Endpoints:Https:Url", - "value": "https://localhost:443" - }, - { - "name": "Logging:LogLevel:Default", - "value": "Warning" - }, - { - "name": "Logging:LogLevel:SemanticKernel.Service", - "value": "Warning" - }, - { - "name": "Logging:LogLevel:Microsoft.SemanticKernel", - "value": "Warning" - }, - { - "name": "Logging:LogLevel:Microsoft.AspNetCore.Hosting", - "value": "Warning" - }, - { - "name": "Logging:LogLevel:Microsoft.Hosting.Lifetimel", - "value": "Warning" - }, - { - "name": "ApplicationInsights:ConnectionString", - "value": "[reference(resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName'))), '2020-02-02').ConnectionString]" - }, - { - "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", - "value": "[reference(resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName'))), '2020-02-02').ConnectionString]" - }, - { - "name": "ApplicationInsightsAgent_EXTENSION_VERSION", - "value": "~2" - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName')))]", - "[resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName')))]", - "[resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName')))]", - "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('cosmos-{0}', variables('uniqueName'))))]", - "[resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName')))]", - "[resourceId('Microsoft.CognitiveServices/accounts', format('cog-{0}', variables('uniqueName')))]" - ] - }, - { - "type": "Microsoft.Web/sites/extensions", - "apiVersion": "2022-09-01", - "name": "[format('{0}/{1}', format('app-{0}-webapi', variables('uniqueName')), 'MSDeploy')]", - "kind": "string", - "properties": { - "packageUri": "[parameters('packageUri')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName')))]", - "[resourceId('Microsoft.Web/sites/config', format('app-{0}-webapi', variables('uniqueName')), 'web')]" - ] - }, - { - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[format('appins-{0}', variables('uniqueName'))]", - "location": "[parameters('location')]", - "kind": "string", - "tags": { - "displayName": "AppInsight" - }, - "properties": { - "Application_Type": "web", - "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('la-{0}', variables('uniqueName')))]" - }, - "dependsOn": [ - "[resourceId('Microsoft.OperationalInsights/workspaces', format('la-{0}', variables('uniqueName')))]" - ] - }, - { - "type": "Microsoft.Web/sites/siteextensions", - "apiVersion": "2022-09-01", - "name": "[format('{0}/{1}', format('app-{0}-webapi', variables('uniqueName')), 'Microsoft.ApplicationInsights.AzureWebSites')]", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName')))]", - "[resourceId('Microsoft.Web/sites/extensions', format('app-{0}-webapi', variables('uniqueName')), 'MSDeploy')]" - ] - }, - { - "type": "Microsoft.OperationalInsights/workspaces", - "apiVersion": "2022-10-01", - "name": "[format('la-{0}', variables('uniqueName'))]", - "location": "[parameters('location')]", - "tags": { - "displayName": "Log Analytics" - }, - "properties": { - "sku": { - "name": "PerGB2018" - }, - "retentionInDays": 90, - "features": { - "searchVersion": 1, - "legacy": 0, - "enableLogAccessUsingOnlyResourcePermissions": true - } - } - }, - { - "condition": "[parameters('deployQdrant')]", - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2022-09-01", - "name": "[format('st{0}', variables('rgIdHash'))]", - "location": "[parameters('location')]", - "kind": "StorageV2", - "sku": { - "name": "Standard_LRS" - }, - "properties": { - "supportsHttpsTrafficOnly": true, - "allowBlobPublicAccess": false - } - }, - { - "condition": "[parameters('deployQdrant')]", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2022-03-01", - "name": "[format('asp-{0}-qdrant', variables('uniqueName'))]", - "location": "[parameters('location')]", - "kind": "linux", - "sku": { - "name": "P1v3" - }, - "properties": { - "reserved": true - } - }, - { - "condition": "[parameters('deployQdrant')]", - "type": "Microsoft.Web/sites", - "apiVersion": "2022-09-01", - "name": "[format('app-{0}-qdrant', variables('uniqueName'))]", - "location": "[parameters('location')]", - "kind": "app,linux,container", - "properties": { - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-qdrant', variables('uniqueName')))]", - "httpsOnly": true, - "reserved": true, - "clientCertMode": "Required", - "virtualNetworkSubnetId": "[reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[1].id]", - "siteConfig": { - "numberOfWorkers": 1, - "linuxFxVersion": "DOCKER|qdrant/qdrant:latest", - "alwaysOn": true, - "vnetRouteAllEnabled": true, - "ipSecurityRestrictions": [ - { - "vnetSubnetResourceId": "[reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[0].id]", - "action": "Allow", - "priority": 300, - "name": "Allow front vnet" - }, - { - "ipAddress": "Any", - "action": "Deny", - "priority": 2147483647, - "name": "Deny all" - } - ], - "azureStorageAccounts": { - "aciqdrantshare": { - "type": "AzureFiles", - "accountName": "[if(parameters('deployQdrant'), format('st{0}', variables('rgIdHash')), 'notdeployed')]", - "shareName": "[variables('storageFileShareName')]", - "mountPath": "/qdrant/storage", - "accessKey": "[if(parameters('deployQdrant'), listKeys(resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash'))), '2022-09-01').keys[0].value, '')]" - } - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-qdrant', variables('uniqueName')))]", - "[resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash')))]", - "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" - ] - }, - { - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2021-05-01", - "name": "[format('vnet-{0}', variables('uniqueName'))]", - "location": "[parameters('location')]", - "properties": { - "addressSpace": { - "addressPrefixes": [ - "10.0.0.0/16" - ] - }, - "subnets": [ - { - "name": "webSubnet", - "properties": { - "addressPrefix": "10.0.1.0/24", - "networkSecurityGroup": { - "id": "[resourceId('Microsoft.Network/networkSecurityGroups', format('nsg-{0}-webapi', variables('uniqueName')))]" - }, - "serviceEndpoints": [ - { - "service": "Microsoft.Web", - "locations": [ - "*" - ] - } - ], - "delegations": [ - { - "name": "delegation", - "properties": { - "serviceName": "Microsoft.Web/serverfarms" - } - } - ], - "privateEndpointNetworkPolicies": "Disabled", - "privateLinkServiceNetworkPolicies": "Enabled" - } - }, - { - "name": "qdrantSubnet", - "properties": { - "addressPrefix": "10.0.2.0/24", - "networkSecurityGroup": { - "id": "[resourceId('Microsoft.Network/networkSecurityGroups', format('nsg-{0}-qdrant', variables('uniqueName')))]" - }, - "serviceEndpoints": [ - { - "service": "Microsoft.Web", - "locations": [ - "*" - ] - } - ], - "delegations": [ - { - "name": "delegation", - "properties": { - "serviceName": "Microsoft.Web/serverfarms" - } - } - ], - "privateEndpointNetworkPolicies": "Disabled", - "privateLinkServiceNetworkPolicies": "Enabled" - } - } - ] - }, - "dependsOn": [ - "[resourceId('Microsoft.Network/networkSecurityGroups', format('nsg-{0}-qdrant', variables('uniqueName')))]", - "[resourceId('Microsoft.Network/networkSecurityGroups', format('nsg-{0}-webapi', variables('uniqueName')))]" - ] - }, - { - "type": "Microsoft.Network/networkSecurityGroups", - "apiVersion": "2022-11-01", - "name": "[format('nsg-{0}-webapi', variables('uniqueName'))]", - "location": "[parameters('location')]", - "properties": { - "securityRules": [ - { - "name": "AllowAnyHTTPSInbound", - "properties": { - "protocol": "TCP", - "sourcePortRange": "*", - "destinationPortRange": "443", - "sourceAddressPrefix": "*", - "destinationAddressPrefix": "*", - "access": "Allow", - "priority": 100, - "direction": "Inbound" - } - } - ] - } - }, - { - "type": "Microsoft.Network/networkSecurityGroups", - "apiVersion": "2022-11-01", - "name": "[format('nsg-{0}-qdrant', variables('uniqueName'))]", - "location": "[parameters('location')]", - "properties": { - "securityRules": [] - } - }, - { - "type": "Microsoft.Web/sites/virtualNetworkConnections", - "apiVersion": "2022-09-01", - "name": "[format('{0}/{1}', format('app-{0}-webapi', variables('uniqueName')), 'webSubnetConnection')]", - "properties": { - "vnetResourceId": "[reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[0].id]", - "isSwift": true - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName')))]", - "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" - ] - }, - { - "condition": "[parameters('deployQdrant')]", - "type": "Microsoft.Web/sites/virtualNetworkConnections", - "apiVersion": "2022-09-01", - "name": "[format('{0}/{1}', format('app-{0}-qdrant', variables('uniqueName')), 'qdrantSubnetConnection')]", - "properties": { - "vnetResourceId": "[reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[1].id]", - "isSwift": true - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName')))]", - "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" - ] - }, - { - "condition": "[parameters('deployCosmosDB')]", - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2023-04-15", - "name": "[toLower(format('cosmos-{0}', variables('uniqueName')))]", - "location": "[parameters('location')]", - "kind": "GlobalDocumentDB", - "properties": { - "consistencyPolicy": { - "defaultConsistencyLevel": "Session" - }, - "locations": [ - { - "locationName": "[parameters('location')]", - "failoverPriority": 0, - "isZoneRedundant": false - } - ], - "databaseAccountOfferType": "Standard" - } - }, - { - "condition": "[parameters('deployCosmosDB')]", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]", - "properties": { - "resource": { - "id": "CopilotChat" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('cosmos-{0}', variables('uniqueName'))))]" - ] - }, - { - "condition": "[parameters('deployCosmosDB')]", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat', 'chatmessages')]", - "properties": { - "resource": { - "id": "chatmessages", - "indexingPolicy": { - "indexingMode": "consistent", - "automatic": true, - "includedPaths": [ - { - "path": "/*" - } - ], - "excludedPaths": [ - { - "path": "/\"_etag\"/?" - } - ] - }, - "partitionKey": { - "paths": [ - "/id" - ], - "kind": "Hash", - "version": 2 - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" - ] - }, - { - "condition": "[parameters('deployCosmosDB')]", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat', 'chatsessions')]", - "properties": { - "resource": { - "id": "chatsessions", - "indexingPolicy": { - "indexingMode": "consistent", - "automatic": true, - "includedPaths": [ - { - "path": "/*" - } - ], - "excludedPaths": [ - { - "path": "/\"_etag\"/?" - } - ] - }, - "partitionKey": { - "paths": [ - "/id" - ], - "kind": "Hash", - "version": 2 - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" - ] - }, - { - "condition": "[parameters('deployCosmosDB')]", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat', 'chatparticipants')]", - "properties": { - "resource": { - "id": "chatparticipants", - "indexingPolicy": { - "indexingMode": "consistent", - "automatic": true, - "includedPaths": [ - { - "path": "/*" - } - ], - "excludedPaths": [ - { - "path": "/\"_etag\"/?" - } - ] - }, - "partitionKey": { - "paths": [ - "/id" - ], - "kind": "Hash", - "version": 2 - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" - ] - }, - { - "condition": "[parameters('deployCosmosDB')]", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2023-04-15", - "name": "[format('{0}/{1}/{2}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat', 'chatmemorysources')]", - "properties": { - "resource": { - "id": "chatmemorysources", - "indexingPolicy": { - "indexingMode": "consistent", - "automatic": true, - "includedPaths": [ - { - "path": "/*" - } - ], - "excludedPaths": [ - { - "path": "/\"_etag\"/?" - } - ] - }, - "partitionKey": { - "paths": [ - "/id" - ], - "kind": "Hash", - "version": 2 - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" - ] - }, - { - "condition": "[parameters('deploySpeechServices')]", - "type": "Microsoft.CognitiveServices/accounts", - "apiVersion": "2022-12-01", - "name": "[format('cog-{0}', variables('uniqueName'))]", - "location": "[parameters('location')]", - "sku": { - "name": "S0" - }, - "kind": "SpeechServices", - "identity": { - "type": "None" - }, - "properties": { - "customSubDomainName": "[format('cog-{0}', variables('uniqueName'))]", - "networkAcls": { - "defaultAction": "Allow" - }, - "publicNetworkAccess": "Enabled" - } - }, - { - "type": "Microsoft.Web/staticSites", - "apiVersion": "2022-09-01", - "name": "[format('swa-{0}', variables('uniqueName'))]", - "location": "[parameters('webappLocation')]", - "properties": { - "provider": "None" - }, - "sku": { - "name": "Free", - "tier": "Free" - } - } - ], - "outputs": { - "webappUrl": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Web/staticSites', format('swa-{0}', variables('uniqueName'))), '2022-09-01').defaultHostname]" - }, - "webappName": { - "type": "string", - "value": "[format('swa-{0}', variables('uniqueName'))]" - }, - "webapiUrl": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName'))), '2022-09-01').defaultHostName]" - }, - "webapiName": { - "type": "string", - "value": "[format('app-{0}-webapi', variables('uniqueName'))]" - } - } -} \ No newline at end of file diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 000000000..dd4255db4 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,139 @@ +# To start docker-compose: +# 1. Copy the example .env file for webapi: +# cp ../docker/webapi/.env.example ../docker/webapi/.env +# 2. Edit the .env file to add the corresponding configuration values. +# 3. Copy the example .env file for web searcher plugin: +# cp ../docker/plugins/web-searcher/.env.example ../docker/plugins/web-searcher/.env +# 4. Edit the .env file for web searcher plugin to add the corresponding configuration values. +# 5. Copy the example .env file for memorypipeline: +# cp ../docker/memorypipeline/.env.example ../docker/memorypipeline/.env +# 6. Edit the .env file for memmorypipeline to add the corresponding configuration values. +# 7. With the .env files populated, you can now start docker-compose by running "docker-compose up --build" + +version: "3" +services: + chat-copilot-webapp-nginx: + image: chat-copilot-webapp-nginx + build: + context: .. + dockerfile: docker/webapp/Dockerfile.nginx + args: + - REACT_APP_BACKEND_URI=http://localhost:3080 + ports: + - 3000:3000 + depends_on: + chat-copilot-webapi: + condition: service_started + # Alternatively use webapi image to serve frontend files + # chat-copilot-webapp: + # image: chat-copilot-webapp + # build: + # context: .. + # dockerfile: docker/webapp/Dockerfile + # ports: + # - 3000:3000 + # environment: + # - REACT_APP_BACKEND_URI=http://localhost:3080 + # depends_on: + # chat-copilot-webapi: + # condition: service_started + chat-copilot-webapi: + image: chat-copilot-webapi + build: + context: .. + dockerfile: docker/webapi/Dockerfile + ports: + - 3080:8080 + env_file: + - webapi/.env + environment: + - Authentication__Type=AzureAd + - KernelMemory__Services__Qdrant__Endpoint=http://qdrant:6333 + - KernelMemory__Services__Qdrant__APIKey=chat-copilot + - KernelMemory__Services__RabbitMq__Host=rabbitmq + - KernelMemory__Services__RabbitMq__Port=5672 + - KernelMemory__Services__RabbitMq__Username=chat-copilot + - KernelMemory__Services__RabbitMq__Password=chat-copilot + - KernelMemory__DocumentStorageType=AzureBlobs + - KernelMemory__ImageOcrType=Tesseract + - KernelMemory__TextGeneratorType=AzureOpenAI + - KernelMemory__DataIngestion__OrchestrationType=Distributed + - KernelMemory__DataIngestion__DistributedOrchestration__QueueType=RabbitMQ + - KernelMemory__DataIngestion__VectorDbTypes__0=Qdrant + - KernelMemory__DataIngestion__EmbeddingGeneratorTypes__0=AzureOpenAI + - KernelMemory__Retrieval__EmbeddingGeneratorType=AzureOpenAI + - KernelMemory__Retrieval__VectorDbType=Qdrant + - Plugins__1__Name=WebSearcher + - Plugins__1__ManifestDomain=http://web-searcher + - Plugins__1__Key=chat-copilot + depends_on: + qdrant: + condition: service_started + rabbitmq: + condition: service_healthy + web-searcher: + condition: service_healthy + qdrant: + image: qdrant/qdrant + ports: + - 6333:6333 + environment: + - QDRANT__SERVICE__API_KEY=chat-copilot + - QDRANT__LOG_LEVEL=INFO + rabbitmq: + image: rabbitmq:management + ports: + - 5672:5672 + - 15672:15672 + environment: + - RABBITMQ_DEFAULT_USER=chat-copilot + - RABBITMQ_DEFAULT_PASS=chat-copilot + healthcheck: + test: rabbitmq-diagnostics check_port_connectivity + interval: 10s + timeout: 5s + retries: 10 + web-searcher: + image: web-searcher + build: + context: .. + dockerfile: docker/plugins/web-searcher/Dockerfile + args: + - AZURE_FUNCTION_MASTER_KEY=chat-copilot + env_file: + - plugins/web-searcher/.env + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/.well-known/ai-plugin.json"] + interval: 10s + timeout: 5s + retries: 5 + chat-copilot-memorypipeline: + image: chat-copilot-memorypipeline + build: + context: .. + dockerfile: docker/memorypipeline/Dockerfile + ports: + - 3280:80 + env_file: + - memorypipeline/.env + environment: + - KernelMemory__Services__Qdrant__Endpoint=http://qdrant:6333 + - KernelMemory__Services__Qdrant__APIKey=chat-copilot + - KernelMemory__Services__RabbitMq__Host=rabbitmq + - KernelMemory__Services__RabbitMq__Port=5672 + - KernelMemory__Services__RabbitMq__Username=chat-copilot + - KernelMemory__Services__RabbitMq__Password=chat-copilot + - KernelMemory__DocumentStorageType=AzureBlobs + - KernelMemory__ImageOcrType=Tesseract + - KernelMemory__TextGeneratorType=AzureOpenAI + - KernelMemory__DataIngestion__OrchestrationType=Distributed + - KernelMemory__DataIngestion__DistributedOrchestration__QueueType=RabbitMQ + - KernelMemory__DataIngestion__VectorDbTypes__0=Qdrant + - KernelMemory__DataIngestion__EmbeddingGeneratorTypes__0=AzureOpenAI + - KernelMemory__Retrieval__EmbeddingGeneratorType=AzureOpenAI + - KernelMemory__Retrieval__VectorDbType=Qdrant + depends_on: + qdrant: + condition: service_started + rabbitmq: + condition: service_healthy diff --git a/docker/memorypipeline/.env.example b/docker/memorypipeline/.env.example new file mode 100644 index 000000000..6d37f0869 --- /dev/null +++ b/docker/memorypipeline/.env.example @@ -0,0 +1,13 @@ +# Azure OpenAI embedding settings +KernelMemory__Services__AzureOpenAIEmbedding__Deployment= +KernelMemory__Services__AzureOpenAIEmbedding__Endpoint= +KernelMemory__Services__AzureOpenAIEmbedding__APIKey= + +# Azure OpenAI text settings +KernelMemory__Services__AzureOpenAIText__Deployment= +KernelMemory__Services__AzureOpenAIText__Endpoint= +KernelMemory__Services__AzureOpenAIText__APIKey= + +# Azure blob +KernelMemory__Services__AzureBlobs__Auth= +KernelMemory__Services__AzureBlobs__ConnectionString= \ No newline at end of file diff --git a/docker/memorypipeline/Dockerfile b/docker/memorypipeline/Dockerfile new file mode 100644 index 000000000..904774443 --- /dev/null +++ b/docker/memorypipeline/Dockerfile @@ -0,0 +1,28 @@ +# docker build -f docker/webapi/Dockerfile -t chat-copilot-memorypipeline . + +# builder +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder +WORKDIR /source +# generate dev-certs for https +RUN dotnet dev-certs https +# copy everything else and build app +COPY memorypipeline memorypipeline +COPY shared shared +RUN cd memorypipeline && \ + dotnet restore --use-current-runtime && \ + apt update && apt install -y wget && \ + dotnet publish --use-current-runtime --self-contained false --no-restore -o /app && \ + mkdir /app/data && \ + wget -P /app/data https://raw.githubusercontent.com/tesseract-ocr/tessdata/main/eng.traineddata + + +# final stage/image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=builder /app . +COPY --from=builder /root/.dotnet/corefx/cryptography/x509stores/my/* /root/.dotnet/corefx/cryptography/x509stores/my/ +RUN apt update && \ + apt install -y libleptonica-dev libtesseract-dev libc6-dev libjpeg62-turbo-dev libgdiplus && \ + ln -s /usr/lib/x86_64-linux-gnu/liblept.so.5 x64/libleptonica-1.82.0.so && \ + ln -s /usr/lib/x86_64-linux-gnu/libtesseract.so.4.0.1 x64/libtesseract50.so +ENTRYPOINT ["./CopilotChatMemoryPipeline"] \ No newline at end of file diff --git a/docker/plugins/web-searcher/.env.example b/docker/plugins/web-searcher/.env.example new file mode 100644 index 000000000..d4fd11320 --- /dev/null +++ b/docker/plugins/web-searcher/.env.example @@ -0,0 +1 @@ +PluginConfig__BingApiKey= \ No newline at end of file diff --git a/docker/plugins/web-searcher/Dockerfile b/docker/plugins/web-searcher/Dockerfile new file mode 100644 index 000000000..efd286109 --- /dev/null +++ b/docker/plugins/web-searcher/Dockerfile @@ -0,0 +1,23 @@ +# docker build -f docker/plugins/web-searcher/Dockerfile -t web-searcher . + +# builder +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS installer-env +ARG AZURE_FUNCTION_MASTER_KEY +WORKDIR /source +COPY plugins/shared shared +COPY plugins/web-searcher web-searcher +RUN cd /source/web-searcher && \ + mkdir -p /home/site/wwwroot && \ + dotnet publish *.csproj --output /home/site/wwwroot && \ + mkdir -p /azure-functions-host/Secrets/ && \ + echo "{\"masterKey\":{\"name\":\"master\",\"value\":\"$AZURE_FUNCTION_MASTER_KEY\",\"encrypted\":false},\"functionKeys\":[]}" > /azure-functions-host/Secrets/host.json + +# final stage/image +FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4.0-dotnet-isolated8.0-appservice +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ + AzureWebJobsSecretStorageType=files +COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"] +COPY --from=installer-env ["/azure-functions-host/Secrets", "/azure-functions-host/Secrets"] + + diff --git a/docker/webapi/.env.example b/docker/webapi/.env.example new file mode 100644 index 000000000..65e911592 --- /dev/null +++ b/docker/webapi/.env.example @@ -0,0 +1,23 @@ +# Backend authentication via Azure AD +# Refer to https://github.com/microsoft/chat-copilot#optional-enable-backend-authentication-via-azure-ad +Authentication__AzureAd__ClientId= +Authentication__AzureAd__TenantId= +Frontend__AadClientId= + +# Azure speech settings +AzureSpeech__Region= +AzureSpeech__Key= + +# Azure OpenAI embedding settings +KernelMemory__Services__AzureOpenAIEmbedding__Deployment= +KernelMemory__Services__AzureOpenAIEmbedding__Endpoint= +KernelMemory__Services__AzureOpenAIEmbedding__APIKey= + +# Azure OpenAI text settings +KernelMemory__Services__AzureOpenAIText__Deployment= +KernelMemory__Services__AzureOpenAIText__Endpoint= +KernelMemory__Services__AzureOpenAIText__APIKey= + +# Azure blobs settings +KernelMemory__Services__AzureBlobs__Auth= +KernelMemory__Services__AzureBlobs__ConnectionString= \ No newline at end of file diff --git a/docker/webapi/Dockerfile b/docker/webapi/Dockerfile new file mode 100644 index 000000000..2b1f257cf --- /dev/null +++ b/docker/webapi/Dockerfile @@ -0,0 +1,27 @@ +# docker build -f docker/webapi/Dockerfile -t chat-copilot-webapi . + +# builder +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder +WORKDIR /source +# generate dev-certs for https +RUN dotnet dev-certs https +# copy everything else and build app +COPY webapi webapi +COPY shared shared +RUN cd webapi && \ + dotnet restore --use-current-runtime && \ + apt update && apt install -y wget && \ + wget -P data https://raw.githubusercontent.com/tesseract-ocr/tessdata/main/eng.traineddata && \ + dotnet publish --use-current-runtime --self-contained false --no-restore -o /app + +# final stage/image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ENV Kestrel__Endpoints__Http__Url=http://0.0.0.0:8080 +WORKDIR /app +COPY --from=builder /app . +COPY --from=builder /root/.dotnet/corefx/cryptography/x509stores/my/* /root/.dotnet/corefx/cryptography/x509stores/my/ +RUN apt update && \ + apt install -y libleptonica-dev libtesseract-dev libc6-dev libjpeg62-turbo-dev libgdiplus && \ + ln -s /usr/lib/x86_64-linux-gnu/liblept.so.5 x64/libleptonica-1.82.0.so && \ + ln -s /usr/lib/x86_64-linux-gnu/libtesseract.so.4.0.1 x64/libtesseract50.so +ENTRYPOINT ["./CopilotChatWebApi"] \ No newline at end of file diff --git a/docker/webapp/Dockerfile b/docker/webapp/Dockerfile new file mode 100644 index 000000000..3fc4671e0 --- /dev/null +++ b/docker/webapp/Dockerfile @@ -0,0 +1,19 @@ +# docker build -f docker/webapp/Dockerfile -t chat-copilot-webapp . + +# builder +FROM node:lts-alpine as builder +WORKDIR /app +COPY webapp/ . +RUN yarn install \ + --prefer-offline \ + --frozen-lockfile \ + --non-interactive \ + --production=false + +# final stage/image +FROM node:lts-alpine +WORKDIR /app +COPY --from=builder /app . +ENV HOST 0.0.0.0 +EXPOSE 3000 +ENTRYPOINT [ "yarn", "start" ] \ No newline at end of file diff --git a/docker/webapp/Dockerfile.nginx b/docker/webapp/Dockerfile.nginx new file mode 100644 index 000000000..505a34234 --- /dev/null +++ b/docker/webapp/Dockerfile.nginx @@ -0,0 +1,21 @@ +# source webapp/.env +# docker build --build-arg REACT_APP_BACKEND_URI=$REACT_APP_BACKEND_URI -f docker/webapp/Dockerfile.nginx -t chat-copilot-webapp-nginx . + +# builder +FROM node:lts-alpine as builder +ARG REACT_APP_BACKEND_URI +WORKDIR /app +COPY webapp/ . +RUN yarn install \ + --prefer-offline \ + --frozen-lockfile \ + --non-interactive \ + --production=false +RUN yarn build + +# final stage/image +FROM nginx:stable-alpine +EXPOSE 3000 +RUN sed -i 's/80/3000/g' /etc/nginx/conf.d/default.conf +COPY --from=builder /app/build /usr/share/nginx/html +CMD ["nginx", "-g", "daemon off;"] diff --git a/images/Cert-Issue.png b/images/Cert-Issue.png deleted file mode 100644 index c0ac3f822..000000000 Binary files a/images/Cert-Issue.png and /dev/null differ diff --git a/images/Document-Memory-Sample-1.png b/images/Document-Memory-Sample-1.png deleted file mode 100644 index 84ced7083..000000000 Binary files a/images/Document-Memory-Sample-1.png and /dev/null differ diff --git a/images/Document-Memory-Sample-2.png b/images/Document-Memory-Sample-2.png deleted file mode 100644 index 777ecba85..000000000 Binary files a/images/Document-Memory-Sample-2.png and /dev/null differ diff --git a/images/UI-Sample.png b/images/UI-Sample.png deleted file mode 100644 index 1bad26fe3..000000000 Binary files a/images/UI-Sample.png and /dev/null differ diff --git a/importdocument/Program.cs b/importdocument/Program.cs deleted file mode 100644 index 3149de2e6..000000000 --- a/importdocument/Program.cs +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.CommandLine; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Identity.Client; - -namespace ImportDocument; - -/// -/// This console app imports a list of files to the CopilotChat WebAPI document memory store. -/// -public static class Program -{ - public static void Main(string[] args) - { - var config = Config.GetConfig(); - if (!Config.Validate(config)) - { - Console.WriteLine("Error: Failed to read appsettings.json."); - return; - } - - var filesOption = new Option>(name: "--files", description: "The files to import to document memory store.") - { - IsRequired = true, - AllowMultipleArgumentsPerToken = true, - }; - - var chatCollectionOption = new Option( - name: "--chat-id", - description: "Save the extracted context to an isolated chat collection.", - getDefaultValue: () => Guid.Empty - ); - - var rootCommand = new RootCommand( - "This console app imports files to the CopilotChat WebAPI's document memory store." - ) - { - filesOption, chatCollectionOption - }; - - rootCommand.SetHandler(async (files, chatCollectionId) => - { - await ImportFilesAsync(files, config!, chatCollectionId); - }, - filesOption, chatCollectionOption - ); - - rootCommand.Invoke(args); - } - - /// - /// Acquires a user account from Azure AD. - /// - /// The App configuration. - /// Sets the account to the first account found. - /// Sets the access token to the first account found. - /// True if the user account was acquired. - private static async Task AcquireUserAccountAsync( - Config config, - Action setAccount, - Action setAccessToken) - { - Console.WriteLine("Requesting User Account ID..."); - - string[] scopes = { "User.Read" }; - try - { - var app = PublicClientApplicationBuilder.Create(config.ClientId) - .WithRedirectUri(config.RedirectUri) - .Build(); - var result = await app.AcquireTokenInteractive(scopes).ExecuteAsync(); - IEnumerable? accounts = await app.GetAccountsAsync(); - IAccount? first = accounts.FirstOrDefault(); - - if (first is null) - { - Console.WriteLine("Error: No accounts found"); - return false; - } - - setAccount(first); - setAccessToken(result.AccessToken); - return true; - } - catch (Exception ex) when (ex is MsalServiceException or MsalClientException) - { - Console.WriteLine($"Error: {ex.Message}"); - return false; - } - } - - /// - /// Conditionally imports a list of files to the Document Store. - /// - /// A list of files to import. - /// Configuration. - /// Save the extracted context to an isolated chat collection. - private static async Task ImportFilesAsync(IEnumerable files, Config config, Guid chatCollectionId) - { - foreach (var file in files) - { - if (!file.Exists) - { - Console.WriteLine($"File {file.FullName} does not exist."); - return; - } - } - - IAccount? userAccount = null; - string? accessToken = null; - - if (await AcquireUserAccountAsync(config, v => { userAccount = v; }, v => { accessToken = v; }) == false) - { - Console.WriteLine("Error: Failed to acquire user account."); - return; - } - Console.WriteLine($"Successfully acquired User ID. Continuing..."); - - using var formContent = new MultipartFormDataContent(); - List filesContent = files.Select(file => new StreamContent(file.OpenRead())).ToList(); - for (int i = 0; i < filesContent.Count; i++) - { - formContent.Add(filesContent[i], "formFiles", files.ElementAt(i).Name); - } - - var userId = userAccount!.HomeAccountId.Identifier; - var userName = userAccount.Username; - using var userIdContent = new StringContent(userId); - using var userNameContent = new StringContent(userName); - formContent.Add(userIdContent, "userId"); - formContent.Add(userNameContent, "userName"); - - if (chatCollectionId != Guid.Empty) - { - Console.WriteLine($"Uploading and parsing file to chat {chatCollectionId}..."); - using var chatScopeContent = new StringContent("Chat"); - using var chatCollectionIdContent = new StringContent(chatCollectionId.ToString()); - formContent.Add(chatScopeContent, "documentScope"); - formContent.Add(chatCollectionIdContent, "chatId"); - - // Calling UploadAsync here to make sure disposable objects are still in scope. - await UploadAsync(formContent, accessToken!, config); - } - else - { - Console.WriteLine("Uploading and parsing file to global collection..."); - using var globalScopeContent = new StringContent("Global"); - formContent.Add(globalScopeContent, "documentScope"); - - // Calling UploadAsync here to make sure disposable objects are still in scope. - await UploadAsync(formContent, accessToken!, config); - } - - // Dispose of all the file streams. - foreach (var fileContent in filesContent) - { - fileContent.Dispose(); - } - } - - /// - /// Sends a POST request to the Document Store to upload a file for parsing. - /// - /// The multipart form data content to send. - /// Configuration. - private static async Task UploadAsync( - MultipartFormDataContent multipartFormDataContent, - string accessToken, - Config config) - { - // Create a HttpClient instance and set the timeout to infinite since - // large documents will take a while to parse. - using HttpClientHandler clientHandler = new() - { - CheckCertificateRevocationList = true - }; - using HttpClient httpClient = new(clientHandler) - { - Timeout = Timeout.InfiniteTimeSpan - }; - // Add required properties to the request header. - httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}"); - if (!string.IsNullOrEmpty(config.ApiKey)) - { - httpClient.DefaultRequestHeaders.Add("x-sk-api-key", config.ApiKey); - } - - try - { - using HttpResponseMessage response = await httpClient.PostAsync( - new Uri(new Uri(config.ServiceUri), "importDocuments"), - multipartFormDataContent - ); - - if (!response.IsSuccessStatusCode) - { - Console.WriteLine($"Error: {response.StatusCode} {response.ReasonPhrase}"); - Console.WriteLine(await response.Content.ReadAsStringAsync()); - return; - } - - Console.WriteLine("Uploading and parsing successful."); - } - catch (HttpRequestException ex) - { - Console.WriteLine($"Error: {ex.Message}"); - } - } -} diff --git a/importdocument/README.md b/importdocument/README.md deleted file mode 100644 index 8ab490ed5..000000000 --- a/importdocument/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Copilot Chat Import Document App - -> **!IMPORTANT** -> This sample is for educational purposes only and is not recommended for production deployments. - -One of the exciting features of the Copilot Chat App is its ability to store contextual information -to [memories](https://github.com/microsoft/semantic-kernel/blob/main/docs/EMBEDDINGS.md) and retrieve -relevant information from memories to provide more meaningful answers to users through out the conversations. - -Memories can be generated from conversations as well as imported from external sources, such as documents. -Importing documents enables Copilot Chat to have up-to-date knowledge of specific contexts, such as enterprise and personal data. - -## Configure your environment -1. A registered App in Azure Portal (https://learn.microsoft.com/azure/active-directory/develop/quickstart-register-app) - - Select Mobile and desktop applications as platform type, and the Redirect URI will be `http://localhost` - - Select **`Accounts in any organizational directory (Any Azure AD directory - Multitenant) - and personal Microsoft accounts (e.g. Skype, Xbox)`** as the supported account - type for this sample. - - Note the **`Application (client) ID`** from your app registration. -2. Make sure the service is running. To start the service, see [here](../webapi/README.md). - -## Running the app -1. Ensure the web api is running at `https://localhost:40443/`. -2. Configure the appsettings.json file under this folder root with the following variables and fill - in with your information, where - `ClientId` is the GUID copied from the **Application (client) ID** from your app registration in the Azure Portal, - `RedirectUri` is the Redirect URI also from the app registration in the Azure Portal, and - `ServiceUri` is the address the web api is running at. - `ApiKey` is the API key to the service if there is one. - -3. Change directory to this folder root. -4. **Run** the following command to import a document to the app under the global document collection where - all users will have access to: - - `dotnet run --files .\sample-docs\ms10k.txt` - - Or **Run** the following command to import a document to the app under a chat isolated document collection where - only the chat session will have access to: - - `dotnet run --files .\sample-docs\ms10k.txt --chat-id [chatId]` - - > Note that this will open a browser window for you to sign in to retrieve your user id to make sure you have access to the chat session. - - > Currently only supports txt and pdf files. A sample file is provided under ./sample-docs. - - Importing may take some time to generate embeddings for each piece/chunk of a document. - - To import multiple files, specify multiple files. For example: - - `dotnet run --files .\sample-docs\ms10k.txt .\sample-docs\Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf` - -5. Chat with the bot. - - Examples: - - With [ms10k.txt](./sample-docs/ms10k.txt): - - ![](../images/Document-Memory-Sample-1.png) - - With [Microsoft Responsible AI Standard v2 General Requirements.pdf](./sample-docs/Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf): - - ![](../images/Document-Memory-Sample-2.png) \ No newline at end of file diff --git a/importdocument/appsettings.json b/importdocument/appsettings.json deleted file mode 100644 index c1aa3ade8..000000000 --- a/importdocument/appsettings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Config": { - "ClientId": "", - "RedirectUri": "", - "ServiceUri": "https://localhost:40443", - "ApiKey": "" - } -} \ No newline at end of file diff --git a/integration-tests/.editorconfig b/integration-tests/.editorconfig new file mode 100644 index 000000000..96b5cd531 --- /dev/null +++ b/integration-tests/.editorconfig @@ -0,0 +1,3 @@ +[**/**.cs] +resharper_inconsistent_naming_highlighting = none +dotnet_diagnostic.IDE1006.severity = none # No need for Async suffix on test names diff --git a/integration-tests/ChatCopilotIntegrationTest.cs b/integration-tests/ChatCopilotIntegrationTest.cs new file mode 100644 index 000000000..719e01c8f --- /dev/null +++ b/integration-tests/ChatCopilotIntegrationTest.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Headers; +using Microsoft.Extensions.Configuration; +using Microsoft.Identity.Client; +using Xunit; + +namespace ChatCopilotIntegrationTests; + +/// +/// Base class for Chat Copilot integration tests +/// +[Trait("Category", "Integration Tests")] +#pragma warning disable CA1051 +public abstract class ChatCopilotIntegrationTest : IDisposable +{ + protected const string BaseUrlSettingName = "BaseServerUrl"; + protected const string ClientIdSettingName = "ClientID"; + protected const string AuthoritySettingName = "Authority"; + protected const string UsernameSettingName = "TestUsername"; + protected const string PasswordSettingName = "TestPassword"; + protected const string ScopesSettingName = "Scopes"; + + protected readonly HttpClient HTTPClient; + protected readonly IConfigurationRoot Configuration; + + protected ChatCopilotIntegrationTest() + { + // Load configuration + this.Configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + string? baseUrl = this.Configuration[BaseUrlSettingName]; + Assert.False(string.IsNullOrEmpty(baseUrl)); + Assert.True(baseUrl.EndsWith('/')); + + this.HTTPClient = new HttpClient(); + this.HTTPClient.BaseAddress = new Uri(baseUrl); + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + this.HTTPClient.Dispose(); + } + } + + protected async Task SetUpAuthAsync() + { + string accesstoken = await this.GetUserTokenByPasswordAsync(); + Assert.True(!string.IsNullOrEmpty(accesstoken)); + + this.HTTPClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accesstoken); + } + + protected async Task GetUserTokenByPasswordAsync() + { + IPublicClientApplication app = PublicClientApplicationBuilder.Create(this.Configuration[ClientIdSettingName]) + .WithAuthority(this.Configuration[AuthoritySettingName]) + .Build(); + + string? scopeString = this.Configuration[ScopesSettingName]; + Assert.NotNull(scopeString); + + string[] scopes = scopeString.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries); + + var accounts = await app.GetAccountsAsync(); + + AuthenticationResult? result = null; + + if (accounts.Any()) + { + result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync(); + } + else + { + result = await app.AcquireTokenByUsernamePassword(scopes, this.Configuration[UsernameSettingName], this.Configuration[PasswordSettingName]).ExecuteAsync(); + } + + return result?.AccessToken ?? string.Empty; + } +} diff --git a/integration-tests/ChatCopilotIntegrationTests.csproj b/integration-tests/ChatCopilotIntegrationTests.csproj new file mode 100644 index 000000000..ebca6076c --- /dev/null +++ b/integration-tests/ChatCopilotIntegrationTests.csproj @@ -0,0 +1,40 @@ + + + + net8.0 + true + 81136671-a63f-4f20-bd0e-a65b2561999a + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + PreserveNewest + + + + diff --git a/integration-tests/ChatTests.cs b/integration-tests/ChatTests.cs new file mode 100644 index 000000000..c03a2dbd1 --- /dev/null +++ b/integration-tests/ChatTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Json; +using System.Text.Json; +using CopilotChat.WebApi.Models.Request; +using CopilotChat.WebApi.Models.Response; +using Xunit; +using static CopilotChat.WebApi.Models.Storage.CopilotChatMessage; + +namespace ChatCopilotIntegrationTests; + +public class ChatTests : ChatCopilotIntegrationTest +{ + private static readonly JsonSerializerOptions jsOpts = new() { PropertyNameCaseInsensitive = true }; + + [Fact] + public async void ChatMessagePostSucceedsWithValidInput() + { + await this.SetUpAuthAsync(); + + // Create chat session + var createChatParams = new CreateChatParameters() { Title = nameof(this.ChatMessagePostSucceedsWithValidInput) }; + HttpResponseMessage response = await this.HTTPClient.PostAsJsonAsync("chats", createChatParams); + response.EnsureSuccessStatusCode(); + + var contentStream = await response.Content.ReadAsStreamAsync(); + var createChatResponse = await JsonSerializer.DeserializeAsync(contentStream, jsOpts); + Assert.NotNull(createChatResponse); + + // Ask something to the bot + var ask = new Ask + { + Input = "Who is Satya Nadella?", + Variables = new KeyValuePair[] { new("MessageType", ChatMessageType.Message.ToString()) } + }; + response = await this.HTTPClient.PostAsJsonAsync($"chats/{createChatResponse.ChatSession.Id}/messages", ask); + response.EnsureSuccessStatusCode(); + + contentStream = await response.Content.ReadAsStreamAsync(); + var askResult = await JsonSerializer.DeserializeAsync(contentStream, jsOpts); + Assert.NotNull(askResult); + Assert.False(string.IsNullOrEmpty(askResult.Value)); + + // Clean up + response = await this.HTTPClient.DeleteAsync($"chats/{createChatResponse.ChatSession.Id}").ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } +} diff --git a/integration-tests/HealthzTests.cs b/integration-tests/HealthzTests.cs new file mode 100644 index 000000000..d3c77ba06 --- /dev/null +++ b/integration-tests/HealthzTests.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Xunit; + +namespace ChatCopilotIntegrationTests; + +/// +/// Class for testing the healthcheck endpoint +/// +public class HealthzTests : ChatCopilotIntegrationTest +{ + [Fact] + public async void HealthzSuccessfullyReturns() + { + HttpResponseMessage response = await this.HTTPClient.GetAsync("healthz"); + + response.EnsureSuccessStatusCode(); + } +} diff --git a/integration-tests/README.md b/integration-tests/README.md new file mode 100644 index 000000000..6674822af --- /dev/null +++ b/integration-tests/README.md @@ -0,0 +1,55 @@ +# Chat Copilot Integration Tests + +## Requirements + +1. A running instance of the Chat Copilot's [backend](../webapi/README.md). + +## Setup + +### Option 1: Use Secret Manager + +Integration tests require the URL of the backend instance. + +We suggest using the .NET [Secret Manager](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) +to avoid the risk of leaking secrets into the repository, branches and pull requests. + +Values set using the Secret Manager will override the settings set in the `testsettings.development.json` file and in environment variables. + +To set your secrets with Secret Manager: + +```ps +cd integration-tests + +dotnet user-secrets init +dotnet user-secrets set "BaseUrl" "https://your-backend-address/" +``` + +### Option 2: Use a Configuration File + +1. Create a `testsettings.development.json` file next to `testsettings.json`. This file will be ignored by git, + the content will not end up in pull requests, so it's safe for personal settings. Keep the file safe. +2. Edit `testsettings.development.json` and + 1. Set your base address - **make sure it ends with a trailing '/' ** + +For example: + +```json +{ + "BaseUrl": "https://localhost:40443/" +} +``` + +### Option 3: Use Environment Variables +You may also set the test settings in your environment variables. The environment variables will override the settings in the `testsettings.development.json` file. + +- bash: + +```bash +export BaseUrl="https://localhost:40443/" +``` + +- PowerShell: + +```ps +$env:BaseUrl = "https://localhost:40443/" +``` diff --git a/integration-tests/ServiceInfoTests.cs b/integration-tests/ServiceInfoTests.cs new file mode 100644 index 000000000..1051b2060 --- /dev/null +++ b/integration-tests/ServiceInfoTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using CopilotChat.WebApi.Models.Response; +using CopilotChat.WebApi.Options; +using Xunit; + +namespace ChatCopilotIntegrationTests; + +public class ServiceInfoTests : ChatCopilotIntegrationTest +{ + private static readonly JsonSerializerOptions jsonOptions = new() { PropertyNameCaseInsensitive = true }; + + [Fact] + public async void GetServiceInfo() + { + await this.SetUpAuthAsync(); + + HttpResponseMessage response = await this.HTTPClient.GetAsync("info/"); + response.EnsureSuccessStatusCode(); + + var contentStream = await response.Content.ReadAsStreamAsync(); + var objectFromResponse = await JsonSerializer.DeserializeAsync(contentStream, jsonOptions); + + Assert.NotNull(objectFromResponse); + Assert.False(string.IsNullOrEmpty(objectFromResponse.MemoryStore.SelectedType)); + Assert.False(string.IsNullOrEmpty(objectFromResponse.Version)); + } + + [Fact] + public async void GetAuthConfig() + { + HttpResponseMessage response = await this.HTTPClient.GetAsync("authConfig/"); + response.EnsureSuccessStatusCode(); + + var contentStream = await response.Content.ReadAsStreamAsync(); + var objectFromResponse = await JsonSerializer.DeserializeAsync(contentStream, jsonOptions); + + Assert.NotNull(objectFromResponse); + Assert.Equal(ChatAuthenticationOptions.AuthenticationType.AzureAd.ToString(), objectFromResponse.AuthType); + Assert.Equal(this.Configuration[AuthoritySettingName], objectFromResponse.AadAuthority); + Assert.Equal(this.Configuration[ClientIdSettingName], objectFromResponse.AadClientId); + Assert.False(string.IsNullOrEmpty(objectFromResponse.AadApiScope)); + } +} diff --git a/integration-tests/SpeechTokenTests.cs b/integration-tests/SpeechTokenTests.cs new file mode 100644 index 000000000..b3fbd6a37 --- /dev/null +++ b/integration-tests/SpeechTokenTests.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using CopilotChat.WebApi.Models.Response; +using Xunit; + +namespace ChatCopilotIntegrationTests; + +public class SpeechTokenTests : ChatCopilotIntegrationTest +{ + private static readonly JsonSerializerOptions jsonOpts = new() { PropertyNameCaseInsensitive = true }; + + [Fact] + public async void GetSpeechToken() + { + await this.SetUpAuthAsync(); + + HttpResponseMessage response = await this.HTTPClient.GetAsync("speechToken/"); + response.EnsureSuccessStatusCode(); + + var contentStream = await response.Content.ReadAsStreamAsync(); + var speechTokenResponse = await JsonSerializer.DeserializeAsync(contentStream, jsonOpts); + + Assert.NotNull(speechTokenResponse); + Assert.True((speechTokenResponse.IsSuccess == true && !string.IsNullOrEmpty(speechTokenResponse.Token)) || + speechTokenResponse.IsSuccess == false); + } +} diff --git a/integration-tests/StaticFiles.cs b/integration-tests/StaticFiles.cs new file mode 100644 index 000000000..42c160ff1 --- /dev/null +++ b/integration-tests/StaticFiles.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Xunit; + +namespace ChatCopilotIntegrationTests; + +public class StaticFiles : ChatCopilotIntegrationTest +{ + [Fact] + public async void GetStaticFiles() + { + HttpResponseMessage response = await this.HTTPClient.GetAsync("index.html"); + response.EnsureSuccessStatusCode(); + Assert.True(response.Content.Headers.ContentLength > 1); + + response = await this.HTTPClient.GetAsync("favicon.ico"); + response.EnsureSuccessStatusCode(); + Assert.True(response.Content.Headers.ContentLength > 1); + } +} diff --git a/integration-tests/testsettings.json b/integration-tests/testsettings.json new file mode 100644 index 000000000..2ec088ac6 --- /dev/null +++ b/integration-tests/testsettings.json @@ -0,0 +1,8 @@ +{ + "BaseServerUrl": "https://localhost:40443/", + "ClientID": "YOUR_FRONTEND_CLIEND_ID", + "Authority": "https://login.microsoftonline.com/YOUR_TENANT_ID", + "TestUsername": "YOUR_TEST_USERNAME", + "TestPassword": "YOUR_TEST_PASSWORD", // Take care not to check your password in + "Scopes": "openid, offline_access, profile, api://YOUR_BACKEND_CLIENT_ID/access_as_user" +} diff --git a/memorypipeline/CopilotChatMemoryPipeline.csproj b/memorypipeline/CopilotChatMemoryPipeline.csproj new file mode 100644 index 000000000..b0133d9aa --- /dev/null +++ b/memorypipeline/CopilotChatMemoryPipeline.csproj @@ -0,0 +1,19 @@ + + + + CopilotChat.MemoryPipeline + net8.0 + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + + + + + + + + + + + + + diff --git a/memorypipeline/Program.cs b/memorypipeline/Program.cs new file mode 100644 index 000000000..3c4c50a02 --- /dev/null +++ b/memorypipeline/Program.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using CopilotChat.Shared; +using Microsoft.KernelMemory; +using Microsoft.KernelMemory.Diagnostics; + +// ******************************************************** +// ************** SETUP *********************************** +// ******************************************************** + +var builder = WebApplication.CreateBuilder(); + +IKernelMemory memory = + new KernelMemoryBuilder(builder.Services) + .FromAppSettings() + .Build(); + +builder.Services.AddSingleton(memory); + +builder.Services.AddApplicationInsightsTelemetry(); + +var app = builder.Build(); + +DateTimeOffset start = DateTimeOffset.UtcNow; + +// Simple ping endpoint +app.MapGet("/", () => +{ + var uptime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - start.ToUnixTimeSeconds(); + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var message = $"Memory pipeline is running. Uptime: {uptime} secs."; + if (!string.IsNullOrEmpty(environment)) + { + message += $" Environment: {environment}"; + } + + return Results.Ok(message); +}); + +// ******************************************************** +// ************** START *********************************** +// ******************************************************** + +app.Logger.LogInformation( + "Starting Chat Copilot Memory pipeline service, .NET Env: {0}, Log Level: {1}", + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), + app.Logger.GetLogLevelName()); + +app.Run(); diff --git a/memorypipeline/README.md b/memorypipeline/README.md new file mode 100644 index 000000000..cdee60d52 --- /dev/null +++ b/memorypipeline/README.md @@ -0,0 +1,108 @@ +# Chat Copilot Memory Pipeline + +> **!IMPORTANT** +> This sample is for educational purposes only and is not recommended for production deployments. + +> **IMPORTANT:** The pipeline will call Azure OpenAI/OpenAI which will use tokens that you may be billed for. + +## Introduction + +### Memory + +One of the exciting features of the Chat Copilot App is its ability to store contextual information +to [memories](https://github.com/microsoft/semantic-kernel/blob/main/docs/EMBEDDINGS.md) and retrieve +relevant information from memories to provide more meaningful answers to users through out the conversations. + +Memories can be generated from conversations as well as imported from external sources, such as documents. +Importing documents enables Chat Copilot to have up-to-date knowledge of specific contexts, such as enterprise and personal data. + +### Memory pipeline in Chat Copilot + +Chat copilot integrates [Kernel Memory](https://github.com/microsoft/kernel-memory) as the memory solution provider. The memory pipeline is designed to be run as an asynchronous service. If you are expecting to import big documents that can require minutes to process or planning to carry long conversations with the bot, then you can deploy the memory pipeline as a separate service along with the [chat copilot webapi](https://github.com/microsoft/chat-copilot/tree/main/webapi). + +### Configuration + +(Optional) Before you get started, make sure you have the following requirements in place: + +- [An Azure Subscription](https://azure.microsoft.com/en-us/free/) +- [Docker Desktop](https://www.docker.com/products/docker-desktop) + +#### Webapi + +Please refer to the [webapi README](../webapi/README.md). + +#### Memorypipeline + +The memorypipeline is only needed when `KernelMemory:DataIngestion:OrchestrationType` is set to `Distributed` in [../webapi/appsettings.json](./appsettings.json). + +- Content Storage: storage solution to save the original contents. Available options: + - AzureBlobs + - SimpleFileStorage: stores data on your local file system. +- [Message Queue](https://learn.microsoft.com/en-us/azure/storage/queues/storage-queues-introduction): asynchronous service to service communication. Available options: + - AzureQueue + - RabbitMQ + - SimpleQueues: stores messages on your local file system. +- [Vector database](https://learn.microsoft.com/en-us/semantic-kernel/memories/vector-db): storage solution for high-dimensional vectors, aka [embeddings](https://github.com/microsoft/semantic-kernel/blob/main/docs/EMBEDDINGS.md). Available options: + - [AzureAISearch](https://learn.microsoft.com/en-us/azure/search/search-what-is-azure-search) + - [Qdrant](https://github.com/qdrant/qdrant) + - SimpleVectorDb + - TextFile: stores vectors on your local file system. + - Volatile: stores vectors in RAM. + > Note that do not configure the memory pipeline to use Volatile. Use volatile in the webapi only when its `KernelMemory:DataIngestion:OrchestrationType` is set to `InProcess`. + +##### AzureBlobs & AzureQueue + +> Note: Make sure to use the same resource for both the webapi and memorypipeline. + +1. Create a storage [account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-create?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&tabs=azure-portal). +2. Find the **connection string** under **Access keys** on the portal. +3. Run the following to set up the authentication to the resources: + ```bash + dotnet user-secrets set KernelMemory:Services:AzureBlobs:Auth ConnectionString + dotnet user-secrets set KernelMemory:Services:AzureBlobs:ConnectionString [your secret] + dotnet user-secrets set KernelMemory:Services:AzureQueue:Auth ConnectionString + dotnet user-secrets set KernelMemory:Services:AzureQueue:ConnectionString [your secret] + ``` + +##### [Azure Cognitive Search](https://learn.microsoft.com/en-us/azure/search/search-what-is-azure-search) + +> Note: Make sure to use the same resource for both the webapi and memorypipeline. + +1. Create a [search service](https://learn.microsoft.com/en-us/azure/search/search-create-service-portal). +2. Find the **Url** under **Overview** and the **key** under **Keys** on the portal. +3. Run the following to set up the authentication to the resources: + ```bash + dotnet user-secrets set KernelMemory:Services:AzureAISearch:Endpoint [your secret] + dotnet user-secrets set KernelMemory:Services:AzureAISearch:APIKey [your secret] + ``` + +##### RabbitMQ + +> Note: Make sure to use the same queue for both webapi and memorypipeline + +Run the following: + +``` +docker run -it --rm --name rabbitmq \ + -e RABBITMQ_DEFAULT_USER=user \ + -e RABBITMQ_DEFAULT_PASS=password \ + -p 5672:5672 \ + rabbitmq:3 +``` + +##### Qdrant + +> Note: Make sure to use the same vector storage for both webapi and memorypipeline + +``` +docker run -it --rm --name qdrant \ +-p 6333:6333 \ +qdrant/qdrant +``` + +> To stop the container, in another terminal window run + +``` +docker container stop [name] +docker container rm [name] +``` diff --git a/memorypipeline/appsettings.json b/memorypipeline/appsettings.json new file mode 100644 index 000000000..6f1755a79 --- /dev/null +++ b/memorypipeline/appsettings.json @@ -0,0 +1,227 @@ +{ + // + // Kernel Memory configuration - https://github.com/microsoft/kernel-memory + // - DocumentStorageType is the storage configuration for memory transfer: "AzureBlobs" or "SimpleFileStorage" + // - TextGeneratorType is the AI completion service configuration: "AzureOpenAIText" or "OpenAI" + // - ImageOcrType is the image OCR configuration: "None" or "AzureAIDocIntel" or "Tesseract" + // - DataIngestion is the configuration section for data ingestion pipelines. + // - Retrieval is the configuration section for memory retrieval. + // - Services is the configuration sections for various memory settings. + // + "KernelMemory": { + "DocumentStorageType": "SimpleFileStorage", + "TextGeneratorType": "AzureOpenAIText", + "ImageOcrType": "None", + // Data ingestion pipelines configuration. + // - OrchestrationType is the pipeline orchestration configuration : "InProcess" or "Distributed" + // InProcess: in process .NET orchestrator, synchronous/no queues + // Distributed: asynchronous queue based orchestrator + // - DistributedOrchestration is the detailed configuration for OrchestrationType=Distributed + // - EmbeddingGeneratorTypes is the list of embedding generator types + // - MemoryDbTypes is the list of vector database types + "DataIngestion": { + "OrchestrationType": "Distributed", + // + // Detailed configuration for OrchestrationType=Distributed. + // - QueueType is the queue configuration: "AzureQueue" or "RabbitMQ" or "SimpleQueues" + // + "DistributedOrchestration": { + "QueueType": "SimpleQueues" + }, + // Multiple generators can be used, e.g. for data migration, A/B testing, etc. + "EmbeddingGeneratorTypes": [ + "AzureOpenAIEmbedding" + ], + // Vectors can be written to multiple storages, e.g. for data migration, A/B testing, etc. + "MemoryDbTypes": [ + "SimpleVectorDb" + ] + }, + // + // Memory retrieval configuration - A single EmbeddingGenerator and VectorDb. + // - MemoryDbType: Vector database configuration: "SimpleVectorDb" or "AzureAISearch" or "Qdrant" + // - EmbeddingGeneratorType: Embedding generator configuration: "AzureOpenAIEmbedding" or "OpenAI" + // + "Retrieval": { + "MemoryDbType": "SimpleVectorDb", + "EmbeddingGeneratorType": "AzureOpenAIEmbedding" + }, + // + // Configuration for the various services used by kernel memory and semantic kernel. + // Section names correspond to type specified in KernelMemory section. All supported + // sections are listed below for reference. Only referenced sections are required. + // + "Services": { + // + // File based storage for local/development use. + // - Directory is the location where files are stored. + // + "SimpleFileStorage": { + "Directory": "../tmp/cache" + }, + // + // File based queue for local/development use. + // - Directory is the location where messages are stored. + // + "SimpleQueues": { + "Directory": "../tmp/queues" + }, + // + // File based vector database for local/development use. + // - StorageType is the storage configuration: "Disk" or "Volatile" + // - Directory is the location where data is stored. + // + "SimpleVectorDb": { + "StorageType": "Disk", + "Directory": "../tmp/database" + }, + // + // Azure blob storage for the memory pipeline + // - Auth is the authentication type: "ConnectionString" or "AzureIdentity". + // - ConnectionString is the connection string for the Azure Storage account and only utilized when Auth=ConnectionString. + // - Account is the name of the Azure Storage account and only utilized when Auth=AzureIdentity. + // - Container is the name of the Azure Storage container used for file storage. + // - EndpointSuffix is used only for country clouds. + // + "AzureBlobs": { + "Auth": "ConnectionString", + //"ConnectionString": "", // dotnet user-secrets set "KernelMemory:Services:AzureBlobs:ConnectionString" "MY_AZUREBLOB_CONNECTIONSTRING" + //"Account": "", + "Container": "chatmemory" + //"EndpointSuffix": "core.windows.net" + }, + // + // Azure storage queue configuration for distributed memory pipeline + // - Auth is the authentication type: "ConnectionString" or "AzureIdentity". + // - ConnectionString is the connection string for the Azure Storage account and only utilized when Auth=ConnectionString. + // - Account is the name of the Azure Storage account and only utilized when Auth=AzureIdentity. + // - EndpointSuffix is used only for country clouds. + // + "AzureQueue": { + "Auth": "ConnectionString" + //"ConnectionString": "", // dotnet user-secrets set "KernelMemory:Services:AzureQueue:ConnectionString" "MY_AZUREQUEUE_CONNECTIONSTRING" + //"Account": "", + //"EndpointSuffix": "core.windows.net" + }, + // + // RabbitMq queue configuration for distributed memory pipeline + // - Username is the RabbitMq user name. + // - Password is the RabbitMq use password + // - Host is the RabbitMq service host name or address. + // - Port is the RabbitMq service port. + // + "RabbitMq": { + //"Username": "user", // dotnet user-secrets set "KernelMemory:Services:RabbitMq:Username" "MY_RABBITMQ_USER" + //"Password": "", // dotnet user-secrets set "KernelMemory:Services:RabbitMq:Password" "MY_RABBITMQ_KEY" + "Host": "127.0.0.1", + "Port": "5672" + }, + // + // Azure Cognitive Search configuration for semantic services. + // - Auth is the authentication type: "APIKey" or "AzureIdentity". + // - APIKey is the key generated to access the service. + // - Endpoint is the service endpoint url. + // + "AzureAISearch": { + "Auth": "ApiKey", + //"APIKey": "", // dotnet user-secrets set "KernelMemory:Services:AzureAISearch:APIKey" "MY_ACS_KEY" + "Endpoint": "" + }, + // + // Qdrant configuration for semantic services. + // - APIKey is the key generated to access the service. + // - Endpoint is the service endpoint url. + // + "Qdrant": { + //"APIKey": "", // dotnet user-secrets set "KernelMemory:Services:Qdrant:APIKey" "MY_QDRANT_KEY" + "Endpoint": "http://127.0.0.1:6333" + }, + // + // AI completion configuration for Azure AI services. + // - Auth is the authentication type: "APIKey" or "AzureIdentity". + // - APIKey is the key generated to access the service. + // - Endpoint is the service endpoint url. + // - Deployment is a completion model (e.g., gpt-4, gpt-4o). + // - APIType is the type of completion model: "ChatCompletion" or "TextCompletion". + // - MaxRetries is the maximum number of retries for a failed request. + // + "AzureOpenAIText": { + "Auth": "ApiKey", + //"APIKey": "", // dotnet user-secrets set "KernelMemory:Services:AzureOpenAIText:APIKey" "MY_AZUREOPENAI_KEY" + "Endpoint": "", + "Deployment": "gpt-4o", + "APIType": "ChatCompletion", + "MaxRetries": 10 + }, + // + // AI embedding configuration for Azure OpenAI services. + // - Auth is the authentication type: "APIKey" or "AzureIdentity". + // - APIKey is the key generated to access the service. + // - Endpoint is the service endpoint url. + // - Deployment is an embedding model (e.g., text-embedding-ada-002). + // + "AzureOpenAIEmbedding": { + "Auth": "ApiKey", + // "APIKey": "", // dotnet user-secrets set "KernelMemory:Services:AzureOpenAIEmbedding:APIKey" "MY_AZUREOPENAI_KEY" + "Endpoint": ".openai.azure.com/", + "Deployment": "text-embedding-ada-002" + }, + // + // AI completion and embedding configuration for OpenAI services. + // - TextModel is a completion model (e.g., gpt-4, gpt-4o). + // - EmbeddingModelSet is an embedding model (e.g., "text-embedding-ada-002"). + // - APIKey is the key generated to access the service. + // - OrgId is the optional OpenAI organization id/key. + // - MaxRetries is the maximum number of retries for a failed request. + // + "OpenAI": { + "TextModel": "gpt-3.5-turbo", + "EmbeddingModel": "text-embedding-ada-002", + //"APIKey": "", // dotnet user-secrets set "KernelMemory:Services:OpenAI:APIKey" "MY_OPENAI_KEY" + "OrgId": "", + "MaxRetries": 10 + }, + // + // Azure Form Recognizer configuration for memory pipeline OCR. + // - Auth is the authentication configuration: "APIKey" or "AzureIdentity". + // - APIKey is the key generated to access the service. + // - Endpoint is the service endpoint url. + // + "AzureAIDocIntel": { + "Auth": "APIKey", + //"APIKey": "", // dotnet user-secrets set "KernelMemory:Services:AzureAIDocIntel:APIKey" "MY_AZURE_AI_DOC_INTEL_KEY" + "Endpoint": "" + }, + // + // Tesseract configuration for memory pipeline OCR. + // - Language is the language supported by the data file. + // - FilePath is the path to the data file. + // + // Note: When using Tesseract OCR Support (In order to upload image file formats such as png, jpg and tiff): + // 1. Obtain language data files here: https://github.com/tesseract-ocr/tessdata . + // 2. Add these files to your `data` folder or the path specified in the "FilePath" property and set the "Copy to Output Directory" value to "Copy if newer". + // + "Tesseract": { + "Language": "eng", + "FilePath": "./data" + } + } + }, + // Logging configuration + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Information" + } + } + }, + "AllowedHosts": "*", + // Application Insights configuration + "ApplicationInsights": { + "ConnectionString": null + } +} \ No newline at end of file diff --git a/plugins/OBO/README.md b/plugins/OBO/README.md new file mode 100644 index 000000000..c550ca118 --- /dev/null +++ b/plugins/OBO/README.md @@ -0,0 +1,103 @@ +# Ms Graph plugin using On-Behalf-Of Flow for Ms Graph APIs + +This repository contains a sample Plugin that uses the On-Behalf-Of (OBO) flow to call Microsoft Graph APIs. + +In this document we will refer to the client app as the WebApp (src/webapp), the middle-tier app as the WebApi (src/webapi) and the backend resource as the Ms Graph Api. + +> **IMPORTANT:** This sample is for educational purposes only and is not recommended for production deployments. + +> **NOTE:** This plugin was implemented as a native Kernel function, in the WebAPI code. This is not an implementation of the OpenAI plugin spec. + +> **NOTE:** This plugin works better GTP-4 or GTP-4-Turbo as these models works better with the function model. + +## Prerequisites + +- Enable backend authentication via Azure AD as described in the main [`README.md`](../../README.md) file. + +## Setup Instructions + +1. **Add the WebApp to the "known client application list" in the WebApi app registration.** + + - Go to the WebApp app registration in your tenant and copy the Application Id (Client ID). + - Go to the WebAPI app registration in your tenant. + - Click on "Manifest" option and add an entry for the `knownClientApplications` attribute using the Application Id (Client ID) of the WebApp registration as described in this [document](https://learn.microsoft.com/en-us/entra/identity-platform/reference-app-manifest#knownclientapplications-attribute) + + - Save the manifest. + +2. **Give the WebApi the delegated permissions.** + + - Go to the WebApi API app registration. + - Select the "API permissions" option. + - Click on "+ Add Permission" option and choose the "Microsoft Graph" option. + - Select "Delegated permission" and choose all the delegated permissions needed. + - Click on "Add Permissions". + - As the UI does not implement incremental consent, you need to grant "Admin Consent" to the new permissions added. + +3. **Create a Client Secret for the WebAPI app registration OBO Configuration.** + + - In the WebAPI app registration click on "Certificates & Secrets". + - Create a new secret by clicking in the "+ New client secret", enter a description and the expiration days. + - Copy the Client Secret and the Application Id (Client ID) to use in the WebAPI appsetting configuration. + +4. **Change the WebAPI `appsettings.json` file.** + - Add your OBO configuration values in the OnBehalfOfAuth section as shown below. The ClientId must be the WebAPI Application Id (Client ID). + +```json + // OBO Configuration for Plugins + "OnBehalfOfAuth": { + "Authority": "https://login.microsoftonline.com", + "TenantId": "[ENTER YOUR TENANT ID]", + "ClientId": "[ENTER YOUR CLIENT ID]", + "ClientSecret": "[ENTER YOUR CLIENT SECRET]" + } +``` + +5. Change the scope for the Ms Graph Obo plugin in the WebApp code + + - As the UI does not implement incremental consent, you need to configure the WebApp to use the [.default scope](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow#default-and-combined-consent). The scope name is formed by the Application ID of the WebAPI app registration so you need to update it with the WebApi Application ID (Client ID). + + - Change the Constants.ts file located in the webapp/src folder, add the msGraphOboScopes entry with the WebApi Application Id, as shown below: + + ```typescript + plugins: { + msGraphOboScopes: ['api://[ENTER THE WE API APPLICATION ID]/.default'], + } + ``` + +## Test Instructions + +1. Login to the app + +![Login Step](./test-step-1.png) + +2. Enable Ms Graph OBO Plugin + +![Plugin Step](./test-step-2.png) + +![Plugin Step 2](./test-step-3.png) + +![Plugin Step 3](./test-step-4.png) + +3. Update the Persona Meta Prompt with the following text: + + ```text + This is a chat between an intelligent AI bot named Copilot and one or more participants. SK stands for Semantic Kernel, the AI platform used to build the bot. The AI was trained on data through 2021 and is not aware of events that have occurred since then. The bot has the ability to call Graph APIs using the MS Graph OBO tool to fetch real-time data. The user must first enable the plugin. To call a Graph API, the bot would call the \\"CallGraphApiTasksAsync\\" function, and provide the Graph API URL with the ODATA query and its required scopes as a list as arguments. The plugin will automatically handle authentication. Otherwise, the bot has no ability to access data on the Internet, so it should not claim that it can or say that it will go and look things up. Try to be concise with your answers, though it is not required. Knowledge cutoff: {{$knowledgeCutoff}} / Current date: {{TimePlugin.Now}}. + ``` + +![Persona Step 1](./test-step-5.png) + +4. Run a prompt to check if the bot understands that can can a graph API and then ask to run a query by providing a sample + +- Hi! Can you call a graph API for me? + +- Please get the list of applications in my tenant. + You can call the Graph API: `https://graph.microsoft.com/v1.0/applications$select=appId,identifierUris,displayName,publisherDomain,signInAudience` + Required scope: Application.Read.All + +![Check Step 1](./test-step-6.png) + +5. After the sample prompt the bot will execute any graph api query without the need of indicating the graph api, odata query or scopes + +- Please get the ObjectID of my user + +![Check Step 2](./test-step-7.png) diff --git a/plugins/OBO/test-step-1.png b/plugins/OBO/test-step-1.png new file mode 100644 index 000000000..6218369f8 Binary files /dev/null and b/plugins/OBO/test-step-1.png differ diff --git a/plugins/OBO/test-step-2.png b/plugins/OBO/test-step-2.png new file mode 100644 index 000000000..dd4e6f1d5 Binary files /dev/null and b/plugins/OBO/test-step-2.png differ diff --git a/plugins/OBO/test-step-3.png b/plugins/OBO/test-step-3.png new file mode 100644 index 000000000..46628d5b8 Binary files /dev/null and b/plugins/OBO/test-step-3.png differ diff --git a/plugins/OBO/test-step-4.png b/plugins/OBO/test-step-4.png new file mode 100644 index 000000000..ebe0ada50 Binary files /dev/null and b/plugins/OBO/test-step-4.png differ diff --git a/plugins/OBO/test-step-5.png b/plugins/OBO/test-step-5.png new file mode 100644 index 000000000..426e9b62f Binary files /dev/null and b/plugins/OBO/test-step-5.png differ diff --git a/plugins/OBO/test-step-6.png b/plugins/OBO/test-step-6.png new file mode 100644 index 000000000..690d39146 Binary files /dev/null and b/plugins/OBO/test-step-6.png differ diff --git a/plugins/OBO/test-step-7.png b/plugins/OBO/test-step-7.png new file mode 100644 index 000000000..e2ca9f3aa Binary files /dev/null and b/plugins/OBO/test-step-7.png differ diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 000000000..28ce8162a --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,51 @@ +# Plugins + +> **IMPORTANT:** This sample is for educational purposes only and is not recommended for production deployments. + +Plugins are cool! They allow Chat Copilot to talk to the internet. Read more about plugins here [Understanding AI plugins in Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/ai-orchestration/plugins/?tabs=Csharp) and here [ChatGPT Plugins](https://platform.openai.com/docs/plugins/introduction). + +## Available Plugins + +> These plugins in this project can be optionally deployed with the Chat Copilot [WebApi](../webapi/README.md) + +- [WebSearcher](./web-searcher/README.md): A plugin that allows the chat bot to perform Bing search. +- More to come. Stay tuned! + +## Third Party plugins + +You can also configure Chat Copilot to use third party plugins. + +> All no-auth plugins will be supported. + +> All service-level-auth and user-level-auth plugins will be supported. + +> OAuth plugins will NOT be supported. + +Read more about plugin authentication here: [Plugin authentication](https://platform.openai.com/docs/plugins/authentication) + +## Plugin Configuration in Chat Copilot + +### Prerequisites + +1. The name of your plugin. This should be identical to the `NameForHuman` in your plugin manifest. + > Please refer to OpenAI for the [manifest requirements](https://platform.openai.com/docs/plugins/getting-started/plugin-manifest). +2. Url of your plugin. + > This should be the root url to your API. Not the manifest url nor the OpenAPI spec url. +3. (Optional) Key of the plugin if it requires one. + +### Local dev + +In `appsettings.json` or `appsettings.development.json` under `../webapi/`, add your plugin to the existing **Plugins** list with the required information. + +### Deployment + +1. Go to your webapi resource in Azure portal. +2. Go to **Configuration** -> **Application settings**. +3. Look for Plugins:[*index*]:\* in the names that has the largest index. +4. Add the following names and their corresponding values: + +``` +Plugins[*index+1*]:Name +Plugins[*index+1*]:Url +Plugins[*index+1*]:Key (only if the plugin requires it) +``` diff --git a/plugins/shared/PluginApi.cs b/plugins/shared/PluginApi.cs new file mode 100644 index 000000000..5335aa732 --- /dev/null +++ b/plugins/shared/PluginApi.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Plugins.PluginShared; + +/// +/// This class represents the plugin API specification. +/// +public class PluginApi +{ + /// + /// The API specification + /// + public string Type { get; set; } = "openapi"; + + /// + /// URL used to fetch the specification + /// + public string Url { get; set; } = string.Empty; +} diff --git a/plugins/shared/PluginAuth.cs b/plugins/shared/PluginAuth.cs new file mode 100644 index 000000000..445038ebf --- /dev/null +++ b/plugins/shared/PluginAuth.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Plugins.PluginShared; + +/// +/// This class represents the OpenAI plugin authentication schema. +/// +public class PluginAuth +{ + /// + /// Tokens for API key authentication + /// + public class VerificationTokens + { + /// + /// The API key + /// + public string OpenAI { get; set; } = string.Empty; + } + + /// + /// The authentication schema + /// Supported values: none, service_http, user_http + /// + public string Type { get; set; } = "none"; + + /// + /// Manifest schema version + /// + [JsonPropertyName("authorization_type")] + public string AuthorizationType { get; } = "bearer"; + + /// + /// Tokens for API key authentication + /// + [JsonPropertyName("verification_tokens")] + public VerificationTokens Tokens { get; set; } = new VerificationTokens(); +} diff --git a/plugins/shared/PluginManifest.cs b/plugins/shared/PluginManifest.cs new file mode 100644 index 000000000..0223233df --- /dev/null +++ b/plugins/shared/PluginManifest.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Plugins.PluginShared; + +/// +/// This class represents the OpenAI plugin manifest: +/// https://platform.openai.com/docs/plugins/getting-started/plugin-manifest +/// +public class PluginManifest +{ + /// + /// Manifest schema version + /// + [JsonPropertyName("schema_version")] + public string SchemaVersion { get; set; } = "v1"; + + /// + /// The name of the plugin that the model will use + /// + [JsonPropertyName("name_for_model")] + public string NameForModel { get; set; } = string.Empty; + + /// + /// Human-readable name of the plugin + /// + [JsonPropertyName("name_for_human")] + public string NameForHuman { get; set; } = string.Empty; + + /// + /// Description of the plugin that the model will use + /// + [JsonPropertyName("description_for_model")] + public string DescriptionForModel { get; set; } = string.Empty; + + /// + /// Human-readable description of the plugin + /// + [JsonPropertyName("description_for_human")] + public string DescriptionForHuman { get; set; } = string.Empty; + + /// + /// The authentication schema + /// + public PluginAuth Auth { get; set; } = new PluginAuth(); + + /// + /// The API specification + /// + public PluginApi Api { get; set; } = new PluginApi(); + + /// + /// URL used to fetch the logo + /// + [JsonPropertyName("logo_url")] + public string LogoUrl { get; set; } = string.Empty; + + /// + /// Email contact for safety/moderation, support and deactivation + /// + [JsonPropertyName("contact_email")] + public string ContactEmail { get; set; } = string.Empty; + + /// + /// Redirect URL for users to get more information about the plugin + /// + [JsonPropertyName("legal_info_url")] + public string LegalInfoUrl { get; set; } = string.Empty; + + /// + /// "Bearer" or "Basic" + /// + public string HttpAuthorizationType { get; set; } = string.Empty; +} diff --git a/plugins/shared/PluginShared.csproj b/plugins/shared/PluginShared.csproj new file mode 100644 index 000000000..1c648a957 --- /dev/null +++ b/plugins/shared/PluginShared.csproj @@ -0,0 +1,8 @@ + + + + net6.0 + Plugins.PluginShared + + + \ No newline at end of file diff --git a/plugins/web-searcher/Icons/bing.png b/plugins/web-searcher/Icons/bing.png new file mode 100644 index 000000000..3da002c0c Binary files /dev/null and b/plugins/web-searcher/Icons/bing.png differ diff --git a/plugins/web-searcher/Models/BingSearchResponse.cs b/plugins/web-searcher/Models/BingSearchResponse.cs new file mode 100644 index 000000000..02e2ebc95 --- /dev/null +++ b/plugins/web-searcher/Models/BingSearchResponse.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Plugins.WebSearcher.Models; + +/// +/// Defines a webpage that is relevant to the query. +/// +internal sealed class WebPage +{ + /// + /// The name of the webpage. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// The URL to the webpage. + /// + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + /// + /// A snippet of text from the webpage that describes its contents. + /// + [JsonPropertyName("snippet")] + public string Snippet { get; set; } = string.Empty; +} + +/// +/// Defines a list of relevant webpage links. +/// +internal sealed class WebPages +{ + /// + /// A list of webpages that are relevant to the query. + /// + [JsonPropertyName("value")] + public WebPage[]? Value { get; set; } +} + +/// +/// The Bing's top-level object for search requests that succeed. +/// +internal sealed class BingSearchResponse +{ + /// + /// A list of webpages that are relevant to the search query. + /// + [JsonPropertyName("webPages")] + public WebPages? WebPages { get; set; } +} diff --git a/plugins/web-searcher/Models/PluginConfig.cs b/plugins/web-searcher/Models/PluginConfig.cs new file mode 100644 index 000000000..2d46115d5 --- /dev/null +++ b/plugins/web-searcher/Models/PluginConfig.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Plugins.WebSearcher.Models; + +/// +/// The plugin configuration. +/// +public class PluginConfig +{ + /// + /// The Bing API base URL. + /// + public string BingApiBaseUrl { get; set; } = "https://api.bing.microsoft.com/v7.0/search"; + + /// + /// The Bing API key. + /// + public string BingApiKey { get; set; } = string.Empty; +} diff --git a/plugins/web-searcher/PluginEndpoint.cs b/plugins/web-searcher/PluginEndpoint.cs new file mode 100644 index 000000000..020450683 --- /dev/null +++ b/plugins/web-searcher/PluginEndpoint.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Microsoft.OpenApi.Models; +using Plugins.PluginShared; +using Plugins.WebSearcher.Models; + +namespace Plugins.WebSearcher; + +/// +/// Plugin endpoints +/// +public class PluginEndpoint +{ + private readonly PluginConfig _config; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public PluginEndpoint(PluginConfig config, ILogger logger) + { + this._config = config; + this._logger = logger; + } + + /// + /// Gets the plugin manifest. + /// + /// The http request data. + /// The manifest in Json + [Function("WellKnownAIPluginManifest")] + public async Task WellKnownAIPluginManifestAsync( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = ".well-known/ai-plugin.json")] + HttpRequestData req) + { + var pluginManifest = new PluginManifest() + { + NameForModel = "WebSearcher", + NameForHuman = "WebSearcher", + DescriptionForModel = "Searches the web", + DescriptionForHuman = "Searches the web", + Auth = new PluginAuth() + { + Type = "user_http" + }, + Api = new PluginApi() + { + Type = "openapi", + Url = $"{req.Url.Scheme}://{req.Url.Host}:{req.Url.Port}/swagger.json" + }, + LogoUrl = $"{req.Url.Scheme}://{req.Url.Host}:{req.Url.Port}/.well-known/icon", + }; + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(pluginManifest); + return response; + } + + /// + /// Gets the plugin's icon. + /// + /// The http request data. + /// The icon. + [Function("Icon")] + public async Task IconAsync( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = ".well-known/icon")] + HttpRequestData req) + { + if (!File.Exists("./Icons/bing.png")) + { + return req.CreateResponse(HttpStatusCode.NotFound); + } + + using (var stream = new FileStream("./Icons/bing.png", FileMode.Open)) + { + var response = req.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "image/png"); + await stream.CopyToAsync(response.Body); + return response; + } + } + + /// + /// Search the web for the given query. + /// + /// The http request data. + /// A string representing the search result. + [OpenApiOperation(operationId: "Search", tags: ["WebSearchfunction"], Description = "Searches the web for the given query.")] + [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "x-functions-key", In = OpenApiSecurityLocationType.Header)] + [OpenApiParameter(name: "Query", In = ParameterLocation.Query, Required = true, Type = typeof(string), Description = "The query")] + [OpenApiParameter(name: "NumResults", In = ParameterLocation.Query, Required = true, Type = typeof(int), Description = "The maximum number of results to return")] + [OpenApiParameter(name: "Offset", In = ParameterLocation.Query, Required = false, Type = typeof(int), Description = "The number of results to skip")] + [OpenApiParameter(name: "Site", In = ParameterLocation.Query, Required = false, Type = typeof(string), Description = "The specific site to search within")] + [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: "text/plain", bodyType: typeof(string), Description = "Returns a collection of search results with the name, URL and snippet for each.")] + [OpenApiResponseWithoutBody(statusCode: HttpStatusCode.BadRequest, Description = "Invalid query")] + [Function("WebSearch")] + public async Task WebSearchAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "search")] HttpRequestData req) + { + var queryParams = QueryHelpers.ParseQuery(req.Url.Query); + + string query = queryParams.TryGetValue("Query", out StringValues q1) ? q1.ToString() : string.Empty; + int numResults = queryParams.TryGetValue("NumResults", out StringValues q2) && int.TryParse(q2, out int q2Value) ? q2Value : 0; + int offset = queryParams.TryGetValue("Offset", out StringValues q3) && int.TryParse(q3, out var q3Value) ? q3Value : 0; + string site = queryParams.TryGetValue("Site", out StringValues q4) ? q4.ToString() : string.Empty; + + if (string.IsNullOrWhiteSpace(query)) + { + return await this.CreateBadRequestResponseAsync(req, "Empty query."); + } + + if (numResults <= 0) + { + return await this.CreateBadRequestResponseAsync(req, "Invalid number of results."); + } + + if (string.IsNullOrWhiteSpace(site)) + { + this._logger.LogDebug("Searching the web for '{0}'", query); + } + else + { + this._logger.LogDebug("Searching the web for '{0}' within '{1}'", query, site); + } + + using (var httpClient = new HttpClient()) + { + var queryString = $"?q={Uri.EscapeDataString(query)}"; + queryString += string.IsNullOrWhiteSpace(site) ? string.Empty : $"+site:{site}"; + queryString += $"&count={numResults}"; + queryString += $"&offset={offset}"; + + var uri = new Uri($"{this._config.BingApiBaseUrl}{queryString}"); + this._logger.LogDebug("Sending request to {0}", uri); + + httpClient.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", this._config.BingApiKey); + + var bingResponse = await httpClient.GetStringAsync(uri); + + this._logger.LogDebug("Search completed. Response: {0}", bingResponse); + + BingSearchResponse? data = JsonSerializer.Deserialize(bingResponse); + WebPage[]? results = data?.WebPages?.Value; + + var responseText = results == null + ? "No results found." + : string.Join(",", + results.Select(r => $"[NAME]{r.Name}[END NAME] [URL]{r.Url}[END URL] [SNIPPET]{r.Snippet}[END SNIPPET]")); + + return await this.CreateOkResponseAsync(req, responseText); + } + } + + /// + /// Creates an OK response containing texts with the given content. + /// + /// The http request data. + /// The content. + /// + private async Task CreateOkResponseAsync(HttpRequestData req, string content) + { + var response = req.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "text/plain; charset=utf-8"); + await response.WriteStringAsync(content); + return response; + } + + /// + /// Creates a bad request response containing the given error message. + /// + /// The http request data. + /// The error message. + /// + private async Task CreateBadRequestResponseAsync(HttpRequestData req, string errMsg) + { + this._logger.LogError(errMsg); + + var response = req.CreateResponse(HttpStatusCode.BadRequest); + response.Headers.Add("Content-Type", "text/plain; charset=utf-8"); + await response.WriteStringAsync(errMsg); + return response; + } +} diff --git a/plugins/web-searcher/Program.cs b/plugins/web-searcher/Program.cs new file mode 100644 index 000000000..edf0bc39d --- /dev/null +++ b/plugins/web-searcher/Program.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Abstractions; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Configurations; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; +using Plugins.WebSearcher.Models; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .ConfigureAppConfiguration(configuration => + { + // `ConfigureFunctionsWorkerDefaults` already adds environment variables as a source. + configuration + .AddUserSecrets(optional: true) + .AddJsonFile(path: "local.settings.json", optional: true, reloadOnChange: true); + }) + .ConfigureServices(services => + { + services.Configure(options => + { + // `ConfigureFunctionsWorkerDefaults` sets the default to ignore casing already. + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); + + var pluginConfig = services.BuildServiceProvider().GetService()?.GetSection(nameof(PluginConfig)).Get(); + services.AddSingleton(pluginConfig!); + + services.AddSingleton(_ => + { + var options = new OpenApiConfigurationOptions() + { + Info = new OpenApiInfo() + { + Version = "1.0.0", + Title = "Web Searcher Plugin", + Description = "This plugin is capable of searching the internet." + }, + Servers = DefaultOpenApiConfigurationOptions.GetHostNames(), + OpenApiVersion = OpenApiVersionType.V3, + IncludeRequestingHostName = true, + ForceHttps = false, + ForceHttp = false, + }; + + return options; + }); + }) + .Build(); + +host.Run(); diff --git a/plugins/web-searcher/README.md b/plugins/web-searcher/README.md new file mode 100644 index 000000000..e9709a6ca --- /dev/null +++ b/plugins/web-searcher/README.md @@ -0,0 +1,48 @@ +# WebSearcher OpenAI Plugin + +> **IMPORTANT:** This sample is for educational purposes only and is not recommended for production deployments. + +An OpenAI Plugin that can be used to search the internet using Bing. + +## Prerequisites: + +1. A [Bing Search](https://learn.microsoft.com/en-us/bing/search-apis/bing-web-search/create-bing-search-service-resource) Resource. +2. [Azure Function core tool](https://learn.microsoft.com/en-us/azure/azure-functions/create-first-function-cli-csharp?tabs=windows%2Cazure-cli#install-the-azure-functions-core-tools). +3. (Optional) An [Azure Function](https://learn.microsoft.com/en-us/azure/azure-functions/functions-get-started?pivots=programming-language-csharp) resource for deployment. + +## Local dev + +1. Open `local.settings.json` and enter the `BingApiKey`. The ApiKey can be found in your Bing Search resource in Azure portal. +2. Run the following command in a new terminal window + +``` +> func start +``` + +## Deploy to Azure + +1. Create an Azure function resource. +2. Create and set `PluginConfig:BingApiKey` to the Bing Api key in **Configuration** -> **Application settings** in Azure portal. +3. Publish the package by running + +``` +dotnet publish --output ./bin/publish --configuration "Release" +``` + +4. Compress the published binary to a zip by running + +``` +Compress-Archive -Path .\bin\publish\* -DestinationPath .\bin\publish.zip +``` + +5. Deploy to Azure via zip push + +``` +az functionapp deployment source config-zip -g [your resource group name] -n [your function app name] --src .\bin\publish.zip +``` + +## Usage + +You can test the functionality by using the Swagger UI. For example: "{your function url}/swagger/ui" + +> Note: The function app does't require any authentication when running locally. diff --git a/plugins/web-searcher/WebSearcher.csproj b/plugins/web-searcher/WebSearcher.csproj new file mode 100644 index 000000000..6864f8e40 --- /dev/null +++ b/plugins/web-searcher/WebSearcher.csproj @@ -0,0 +1,37 @@ + + + net8.0 + v4 + Exe + Plugins.WebSearcher + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + PreserveNewest + + + + + + + \ No newline at end of file diff --git a/plugins/web-searcher/host.json b/plugins/web-searcher/host.json new file mode 100644 index 000000000..3ca80ebc2 --- /dev/null +++ b/plugins/web-searcher/host.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "functionTimeout": "00:05:00", + "logging": { + "fileLoggingMode": "DebugOnly", + "logLevel": { + "default": "Information", + "WebSearcher.PluginEndpoint": "Information" + }, + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensions": { + "http": { + "routePrefix": "" + } + } +} \ No newline at end of file diff --git a/plugins/web-searcher/local.settings.json b/plugins/web-searcher/local.settings.json new file mode 100644 index 000000000..0e06eb253 --- /dev/null +++ b/plugins/web-searcher/local.settings.json @@ -0,0 +1,13 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=none", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" + }, + "Host": { + "CORS": "*" + }, + "PluginConfig": { + "BingApiKey": "" + } +} \ No newline at end of file diff --git a/scripts/.env b/scripts/.env new file mode 100644 index 000000000..dcac41131 --- /dev/null +++ b/scripts/.env @@ -0,0 +1,15 @@ +# Default environment file to be read by scripts + +# Default values +ENV_COMPLETION_MODEL_OPEN_AI="gpt-4o" +ENV_COMPLETION_MODEL_AZURE_OPEN_AI="gpt-4o" +ENV_EMBEDDING_MODEL="text-embedding-ada-002" +ENV_ASPNETCORE="Development" +ENV_INSTANCE="https://login.microsoftonline.com" + +# Constants +ENV_AZURE_OPEN_AI="AzureOpenAI" +ENV_OPEN_AI="OpenAI" +ENV_AZURE_AD="AzureAd" +ENV_NONE="None" +ENV_SCOPES="access_as_user" diff --git a/scripts/Configure.ps1 b/scripts/Configure.ps1 index 622bb1927..94819526c 100644 --- a/scripts/Configure.ps1 +++ b/scripts/Configure.ps1 @@ -1,112 +1,192 @@ <# .SYNOPSIS -Configure user secrets, appsettings.Development.json, and .env for Copilot Chat. +Configure user secrets, appsettings.Development.json, and webapp/.env for Chat Copilot. -.PARAMETER OpenAI -Switch to configure for OpenAI. +.PARAMETER AIService +The service type used: OpenAI or AzureOpenAI. -.PARAMETER AzureOpenAI -Switch to configure for Azure OpenAI. +.PARAMETER APIKey +The API key for the AI service. .PARAMETER Endpoint Set when using Azure OpenAI. -.PARAMETER ApiKey -The API key for the AI service. - .PARAMETER CompletionModel The chat completion model to use (e.g., gpt-3.5-turbo or gpt-4). .PARAMETER EmbeddingModel The embedding model to use (e.g., text-embedding-ada-002). -.PARAMETER PlannerModel -The chat completion model to use for planning (e.g., gpt-3.5-turbo or gpt-4). +.PARAMETER FrontendClientId +The client (application) ID associated with your frontend's AAD app registration. -.PARAMETER ClientID -The client (application) ID associated with your AAD app registration. +.PARAMETER BackendClientId +The client (application) ID associated with your backend's AAD app registration. -.PARAMETER Tenant -The tenant (directory) associated with your AAD app registration. -Defaults to 'common'. +.PARAMETER TenantId +The tenant (directory) associated with your AAD app registrations. See https://learn.microsoft.com/en-us/azure/active-directory/develop/msal-client-application-configuration#authority. + +.PARAMETER Instance +The Azure cloud instance used for authenticating users. Defaults to https://login.microsoftonline.com. +See https://learn.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints. #> param( - [Parameter(ParameterSetName='OpenAI',Mandatory=$false)] - [switch]$OpenAI, - - [Parameter(ParameterSetName='AzureOpenAI',Mandatory=$false)] - [switch]$AzureOpenAI, + [Parameter(Mandatory = $true)] + [string]$AIService, - [Parameter(ParameterSetName='AzureOpenAI',Mandatory=$true)] + [Parameter(Mandatory = $true)] + [string]$APIKey, + + [Parameter(Mandatory = $false)] [string]$Endpoint, - [Parameter(Mandatory=$true)] - [string]$ApiKey, + [Parameter(Mandatory = $false)] + [string]$CompletionModel, - [Parameter(Mandatory=$false)] - [string]$CompletionModel = "gpt-3.5-turbo", + [Parameter(Mandatory = $false)] + [string]$EmbeddingModel, - [Parameter(Mandatory=$false)] - [string]$EmbeddingModel = "text-embedding-ada-002", + [Parameter(Mandatory = $false)] + [string] $FrontendClientId, - [Parameter(Mandatory=$false)] - [string]$PlannerModel = "gpt-3.5-turbo", + [Parameter(Mandatory = $false)] + [string] $BackendClientId, - [Parameter(Mandatory = $true)] - [string] $ClientId, + [Parameter(Mandatory = $false)] + [string] $TenantId, [Parameter(Mandatory = $false)] - [string] $Tenant = 'common' + [string] $Instance ) +# Get defaults and constants +$varScriptFilePath = Join-Path "$PSScriptRoot" 'Variables.ps1' +. $varScriptFilePath + +# Set remaining values from Variables.ps1 +if ($AIService -eq $varOpenAI) { + if (!$CompletionModel) { + Write-Host "No completion model provided - Defaulting to $varCompletionModelOpenAI" + $CompletionModel = $varCompletionModelOpenAI + } + + # TO DO: Validate model values if set by command line. +} +elseif ($AIService -eq $varAzureOpenAI) { + if (!$CompletionModel) { + Write-Host "No completion model provided - Defaulting to $varCompletionModelAzureOpenAI" + $CompletionModel = $varCompletionModelAzureOpenAI + } + + # TO DO: Validate model values if set by command line. + + if (!$Endpoint) { + Write-Error "Please specify an endpoint for -Endpoint when using AzureOpenAI." + exit(1) + } +} +else { + Write-Error "Please specify an AI service (AzureOpenAI or OpenAI) for -AIService." + exit(1) +} + +if (!$EmbeddingModel) { + $EmbeddingModel = $varEmbeddingModel + # TO DO: Validate model values if set by command line. +} + +# Determine authentication type based on arguments +if ($FrontendClientId -and $BackendClientId -and $TenantId) { + $authType = $varAzureAd + if (!$Instance) { + $Instance = $varInstance + } +} +elseif (!$FrontendClientId -and !$BackendClientId -and !$TenantId) { + $authType = $varNone +} +else { + Write-Error "To use Azure AD authentication, please set -FrontendClientId, -BackendClientId, and -TenantId." + exit(1) +} + Write-Host "#########################" Write-Host "# Backend configuration #" Write-Host "#########################" # Install dev certificate -if ($IsLinux) -{ +if ($IsLinux) { dotnet dev-certs https if ($LASTEXITCODE -ne 0) { exit(1) } } -else # Windows/MacOS -{ +else { + # Windows/MacOS dotnet dev-certs https --trust if ($LASTEXITCODE -ne 0) { exit(1) } } -if ($OpenAI) -{ - $aiServiceType = "OpenAI" - $Endpoint = "" -} -elseif ($AzureOpenAI) -{ - $aiServiceType = "AzureOpenAI" - - # Azure OpenAI has a different model name for gpt-3.5-turbo (no decimal). - $CompletionModel = $CompletionModel.Replace("3.5", "35") - $EmbeddingModel = $EmbeddingModel.Replace("3.5", "35") - $PlannerModel = $PlannerModel.Replace("3.5", "35") +$webapiProjectPath = Join-Path "$PSScriptRoot" '../webapi' + +Write-Host "Setting 'APIKey' user secret for $AIService..." +if ($AIService -eq $varOpenAI) { + dotnet user-secrets set --project $webapiProjectPath KernelMemory:Services:OpenAI:APIKey $ApiKey + if ($LASTEXITCODE -ne 0) { exit(1) } + $AIServiceOverrides = @{ + OpenAI = @{ + TextModel = $CompletionModel; + EmbeddingModel = $EmbeddingModel; + } + }; } else { - Write-Error "Please specify either -OpenAI or -AzureOpenAI" - exit(1) + dotnet user-secrets set --project $webapiProjectPath KernelMemory:Services:AzureOpenAIText:APIKey $ApiKey + if ($LASTEXITCODE -ne 0) { exit(1) } + dotnet user-secrets set --project $webapiProjectPath KernelMemory:Services:AzureOpenAIEmbedding:APIKey $ApiKey + if ($LASTEXITCODE -ne 0) { exit(1) } + $AIServiceOverrides = @{ + AzureOpenAIText = @{ + Endpoint = $Endpoint; + Deployment = $CompletionModel; + }; + AzureOpenAIEmbedding = @{ + Endpoint = $Endpoint; + Deployment = $EmbeddingModel; + } + }; } -$appsettingsOverrides = @{ AIService = @{ Type = $aiServiceType; Endpoint = $Endpoint; Models = @{ Completion = $CompletionModel; Embedding = $EmbeddingModel; Planner = $PlannerModel } } } - -$webapiProjectPath = Join-Path "$PSScriptRoot" '../webapi' -$appsettingsOverridesFilePath = Join-Path $webapiProjectPath 'appsettings.Development.json' - -Write-Host "Setting 'AIService:Key' user secret for $aiServiceType..." -dotnet user-secrets set --project $webapiProjectPath AIService:Key $ApiKey -if ($LASTEXITCODE -ne 0) { exit(1) } +$appsettingsOverrides = @{ + Authentication = @{ + Type = $authType; + AzureAd = @{ + Instance = $Instance; + TenantId = $TenantId; + ClientId = $BackendClientId; + Scopes = $varScopes + } + }; + KernelMemory = @{ + TextGeneratorType = $AIService; + DataIngestion = @{ + EmbeddingGeneratorTypes = @($AIService) + }; + Retrieval = @{ + EmbeddingGeneratorType = $AIService + }; + Services = $AIServiceOverrides; + }; + Frontend = @{ + AadClientId = $FrontendClientId + }; +} +$appSettingsJson = -join ("appsettings.", $varASPNetCore, ".json"); +$appsettingsOverridesFilePath = Join-Path $webapiProjectPath $appSettingsJson -Write-Host "Setting up 'appsettings.Development.json' for $aiServiceType..." -ConvertTo-Json $appsettingsOverrides | Out-File -Encoding utf8 $appsettingsOverridesFilePath +Write-Host "Setting up '$appSettingsJson' for $AIService..." +# Setting depth to 100 to avoid truncating the JSON +ConvertTo-Json $appsettingsOverrides -Depth 100 | Out-File -Encoding utf8 $appsettingsOverridesFilePath Write-Host "($appsettingsOverridesFilePath)" Write-Host "========" @@ -118,19 +198,15 @@ Write-Host "##########################" Write-Host "# Frontend configuration #" Write-Host "##########################" -$envFilePath = Join-Path "$PSScriptRoot" '../webapp/.env' +$webappProjectPath = Join-Path "$PSScriptRoot" '../webapp' +$webappEnvFilePath = Join-Path "$webappProjectPath" '/.env' Write-Host "Setting up '.env'..." -Set-Content -Path $envFilePath -Value "REACT_APP_BACKEND_URI=https://localhost:40443/" -Add-Content -Path $envFilePath -Value "REACT_APP_AAD_AUTHORITY=https://login.microsoftonline.com/$Tenant" -Add-Content -Path $envFilePath -Value "REACT_APP_AAD_CLIENT_ID=$ClientId" -Add-Content -Path $envFilePath -Value "" -Add-Content -Path $envFilePath -Value "# Web Service API key (not required when running locally)" -Add-Content -Path $envFilePath -Value "REACT_APP_SK_API_KEY=" - -Write-Host "($envFilePath)" +Set-Content -Path $webappEnvFilePath -Value "REACT_APP_BACKEND_URI=https://localhost:40443/" + +Write-Host "($webappEnvFilePath)" Write-Host "========" -Get-Content $envFilePath | Write-Host +Get-Content $webappEnvFilePath | Write-Host Write-Host "========" Write-Host "Done!" diff --git a/scripts/Configure.sh b/scripts/Configure.sh deleted file mode 100644 index 06fa0e6f9..000000000 --- a/scripts/Configure.sh +++ /dev/null @@ -1,154 +0,0 @@ -#!/bin/bash -# Configure user secrets, appsettings.Development.json, and .env for Copilot Chat. - -set -e - -# Defaults -COMPLETION_MODEL="gpt-3.5-turbo" -EMBEDDING_MODEL="text-embedding-ada-002" -PLANNER_MODEL="gpt-3.5-turbo" -TENANT_ID="common" - -# Argument parsing -POSITIONAL_ARGS=() - -while [[ $# -gt 0 ]]; do - case $1 in - --openai) - OPENAI=YES - shift # past argument - ;; - --azureopenai) - AZURE_OPENAI=YES - shift - ;; - -e|--endpoint) - ENDPOINT="$2" - shift # past argument - shift # past value - ;; - -a|--apikey) - API_KEY="$2" - shift - shift - ;; - --completion) - COMPLETION_MODEL="$2" - shift - shift - ;; - --embedding) - EMBEDDING_MODEL="$2" - shift - shift - ;; - --planner) - PLANNER_MODEL="$2" - shift - shift - ;; - -c|--clientid) - CLIENT_ID="$2" - shift - shift - ;; - -t|--tenantid) - TENANT_ID="$2" - shift - shift - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - POSITIONAL_ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters - -SCRIPT_DIRECTORY="$(dirname $0)" - -# Validate arguments -if [ -z "$API_KEY" ]; then - echo "Please specify an API key with -a or --apikey."; exit 1; -fi -if [ -z "$CLIENT_ID" ]; then - echo "Please specify a client (application) ID with -c or --clientid."; exit 1; -fi -if [ "$AZURE_OPENAI" = "YES" ] && [ -z "$ENDPOINT" ]; then - echo "When using --azureopenti, please specify an endpoint with -e or --endpoint."; exit 1; -fi - -echo "#########################" -echo "# Backend configuration #" -echo "#########################" - -# Install dev certificate -case "$OSTYPE" in - darwin*) - dotnet dev-certs https --trust - if [ $? -ne 0 ]; then `exit` 1; fi ;; - msys*) - dotnet dev-certs https --trust - if [ $? -ne 0 ]; then exit 1; fi ;; - cygwin*) - dotnet dev-certs https --trust - if [ $? -ne 0 ]; then exit 1; fi ;; - linux*) - dotnet dev-certs https - if [ $? -ne 0 ]; then exit 1; fi ;; -esac - -if [ "$OPENAI" = "YES" ]; then - AI_SERVICE_TYPE="OpenAI" -elif [ "$AZURE_OPENAI" = "YES" ]; then - # Azure OpenAI has a different model name for gpt-3.5-turbo (no decimal). - AI_SERVICE_TYPE="AzureOpenAI" - COMPLETION_MODEL="${COMPLETION_MODEL/3.5/"35"}" - EMBEDDING_MODEL="${EMBEDDING_MODEL/3.5/"35"}" - PLANNER_MODEL="${PLANNER_MODEL/3.5/"35"}" -else - echo "Please specify either --openai or --azureopenai." - exit 1 -fi - -APPSETTINGS_JSON="{ \"AIService\": { \"Type\": \"${AI_SERVICE_TYPE}\", \"Endpoint\": \"${ENDPOINT}\", \"Models\": { \"Completion\": \"${COMPLETION_MODEL}\", \"Embedding\": \"${EMBEDDING_MODEL}\", \"Planner\": \"${PLANNER_MODEL}\" } } }" -WEBAPI_PROJECT_PATH="${SCRIPT_DIRECTORY}/../webapi" -APPSETTINGS_OVERRIDES_FILEPATH="${WEBAPI_PROJECT_PATH}/appsettings.Development.json" - -echo "Setting 'AIService:Key' user secret for $AI_SERVICE_TYPE..." -dotnet user-secrets set --project $WEBAPI_PROJECT_PATH AIService:Key $API_KEY -if [ $? -ne 0 ]; then exit 1; fi - -echo "Setting up 'appsettings.Development.json' for $AI_SERVICE_TYPE..." -echo $APPSETTINGS_JSON > $APPSETTINGS_OVERRIDES_FILEPATH - -echo "($APPSETTINGS_OVERRIDES_FILEPATH)" -echo "========" -cat $APPSETTINGS_OVERRIDES_FILEPATH -echo "========" - -echo "" -echo "##########################" -echo "# Frontend configuration #" -echo "##########################" - -ENV_FILEPATH="${SCRIPT_DIRECTORY}/../webapp/.env" - -echo "Setting up '.env'..." -echo "REACT_APP_BACKEND_URI=https://localhost:40443/" > $ENV_FILEPATH -echo "REACT_APP_AAD_AUTHORITY=https://login.microsoftonline.com/$TENANT_ID" >> $ENV_FILEPATH -echo "REACT_APP_AAD_CLIENT_ID=$CLIENT_ID" >> $ENV_FILEPATH -echo "# Web Service API key (not required when running locally)" >> $ENV_FILEPATH -echo "REACT_APP_SK_API_KEY=" >> $ENV_FILEPATH - -echo "($ENV_FILEPATH)" -echo "========" -cat $ENV_FILEPATH -echo "========" - -echo "Done!" \ No newline at end of file diff --git a/scripts/Install-Requirements.ps1 b/scripts/Install.ps1 similarity index 88% rename from scripts/Install-Requirements.ps1 rename to scripts/Install.ps1 index c7020ea5c..3e7fb286b 100644 --- a/scripts/Install-Requirements.ps1 +++ b/scripts/Install.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS -Installs the requirements for running Copilot Chat. +Installs the requirements for running Chat Copilot. #> if ($IsLinux) @@ -26,7 +26,7 @@ if (!$ChocoInstalled) } # Ensure required packages are installed -$Packages = 'dotnet-6.0-sdk', 'nodejs', 'yarn' +$Packages = 'dotnet-8.0-sdk', 'nodejs', 'yarn' foreach ($PackageName in $Packages) { choco install $PackageName -y diff --git a/scripts/README.md b/scripts/README.md index de51f5bf0..5f5e3eb19 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,79 +1,3 @@ -# Copilot Chat Setup Scripts (local deployment) +# Chat Copilot setup scripts - Local deployment -## Before You Begin -To run Copilot Chat, you will need the following: -- *Application ID* - - This is the Client ID (i.e., Application ID) associated with your Azure Active Directory (AAD) application registration, which you can find in the Azure portal. -- *Azure OpenAI or OpenAI API Key* - - This is your API key for Azure OpenAI or OpenAI - -## 1. Configure your environment -### Windows -Open a PowerShell terminal as an administrator, navigate to this directory, and run the following command: -```powershell -./Install-Requirements.ps1 -``` -> This script uses the Chocolatey package manager install .NET 6.0 SDK, latest Node.js, and Yarn package manager. - -### Ubuntu/Debian Linux -Open a bash terminal as an administrator, navigate to this directory, and run the following command: -```bash -./Install-Requirements-UbuntuDebian.ps1 -``` - -### Other Linux/MacOS -For all other operating systems, ensure NET 6.0 SDK (or newer), Node.js 14 (or newer), and Yarn classic ([v1.22.19](https://classic.yarnpkg.com/)) package manager are installed before proceeding. - -## 2. Configure CopilotChat -Configure the projects with your AI service and application registration information from above. - -**Powershell** -```powershell -./Configure.ps1 -AzureOpenAI -Endpoint {AZURE_OPENAI_ENDPOINT} -ApiKey {AZURE_OPENAI_API_KEY} -ClientId {CLIENT_ID} -``` -> For OpenAI, replace `-AzureOpenAI` with `-OpenAI` and omit `-Endpoint`. - -**Bash** -```bash -./Configure.sh --azureopenai --endpoint {AZURE_OPENAI_ENDPOINT} --apikey {AZURE_OPENAI_API_KEY} --clientid {CLIENT_ID} -``` -> For OpenAI, replace `--azureopenai` with `--openai` and omit `--endpoint`. - -> **Note:** `Configure.ps1`/`Configure.sh` scripts also have parameters for setting additional options, such as AI models and Azure Active Directory tenant IDs. - -## 3. Run Copilot Chat -The `Start` script initializes and runs the WebApp (frontend) and WebApi (backend) for Copilot Chat on your local machine. - -### PowerShell -Open a PowerShell window, navigate to this directory, and run the following command: -```powershell -./Start.ps1 -``` - -### Bash -Open a Bash window, navigate to this directory, and run the following commands: -```bash -# Ensure ./Start.sh is executable -chmod +x Start.sh -# Start CopilotChat -./Start.sh -``` -> **Note:** The first time you run this may take a few minutes for Yarn packages to install. -> **Note:** This script starts `CopilotChatWebApi.exe` as a background process. Be sure to terminate it when you are finished. - -# Troubleshooting -## 1. "A fatal error occurred. The folder [/usr/share/dotnet/host/fxr] does not exist" when running dotnet commands on Linux. -> From https://stackoverflow.com/questions/73753672/a-fatal-error-occurred-the-folder-usr-share-dotnet-host-fxr-does-not-exist - -When .NET (Core) was first released for Linux, it was not yet available in the official Ubuntu repo. So instead, many of us added the Microsoft APT repo in order to install it. Now, the packages are part of the Ubuntu repo, and they are conflicting with the Microsoft packages. This error is a result of mixed packages. -```bash -# Remove all existing packages to get to a clean state: -sudo apt remove --assume-yes dotnet*; -sudo apt remove --assume-yes aspnetcore*; -sudo apt remove --assume-yes netstandard*; -# Set the Microsoft package provider priority -echo -e "Package: *\nPin: origin \"packages.microsoft.com\"\nPin-Priority: 1001" | sudo tee /etc/apt/preferences.d/99microsoft-dotnet.pref; -# Update and install dotnet -sudo apt update; -sudo apt install --assume-yes dotnet-sdk-6.0; -``` \ No newline at end of file +To use these scripts, please follow the quick-start guide found [here](../README.md). diff --git a/scripts/Start-Backend.ps1 b/scripts/Start-Backend.ps1 index 2a459d608..2e9a5f424 100644 --- a/scripts/Start-Backend.ps1 +++ b/scripts/Start-Backend.ps1 @@ -1,8 +1,19 @@ <# .SYNOPSIS -Builds and runs the Copilot Chat backend. +Builds and runs the Chat Copilot backend. #> +# Stop any existing backend API process +Get-Process -Name "CopilotChatWebApi" -ErrorAction SilentlyContinue | Stop-Process + +# Get defaults and constants +$varScriptFilePath = Join-Path "$PSScriptRoot" 'Variables.ps1' +. $varScriptFilePath + +# Environment variable `ASPNETCORE_ENVIRONMENT` required to override appsettings.json with +# appsettings.$varASPNetCore.json. See `webapi/ConfigurationExtensions.cs` +$Env:ASPNETCORE_ENVIRONMENT=$varASPNetCore + Join-Path "$PSScriptRoot" '../webapi' | Set-Location dotnet build dotnet run diff --git a/scripts/Start-Backend.sh b/scripts/Start-Backend.sh deleted file mode 100644 index dce319620..000000000 --- a/scripts/Start-Backend.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# Builds and runs the Copilot Chat backend. - -set -e - -ScriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$ScriptDir/../WebApi" - -# Build and run the backend API server -dotnet build && dotnet run diff --git a/scripts/Start-Frontend.ps1 b/scripts/Start-Frontend.ps1 index 13e870c91..c8f059f37 100644 --- a/scripts/Start-Frontend.ps1 +++ b/scripts/Start-Frontend.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS -Builds and runs the Copilot Chat frontend. +Builds and runs the Chat Copilot frontend. #> Join-Path "$PSScriptRoot" '../webapp' | Set-Location diff --git a/scripts/Start-Frontend.sh b/scripts/Start-Frontend.sh deleted file mode 100644 index 152b544bf..000000000 --- a/scripts/Start-Frontend.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# Initializes and runs the Copilot Chat frontend. - -set -e - -ScriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$ScriptDir/../webapp" - -# Build and run the frontend application -yarn install && yarn start diff --git a/scripts/Start.ps1 b/scripts/Start.ps1 index e5f046f29..d04fee035 100644 --- a/scripts/Start.ps1 +++ b/scripts/Start.ps1 @@ -1,13 +1,53 @@ <# .SYNOPSIS -Builds and runs both the backend and frontend for Copilot Chat. +Initializes and runs both the backend and frontend for Chat Copilot. #> +# Verify "Core" version of powershell installed (not "Desktop"): https://aka.ms/powershell +$ErrorActionPreference = 'Ignore' +$cmd = get-command 'pwsh' +$ErrorActionPreference = 'Continue' + +if (!$cmd) { + Write-Warning "Please update your powershell installation: https://aka.ms/powershell" + return; +} + $BackendScript = Join-Path "$PSScriptRoot" 'Start-Backend.ps1' $FrontendScript = Join-Path "$PSScriptRoot" 'Start-Frontend.ps1' # Start backend (in new PS process) -Start-Process pwsh -ArgumentList "-noexit", "-command $BackendScript" +Start-Process pwsh -ArgumentList "-command ""& '$BackendScript'""" +# check if the backend is running before proceeding +$backendRunning = $false + +# get the port from the REACT_APP_BACKEND_URI env variable +$envFilePath = Join-Path $PSScriptRoot '..\webapp\.env' +$envContent = Get-Content -Path $envFilePath +$port = [regex]::Match($envContent, ':(\d+)/').Groups[1].Value + +$maxRetries = 5 +$retryCount = 0 +$retryWait = 5 # set the number of seconds to wait before retrying + +# check if the backend is running and check if the retry count is less than the max retries +while ($backendRunning -eq $false -and $retryCount -lt $maxRetries) { + $retryCount++ + $backendRunning = Test-NetConnection -ComputerName localhost -Port $port -InformationLevel Quiet + Start-Sleep -Seconds $retryWait +} -# Start frontend (in current PS process) -& $FrontendScript +# if the backend is running, start the frontend +if ($backendRunning -eq $true) { + # Start frontend (in current PS process) + & $FrontendScript +} +else { + # otherwise, write to the console that the backend is not running and we have exceeded the number of retries and we are exiting + Write-Host "*************************************************" + Write-Host "Backend is not running and we have exceeded " + Write-Host "the maximum number of retries." + Write-Host "" + Write-Host "Therefore, we are exiting." + Write-Host "*************************************************" +} \ No newline at end of file diff --git a/scripts/Start.sh b/scripts/Start.sh deleted file mode 100644 index edf88aff4..000000000 --- a/scripts/Start.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -# Initializes and runs both the backend and frontend for Copilot Chat. - -set -e - -ScriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$ScriptDir" - -# Start backend (in background) -./Start-Backend.sh & - -# Start frontend -./Start-Frontend.sh diff --git a/scripts/Variables.ps1 b/scripts/Variables.ps1 new file mode 100644 index 000000000..b4ff44529 --- /dev/null +++ b/scripts/Variables.ps1 @@ -0,0 +1,15 @@ +# Default environment file to be read by scripts + +# Default values +$varCompletionModelOpenAI = "gpt-4o" +$varCompletionModelAzureOpenAI = "gpt-4o" +$varEmbeddingModel = "text-embedding-ada-002" +$varASPNetCore = "Development" +$varInstance = "https://login.microsoftonline.com" + +# Constants +$varAzureOpenAI = "AzureOpenAI" +$varOpenAI = "OpenAI" +$varAzureAd = "AzureAd" +$varNone = "None" +$varScopes = "access_as_user" \ No newline at end of file diff --git a/scripts/configure.sh b/scripts/configure.sh new file mode 100755 index 000000000..7cf2a591b --- /dev/null +++ b/scripts/configure.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Configure user secrets, appsettings.Development.json, and webapp/.env for Chat Copilot. + +set -e + +# Get defaults and constants +SCRIPT_DIRECTORY="$(dirname $0)" +. $SCRIPT_DIRECTORY/.env + +# Argument parsing +POSITIONAL_ARGS=() + +while [[ $# -gt 0 ]]; do + case $1 in + --aiservice) # Required argument + AI_SERVICE="$2" + shift + shift + ;; + -a | --apikey) # Required argument + API_KEY="$2" + shift + shift + ;; + -e | --endpoint) # Required argument for Azure OpenAI + ENDPOINT="$2" + shift + shift + ;; + --completionmodel) + COMPLETION_MODEL="$2" + shift + shift + ;; + --embeddingmodel) + EMBEDDING_MODEL="$2" + shift + shift + ;; + -fc | --frontend-clientid) + FRONTEND_CLIENT_ID="$2" + shift + shift + ;; + -bc | --backend-clientid) + BACKEND_CLIENT_ID="$2" + shift + shift + ;; + -t | --tenantid) + TENANT_ID="$2" + shift + shift + ;; + -i | --instance) + INSTANCE="$2" + shift + shift + ;; + -* | --*) + echo "Unknown option $1" + exit 1 + ;; + *) + POSITIONAL_ARGS+=("$1") # save positional arg + shift # past argument + ;; + esac +done + +set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters + +# Validate arguments +if [ -z "$AI_SERVICE" -o \( "$AI_SERVICE" != "$ENV_OPEN_AI" -a "$AI_SERVICE" != "$ENV_AZURE_OPEN_AI" \) ]; then + echo "Please specify an AI service (AzureOpenAI or OpenAI) for --aiservice. " + exit 1 +fi +if [ -z "$API_KEY" ]; then + echo "Please specify an API key with -a or --apikey." + exit 1 +fi +if [ "$AI_SERVICE" = "$ENV_AZURE_OPEN_AI" ] && [ -z "$ENDPOINT" ]; then + echo "When using $(--aiservice AzureOpenAI), please specify an endpoint with -e or --endpoint." + exit 1 +fi + +if [ "$FRONTEND_CLIENT_ID" ] && [ "$BACKEND_CLIENT_ID" ] && [ "$TENANT_ID" ]; then + # Set auth type to AzureAd + AUTH_TYPE="$ENV_AZURE_AD" + # If instance empty, use default + if [ -z "$INSTANCE" ]; then + INSTANCE="$ENV_INSTANCE" + fi +else + if [ -z "$FRONTEND_CLIENT_ID" ] && [ -z "$BACKEND_CLIENT_ID" ] && [ -z "$TENANT_ID" ]; then + # Set auth type to None + AUTH_TYPE="$ENV_NONE" + else + echo "To use Azure AD authentication, please set --frontend-clientid, --backend-clientid, and --tenantid." + exit 1 + fi +fi + +# Set remaining values from .env if not passed as argument +if [ "$AI_SERVICE" = "$ENV_OPEN_AI" ]; then + if [ -z "$COMPLETION_MODEL" ]; then + COMPLETION_MODEL="$ENV_COMPLETION_MODEL_OPEN_AI" + fi + # TO DO: Validate model values if set by command line. +else # elif [ "$AI_SERVICE" = "$ENV_AZURE_OPEN_AI" ]; then + if [ -z "$COMPLETION_MODEL" ]; then + COMPLETION_MODEL="$ENV_COMPLETION_MODEL_AZURE_OPEN_AI" + fi + # TO DO: Validate model values if set by command line. +fi + +if [ -z "$EMBEDDING_MODEL" ]; then + EMBEDDING_MODEL="$ENV_EMBEDDING_MODEL" + # TO DO: Validate model values if set by command line. +fi + +echo "#########################" +echo "# Backend configuration #" +echo "#########################" + +# Install dev certificate +case "$OSTYPE" in +darwin*) + dotnet dev-certs https --trust + if [ $? -ne 0 ]; then $(exit) 1; fi + ;; +msys*) + dotnet dev-certs https --trust + if [ $? -ne 0 ]; then exit 1; fi + ;; +cygwin*) + dotnet dev-certs https --trust + if [ $? -ne 0 ]; then exit 1; fi + ;; +linux*) + dotnet dev-certs https + if [ $? -ne 0 ]; then exit 1; fi + ;; +esac + +WEBAPI_PROJECT_PATH="${SCRIPT_DIRECTORY}/../webapi" + +echo "Setting 'APIKey' user secret for $AI_SERVICE..." +if [ "$AI_SERVICE" = "$ENV_OPEN_AI" ]; then + dotnet user-secrets set --project $WEBAPI_PROJECT_PATH KernelMemory:Services:OpenAI:APIKey $API_KEY + if [ $? -ne 0 ]; then exit 1; fi + AISERVICE_OVERRIDES="{ + \"OpenAI\": + { + \"TextModel\": \"${COMPLETION_MODEL}\", + \"EmbeddingModel\": \"${EMBEDDING_MODEL}\", + } + }" +else + dotnet user-secrets set --project $WEBAPI_PROJECT_PATH KernelMemory:Services:AzureOpenAIText:APIKey $API_KEY + if [ $? -ne 0 ]; then exit 1; fi + dotnet user-secrets set --project $WEBAPI_PROJECT_PATH KernelMemory:Services:AzureOpenAIEmbedding:APIKey $API_KEY + if [ $? -ne 0 ]; then exit 1; fi + AISERVICE_OVERRIDES="{ + \"AzureOpenAIText\": { + \"Endpoint\": \"${ENDPOINT}\", + \"Deployment\": \"${COMPLETION_MODEL}\" + }, + \"AzureOpenAIEmbedding\": { + \"Endpoint\": \"${ENDPOINT}\", + \"Deployment\": \"${EMBEDDING_MODEL}\" + } + }" +fi + +APPSETTINGS_OVERRIDES="{ + \"Authentication\": { + \"Type\": \"${AUTH_TYPE}\", + \"AzureAd\": { + \"Instance\": \"${INSTANCE}\", + \"TenantId\": \"${TENANT_ID}\", + \"ClientId\": \"${BACKEND_CLIENT_ID}\", + \"Scopes\": \"${ENV_SCOPES}\" + } + }, + \"KernelMemory\": { + \"TextGeneratorType\": \"${AI_SERVICE}\", + \"DataIngestion\": { + \"EmbeddingGeneratorTypes\": [\"${AI_SERVICE}\"] + }, + \"Retrieval\": { + \"EmbeddingGeneratorType\": \"${AI_SERVICE}\" + }, + \"Services\": ${AISERVICE_OVERRIDES} + }, + \"Frontend\": { + \"AadClientId\": \"${FRONTEND_CLIENT_ID}\" + } +}" +APPSETTINGS_OVERRIDES_FILEPATH="${WEBAPI_PROJECT_PATH}/appsettings.${ENV_ASPNETCORE}.json" + +echo "Setting up 'appsettings.${ENV_ASPNETCORE}.json' for $AI_SERVICE..." +echo $APPSETTINGS_OVERRIDES >$APPSETTINGS_OVERRIDES_FILEPATH + +echo "($APPSETTINGS_OVERRIDES_FILEPATH)" +echo "========" +cat $APPSETTINGS_OVERRIDES_FILEPATH +echo "========" + +echo "" +echo "##########################" +echo "# Frontend configuration #" +echo "##########################" + +WEBAPP_PROJECT_PATH="${SCRIPT_DIRECTORY}/../webapp" +WEBAPP_ENV_FILEPATH="${WEBAPP_PROJECT_PATH}/.env" + +echo "Setting up '.env' for webapp..." +echo "REACT_APP_BACKEND_URI=https://localhost:40443/" >$WEBAPP_ENV_FILEPATH + +echo "($WEBAPP_ENV_FILEPATH)" +echo "========" +cat $WEBAPP_ENV_FILEPATH +echo "========" + +echo "Done!" diff --git a/scripts/deploy/README.md b/scripts/deploy/README.md new file mode 100644 index 000000000..5b93a17e6 --- /dev/null +++ b/scripts/deploy/README.md @@ -0,0 +1,232 @@ +# Deploying Chat Copilot + +This document details how to deploy Chat Copilot's required resources to your Azure subscription. + +## Things to know + +- Access to Azure OpenAI is currently limited as we navigate high demand, upcoming product improvements, and Microsoft’s commitment to responsible AI. + For more details and information on applying for access, go [here](https://learn.microsoft.com/azure/cognitive-services/openai/overview?ocid=AID3051475#how-do-i-get-access-to-azure-openai). + For regional availability of Azure OpenAI, see the [availability map](https://azure.microsoft.com/explore/global-infrastructure/products-by-region/?products=cognitive-services). +- With the limited availability of Azure OpenAI, consider sharing an Azure OpenAI instance across multiple resources. + +- `F1` and `D1` SKUs for the App Service Plans are not currently supported for this deployment in order to support private networking. + +- Chat Copilot deployments use Azure Active Directory for authentication. All endpoints (except `/healthz` and `/authInfo`) require authentication to access. + +# Configure your environment + +Before you get started, make sure you have the following requirements in place: + +- [Azure AD Tenant](https://learn.microsoft.com/azure/active-directory/develop/quickstart-create-new-tenant) +- Azure CLI (i.e., az) (if you already installed Azure CLI, make sure to update your installation to the latest version) + - Windows, go to https://aka.ms/installazurecliwindows + - Linux, run "`curl -L https://aka.ms/InstallAzureCli | bash`" +- (Linux only) `zip` can be installed by running "`sudo apt install zip`" + +## App registrations (identity) + +You will need two Azure Active Directory (AAD) application registrations -- one for the frontend web app and one for the backend API. + +> For details on creating an application registration, go [here](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app). + +> NOTE: Other account types can be used to allow multitenant and personal Microsoft accounts to use your application if you desire. Doing so may result in more users and therefore higher costs. + +### Frontend app registration + +- Select `Single-page application (SPA)` as platform type, and set the redirect URI to `http://localhost:3000` +- Select `Accounts in this organizational directory only ({YOUR TENANT} only - Single tenant)` as supported account types. +- Make a note of the `Application (client) ID` from the Azure Portal for use in the `Deploy Frontend` step below. + +### Backend app registration + +- Do not set a redirect URI +- Select `Accounts in this organizational directory only ({YOUR TENANT} only - Single tenant)` as supported account types. +- Make a note of the `Application (client) ID` from the Azure Portal for use in the `Deploy Azure infrastructure` step below. + +### Linking the frontend to the backend + +1. Expose an API within the backend app registration + + 1. Select _Expose an API_ from the menu + + 2. Add an _Application ID URI_ + + 1. This will generate an `api://` URI + + 2. Click _Save_ to store the generated URI + + 3. Add a scope for `access_as_user` + + 1. Click _Add scope_ + + 2. Set _Scope name_ to `access_as_user` + + 3. Set _Who can consent_ to _Admins and users_ + + 4. Set _Admin consent display name_ and _User consent display name_ to `Access Chat Copilot as a user` + + 5. Set _Admin consent description_ and _User consent description_ to `Allows the accesses to the Chat Copilot web API as a user` + + 4. Add the web app frontend as an authorized client application + + 1. Click _Add a client application_ + + 2. For _Client ID_, enter the frontend's application (client) ID + + 3. Check the checkbox under _Authorized scopes_ + + 4. Click _Add application_ + +2. Add permissions to web app frontend to access web api as user + + 1. Open app registration for web app frontend + + 2. Go to _API Permissions_ + + 3. Click _Add a permission_ + + 4. Select the tab _APIs my organization uses_ + + 5. Choose the app registration representing the web api backend + + 6. Select permissions `access_as_user` + + 7. Click _Add permissions_ + +# Deploy Azure Infrastructure + +The examples below assume you are using an existing Azure OpenAI resource. See the notes following each command for using OpenAI or creating a new Azure OpenAI resource. + +## PowerShell + +```powershell +./deploy-azure.ps1 -Subscription {YOUR_SUBSCRIPTION_ID} -DeploymentName {YOUR_DEPLOYMENT_NAME} -AIService {AzureOpenAI or OpenAI} -AIApiKey {YOUR_AI_KEY} -AIEndpoint {YOUR_AZURE_OPENAI_ENDPOINT} -BackendClientId {YOUR_BACKEND_APPLICATION_ID} -FrontendClientId {YOUR_FRONTEND_APPLICATION_ID} -TenantId {YOUR_TENANT_ID} +``` + +- To use an existing Azure OpenAI resource, set `-AIService` to `AzureOpenAI` and include `-AIApiKey` and `-AIEndpoint`. +- To deploy a new Azure OpenAI resource, set `-AIService` to `AzureOpenAI` and omit `-AIApiKey` and `-AIEndpoint`. +- To use an an OpenAI account, set `-AIService` to `OpenAI` and include `-AIApiKey`. + +## Bash + +```bash +chmod +x ./deploy-azure.sh +./deploy-azure.sh --subscription {YOUR_SUBSCRIPTION_ID} --deployment-name {YOUR_DEPLOYMENT_NAME} --ai-service {AzureOpenAI or OpenAI} --ai-service-key {YOUR_AI_KEY} --ai-endpoint {YOUR_AZURE_OPENAI_ENDPOINT} --client-id {YOUR_BACKEND_APPLICATION_ID} --frontend-client-id {YOUR_FRONTEND_APPLICATION_ID} --tenant-id {YOUR_TENANT_ID} +``` + +- To use an existing Azure OpenAI resource, set `--ai-service` to `AzureOpenAI` and include `--ai-service-key` and `--ai-endpoint`. +- To deploy a new Azure OpenAI resource, set `--ai-service` to `AzureOpenAI` and omit `--ai-service-key` and `--ai-endpoint`. +- To use an an OpenAI account, set `--ai-service` to `OpenAI` and include `--ai-service-key`. + +## Azure Portal + +You can also deploy the infrastructure directly from the Azure Portal by clicking the button below: + +[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://aka.ms/sk-deploy-existing-azureopenai-portal) + +> This will automatically deploy the most recent release of Chat Copilot binaries ([link](https://github.com/microsoft/chat-copilot/releases)). + +> To find the deployment name when using `Deploy to Azure`, look for a deployment in your resource group that starts with `Microsoft.Template`. + +# Deploy Application + +To deploy the application, first package it, then deploy it to the Azure resources created above. + +## PowerShell + +```powershell +./package-webapi.ps1 + +./deploy-webapi.ps1 -Subscription {YOUR_SUBSCRIPTION_ID} -ResourceGroupName {YOUR_RESOURCE_GROUP_NAME} -DeploymentName {YOUR_DEPLOYMENT_NAME} +``` + +## Bash + +```bash +chmod +x ./package-webapi.sh +./package-webapi.sh + +chmod +x ./deploy-webapi.sh +./deploy-webapi.sh --subscription {YOUR_SUBSCRIPTION_ID} --resource-group {YOUR_RESOURCE_GROUP_NAME} --deployment-name {YOUR_DEPLOYMENT_NAME} +``` + +# Deploy Hosted Plugins + +> **_NOTE:_** This step can be skipped if the required resources for the web searcher plugin are not deployed. The required resources include a Bing resource and an Azure Function. The required resources are NOT deployed by default. To deploy the required resources, use the `-DeployWebSearcherPlugin` or `--deploy-web-searcher-plugin` flag when running the **deploy-azure.ps1/deploy-azure.sh** script. + +> **_NOTE:_** This step can be skipped if the previous Azure Resources creation step, including the resources required by the Web Search plugin, succeeded without errors. The `deployPackages = true` setting in main.bicep ensures that the WebSearcher is deployed. + +> **_NOTE:_** More hosted plugins will be available. + +To deploy the plugins, build the packages first and deploy them to the Azure resources created above. + +## PowerShell + +```powershell +./package-plugins.ps1 + +./deploy-plugins.ps1 -Subscription {YOUR_SUBSCRIPTION_ID} -ResourceGroupName rg-{YOUR_DEPLOYMENT_NAME} -DeploymentName {YOUR_DEPLOYMENT_NAME} +``` + +## Bash + +```bash +chmod +x ./package-plugins.sh +./package-webapi.sh + +chmod +x ./deploy-plugins.sh +./deploy-webapi.sh --subscription {YOUR_SUBSCRIPTION_ID} --resource-group rg-{YOUR_DEPLOYMENT_NAME} --deployment-name {YOUR_DEPLOYMENT_NAME} +``` + +# (Optional) Deploy Memory Pipeline + +> **_NOTE:_** This step can be skipped if the WebApi is NOT configured to run asynchronously for document processing. By default, the WebApi is configured to run asynchronously for document processing in deployment. + +> **_NOTE:_** This step can be skipped if the previous Azure Resources creation step succeeded without errors. The deployPackages = true setting in main.bicep ensures that the latest Chat Copilot memory pipeline is deployed. + +To deploy the memorypipeline, build the deployment package first and deploy it to the Azure resources created above. + +## PowerShell + +```powershell +.\package-memorypipeline.ps1 + +.\deploy-memorypipeline.ps1 -Subscription {YOUR_SUBSCRIPTION_ID} -ResourceGroupName {YOUR_RESOURCE_GROUP_NAME} -DeploymentName {YOUR_DEPLOYMENT_NAME} +``` + +## Bash + +```bash +chmod +x ./package-memorypipeline.sh +./package-memorypipeline.sh + +chmod +x ./deploy-memorypipeline.sh +./deploy-memorypipeline.sh --subscription {YOUR_SUBSCRIPTION_ID} --resource-group {YOUR_RESOURCE_GROUP_NAME} --deployment-name {YOUR_DEPLOYMENT_NAME} +``` + +Your Chat Copilot application is now deployed! + +# Appendix + +## Using custom web frontends to access your deployment + +Make sure to include your frontend's URL as an allowed origin in your deployment's CORS settings. Otherwise, web browsers will refuse to let JavaScript make calls to your deployment. + +To do this, go on the Azure portal, select your Semantic Kernel App Service, then click on "CORS" under the "API" section of the resource menu on the left of the page. +This will get you to the CORS page where you can add your allowed hosts. + +### PowerShell + +```powershell +$webApiName = $(az deployment group show --name {DEPLOYMENT_NAME} --resource-group {YOUR_RESOURCE_GROUP_NAME} --output json | ConvertFrom-Json).properties.outputs.webapiName.value + +az webapp cors add --name $webapiName --resource-group $ResourceGroupName --subscription $Subscription --allowed-origins YOUR_FRONTEND_URL +``` + +### Bash + +```bash +eval WEB_API_NAME=$(az deployment group show --name $DEPLOYMENT_NAME --resource-group $RESOURCE_GROUP --output json) | jq -r '.properties.outputs.webapiName.value' + +az webapp cors add --name $WEB_API_NAME --resource-group $RESOURCE_GROUP --subscription $SUBSCRIPTION --allowed-origins YOUR_FRONTEND_URL +``` diff --git a/deploy/deploy-azure.ps1 b/scripts/deploy/deploy-azure.ps1 similarity index 64% rename from deploy/deploy-azure.ps1 rename to scripts/deploy/deploy-azure.ps1 index 487f3619f..7dd91276d 100644 --- a/deploy/deploy-azure.ps1 +++ b/scripts/deploy/deploy-azure.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS -Deploy CopilotChat Azure resources +Deploy Chat Copilot Azure resources #> param( @@ -15,10 +15,24 @@ param( $Subscription, [Parameter(Mandatory)] - [ValidateSet("AzureOpenAI","OpenAI")] + [string] + # Azure AD client ID for the Web API backend app registration + $BackendClientId, + + [Parameter(Mandatory)] + [string] + # Azure AD client ID for the frontend app registration + $FrontendClientId, + + [Parameter(Mandatory)] + [string] + # Azure AD tenant ID for authenticating users + $TenantId, + + [ValidateSet("AzureOpenAI", "OpenAI")] [string] # AI service to use - $AIService, + $AIService = "AzureOpenAI", [string] # API key for existing Azure OpenAI resource or OpenAI account @@ -33,16 +47,21 @@ param( $ResourceGroup, [string] - # Region to which to make the deployment (ignored when deploying to an existing resource group) - $Region = "centralus", + # Region to which to make the deployment + $Region = "southcentralus", [string] # SKU for the Azure App Service plan $WebAppServiceSku = "B1", - [switch] - # Don't deploy Qdrant for memory storage - Use volatile memory instead - $NoQdrant, + [string] + # Azure AD cloud instance for authenticating users + $AzureAdInstance = "https://login.microsoftonline.com", + + [ValidateSet("AzureAISearch", "Qdrant")] + [string] + # What method to use to persist embeddings + $MemoryStore = "AzureAISearch", [switch] # Don't deploy Cosmos DB for chat storage - Use volatile memory instead @@ -52,27 +71,34 @@ param( # Don't deploy Speech Services to enable speech as chat input $NoSpeechServices, + [switch] + # Deploy the web searcher plugin + $DeployWebSearcherPlugin, + [switch] # Switches on verbose template deployment output - $DebugDeployment + $DebugDeployment, + + [switch] + # Skip deployment of binary packages + $NoDeployPackage ) # if AIService is AzureOpenAI if ($AIService -eq "AzureOpenAI") { # Both $AIEndpoint and $AIApiKey must be set if ((!$AIEndpoint -and $AIApiKey) -or ($AIEndpoint -and !$AIApiKey)) { - Write-Error "When AIService is AzureOpenAI, when either AIEndpoint and AIApiKey are set then both must be set." + Write-Error "When AIService is AzureOpenAI, both AIEndpoint and AIApiKey must be set." exit 1 } # If both $AIEndpoint and $AIApiKey are not set, set $DeployAzureOpenAI to true and inform the user. Otherwise set $DeployAzureOpenAI to false and inform the user. if (!$AIEndpoint -and !$AIApiKey) { $DeployAzureOpenAI = $true - Write-Host "When AIService is AzureOpenAI and both AIEndpoint and AIApiKey are not set then a new Azure OpenAI resource will be created." + Write-Host "When AIService is AzureOpenAI and both AIEndpoint and AIApiKey are not set, then a new Azure OpenAI resource will be created." } else { $DeployAzureOpenAI = $false - Write-Host "When AIService is AzureOpenAI and both AIEndpoint and AIApiKey are set, use the existing Azure OpenAI resource." } } @@ -84,26 +110,30 @@ if ($AIService -eq "OpenAI" -and !$AIApiKey) { $jsonConfig = " { - `\`"name`\`": { `\`"value`\`": `\`"$DeploymentName`\`" }, `\`"webAppServiceSku`\`": { `\`"value`\`": `\`"$WebAppServiceSku`\`" }, `\`"aiService`\`": { `\`"value`\`": `\`"$AIService`\`" }, `\`"aiApiKey`\`": { `\`"value`\`": `\`"$AIApiKey`\`" }, `\`"aiEndpoint`\`": { `\`"value`\`": `\`"$AIEndpoint`\`" }, + `\`"deployPackages`\`": { `\`"value`\`": $(If ($NoDeployPackage) {"false"} Else {"true"}) }, + `\`"azureAdInstance`\`": { `\`"value`\`": `\`"$AzureAdInstance`\`" }, + `\`"azureAdTenantId`\`": { `\`"value`\`": `\`"$TenantId`\`" }, + `\`"webApiClientId`\`": { `\`"value`\`": `\`"$BackendClientId`\`"}, + `\`"frontendClientId`\`": { `\`"value`\`": `\`"$FrontendClientId`\`"}, `\`"deployNewAzureOpenAI`\`": { `\`"value`\`": $(If ($DeployAzureOpenAI) {"true"} Else {"false"}) }, - `\`"deployQdrant`\`": { `\`"value`\`": $(If (!($NoQdrant)) {"true"} Else {"false"}) }, + `\`"memoryStore`\`": { `\`"value`\`": `\`"$MemoryStore`\`" }, `\`"deployCosmosDB`\`": { `\`"value`\`": $(If (!($NoCosmosDb)) {"true"} Else {"false"}) }, - `\`"deploySpeechServices`\`": { `\`"value`\`": $(If (!($NoSpeechServices)) {"true"} Else {"false"}) } + `\`"deploySpeechServices`\`": { `\`"value`\`": $(If (!($NoSpeechServices)) {"true"} Else {"false"}) }, + `\`"deployWebSearcherPlugin`\`": { `\`"value`\`": $(If ($DeployWebSearcherPlugin) {"true"} Else {"false"}) } } " -$jsonConfig = $jsonConfig -replace '\s','' +$jsonConfig = $jsonConfig -replace '\s', '' $ErrorActionPreference = "Stop" $templateFile = "$($PSScriptRoot)/main.bicep" -if (!$ResourceGroup) -{ +if (!$ResourceGroup) { $ResourceGroup = "rg-" + $DeploymentName } @@ -115,13 +145,13 @@ if ($LASTEXITCODE -ne 0) { az account set -s $Subscription if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE + exit $LASTEXITCODE } Write-Host "Ensuring resource group '$ResourceGroup' exists..." az group create --location $Region --name $ResourceGroup --tags Creator=$env:UserName if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE + exit $LASTEXITCODE } Write-Host "Validating template file..." diff --git a/deploy/deploy-azure.sh b/scripts/deploy/deploy-azure.sh old mode 100644 new mode 100755 similarity index 62% rename from deploy/deploy-azure.sh rename to scripts/deploy/deploy-azure.sh index 6e6999159..e6df71d05 --- a/deploy/deploy-azure.sh +++ b/scripts/deploy/deploy-azure.sh @@ -1,88 +1,124 @@ -#!/bin/bash +#!/usr/bin/env bash -# Deploy CopilotChat Azure resources. +# Deploy Chat Copilot Azure resources. set -e usage() { - echo "Usage: $0 -d DEPLOYMENT_NAME -s SUBSCRIPTION -ai AI_SERVICE_TYPE -aikey AI_SERVICE_KEY [OPTIONS]" + echo "Usage: $0 -d DEPLOYMENT_NAME -s SUBSCRIPTION -c BACKEND_CLIENT_ID -fc FRONTEND_CLIENT_ID -t AZURE_AD_TENANT_ID -ai AI_SERVICE_TYPE [OPTIONS]" echo "" echo "Arguments:" echo " -d, --deployment-name DEPLOYMENT_NAME Name for the deployment (mandatory)" echo " -s, --subscription SUBSCRIPTION Subscription to which to make the deployment (mandatory)" - echo " -ai, --ai-service AI_SERVICE_TYPE Type of AI service to use (i.e., OpenAI or AzureOpenAI)" - echo " -aikey, --ai-service-key AI_SERVICE_KEY API key for existing Azure OpenAI resource or OpenAI account" + echo " -c, --client-id BACKEND_CLIENT_ID Azure AD client ID for the Web API backend app registration (mandatory)" + echo " -fc, --frontend-client-id FE_CLIENT_ID Azure AD client ID for the frontend app registration (mandatory)" + echo " -t, --tenant-id AZURE_AD_TENANT_ID Azure AD tenant ID for authenticating users (mandatory)" + echo " -ai, --ai-service AI_SERVICE_TYPE Type of AI service to use (i.e., OpenAI or AzureOpenAI) (mandatory)" echo " -aiend, --ai-endpoint AI_ENDPOINT Endpoint for existing Azure OpenAI resource" + echo " -aikey, --ai-service-key AI_SERVICE_KEY API key for existing Azure OpenAI resource or OpenAI account" echo " -rg, --resource-group RESOURCE_GROUP Resource group to which to make the deployment (default: \"rg-\$DEPLOYMENT_NAME\")" echo " -r, --region REGION Region to which to make the deployment (default: \"South Central US\")" echo " -a, --app-service-sku WEB_APP_SVC_SKU SKU for the Azure App Service plan (default: \"B1\")" - echo " -nq, --no-qdrant Don't deploy Qdrant for memory storage - Use volatile memory instead" + echo " -i, --instance AZURE_AD_INSTANCE Azure AD cloud instance for authenticating users" + echo " (default: \"https://login.microsoftonline.com\")" + echo " -ms, --memory-store Method to use to persist embeddings. Valid values are" + echo " \"AzureAISearch\" (default) and \"Qdrant\"" echo " -nc, --no-cosmos-db Don't deploy Cosmos DB for chat storage - Use volatile memory instead" echo " -ns, --no-speech-services Don't deploy Speech Services to enable speech as chat input" + echo " -ws, --deploy-web-searcher-plugin Deploy the web searcher plugin" echo " -dd, --debug-deployment Switches on verbose template deployment output" + echo " -ndp, --no-deploy-package Skips deploying binary packages to cloud when set." } # Parse arguments while [[ $# -gt 0 ]]; do key="$1" case $key in - -d|--deployment-name) + -d | --deployment-name) DEPLOYMENT_NAME="$2" shift shift ;; - -s|--subscription) + -s | --subscription) SUBSCRIPTION="$2" shift shift ;; - -ai|--ai-service) + -c | --client-id) + BACKEND_CLIENT_ID="$2" + shift + shift + ;; + -fc | --frontend-client-id) + FRONTEND_CLIENT_ID="$2" + shift + shift + ;; + -t | --tenant-id) + AZURE_AD_TENANT_ID="$2" + shift + shift + ;; + -ai | --ai-service) AI_SERVICE_TYPE="$2" shift shift ;; - -aikey|--ai-service-key) + -aikey | --ai-service-key) AI_SERVICE_KEY="$2" shift shift ;; - -aiend|--ai-endpoint) + -aiend | --ai-endpoint) AI_ENDPOINT="$2" shift shift ;; - -rg|--resource-group) + -rg | --resource-group) RESOURCE_GROUP="$2" shift shift ;; - -r|--region) + -r | --region) REGION="$2" shift shift ;; - -a|--app-service-sku) + -a | --app-service-sku) WEB_APP_SVC_SKU="$2" shift shift ;; - -nq|--no-qdrant) - NO_QDRANT=true + -i | --instance) + AZURE_AD_INSTANCE="$2" + shift + shift + ;; + -ms | --memory-store) + MEMORY_STORE=="$2" shift ;; - -nc|--no-cosmos-db) + -nc | --no-cosmos-db) NO_COSMOS_DB=true shift ;; - -ns|--no-speech-services) + -ns | --no-speech-services) NO_SPEECH_SERVICES=true shift ;; - -dd|--debug-deployment) + -ws | --deploy-web-searcher-plugin) + DEPLOY_WEB_SEARCHER_PLUGIN=true + shift + ;; + -dd | --debug-deployment) DEBUG_DEPLOYMENT=true shift ;; - *) + -ndp | --no-deploy-package) + NO_DEPLOY_PACKAGE=true + shift + ;; + *) echo "Unknown option $1" usage exit 1 @@ -91,7 +127,7 @@ while [[ $# -gt 0 ]]; do done # Check mandatory arguments -if [[ -z "$DEPLOYMENT_NAME" ]] || [[ -z "$SUBSCRIPTION" ]] || [[ -z "$AI_SERVICE_TYPE" ]]; then +if [[ -z "$DEPLOYMENT_NAME" ]] || [[ -z "$SUBSCRIPTION" ]] || [[ -z "$BACKEND_CLIENT_ID" ]] || [[ -z "$FRONTEND_CLIENT_ID" ]] || [[ -z "$AZURE_AD_TENANT_ID" ]] || [[ -z "$AI_SERVICE_TYPE" ]]; then usage exit 1 fi @@ -145,29 +181,37 @@ fi az account set -s "$SUBSCRIPTION" # Set defaults -: "${REGION:="centralus"}" +: "${REGION:="southcentralus"}" : "${WEB_APP_SVC_SKU:="B1"}" -: "${NO_QDRANT:=false}" +: "${AZURE_AD_INSTANCE:="https://login.microsoftonline.com"}" +: "${MEMORY_STORE:="AzureAISearch"}" : "${NO_COSMOS_DB:=false}" : "${NO_SPEECH_SERVICES:=false}" +: "${DEPLOY_WEB_SEARCHER_PLUGIN:=false}" # Create JSON config -JSON_CONFIG=$(cat << EOF +JSON_CONFIG=$( + cat < param( @@ -20,13 +20,13 @@ param( $DeploymentName, [string] - # CopilotChat WebApi package to deploy - $PackageFilePath = "$PSScriptRoot/out/webapi.zip" + # CopilotChat memorypipeline package to deploy + $PackageFilePath = "$PSScriptRoot/out/memorypipeline.zip" ) # Ensure $PackageFilePath exists if (!(Test-Path $PackageFilePath)) { - Write-Error "Package file '$PackageFilePath' does not exist. Have you run 'package-webapi.ps1' yet?" + Write-Error "Package file '$PackageFilePath' does not exist. Have you run 'package-memorypipeline.ps1' yet?" exit 1 } @@ -42,22 +42,22 @@ if ($LASTEXITCODE -ne 0) { } Write-Host "Getting Azure WebApp resource name..." -$webappName=$(az deployment group show --name $DeploymentName --resource-group $ResourceGroupName --output json | ConvertFrom-Json).properties.outputs.webapiName.value -if ($null -eq $webAppName) { +$memoryPipelineName=$(az deployment group show --name $DeploymentName --resource-group $ResourceGroupName --output json | ConvertFrom-Json).properties.outputs.memoryPipelineName.value +if ($null -eq $memoryPipelineName) { Write-Error "Could not get Azure WebApp resource name from deployment output." exit 1 } -Write-Host "Azure WebApp name: $webappName" +Write-Host "Azure WebApp name: $memoryPipelineName" Write-Host "Configuring Azure WebApp to run from package..." -az webapp config appsettings set --resource-group $ResourceGroupName --name $webappName --settings WEBSITE_RUN_FROM_PACKAGE="1" | out-null +az webapp config appsettings set --resource-group $ResourceGroupName --name $memoryPipelineName --settings WEBSITE_RUN_FROM_PACKAGE="1" --output none if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -Write-Host "Deploying '$PackageFilePath' to Azure WebApp '$webappName'..." -az webapp deployment source config-zip --resource-group $ResourceGroupName --name $webappName --src $PackageFilePath +Write-Host "Deploying '$PackageFilePath' to Azure WebApp '$memoryPipelineName'..." +az webapp deployment source config-zip --resource-group $ResourceGroupName --name $memoryPipelineName --src $PackageFilePath if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } \ No newline at end of file diff --git a/deploy/deploy-webapi.sh b/scripts/deploy/deploy-memorypipeline.sh similarity index 67% rename from deploy/deploy-webapi.sh rename to scripts/deploy/deploy-memorypipeline.sh index 6e0ea511e..a2b79ba89 100644 --- a/deploy/deploy-webapi.sh +++ b/scripts/deploy/deploy-memorypipeline.sh @@ -1,6 +1,6 @@ -#!/bin/bash +#!/bin/bash -# Deploy CopilotChat's WebAPI to Azure. +# Deploy CopilotChat's MemoryPipeline to Azure. set -e @@ -11,7 +11,7 @@ usage() { echo " -d, --deployment-name DEPLOYMENT_NAME Name of the deployment from a 'deploy-azure.sh' deployment (mandatory)" echo " -s, --subscription SUBSCRIPTION Subscription to which to make the deployment (mandatory)" echo " -rg, --resource-group RESOURCE_GROUP Resource group name from a 'deploy-azure.sh' deployment (mandatory)" - echo " -p, --package PACKAGE_FILE_PATH Path to the WebAPI package file from a 'package-webapi.sh' run" + echo " -p, --package PACKAGE_FILE_PATH Path to the MemoryPipeline package file from a 'package-memorypipeline.sh' run" } # Parse arguments @@ -53,11 +53,11 @@ if [[ -z "$DEPLOYMENT_NAME" ]] || [[ -z "$SUBSCRIPTION" ]] || [[ -z "$RESOURCE_G fi # Set defaults -: "${PACKAGE_FILE_PATH:="$(dirname "$0")/out/webapi.zip"}" +: "${PACKAGE_FILE_PATH:="$(dirname "$0")/out/memorypipeline.zip"}" # Ensure $PACKAGE_FILE_PATH exists if [[ ! -f "$PACKAGE_FILE_PATH" ]]; then - echo "Package file '$PACKAGE_FILE_PATH' does not exist. Have you run 'package-webapi.sh' yet?" + echo "Package file '$PACKAGE_FILE_PATH' does not exist. Have you run 'package-memorypipeline.sh' yet?" exit 1 fi @@ -70,28 +70,25 @@ fi az account set -s "$SUBSCRIPTION" echo "Getting Azure WebApp resource name..." -eval WEB_APP_NAME=$(az deployment group show --name $DEPLOYMENT_NAME --resource-group $RESOURCE_GROUP --output json | jq '.properties.outputs.webapiName.value') -# Ensure $WEB_APP_NAME is set -if [[ -z "$WEB_APP_NAME" ]]; then +eval MEMORY_PIPELINE_NAME=$(az deployment group show --name $DEPLOYMENT_NAME --resource-group $RESOURCE_GROUP --output json | jq -r '.properties.outputs.memoryPipelineName.value') +# Ensure $MEMORY_PIPELINE_NAME is set +if [[ -z "$MEMORY_PIPELINE_NAME" ]]; then echo "Could not get Azure WebApp resource name from deployment output." exit 1 fi -echo "Azure WebApp name: $webappName" +echo "Azure WebApp name: $MEMORY_PIPELINE_NAME" echo "Configuring Azure WebApp to run from package..." -az webapp config appsettings set --resource-group $RESOURCE_GROUP --name $WEB_APP_NAME --settings WEBSITE_RUN_FROM_PACKAGE="1" +az webapp config appsettings set --resource-group $RESOURCE_GROUP --name $MEMORY_PIPELINE_NAME --settings WEBSITE_RUN_FROM_PACKAGE="1" --output none if [ $? -ne 0 ]; then echo "Could not configure Azure WebApp to run from package." exit 1 fi -echo "Deploying '$PackageFilePath' to Azure WebApp '$webappName'..." -az webapp deployment source config-zip --resource-group $RESOURCE_GROUP --name $WEB_APP_NAME --src $PACKAGE_FILE_PATH +echo "Deploying '$PACKAGE_FILE_PATH' to Azure WebApp '$MEMORY_PIPELINE_NAME'..." +az webapp deployment source config-zip --resource-group $RESOURCE_GROUP --name $MEMORY_PIPELINE_NAME --src $PACKAGE_FILE_PATH --debug if [ $? -ne 0 ]; then - echo "Could not deploy '$PackageFilePath' to Azure WebApp '$webappName'." + echo "Could not deploy '$PACKAGE_FILE_PATH' to Azure WebApp '$MEMORY_PIPELINE_NAME'." exit 1 -fi - -eval WEB_APP_URL=$(az deployment group show --name $DEPLOYMENT_NAME --resource-group $RESOURCE_GROUP --output json | jq '.properties.outputs.webapiUrl.value') -echo "To verify your deployment, go to 'https://$WEB_APP_URL/healthz' in your browser." +fi \ No newline at end of file diff --git a/scripts/deploy/deploy-plugins.ps1 b/scripts/deploy/deploy-plugins.ps1 new file mode 100644 index 000000000..ffb2946b1 --- /dev/null +++ b/scripts/deploy/deploy-plugins.ps1 @@ -0,0 +1,113 @@ +<# +.SYNOPSIS +Deploy CopilotChat's Plugins to Azure +#> + +param( + [Parameter(Mandatory)] + [string] + # Subscription to which to make the deployment + $Subscription, + + [Parameter(Mandatory)] + [string] + # Resource group to which to make the deployment + $ResourceGroupName, + + [Parameter(Mandatory)] + [string] + # Name of the previously deployed Azure deployment + $DeploymentName, + + [string] + # Path that contains the plugin packages to deploy + $PackagesPath = "$PSScriptRoot/out/plugins" +) + +# Ensure $PackageFilePath exists +if (!(Test-Path $PackagesPath)) { + Write-Error "Package file '$PackagesPath' does not exist. Have you run 'package-plugins.ps1' yet?" + exit 1 +} + +# Get all the plugin packages +$pluginPackages = Get-ChildItem -Path $PackagesPath -Filter *.zip +if ($null -eq $pluginPackages) { + Write-Error "No plugin packages found in '$PackagesPath'. Have you run 'package-plugins.ps1' yet?" + exit 1 +} + +az account show --output none +if ($LASTEXITCODE -ne 0) { + Write-Host "Log into your Azure account" + az login --output none +} + +az account set -s $Subscription +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Write-Host "Getting Azure Function resource names" +$pluginDeploymentNames = $( + az deployment group show ` + --name $DeploymentName ` + --resource-group $ResourceGroupName ` + --output json | ConvertFrom-Json +).properties.outputs.pluginNames.value +Write-Host "-----Found the following Azure Function names-----" +foreach ($pluginDeploymentName in $pluginDeploymentNames) { + Write-Host "$pluginDeploymentName" +} +Write-Host "" + +# Find the Azure Function resource name for each plugin package +# before we deploy the plugins. This can minimize the risk of +# deploying to the wrong Azure Function resource. +Write-Host "---Matching plugins to Azure Function resources---" +$pluginDeploymentMatches = @{} +foreach ($pluginPackage in $pluginPackages) { + $pluginName = $pluginPackage.BaseName + Write-Host "Looking for the resource for '$pluginName'..." + + # Check if the plugin name matches any of the Azure Function names we got from the deployment output + $matchedNumber = 0 + $matchedDeployment = "" + foreach ($pluginDeploymentName in $pluginDeploymentNames) { + if ($pluginDeploymentName -match "function-.*$pluginName-plugin") { + $matchedNumber++ + $matchedDeployment = $pluginDeploymentName + } + } + + if ($matchedNumber -eq 0) { + Write-Error "Could not find Azure Function resource name for '$pluginName'." + Write-Error "Make sure the deployed Azure Function resource name matches the plugin zip package name." + exit 1 + } + + if ($matchedNumber -gt 1) { + Write-Error "Found multiple Azure Function resource names for '$pluginName'." + Write-Error "Make sure the deployed Azure Function resource name matches the plugin zip package name." + exit 1 + } + + Write-Host "Matched Azure Function resource name '$matchedDeployment' for '$pluginName'" + $pluginDeploymentMatches.Add($pluginPackage, $matchedDeployment) +} +Write-Host "" + +Write-Host "-------Deploying plugins to Azure Functions-------" +foreach ($pluginDeploymentMatches in $pluginDeploymentMatches.GetEnumerator()) { + $pluginPackage = $pluginDeploymentMatches.Key + $pluginDeploymentName = $pluginDeploymentMatches.Value + + Write-Host "Deploying '$pluginPackage' to Azure Function '$pluginDeploymentName'..." + az functionapp deployment source config-zip ` + --resource-group $ResourceGroupName ` + --name $pluginDeploymentName ` + --src $pluginPackage + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} \ No newline at end of file diff --git a/scripts/deploy/deploy-plugins.sh b/scripts/deploy/deploy-plugins.sh new file mode 100755 index 000000000..f681ce340 --- /dev/null +++ b/scripts/deploy/deploy-plugins.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +# Deploy CopilotChat's Plugins to Azure. + +set -e + +usage() { + echo "Usage: $0 -d DEPLOYMENT_NAME -s SUBSCRIPTION -rg RESOURCE_GROUP [OPTIONS]" + echo "" + echo "Arguments:" + echo " -d, --deployment-name DEPLOYMENT_NAME Name of the deployment from a 'deploy-azure.sh' deployment (mandatory)" + echo " -s, --subscription SUBSCRIPTION Subscription to which to make the deployment (mandatory)" + echo " -rg, --resource-group RESOURCE_GROUP Resource group name from a 'deploy-azure.sh' deployment (mandatory)" + echo " -p, --packages PACKAGES_PATH Path that contains the plugin packages to deploy" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + -d | --deployment-name) + DEPLOYMENT_NAME="$2" + shift + shift + ;; + -s | --subscription) + SUBSCRIPTION="$2" + shift + shift + ;; + -rg | --resource-group) + RESOURCE_GROUP="$2" + shift + shift + ;; + -p | --packages) + PACKAGES_PATH="$2" + shift + shift + ;; + *) + echo "Unknown option $1" + usage + exit 1 + ;; + esac +done + +# Check mandatory arguments +if [[ -z "$DEPLOYMENT_NAME" ]] || [[ -z "$SUBSCRIPTION" ]] || [[ -z "$RESOURCE_GROUP" ]]; then + usage + exit 1 +fi + +# Set defaults +: "${PACKAGES_PATH:="$(dirname "$0")/out/plugins"}" + +# Ensure $PACKAGES_PATH exists +if [[ ! -d "$PACKAGES_PATH" ]]; then + echo "Package file '$PACKAGES_PATH' does not exist. Have you run 'package-plugins.sh' yet?" + exit 1 +fi + +az account show --output none +if [ $? -ne 0 ]; then + echo "Log into your Azure account" + az login --use-device-code +fi + +az account set -s "$SUBSCRIPTION" + +echo "Getting Azure Function resource names" +PLUGIN_DEPLOYMENT_NAMES=$( + az deployment group show \ + --name $DEPLOYMENT_NAME \ + --resource-group $RESOURCE_GROUP \ + --output json | jq -r '.properties.outputs.pluginNames.value[]' +) +echo "-----Found the following Azure Function names-----" +for PLUGIN_DEPLOYMENT_NAME in $PLUGIN_DEPLOYMENT_NAMES; do + echo $PLUGIN_DEPLOYMENT_NAME +done +echo "" + +# Find the Azure Function resource name for each plugin package +# before we deploy the plugins. This can minimize the risk of +# deploying to the wrong Azure Function resource. +echo "---Matching plugins to Azure Function resources---" +PLUGIN_DEPLOYMENT_MATCHES=() +PLUGIN_PACKAGE_MATCHES=() +for PLUGIN_PACKAGE in $PACKAGES_PATH/*; do + PLUGIN_PACKAGE_NAME=$(basename $PLUGIN_PACKAGE) + PLUGIN_NAME=$(echo $PLUGIN_PACKAGE_NAME | sed 's/\.zip//g') + + echo "Looking for the resource for '$PLUGIN_NAME'..." + + MATCHED_NUMBER=0 + MATCHED_DEPLOYMENT="" + for PLUGIN_DEPLOYMENT_NAME in $PLUGIN_DEPLOYMENT_NAMES; do + if [[ "$PLUGIN_DEPLOYMENT_NAME" =~ ^function-.*$PLUGIN_NAME-plugin$ ]]; then + MATCHED_NUMBER=$((MATCHED_NUMBER + 1)) + MATCHED_DEPLOYMENT=$PLUGIN_DEPLOYMENT_NAME + fi + done + + if [[ $MATCHED_NUMBER -eq 0 ]]; then + echo "Could not find Azure Function resource name for plugin '$PLUGIN_NAME'." + echo "Make sure the deployed Azure Function resource name matches the plugin zip package name." + exit 1 + elif [[ $MATCHED_NUMBER -gt 1 ]]; then + echo "Found multiple Azure Function resource names for plugin '$PLUGIN_NAME'." + echo "Make sure the deployed Azure Function resource name matches the plugin zip package name." + exit 1 + fi + + echo "Matched Azure Function resource name '$MATCHED_DEPLOYMENT' for '$PLUGIN_NAME'" + PLUGIN_DEPLOYMENT_MATCHES+=($MATCHED_DEPLOYMENT) + PLUGIN_PACKAGE_MATCHES+=($PLUGIN_PACKAGE) +done +echo "" + +echo "-------Deploying plugins to Azure Functions-------" +for i in "${!PLUGIN_DEPLOYMENT_MATCHES[@]}"; do + PLUGIN_DEPLOYMENT_NAME=${PLUGIN_DEPLOYMENT_MATCHES[$i]} + PLUGIN_PACKAGE=${PLUGIN_PACKAGE_MATCHES[$i]} + + echo "Deploying '$PLUGIN_PACKAGE' to Azure Function '$PLUGIN_DEPLOYMENT_NAME'..." + az functionapp deployment source config-zip \ + --resource-group $RESOURCE_GROUP \ + --name $PLUGIN_DEPLOYMENT_NAME \ + --src $PLUGIN_PACKAGE + + if [ $? -ne 0 ]; then + echo "Could not deploy '$PLUGIN_PACKAGE' to Azure Function '$PLUGIN_DEPLOYMENT_NAME'." + exit 1 + fi +done diff --git a/scripts/deploy/deploy-webapi.ps1 b/scripts/deploy/deploy-webapi.ps1 new file mode 100644 index 000000000..cc5fa5064 --- /dev/null +++ b/scripts/deploy/deploy-webapi.ps1 @@ -0,0 +1,163 @@ +<# +.SYNOPSIS +Deploy Chat Copilot application to Azure +#> + +param( + [Parameter(Mandatory)] + [string] + # Subscription to which to make the deployment + $Subscription, + + [Parameter(Mandatory)] + [string] + # Resource group to which to make the deployment + $ResourceGroupName, + + [Parameter(Mandatory)] + [string] + # Name of the previously deployed Azure deployment + $DeploymentName, + + [string] + # Name of the web app deployment slot + $DeploymentSlot, + + [string] + $PackageFilePath = "$PSScriptRoot/out/webapi.zip", + + [switch] + # Don't attempt to add our URIs in frontend app registration's redirect URIs + $SkipAppRegistration, + + [switch] + # Don't attempt to add our URIs in CORS origins for our plugins + $SkipCorsRegistration +) + +# Ensure $PackageFilePath exists +if (!(Test-Path $PackageFilePath)) { + Write-Error "Package file '$PackageFilePath' does not exist. Have you run 'package-webapi.ps1' yet?" + exit 1 +} + +az account show --output none +if ($LASTEXITCODE -ne 0) { + Write-Host "Log into your Azure account" + az login --output none +} + +az account set -s $Subscription +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Write-Host "Getting Azure WebApp resource name..." +$deployment=$(az deployment group show --name $DeploymentName --resource-group $ResourceGroupName --output json | ConvertFrom-Json) +$webApiUrl = $deployment.properties.outputs.webapiUrl.value +$webApiName = $deployment.properties.outputs.webapiName.value +$pluginNames = $deployment.properties.outputs.pluginNames.value + +if ($null -eq $webApiName) { + Write-Error "Could not get Azure WebApp resource name from deployment output." + exit 1 +} + +Write-Host "Azure WebApp name: $webApiName" + +Write-Host "Configuring Azure WebApp to run from package..." +az webapp config appsettings set --resource-group $ResourceGroupName --name $webApiName --settings WEBSITE_RUN_FROM_PACKAGE="1" --output none +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +# Set up deployment command as a string +$azWebAppCommand = "az webapp deployment source config-zip --resource-group $ResourceGroupName --name $webApiName --src $PackageFilePath" + +# Check if DeploymentSlot parameter was passed +$origins = @("$webApiUrl") +if ($DeploymentSlot) { + Write-Host "Checking if slot $DeploymentSlot exists for '$webApiName'..." + $azWebAppCommand += " --slot $DeploymentSlot" + $slotInfo = az webapp deployment slot list --resource-group $ResourceGroupName --name $webApiName | ConvertFrom-JSON + $availableSlots = $slotInfo | Select-Object -Property Name + $origins = $slotInfo | Select-Object -Property defaultHostName + $slotExists = false + + foreach ($slot in $availableSlots) { + if ($slot.name -eq $DeploymentSlot) { + # Deployment slot was found we dont need to create it + $slotExists = true + } + } + + # Web App deployment slot does not exist, create it + if (!$slotExists) { + Write-Host "Deployment slot $DeploymentSlot does not exist, creating..." + az webapp deployment slot create --slot $DeploymentSlot --resource-group $ResourceGroupName --name $webApiName --output none + $origins = az webapp deployment slot list --resource-group $ResourceGroupName --name $webApiName | ConvertFrom-JSON | Select-Object -Property defaultHostName + } +} + +Write-Host "Deploying '$PackageFilePath' to Azure WebApp '$webApiName'..." + +# Invoke the command string +Invoke-Expression $azWebAppCommand +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +if (-Not $SkipAppRegistration) { + $webapiSettings = $(az webapp config appsettings list --name $webapiName --resource-group $ResourceGroupName | ConvertFrom-JSON) + $frontendClientId = ($webapiSettings | Where-Object -Property name -EQ -Value Frontend:AadClientId).value + $objectId = (az ad app show --id $frontendClientId | ConvertFrom-Json).id + $redirectUris = (az rest --method GET --uri "https://graph.microsoft.com/v1.0/applications/$objectId" --headers 'Content-Type=application/json' | ConvertFrom-Json).spa.redirectUris + $needToUpdateRegistration = $false + + foreach ($address in $origins) { + $origin = "https://$address" + Write-Host "Ensuring '$origin' is included in AAD app registration's redirect URIs..." + + if ($redirectUris -notcontains "$origin") { + $redirectUris += "$origin" + $needToUpdateRegistration = $true + } + } + + if ($needToUpdateRegistration) { + $body = "{spa:{redirectUris:[" + foreach ($uri in $redirectUris) { + $body += "'$uri'," + } + $body += "]}}" + + az rest ` + --method PATCH ` + --uri "https://graph.microsoft.com/v1.0/applications/$objectId" ` + --headers 'Content-Type=application/json' ` + --body $body + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to update AAD app registration - Use -SkipAppRegistration switch to skip this step" + exit $LASTEXITCODE + } + } +} + +if (-Not $SkipCorsRegistration) { + foreach ($pluginName in $pluginNames) { + $allowedOrigins = $((az webapp cors show --name $pluginName --resource-group $ResourceGroupName --subscription $Subscription | ConvertFrom-Json).allowedOrigins) + foreach ($address in $origins) { + $origin = "https://$address" + Write-Host "Ensuring '$origin' is included in CORS origins for plugin '$pluginName'..." + if (-not $allowedOrigins -contains $origin) { + az webapp cors add --name $pluginName --resource-group $ResourceGroupName --subscription $Subscription --allowed-origins $origin + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to update plugin CORS URIs - Use -SkipCorsRegistration switch to skip this step" + exit $LASTEXITCODE + } + } + } + } +} + +Write-Host "To verify your deployment, go to 'https://$webApiUrl/' in your browser." \ No newline at end of file diff --git a/scripts/deploy/deploy-webapi.sh b/scripts/deploy/deploy-webapi.sh new file mode 100755 index 000000000..764fe75c1 --- /dev/null +++ b/scripts/deploy/deploy-webapi.sh @@ -0,0 +1,203 @@ +#!/bin/bash + +# Deploy Chat Copilot application to Azure + +usage() { + echo "Usage: $0 -d DEPLOYMENT_NAME -s SUBSCRIPTION -rg RESOURCE_GROUP [OPTIONS]" + echo "" + echo "Arguments:" + echo " -d, --deployment-name DEPLOYMENT_NAME Name of the deployment from a 'deploy-azure.sh' deployment (mandatory)" + echo " -s, --subscription SUBSCRIPTION Subscription to which to make the deployment (mandatory)" + echo " -rg, --resource-group RESOURCE_GROUP Resource group name from a 'deploy-azure.sh' deployment (mandatory)" + echo " -p, --package PACKAGE_FILE_PATH Path to the package file from a 'package-webapi.sh' run (default: \"./out/webapi.zip\")" + echo " -o, --slot DEPLOYMENT_SLOT Name of the target web app deployment slot" + echo " -sr, --skip-app-registration Skip adding our URI in app registration's redirect URIs" + echo " -sc, --skip-cors-registration Skip registration of service with the plugins as allowed CORS origin" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + -d|--deployment-name) + DEPLOYMENT_NAME="$2" + shift + shift + ;; + -s|--subscription) + SUBSCRIPTION="$2" + shift + shift + ;; + -rg|--resource-group) + RESOURCE_GROUP="$2" + shift + shift + ;; + -p|--package) + PACKAGE_FILE_PATH="$2" + shift + shift + ;; + -r|--skip-app-registration) + REGISTER_APP=false + shift + ;; + -o|--slot) + DEPLOYMENT_SLOT="$2" + shift + shift + ;; + -c|--skip-cors-registration) + REGISTER_CORS=false + shift + ;; + *) + echo "Unknown option $1" + usage + exit 1 + ;; + esac +done + +# Check mandatory arguments +if [[ -z "$DEPLOYMENT_NAME" ]] || [[ -z "$SUBSCRIPTION" ]] || [[ -z "$RESOURCE_GROUP" ]]; then + usage + exit 1 +fi + +# Set defaults +: "${PACKAGE_FILE_PATH:="$(dirname "$0")/out/webapi.zip"}" + +# Ensure $PACKAGE_FILE_PATH exists +if [[ ! -f "$PACKAGE_FILE_PATH" ]]; then + echo "Package file '$PACKAGE_FILE_PATH' does not exist. Have you run 'package-webapi.sh' yet?" + exit 1 +fi + +az account show --output none +if [ $? -ne 0 ]; then + echo "Log into your Azure account" + az login --use-device-code +fi + +az account set -s "$SUBSCRIPTION" + +echo "Getting Azure WebApp resource name..." +DEPLOYMENT_JSON=$(az deployment group show --name $DEPLOYMENT_NAME --resource-group $RESOURCE_GROUP --output json) +WEB_API_URL=$(echo $DEPLOYMENT_JSON | jq -r '.properties.outputs.webapiUrl.value') +echo "WEB_API_URL: $WEB_API_URL" +WEB_API_NAME=$(echo $DEPLOYMENT_JSON | jq -r '.properties.outputs.webapiName.value') +echo "WEB_API_NAME: $WEB_API_NAME" +PLUGIN_NAMES=$(echo $DEPLOYMENT_JSON | jq -r '.properties.outputs.pluginNames.value[]') +# Remove double quotes +PLUGIN_NAMES=${PLUGIN_NAMES//\"/} +echo "PLUGIN_NAMES: $PLUGIN_NAMES" +# Ensure $WEB_API_NAME is set +if [[ -z "$WEB_API_NAME" ]]; then + echo "Could not get Azure WebApp resource name from deployment output." + exit 1 +fi + +echo "Configuring Azure WebApp to run from package..." +az webapp config appsettings set --resource-group $RESOURCE_GROUP --name $WEB_API_NAME --settings WEBSITE_RUN_FROM_PACKAGE="1" --output none +if [ $? -ne 0 ]; then + echo "Could not configure Azure WebApp to run from package." + exit 1 +fi + +# Set up deployment command as a string +AZ_WEB_APP_CMD="az webapp deployment source config-zip --resource-group $RESOURCE_GROUP --name $WEB_API_NAME --src $PACKAGE_FILE_PATH" + +ORIGINS="$WEB_API_URL" +if [ -n "$DEPLOYMENT_SLOT" ]; then + AZ_WEB_APP_CMD+=" --slot ${DEPLOYMENT_SLOT}" + echo "Checking whether slot $DEPLOYMENT_SLOT exists for $WEB_APP_NAME..." + SLOT_INFO=$(az webapp deployment slot list --resource-group $RESOURCE_GROUP --name $WEB_API_NAME) + + AVAILABLE_SLOTS=$(echo $SLOT_INFO | jq '.[].name') + ORIGINS=$(echo $slotInfo | jq '.[].defaultHostName') + SLOT_EXISTS=false + + # Checking if the slot exists + for SLOT in $(echo "$AVAILABLE_SLOTS" | tr '\n' ' '); do + if [[ "$SLOT" == "$DEPLOYMENT_SLOT" ]]; then + SLOT_EXISTS=true + break + fi + done + + if [[ "$SLOT_EXISTS" = false ]]; then + echo "Deployment slot ${DEPLOYMENT_SLOT} does not exist, creating..." + + az webapp deployment slot create --slot=$DEPLOYMENT_SLOT --resource-group=$RESOURCE_GROUP --name $WEB_API_NAME + + ORIGINS=$(az webapp deployment slot list --resource-group=$RESOURCE_GROUP --name $WEB_API_NAME | jq '.[].defaultHostName') + fi +fi + +echo "Deploying '$PACKAGE_FILE_PATH' to Azure WebApp '$WEB_API_NAME'..." +eval "$AZ_WEB_APP_CMD" +if [ $? -ne 0 ]; then + echo "Could not deploy '$PACKAGE_FILE_PATH' to Azure WebApp '$WEB_API_NAME'." + exit 1 +fi + +if [[ -z $REGISTER_APP ]]; then + WEBAPI_SETTINGS=$(az webapp config appsettings list --name $WEB_API_NAME --resource-group $RESOURCE_GROUP --output json) + FRONTEND_CLIENT_ID=$(echo $WEBAPI_SETTINGS | jq -r '.[] | select(.name == "Frontend:AadClientId") | .value') + OBJECT_ID=$(az ad app show --id $FRONTEND_CLIENT_ID | jq -r '.id') + REDIRECT_URIS=$(az rest --method GET --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" --headers 'Content-Type=application/json' | jq -r '.spa.redirectUris[]') + NEED_TO_UPDATE_REG=false + + for ADDRESS in $(echo "$ORIGINS"); do + ORIGIN="https://$ADDRESS" + echo "Ensuring '$ORIGIN' is included in AAD app registration's redirect URIs..." + + if [[ ! "$REDIRECT_URIS" =~ "$ORIGIN" ]]; then + if [[ -n $REDIRECT_URIS ]]; then + REDIRECT_URIS=$(echo "$REDIRECT_URIS,$ORIGIN") + else + REDIRECT_URIS=$(echo "$ORIGIN") + fi + NEED_TO_UPDATE_REG=true + fi + done + + if [ $NEED_TO_UPDATE_REG = true ]; then + BODY="{spa:{redirectUris:['$(echo "$REDIRECT_URIS")']}}" + BODY="${BODY//\,/\',\'}" + + echo "Updating redirects with $BODY" + + az rest \ + --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ + --headers 'Content-Type=application/json' \ + --body $BODY + + if [ $? -ne 0 ]; then + echo "Failed to update app registration $OBJECT_ID with redirect URIs - Use -sc switch to skip this step" + exit 1 + fi + fi +fi + +if [[ -z $REGISTER_CORS ]]; then + for PLUGIN_NAME in $PLUGIN_NAMES; do + ALLOWED_ORIGINS=$(az webapp cors show --name $PLUGIN_NAME --resource-group $RESOURCE_GROUP --subscription $SUBSCRIPTION | jq -r '.allowedOrigins[]') + for ADDRESS in $(echo "$ORIGINS"); do + ORIGIN="https://$ADDRESS" + echo "Ensuring '$ORIGIN' is included in CORS origins for plugin '$PLUGIN_NAME'..." + if [[ ! "$ALLOWED_ORIGINS" =~ "$ORIGIN" ]]; then + az webapp cors add --name $PLUGIN_NAME --resource-group $RESOURCE_GROUP --subscription $SUBSCRIPTION --allowed-origins "$ORIGIN" + if [ $? -ne 0 ]; then + echo "Failed to update CORS origins with $ORIGIN - Use -sc switch to skip this step" + exit 1 + fi + fi + done + done +fi + +echo "To verify your deployment, go to 'https://$WEB_API_URL/' in your browser." diff --git a/scripts/deploy/main.bicep b/scripts/deploy/main.bicep new file mode 100644 index 000000000..cc7991e7c --- /dev/null +++ b/scripts/deploy/main.bicep @@ -0,0 +1,1149 @@ +/* +Copyright (c) Microsoft. All rights reserved. +Licensed under the MIT license. See LICENSE file in the project root for full license information. + +Bicep template for deploying CopilotChat Azure resources. +*/ + +@description('Name for the deployment consisting of alphanumeric characters or dashes (\'-\')') +param name string = 'copichat' + +@description('SKU for the Azure App Service plan') +@allowed([ 'B1', 'S1', 'S2', 'S3', 'P1V3', 'P2V3', 'I1V2', 'I2V2' ]) +param webAppServiceSku string = 'B1' + +@description('Location of package to deploy as the web service') +#disable-next-line no-hardcoded-env-urls +param webApiPackageUri string = 'https://aka.ms/copilotchat/webapi/latest' + +@description('Location of package to deploy as the memory pipeline') +#disable-next-line no-hardcoded-env-urls +param memoryPipelinePackageUri string = 'https://aka.ms/copilotchat/memorypipeline/latest' + +@description('Location of the websearcher plugin to deploy') +#disable-next-line no-hardcoded-env-urls +param webSearcherPackageUri string = 'https://aka.ms/copilotchat/websearcher/latest' + +@description('Underlying AI service') +@allowed([ + 'AzureOpenAI' + 'OpenAI' +]) +param aiService string = 'AzureOpenAI' + +@description('Model to use for chat completions') +param completionModel string = 'gpt-4o' + +@description('Model to use for text embeddings') +param embeddingModel string = 'text-embedding-ada-002' + +@description('Azure OpenAI endpoint to use (Azure OpenAI only)') +param aiEndpoint string = '' + +@secure() +@description('Azure OpenAI or OpenAI API key') +param aiApiKey string + +@description('Azure AD client ID for the backend web API') +param webApiClientId string + +@description('Azure AD client ID for the frontend') +param frontendClientId string + +@description('Azure AD tenant ID for authenticating users') +param azureAdTenantId string + +@description('Azure AD cloud instance for authenticating users') +param azureAdInstance string = environment().authentication.loginEndpoint + +@description('Whether to deploy a new Azure OpenAI instance') +param deployNewAzureOpenAI bool = false + +@description('Whether to deploy Cosmos DB for persistent chat storage') +param deployCosmosDB bool = true + +@description('What method to use to persist embeddings') +@allowed([ + 'AzureAISearch' + 'Qdrant' +]) +param memoryStore string = 'AzureAISearch' + +@description('Whether to deploy Azure Speech Services to enable input by voice') +param deploySpeechServices bool = true + +@description('Whether to deploy the web searcher plugin, which requires a Bing resource') +param deployWebSearcherPlugin bool = false + +@description('Whether to deploy pre-built binary packages to the cloud') +param deployPackages bool = true + +@description('Region for the resources') +param location string = resourceGroup().location + +@description('Hash of the resource group ID') +var rgIdHash = uniqueString(resourceGroup().id) + +@description('Deployment name unique to resource group') +var uniqueName = '${name}-${rgIdHash}' + +@description('Name of the Azure Storage file share to create') +var storageFileShareName = 'aciqdrantshare' + +resource openAI 'Microsoft.CognitiveServices/accounts@2023-05-01' = if (deployNewAzureOpenAI) { + name: 'ai-${uniqueName}' + location: location + kind: 'OpenAI' + sku: { + name: 'S0' + } + properties: { + customSubDomainName: toLower(uniqueName) + } +} + +resource openAI_completionModel 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = if (deployNewAzureOpenAI) { + parent: openAI + name: completionModel + sku: { + name: 'Standard' + capacity: 30 + } + properties: { + model: { + format: 'OpenAI' + name: completionModel + } + } +} + +resource openAI_embeddingModel 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = if (deployNewAzureOpenAI) { + parent: openAI + name: embeddingModel + sku: { + name: 'Standard' + capacity: 30 + } + properties: { + model: { + format: 'OpenAI' + name: embeddingModel + } + } + dependsOn: [// This "dependency" is to create models sequentially because the resource + openAI_completionModel // provider does not support parallel creation of models properly. + ] +} + +resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = { + name: 'asp-${uniqueName}-webapi' + location: location + kind: 'app' + sku: { + name: webAppServiceSku + } +} + +resource appServiceWeb 'Microsoft.Web/sites@2022-09-01' = { + name: 'app-${uniqueName}-webapi' + location: location + kind: 'app' + tags: { + skweb: '1' + } + properties: { + serverFarmId: appServicePlan.id + httpsOnly: true + virtualNetworkSubnetId: memoryStore == 'Qdrant' ? virtualNetwork.properties.subnets[0].id : null + siteConfig: { + healthCheckPath: '/healthz' + } + } +} + +resource appServiceWebConfig 'Microsoft.Web/sites/config@2022-09-01' = { + parent: appServiceWeb + name: 'web' + dependsOn: [ + webSubnetConnection + ] + properties: { + alwaysOn: false + cors: { + allowedOrigins: [ + 'http://localhost:3000' + 'https://localhost:3000' + ] + supportCredentials: true + } + detailedErrorLoggingEnabled: true + minTlsVersion: '1.2' + netFrameworkVersion: 'v6.0' + use32BitWorkerProcess: false + vnetRouteAllEnabled: true + webSocketsEnabled: true + appSettings: concat([ + { + name: 'Authentication:Type' + value: 'AzureAd' + } + { + name: 'Authentication:AzureAd:Instance' + value: azureAdInstance + } + { + name: 'Authentication:AzureAd:TenantId' + value: azureAdTenantId + } + { + name: 'Authentication:AzureAd:ClientId' + value: webApiClientId + } + { + name: 'Authentication:AzureAd:Scopes' + value: 'access_as_user' + } + { + name: 'ChatStore:Type' + value: deployCosmosDB ? 'cosmos' : 'volatile' + } + { + name: 'ChatStore:Cosmos:Database' + value: 'CopilotChat' + } + { + name: 'ChatStore:Cosmos:ChatSessionsContainer' + value: 'chatsessions' + } + { + name: 'ChatStore:Cosmos:ChatMessagesContainer' + value: 'chatmessages' + } + { + name: 'ChatStore:Cosmos:ChatMemorySourcesContainer' + value: 'chatmemorysources' + } + { + name: 'ChatStore:Cosmos:ChatParticipantsContainer' + value: 'chatparticipants' + } + { + name: 'ChatStore:Cosmos:ConnectionString' + value: deployCosmosDB ? cosmosAccount.listConnectionStrings().connectionStrings[0].connectionString : '' + } + { + name: 'AzureSpeech:Region' + value: location + } + { + name: 'AzureSpeech:Key' + value: deploySpeechServices ? speechAccount.listKeys().key1 : '' + } + { + name: 'AllowedOrigins' + value: '[*]' // Defer list of allowed origins to the Azure service app's CORS configuration + } + { + name: 'Kestrel:Endpoints:Https:Url' + value: 'https://localhost:443' + } + { + name: 'Frontend:AadClientId' + value: frontendClientId + } + { + name: 'Logging:LogLevel:Default' + value: 'Warning' + } + { + name: 'Logging:LogLevel:CopilotChat.WebApi' + value: 'Warning' + } + { + name: 'Logging:LogLevel:Microsoft.SemanticKernel' + value: 'Warning' + } + { + name: 'Logging:LogLevel:Microsoft.AspNetCore.Hosting' + value: 'Warning' + } + { + name: 'Logging:LogLevel:Microsoft.Hosting.Lifetimel' + value: 'Warning' + } + { + name: 'Logging:ApplicationInsights:LogLevel:Default' + value: 'Warning' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appInsights.properties.ConnectionString + } + { + name: 'ApplicationInsightsAgent_EXTENSION_VERSION' + value: '~2' + } + { + name: 'KernelMemory:DocumentStorageType' + value: 'AzureBlobs' + } + { + name: 'KernelMemory:TextGeneratorType' + value: aiService + } + { + name: 'KernelMemory:DataIngestion:OrchestrationType' + value: 'Distributed' + } + { + name: 'KernelMemory:DataIngestion:DistributedOrchestration:QueueType' + value: 'AzureQueue' + } + { + name: 'KernelMemory:DataIngestion:EmbeddingGeneratorTypes:0' + value: aiService + } + { + name: 'KernelMemory:DataIngestion:MemoryDbTypes:0' + value: memoryStore + } + { + name: 'KernelMemory:Retrieval:MemoryDbType' + value: memoryStore + } + { + name: 'KernelMemory:Retrieval:EmbeddingGeneratorType' + value: aiService + } + { + name: 'KernelMemory:Services:AzureBlobs:Auth' + value: 'ConnectionString' + } + { + name: 'KernelMemory:Services:AzureBlobs:ConnectionString' + value: 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[1].value}' + } + { + name: 'KernelMemory:Services:AzureBlobs:Container' + value: 'chatmemory' + } + { + name: 'KernelMemory:Services:AzureQueue:Auth' + value: 'ConnectionString' + } + { + name: 'KernelMemory:Services:AzureQueue:ConnectionString' + value: 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[1].value}' + } + { + name: 'KernelMemory:Services:AzureAISearch:Auth' + value: 'ApiKey' + } + { + name: 'KernelMemory:Services:AzureAISearch:Endpoint' + value: memoryStore == 'AzureAISearch' ? 'https://${azureAISearch.name}.search.windows.net' : '' + } + { + name: 'KernelMemory:Services:AzureAISearch:APIKey' + value: memoryStore == 'AzureAISearch' ? azureAISearch.listAdminKeys().primaryKey : '' + } + { + name: 'KernelMemory:Services:Qdrant:Endpoint' + value: memoryStore == 'Qdrant' ? 'https://${appServiceQdrant.properties.defaultHostName}' : '' + } + { + name: 'KernelMemory:Services:AzureOpenAIText:Auth' + value: 'ApiKey' + } + { + name: 'KernelMemory:Services:AzureOpenAIText:Endpoint' + value: deployNewAzureOpenAI ? openAI.properties.endpoint : aiEndpoint + } + { + name: 'KernelMemory:Services:AzureOpenAIText:APIKey' + value: deployNewAzureOpenAI ? openAI.listKeys().key1 : aiApiKey + } + { + name: 'KernelMemory:Services:AzureOpenAIText:Deployment' + value: completionModel + } + { + name: 'KernelMemory:Services:AzureOpenAIEmbedding:Auth' + value: 'ApiKey' + } + { + name: 'KernelMemory:Services:AzureOpenAIEmbedding:Endpoint' + value: deployNewAzureOpenAI ? openAI.properties.endpoint : aiEndpoint + } + { + name: 'KernelMemory:Services:AzureOpenAIEmbedding:APIKey' + value: deployNewAzureOpenAI ? openAI.listKeys().key1 : aiApiKey + } + { + name: 'KernelMemory:Services:AzureOpenAIEmbedding:Deployment' + value: embeddingModel + } + { + name: 'KernelMemory:Services:OpenAI:TextModel' + value: completionModel + } + { + name: 'KernelMemory:Services:OpenAI:EmbeddingModel' + value: embeddingModel + } + { + name: 'KernelMemory:Services:OpenAI:APIKey' + value: aiApiKey + } + { + name: 'Plugins:0:Name' + value: 'Klarna Shopping' + } + { + name: 'Plugins:0:ManifestDomain' + value: 'https://www.klarna.com' + } + ], + (deployWebSearcherPlugin) ? [ + { + name: 'Plugins:1:Name' + value: 'WebSearcher' + } + { + name: 'Plugins:1:ManifestDomain' + value: 'https://${functionAppWebSearcherPlugin.properties.defaultHostName}' + } + { + name: 'Plugins:1:Key' + value: listkeys('${functionAppWebSearcherPlugin.id}/host/default/', '2022-09-01').functionKeys.default + } + ] : [] + ) + } +} + +resource appServiceWebDeploy 'Microsoft.Web/sites/extensions@2022-09-01' = if (deployPackages) { + name: 'MSDeploy' + kind: 'string' + parent: appServiceWeb + properties: { + packageUri: webApiPackageUri + } + dependsOn: [ + appServiceWebConfig + ] +} + +resource appServiceMemoryPipeline 'Microsoft.Web/sites@2022-09-01' = { + name: 'app-${uniqueName}-memorypipeline' + location: location + kind: 'app' + tags: { + skweb: '1' + } + properties: { + httpsOnly: true + serverFarmId: appServicePlan.id + virtualNetworkSubnetId: memoryStore == 'Qdrant' ? virtualNetwork.properties.subnets[0].id : null + siteConfig: { + alwaysOn: true + } + } +} + +resource appServiceMemoryPipelineConfig 'Microsoft.Web/sites/config@2022-09-01' = { + parent: appServiceMemoryPipeline + name: 'web' + dependsOn: [ + memSubnetConnection + ] + properties: { + alwaysOn: true + detailedErrorLoggingEnabled: true + minTlsVersion: '1.2' + netFrameworkVersion: 'v6.0' + use32BitWorkerProcess: false + vnetRouteAllEnabled: true + appSettings: [ + { + name: 'KernelMemory:DocumentStorageType' + value: 'AzureBlobs' + } + { + name: 'KernelMemory:TextGeneratorType' + value: aiService + } + { + name: 'KernelMemory:DataIngestion:ImageOcrType' + value: 'AzureFormRecognizer' + } + { + name: 'KernelMemory:DataIngestion:OrchestrationType' + value: 'Distributed' + } + { + name: 'KernelMemory:DataIngestion:DistributedOrchestration:QueueType' + value: 'AzureQueue' + } + { + name: 'KernelMemory:DataIngestion:EmbeddingGeneratorTypes:0' + value: aiService + } + { + name: 'KernelMemory:DataIngestion:MemoryDbTypes:0' + value: memoryStore + } + { + name: 'KernelMemory:Retrieval:MemoryDbType' + value: memoryStore + } + { + name: 'KernelMemory:Retrieval:EmbeddingGeneratorType' + value: aiService + } + { + name: 'KernelMemory:Services:AzureBlobs:Auth' + value: 'ConnectionString' + } + { + name: 'KernelMemory:Services:AzureBlobs:ConnectionString' + value: 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[1].value}' + } + { + name: 'KernelMemory:Services:AzureBlobs:Container' + value: 'chatmemory' + } + { + name: 'KernelMemory:Services:AzureQueue:Auth' + value: 'ConnectionString' + } + { + name: 'KernelMemory:Services:AzureQueue:ConnectionString' + value: 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[1].value}' + } + { + name: 'KernelMemory:Services:AzureAISearch:Auth' + value: 'ApiKey' + } + { + name: 'KernelMemory:Services:AzureAISearch:Endpoint' + value: memoryStore == 'AzureAISearch' ? 'https://${azureAISearch.name}.search.windows.net' : '' + } + { + name: 'KernelMemory:Services:AzureAISearch:APIKey' + value: memoryStore == 'AzureAISearch' ? azureAISearch.listAdminKeys().primaryKey : '' + } + { + name: 'KernelMemory:Services:Qdrant:Endpoint' + value: memoryStore == 'Qdrant' ? 'https://${appServiceQdrant.properties.defaultHostName}' : '' + } + { + name: 'KernelMemory:Services:AzureOpenAIText:Auth' + value: 'ApiKey' + } + { + name: 'KernelMemory:Services:AzureOpenAIText:Endpoint' + value: deployNewAzureOpenAI ? openAI.properties.endpoint : aiEndpoint + } + { + name: 'KernelMemory:Services:AzureOpenAIText:APIKey' + value: deployNewAzureOpenAI ? openAI.listKeys().key1 : aiApiKey + } + { + name: 'KernelMemory:Services:AzureOpenAIText:Deployment' + value: completionModel + } + { + name: 'KernelMemory:Services:AzureOpenAIEmbedding:Auth' + value: 'ApiKey' + } + { + name: 'KernelMemory:Services:AzureOpenAIEmbedding:Endpoint' + value: deployNewAzureOpenAI ? openAI.properties.endpoint : aiEndpoint + } + { + name: 'KernelMemory:Services:AzureOpenAIEmbedding:APIKey' + value: deployNewAzureOpenAI ? openAI.listKeys().key1 : aiApiKey + } + { + name: 'KernelMemory:Services:AzureOpenAIEmbedding:Deployment' + value: embeddingModel + } + { + name: 'KernelMemory:Services:AzureFormRecognizer:Auth' + value: 'ApiKey' + } + { + name: 'KernelMemory:Services:AzureFormRecognizer:Endpoint' + value: ocrAccount.properties.endpoint + } + { + name: 'KernelMemory:Services:AzureFormRecognizer:APIKey' + value: ocrAccount.listKeys().key1 + } + { + name: 'KernelMemory:Services:OpenAI:TextModel' + value: completionModel + } + { + name: 'KernelMemory:Services:OpenAI:EmbeddingModel' + value: embeddingModel + } + { + name: 'KernelMemory:Services:OpenAI:APIKey' + value: aiApiKey + } + { + name: 'Logging:LogLevel:Default' + value: 'Information' + } + { + name: 'Logging:LogLevel:AspNetCore' + value: 'Warning' + } + { + name: 'Logging:ApplicationInsights:LogLevel:Default' + value: 'Warning' + } + { + name: 'ApplicationInsights:ConnectionString' + value: appInsights.properties.ConnectionString + } + ] + } +} + +resource appServiceMemoryPipelineDeploy 'Microsoft.Web/sites/extensions@2022-09-01' = if (deployPackages) { + name: 'MSDeploy' + kind: 'string' + parent: appServiceMemoryPipeline + properties: { + packageUri: memoryPipelinePackageUri + } + dependsOn: [ + appServiceMemoryPipelineConfig + ] +} + +resource functionAppWebSearcherPlugin 'Microsoft.Web/sites@2022-09-01' = if (deployWebSearcherPlugin) { + name: 'function-${uniqueName}-websearcher-plugin' + location: location + kind: 'functionapp' + tags: { + skweb: '1' + } + properties: { + serverFarmId: appServicePlan.id + httpsOnly: true + siteConfig: { + alwaysOn: true + } + } +} + +resource functionAppWebSearcherPluginConfig 'Microsoft.Web/sites/config@2022-09-01' = if (deployWebSearcherPlugin) { + parent: functionAppWebSearcherPlugin + name: 'web' + properties: { + minTlsVersion: '1.2' + appSettings: [ + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'dotnet-isolated' + } + { + name: 'AzureWebJobsStorage' + value: 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[1].value}' + } + { + name: 'APPINSIGHTS_INSTRUMENTATIONKEY' + value: appInsights.properties.InstrumentationKey + } + { + name: 'PluginConfig:BingApiKey' + value: (deployWebSearcherPlugin) ? bingSearchService.listKeys().key1 : '' + } + ] + } +} + +resource functionAppWebSearcherDeploy 'Microsoft.Web/sites/extensions@2022-09-01' = if (deployPackages && deployWebSearcherPlugin) { + name: 'MSDeploy' + kind: 'string' + parent: functionAppWebSearcherPlugin + properties: { + packageUri: webSearcherPackageUri + } + dependsOn: [ + functionAppWebSearcherPluginConfig + ] +} + +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: 'appins-${uniqueName}' + location: location + kind: 'string' + tags: { + displayName: 'AppInsight' + } + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspace.id + } +} + +resource appInsightExtensionWeb 'Microsoft.Web/sites/siteextensions@2022-09-01' = { + parent: appServiceWeb + name: 'Microsoft.ApplicationInsights.AzureWebSites' + dependsOn: [ appServiceWebDeploy ] +} + +resource appInsightExtensionMemory 'Microsoft.Web/sites/siteextensions@2022-09-01' = { + parent: appServiceMemoryPipeline + name: 'Microsoft.ApplicationInsights.AzureWebSites' + dependsOn: [ appServiceMemoryPipelineDeploy ] +} + +resource appInsightExtensionWebSearchPlugin 'Microsoft.Web/sites/siteextensions@2022-09-01' = if (deployWebSearcherPlugin) { + parent: functionAppWebSearcherPlugin + name: 'Microsoft.ApplicationInsights.AzureWebSites' + dependsOn: [ functionAppWebSearcherDeploy ] +} + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: 'la-${uniqueName}' + location: location + tags: { + displayName: 'Log Analytics' + } + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 90 + features: { + searchVersion: 1 + legacy: 0 + enableLogAccessUsingOnlyResourcePermissions: true + } + } +} + +resource storage 'Microsoft.Storage/storageAccounts@2022-09-01' = { + name: 'st${rgIdHash}' // Not using full unique name to avoid hitting 24 char limit + location: location + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + properties: { + supportsHttpsTrafficOnly: true + allowBlobPublicAccess: false + } + resource fileservices 'fileServices' = if (memoryStore == 'Qdrant') { + name: 'default' + resource share 'shares' = { + name: storageFileShareName + } + } +} + +resource appServicePlanQdrant 'Microsoft.Web/serverfarms@2022-03-01' = if (memoryStore == 'Qdrant') { + name: 'asp-${uniqueName}-qdrant' + location: location + kind: 'linux' + sku: { + name: 'P1v3' + } + properties: { + reserved: true + } +} + +resource appServiceQdrant 'Microsoft.Web/sites@2022-09-01' = if (memoryStore == 'Qdrant') { + name: 'app-${uniqueName}-qdrant' + location: location + kind: 'app,linux,container' + properties: { + serverFarmId: appServicePlanQdrant.id + httpsOnly: true + reserved: true + clientCertMode: 'Required' + virtualNetworkSubnetId: memoryStore == 'Qdrant' ? virtualNetwork.properties.subnets[1].id : null + siteConfig: { + numberOfWorkers: 1 + linuxFxVersion: 'DOCKER|qdrant/qdrant:latest' + alwaysOn: true + vnetRouteAllEnabled: true + ipSecurityRestrictions: [ + { + vnetSubnetResourceId: memoryStore == 'Qdrant' ? virtualNetwork.properties.subnets[0].id : null + action: 'Allow' + priority: 300 + name: 'Allow front vnet' + } + { + ipAddress: 'Any' + action: 'Deny' + priority: 2147483647 + name: 'Deny all' + } + ] + azureStorageAccounts: { + aciqdrantshare: { + type: 'AzureFiles' + accountName: memoryStore == 'Qdrant' ? storage.name : 'notdeployed' + shareName: storageFileShareName + mountPath: '/qdrant/storage' + accessKey: memoryStore == 'Qdrant' ? storage.listKeys().keys[0].value : '' + } + } + } + } +} + +resource azureAISearch 'Microsoft.Search/searchServices@2022-09-01' = if (memoryStore == 'AzureAISearch') { + name: 'acs-${uniqueName}' + location: location + sku: { + name: 'basic' + } + properties: { + replicaCount: 1 + partitionCount: 1 + } +} + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2021-05-01' = if (memoryStore == 'Qdrant') { + name: 'vnet-${uniqueName}' + location: location + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + subnets: [ + { + name: 'webSubnet' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: webNsg.id + } + serviceEndpoints: [ + { + service: 'Microsoft.Web' + locations: [ + '*' + ] + } + ] + delegations: [ + { + name: 'delegation' + properties: { + serviceName: 'Microsoft.Web/serverfarms' + } + } + ] + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + } + } + { + name: 'qdrantSubnet' + properties: { + addressPrefix: '10.0.2.0/24' + networkSecurityGroup: { + id: qdrantNsg.id + } + serviceEndpoints: [ + { + service: 'Microsoft.Web' + locations: [ + '*' + ] + } + ] + delegations: [ + { + name: 'delegation' + properties: { + serviceName: 'Microsoft.Web/serverfarms' + } + } + ] + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + } + } + ] + } +} + +resource webNsg 'Microsoft.Network/networkSecurityGroups@2022-11-01' = if (memoryStore == 'Qdrant') { + name: 'nsg-${uniqueName}-webapi' + location: location + properties: { + securityRules: [ + { + name: 'AllowAnyHTTPSInbound' + properties: { + protocol: 'TCP' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: '*' + access: 'Allow' + priority: 100 + direction: 'Inbound' + } + } + ] + } +} + +resource qdrantNsg 'Microsoft.Network/networkSecurityGroups@2022-11-01' = if (memoryStore == 'Qdrant') { + name: 'nsg-${uniqueName}-qdrant' + location: location + properties: { + securityRules: [] + } +} + +resource webSubnetConnection 'Microsoft.Web/sites/virtualNetworkConnections@2022-09-01' = if (memoryStore == 'Qdrant') { + parent: appServiceWeb + name: 'webSubnetConnection' + properties: { + vnetResourceId: memoryStore == 'Qdrant' ? virtualNetwork.properties.subnets[0].id : null + isSwift: true + } +} + +resource memSubnetConnection 'Microsoft.Web/sites/virtualNetworkConnections@2022-09-01' = if (memoryStore == 'Qdrant') { + parent: appServiceMemoryPipeline + name: 'memSubnetConnection' + properties: { + vnetResourceId: memoryStore == 'Qdrant' ? virtualNetwork.properties.subnets[0].id : null + isSwift: true + } +} + +resource qdrantSubnetConnection 'Microsoft.Web/sites/virtualNetworkConnections@2022-09-01' = if (memoryStore == 'Qdrant') { + parent: appServiceQdrant + name: 'qdrantSubnetConnection' + properties: { + vnetResourceId: memoryStore == 'Qdrant' ? virtualNetwork.properties.subnets[1].id : null + isSwift: true + } +} + +resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = if (deployCosmosDB) { + name: toLower('cosmos-${uniqueName}') + location: location + kind: 'GlobalDocumentDB' + properties: { + consistencyPolicy: { defaultConsistencyLevel: 'Session' } + locations: [ { + locationName: location + failoverPriority: 0 + isZoneRedundant: false + } + ] + databaseAccountOfferType: 'Standard' + } +} + +resource cosmosDatabase 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2023-04-15' = if (deployCosmosDB) { + parent: cosmosAccount + name: 'CopilotChat' + properties: { + resource: { + id: 'CopilotChat' + } + } +} + +resource messageContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = if (deployCosmosDB) { + parent: cosmosDatabase + name: 'chatmessages' + properties: { + resource: { + id: 'chatmessages' + indexingPolicy: { + indexingMode: 'consistent' + automatic: true + includedPaths: [ + { + path: '/*' + } + ] + excludedPaths: [ + { + path: '/"_etag"/?' + } + ] + } + partitionKey: { + paths: [ + '/chatId' + ] + kind: 'Hash' + version: 2 + } + } + } +} + +resource sessionContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = if (deployCosmosDB) { + parent: cosmosDatabase + name: 'chatsessions' + properties: { + resource: { + id: 'chatsessions' + indexingPolicy: { + indexingMode: 'consistent' + automatic: true + includedPaths: [ + { + path: '/*' + } + ] + excludedPaths: [ + { + path: '/"_etag"/?' + } + ] + } + partitionKey: { + paths: [ + '/id' + ] + kind: 'Hash' + version: 2 + } + } + } +} + +resource participantContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = if (deployCosmosDB) { + parent: cosmosDatabase + name: 'chatparticipants' + properties: { + resource: { + id: 'chatparticipants' + indexingPolicy: { + indexingMode: 'consistent' + automatic: true + includedPaths: [ + { + path: '/*' + } + ] + excludedPaths: [ + { + path: '/"_etag"/?' + } + ] + } + partitionKey: { + paths: [ + '/userId' + ] + kind: 'Hash' + version: 2 + } + } + } +} + +resource memorySourcesContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = if (deployCosmosDB) { + parent: cosmosDatabase + name: 'chatmemorysources' + properties: { + resource: { + id: 'chatmemorysources' + indexingPolicy: { + indexingMode: 'consistent' + automatic: true + includedPaths: [ + { + path: '/*' + } + ] + excludedPaths: [ + { + path: '/"_etag"/?' + } + ] + } + partitionKey: { + paths: [ + '/chatId' + ] + kind: 'Hash' + version: 2 + } + } + } +} + +resource speechAccount 'Microsoft.CognitiveServices/accounts@2022-12-01' = if (deploySpeechServices) { + name: 'cog-speech-${uniqueName}' + location: location + sku: { + name: 'S0' + } + kind: 'SpeechServices' + identity: { + type: 'None' + } + properties: { + customSubDomainName: 'cog-speech-${uniqueName}' + networkAcls: { + defaultAction: 'Allow' + } + publicNetworkAccess: 'Enabled' + } +} + +resource ocrAccount 'Microsoft.CognitiveServices/accounts@2022-12-01' = { + name: 'cog-ocr-${uniqueName}' + location: location + sku: { + name: 'S0' + } + kind: 'FormRecognizer' + identity: { + type: 'None' + } + properties: { + customSubDomainName: 'cog-ocr-${uniqueName}' + networkAcls: { + defaultAction: 'Allow' + } + publicNetworkAccess: 'Enabled' + } +} + +resource bingSearchService 'Microsoft.Bing/accounts@2020-06-10' = if (deployWebSearcherPlugin) { + name: 'bing-search-${uniqueName}' + location: 'global' + sku: { + name: 'S1' + } + kind: 'Bing.Search.v7' +} + +output webapiUrl string = appServiceWeb.properties.defaultHostName +output webapiName string = appServiceWeb.name +output memoryPipelineName string = appServiceMemoryPipeline.name +output pluginNames array = concat( + [], + (deployWebSearcherPlugin) ? [ functionAppWebSearcherPlugin.name ] : [] +) diff --git a/scripts/deploy/main.json b/scripts/deploy/main.json new file mode 100644 index 000000000..92141d4fb --- /dev/null +++ b/scripts/deploy/main.json @@ -0,0 +1,1160 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.28.1.47646", + "templateHash": "18195377528136882013" + } + }, + "parameters": { + "name": { + "type": "string", + "defaultValue": "copichat", + "metadata": { + "description": "Name for the deployment consisting of alphanumeric characters or dashes ('-')" + } + }, + "webAppServiceSku": { + "type": "string", + "defaultValue": "B1", + "allowedValues": [ + "B1", + "S1", + "S2", + "S3", + "P1V3", + "P2V3", + "I1V2", + "I2V2" + ], + "metadata": { + "description": "SKU for the Azure App Service plan" + } + }, + "webApiPackageUri": { + "type": "string", + "defaultValue": "https://aka.ms/copilotchat/webapi/latest", + "metadata": { + "description": "Location of package to deploy as the web service" + } + }, + "memoryPipelinePackageUri": { + "type": "string", + "defaultValue": "https://aka.ms/copilotchat/memorypipeline/latest", + "metadata": { + "description": "Location of package to deploy as the memory pipeline" + } + }, + "webSearcherPackageUri": { + "type": "string", + "defaultValue": "https://aka.ms/copilotchat/websearcher/latest", + "metadata": { + "description": "Location of the websearcher plugin to deploy" + } + }, + "aiService": { + "type": "string", + "defaultValue": "AzureOpenAI", + "allowedValues": [ + "AzureOpenAI", + "OpenAI" + ], + "metadata": { + "description": "Underlying AI service" + } + }, + "completionModel": { + "type": "string", + "defaultValue": "gpt-4o", + "metadata": { + "description": "Model to use for chat completions" + } + }, + "embeddingModel": { + "type": "string", + "defaultValue": "text-embedding-ada-002", + "metadata": { + "description": "Model to use for text embeddings" + } + }, + "aiEndpoint": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Azure OpenAI endpoint to use (Azure OpenAI only)" + } + }, + "aiApiKey": { + "type": "securestring", + "metadata": { + "description": "Azure OpenAI or OpenAI API key" + } + }, + "webApiClientId": { + "type": "string", + "metadata": { + "description": "Azure AD client ID for the backend web API" + } + }, + "frontendClientId": { + "type": "string", + "metadata": { + "description": "Azure AD client ID for the frontend" + } + }, + "azureAdTenantId": { + "type": "string", + "metadata": { + "description": "Azure AD tenant ID for authenticating users" + } + }, + "azureAdInstance": { + "type": "string", + "defaultValue": "[environment().authentication.loginEndpoint]", + "metadata": { + "description": "Azure AD cloud instance for authenticating users" + } + }, + "deployNewAzureOpenAI": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Whether to deploy a new Azure OpenAI instance" + } + }, + "deployCosmosDB": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Whether to deploy Cosmos DB for persistent chat storage" + } + }, + "memoryStore": { + "type": "string", + "defaultValue": "AzureAISearch", + "allowedValues": [ + "AzureAISearch", + "Qdrant" + ], + "metadata": { + "description": "What method to use to persist embeddings" + } + }, + "deploySpeechServices": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Whether to deploy Azure Speech Services to enable input by voice" + } + }, + "deployWebSearcherPlugin": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Whether to deploy the web searcher plugin, which requires a Bing resource" + } + }, + "deployPackages": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Whether to deploy pre-built binary packages to the cloud" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Region for the resources" + } + } + }, + "variables": { + "rgIdHash": "[uniqueString(resourceGroup().id)]", + "uniqueName": "[format('{0}-{1}', parameters('name'), variables('rgIdHash'))]", + "storageFileShareName": "aciqdrantshare" + }, + "resources": [ + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Storage/storageAccounts/fileServices/shares", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}/{2}', format('st{0}', variables('rgIdHash')), 'default', variables('storageFileShareName'))]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/fileServices', format('st{0}', variables('rgIdHash')), 'default')]" + ] + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Storage/storageAccounts/fileServices", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('st{0}', variables('rgIdHash')), 'default')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash')))]" + ] + }, + { + "condition": "[parameters('deployNewAzureOpenAI')]", + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2023-05-01", + "name": "[format('ai-{0}', variables('uniqueName'))]", + "location": "[parameters('location')]", + "kind": "OpenAI", + "sku": { + "name": "S0" + }, + "properties": { + "customSubDomainName": "[toLower(variables('uniqueName'))]" + } + }, + { + "condition": "[parameters('deployNewAzureOpenAI')]", + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2023-05-01", + "name": "[format('{0}/{1}', format('ai-{0}', variables('uniqueName')), parameters('completionModel'))]", + "sku": { + "name": "Standard", + "capacity": 30 + }, + "properties": { + "model": { + "format": "OpenAI", + "name": "[parameters('completionModel')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName')))]" + ] + }, + { + "condition": "[parameters('deployNewAzureOpenAI')]", + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2023-05-01", + "name": "[format('{0}/{1}', format('ai-{0}', variables('uniqueName')), parameters('embeddingModel'))]", + "sku": { + "name": "Standard", + "capacity": 30 + }, + "properties": { + "model": { + "format": "OpenAI", + "name": "[parameters('embeddingModel')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.CognitiveServices/accounts/deployments', format('ai-{0}', variables('uniqueName')), parameters('completionModel'))]" + ] + }, + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2022-03-01", + "name": "[format('asp-{0}-webapi', variables('uniqueName'))]", + "location": "[parameters('location')]", + "kind": "app", + "sku": { + "name": "[parameters('webAppServiceSku')]" + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2022-09-01", + "name": "[format('app-{0}-webapi', variables('uniqueName'))]", + "location": "[parameters('location')]", + "kind": "app", + "tags": { + "skweb": "1" + }, + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-webapi', variables('uniqueName')))]", + "httpsOnly": true, + "virtualNetworkSubnetId": "[if(equals(parameters('memoryStore'), 'Qdrant'), reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[0].id, null())]", + "siteConfig": { + "healthCheckPath": "/healthz" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-webapi', variables('uniqueName')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + ] + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-webapi', variables('uniqueName')), 'web')]", + "properties": { + "alwaysOn": false, + "cors": { + "allowedOrigins": [ + "http://localhost:3000", + "https://localhost:3000" + ], + "supportCredentials": true + }, + "detailedErrorLoggingEnabled": true, + "minTlsVersion": "1.2", + "netFrameworkVersion": "v6.0", + "use32BitWorkerProcess": false, + "vnetRouteAllEnabled": true, + "webSocketsEnabled": true, + "appSettings": "[concat(createArray(createObject('name', 'Authentication:Type', 'value', 'AzureAd'), createObject('name', 'Authentication:AzureAd:Instance', 'value', parameters('azureAdInstance')), createObject('name', 'Authentication:AzureAd:TenantId', 'value', parameters('azureAdTenantId')), createObject('name', 'Authentication:AzureAd:ClientId', 'value', parameters('webApiClientId')), createObject('name', 'Authentication:AzureAd:Scopes', 'value', 'access_as_user'), createObject('name', 'ChatStore:Type', 'value', if(parameters('deployCosmosDB'), 'cosmos', 'volatile')), createObject('name', 'ChatStore:Cosmos:Database', 'value', 'CopilotChat'), createObject('name', 'ChatStore:Cosmos:ChatSessionsContainer', 'value', 'chatsessions'), createObject('name', 'ChatStore:Cosmos:ChatMessagesContainer', 'value', 'chatmessages'), createObject('name', 'ChatStore:Cosmos:ChatMemorySourcesContainer', 'value', 'chatmemorysources'), createObject('name', 'ChatStore:Cosmos:ChatParticipantsContainer', 'value', 'chatparticipants'), createObject('name', 'ChatStore:Cosmos:ConnectionString', 'value', if(parameters('deployCosmosDB'), listConnectionStrings(resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('cosmos-{0}', variables('uniqueName')))), '2023-04-15').connectionStrings[0].connectionString, '')), createObject('name', 'AzureSpeech:Region', 'value', parameters('location')), createObject('name', 'AzureSpeech:Key', 'value', if(parameters('deploySpeechServices'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', format('cog-speech-{0}', variables('uniqueName'))), '2022-12-01').key1, '')), createObject('name', 'AllowedOrigins', 'value', '[*]'), createObject('name', 'Kestrel:Endpoints:Https:Url', 'value', 'https://localhost:443'), createObject('name', 'Frontend:AadClientId', 'value', parameters('frontendClientId')), createObject('name', 'Logging:LogLevel:Default', 'value', 'Warning'), createObject('name', 'Logging:LogLevel:CopilotChat.WebApi', 'value', 'Warning'), createObject('name', 'Logging:LogLevel:Microsoft.SemanticKernel', 'value', 'Warning'), createObject('name', 'Logging:LogLevel:Microsoft.AspNetCore.Hosting', 'value', 'Warning'), createObject('name', 'Logging:LogLevel:Microsoft.Hosting.Lifetimel', 'value', 'Warning'), createObject('name', 'Logging:ApplicationInsights:LogLevel:Default', 'value', 'Warning'), createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference(resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName'))), '2020-02-02').ConnectionString), createObject('name', 'ApplicationInsightsAgent_EXTENSION_VERSION', 'value', '~2'), createObject('name', 'KernelMemory:DocumentStorageType', 'value', 'AzureBlobs'), createObject('name', 'KernelMemory:TextGeneratorType', 'value', parameters('aiService')), createObject('name', 'KernelMemory:DataIngestion:OrchestrationType', 'value', 'Distributed'), createObject('name', 'KernelMemory:DataIngestion:DistributedOrchestration:QueueType', 'value', 'AzureQueue'), createObject('name', 'KernelMemory:DataIngestion:EmbeddingGeneratorTypes:0', 'value', parameters('aiService')), createObject('name', 'KernelMemory:DataIngestion:MemoryDbTypes:0', 'value', parameters('memoryStore')), createObject('name', 'KernelMemory:Retrieval:MemoryDbType', 'value', parameters('memoryStore')), createObject('name', 'KernelMemory:Retrieval:EmbeddingGeneratorType', 'value', parameters('aiService')), createObject('name', 'KernelMemory:Services:AzureBlobs:Auth', 'value', 'ConnectionString'), createObject('name', 'KernelMemory:Services:AzureBlobs:ConnectionString', 'value', format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1}', format('st{0}', variables('rgIdHash')), listKeys(resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash'))), '2022-09-01').keys[1].value)), createObject('name', 'KernelMemory:Services:AzureBlobs:Container', 'value', 'chatmemory'), createObject('name', 'KernelMemory:Services:AzureQueue:Auth', 'value', 'ConnectionString'), createObject('name', 'KernelMemory:Services:AzureQueue:ConnectionString', 'value', format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1}', format('st{0}', variables('rgIdHash')), listKeys(resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash'))), '2022-09-01').keys[1].value)), createObject('name', 'KernelMemory:Services:AzureAISearch:Auth', 'value', 'ApiKey'), createObject('name', 'KernelMemory:Services:AzureAISearch:Endpoint', 'value', if(equals(parameters('memoryStore'), 'AzureAISearch'), format('https://{0}.search.windows.net', format('acs-{0}', variables('uniqueName'))), '')), createObject('name', 'KernelMemory:Services:AzureAISearch:APIKey', 'value', if(equals(parameters('memoryStore'), 'AzureAISearch'), listAdminKeys(resourceId('Microsoft.Search/searchServices', format('acs-{0}', variables('uniqueName'))), '2022-09-01').primaryKey, '')), createObject('name', 'KernelMemory:Services:Qdrant:Endpoint', 'value', if(equals(parameters('memoryStore'), 'Qdrant'), format('https://{0}', reference(resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName'))), '2022-09-01').defaultHostName), '')), createObject('name', 'KernelMemory:Services:AzureOpenAIText:Auth', 'value', 'ApiKey'), createObject('name', 'KernelMemory:Services:AzureOpenAIText:Endpoint', 'value', if(parameters('deployNewAzureOpenAI'), reference(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2023-05-01').endpoint, parameters('aiEndpoint'))), createObject('name', 'KernelMemory:Services:AzureOpenAIText:APIKey', 'value', if(parameters('deployNewAzureOpenAI'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2023-05-01').key1, parameters('aiApiKey'))), createObject('name', 'KernelMemory:Services:AzureOpenAIText:Deployment', 'value', parameters('completionModel')), createObject('name', 'KernelMemory:Services:AzureOpenAIEmbedding:Auth', 'value', 'ApiKey'), createObject('name', 'KernelMemory:Services:AzureOpenAIEmbedding:Endpoint', 'value', if(parameters('deployNewAzureOpenAI'), reference(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2023-05-01').endpoint, parameters('aiEndpoint'))), createObject('name', 'KernelMemory:Services:AzureOpenAIEmbedding:APIKey', 'value', if(parameters('deployNewAzureOpenAI'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2023-05-01').key1, parameters('aiApiKey'))), createObject('name', 'KernelMemory:Services:AzureOpenAIEmbedding:Deployment', 'value', parameters('embeddingModel')), createObject('name', 'KernelMemory:Services:OpenAI:TextModel', 'value', parameters('completionModel')), createObject('name', 'KernelMemory:Services:OpenAI:EmbeddingModel', 'value', parameters('embeddingModel')), createObject('name', 'KernelMemory:Services:OpenAI:APIKey', 'value', parameters('aiApiKey')), createObject('name', 'Plugins:0:Name', 'value', 'Klarna Shopping'), createObject('name', 'Plugins:0:ManifestDomain', 'value', 'https://www.klarna.com')), if(parameters('deployWebSearcherPlugin'), createArray(createObject('name', 'Plugins:1:Name', 'value', 'WebSearcher'), createObject('name', 'Plugins:1:ManifestDomain', 'value', format('https://{0}', reference(resourceId('Microsoft.Web/sites', format('function-{0}-websearcher-plugin', variables('uniqueName'))), '2022-09-01').defaultHostName)), createObject('name', 'Plugins:1:Key', 'value', listkeys(format('{0}/host/default/', resourceId('Microsoft.Web/sites', format('function-{0}-websearcher-plugin', variables('uniqueName')))), '2022-09-01').functionKeys.default)), createArray()))]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName')))]", + "[resourceId('Microsoft.Search/searchServices', format('acs-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('cosmos-{0}', variables('uniqueName'))))]", + "[resourceId('Microsoft.Web/sites', format('function-{0}-websearcher-plugin', variables('uniqueName')))]", + "[resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.CognitiveServices/accounts', format('cog-speech-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash')))]", + "[resourceId('Microsoft.Web/sites/virtualNetworkConnections', format('app-{0}-webapi', variables('uniqueName')), 'webSubnetConnection')]" + ] + }, + { + "condition": "[parameters('deployPackages')]", + "type": "Microsoft.Web/sites/extensions", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-webapi', variables('uniqueName')), 'MSDeploy')]", + "kind": "string", + "properties": { + "packageUri": "[parameters('webApiPackageUri')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites/config', format('app-{0}-webapi', variables('uniqueName')), 'web')]" + ] + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2022-09-01", + "name": "[format('app-{0}-memorypipeline', variables('uniqueName'))]", + "location": "[parameters('location')]", + "kind": "app", + "tags": { + "skweb": "1" + }, + "properties": { + "httpsOnly": true, + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-webapi', variables('uniqueName')))]", + "virtualNetworkSubnetId": "[if(equals(parameters('memoryStore'), 'Qdrant'), reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[0].id, null())]", + "siteConfig": { + "alwaysOn": true + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-webapi', variables('uniqueName')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + ] + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-memorypipeline', variables('uniqueName')), 'web')]", + "properties": { + "alwaysOn": true, + "detailedErrorLoggingEnabled": true, + "minTlsVersion": "1.2", + "netFrameworkVersion": "v6.0", + "use32BitWorkerProcess": false, + "vnetRouteAllEnabled": true, + "appSettings": [ + { + "name": "KernelMemory:DocumentStorageType", + "value": "AzureBlobs" + }, + { + "name": "KernelMemory:TextGeneratorType", + "value": "[parameters('aiService')]" + }, + { + "name": "KernelMemory:DataIngestion:ImageOcrType", + "value": "AzureFormRecognizer" + }, + { + "name": "KernelMemory:DataIngestion:OrchestrationType", + "value": "Distributed" + }, + { + "name": "KernelMemory:DataIngestion:DistributedOrchestration:QueueType", + "value": "AzureQueue" + }, + { + "name": "KernelMemory:DataIngestion:EmbeddingGeneratorTypes:0", + "value": "[parameters('aiService')]" + }, + { + "name": "KernelMemory:DataIngestion:MemoryDbTypes:0", + "value": "[parameters('memoryStore')]" + }, + { + "name": "KernelMemory:Retrieval:MemoryDbType", + "value": "[parameters('memoryStore')]" + }, + { + "name": "KernelMemory:Retrieval:EmbeddingGeneratorType", + "value": "[parameters('aiService')]" + }, + { + "name": "KernelMemory:Services:AzureBlobs:Auth", + "value": "ConnectionString" + }, + { + "name": "KernelMemory:Services:AzureBlobs:ConnectionString", + "value": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1}', format('st{0}', variables('rgIdHash')), listKeys(resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash'))), '2022-09-01').keys[1].value)]" + }, + { + "name": "KernelMemory:Services:AzureBlobs:Container", + "value": "chatmemory" + }, + { + "name": "KernelMemory:Services:AzureQueue:Auth", + "value": "ConnectionString" + }, + { + "name": "KernelMemory:Services:AzureQueue:ConnectionString", + "value": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1}', format('st{0}', variables('rgIdHash')), listKeys(resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash'))), '2022-09-01').keys[1].value)]" + }, + { + "name": "KernelMemory:Services:AzureAISearch:Auth", + "value": "ApiKey" + }, + { + "name": "KernelMemory:Services:AzureAISearch:Endpoint", + "value": "[if(equals(parameters('memoryStore'), 'AzureAISearch'), format('https://{0}.search.windows.net', format('acs-{0}', variables('uniqueName'))), '')]" + }, + { + "name": "KernelMemory:Services:AzureAISearch:APIKey", + "value": "[if(equals(parameters('memoryStore'), 'AzureAISearch'), listAdminKeys(resourceId('Microsoft.Search/searchServices', format('acs-{0}', variables('uniqueName'))), '2022-09-01').primaryKey, '')]" + }, + { + "name": "KernelMemory:Services:Qdrant:Endpoint", + "value": "[if(equals(parameters('memoryStore'), 'Qdrant'), format('https://{0}', reference(resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName'))), '2022-09-01').defaultHostName), '')]" + }, + { + "name": "KernelMemory:Services:AzureOpenAIText:Auth", + "value": "ApiKey" + }, + { + "name": "KernelMemory:Services:AzureOpenAIText:Endpoint", + "value": "[if(parameters('deployNewAzureOpenAI'), reference(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2023-05-01').endpoint, parameters('aiEndpoint'))]" + }, + { + "name": "KernelMemory:Services:AzureOpenAIText:APIKey", + "value": "[if(parameters('deployNewAzureOpenAI'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2023-05-01').key1, parameters('aiApiKey'))]" + }, + { + "name": "KernelMemory:Services:AzureOpenAIText:Deployment", + "value": "[parameters('completionModel')]" + }, + { + "name": "KernelMemory:Services:AzureOpenAIEmbedding:Auth", + "value": "ApiKey" + }, + { + "name": "KernelMemory:Services:AzureOpenAIEmbedding:Endpoint", + "value": "[if(parameters('deployNewAzureOpenAI'), reference(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2023-05-01').endpoint, parameters('aiEndpoint'))]" + }, + { + "name": "KernelMemory:Services:AzureOpenAIEmbedding:APIKey", + "value": "[if(parameters('deployNewAzureOpenAI'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName'))), '2023-05-01').key1, parameters('aiApiKey'))]" + }, + { + "name": "KernelMemory:Services:AzureOpenAIEmbedding:Deployment", + "value": "[parameters('embeddingModel')]" + }, + { + "name": "KernelMemory:Services:AzureFormRecognizer:Auth", + "value": "ApiKey" + }, + { + "name": "KernelMemory:Services:AzureFormRecognizer:Endpoint", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', format('cog-ocr-{0}', variables('uniqueName'))), '2022-12-01').endpoint]" + }, + { + "name": "KernelMemory:Services:AzureFormRecognizer:APIKey", + "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', format('cog-ocr-{0}', variables('uniqueName'))), '2022-12-01').key1]" + }, + { + "name": "KernelMemory:Services:OpenAI:TextModel", + "value": "[parameters('completionModel')]" + }, + { + "name": "KernelMemory:Services:OpenAI:EmbeddingModel", + "value": "[parameters('embeddingModel')]" + }, + { + "name": "KernelMemory:Services:OpenAI:APIKey", + "value": "[parameters('aiApiKey')]" + }, + { + "name": "Logging:LogLevel:Default", + "value": "Information" + }, + { + "name": "Logging:LogLevel:AspNetCore", + "value": "Warning" + }, + { + "name": "Logging:ApplicationInsights:LogLevel:Default", + "value": "Warning" + }, + { + "name": "ApplicationInsights:ConnectionString", + "value": "[reference(resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName'))), '2020-02-02').ConnectionString]" + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites', format('app-{0}-memorypipeline', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName')))]", + "[resourceId('Microsoft.Search/searchServices', format('acs-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites/virtualNetworkConnections', format('app-{0}-memorypipeline', variables('uniqueName')), 'memSubnetConnection')]", + "[resourceId('Microsoft.CognitiveServices/accounts', format('cog-ocr-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash')))]" + ] + }, + { + "condition": "[parameters('deployPackages')]", + "type": "Microsoft.Web/sites/extensions", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-memorypipeline', variables('uniqueName')), 'MSDeploy')]", + "kind": "string", + "properties": { + "packageUri": "[parameters('memoryPipelinePackageUri')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-{0}-memorypipeline', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites/config', format('app-{0}-memorypipeline', variables('uniqueName')), 'web')]" + ] + }, + { + "condition": "[parameters('deployWebSearcherPlugin')]", + "type": "Microsoft.Web/sites", + "apiVersion": "2022-09-01", + "name": "[format('function-{0}-websearcher-plugin', variables('uniqueName'))]", + "location": "[parameters('location')]", + "kind": "functionapp", + "tags": { + "skweb": "1" + }, + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-webapi', variables('uniqueName')))]", + "httpsOnly": true, + "siteConfig": { + "alwaysOn": true + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-webapi', variables('uniqueName')))]" + ] + }, + { + "condition": "[parameters('deployWebSearcherPlugin')]", + "type": "Microsoft.Web/sites/config", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('function-{0}-websearcher-plugin', variables('uniqueName')), 'web')]", + "properties": { + "minTlsVersion": "1.2", + "appSettings": [ + { + "name": "FUNCTIONS_EXTENSION_VERSION", + "value": "~4" + }, + { + "name": "FUNCTIONS_WORKER_RUNTIME", + "value": "dotnet-isolated" + }, + { + "name": "AzureWebJobsStorage", + "value": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1}', format('st{0}', variables('rgIdHash')), listKeys(resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash'))), '2022-09-01').keys[1].value)]" + }, + { + "name": "APPINSIGHTS_INSTRUMENTATIONKEY", + "value": "[reference(resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName'))), '2020-02-02').InstrumentationKey]" + }, + { + "name": "PluginConfig:BingApiKey", + "value": "[if(parameters('deployWebSearcherPlugin'), listKeys(resourceId('Microsoft.Bing/accounts', format('bing-search-{0}', variables('uniqueName'))), '2020-06-10').key1, '')]" + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', format('appins-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.Bing/accounts', format('bing-search-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites', format('function-{0}-websearcher-plugin', variables('uniqueName')))]", + "[resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash')))]" + ] + }, + { + "condition": "[and(parameters('deployPackages'), parameters('deployWebSearcherPlugin'))]", + "type": "Microsoft.Web/sites/extensions", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('function-{0}-websearcher-plugin', variables('uniqueName')), 'MSDeploy')]", + "kind": "string", + "properties": { + "packageUri": "[parameters('webSearcherPackageUri')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('function-{0}-websearcher-plugin', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites/config', format('function-{0}-websearcher-plugin', variables('uniqueName')), 'web')]" + ] + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[format('appins-{0}', variables('uniqueName'))]", + "location": "[parameters('location')]", + "kind": "string", + "tags": { + "displayName": "AppInsight" + }, + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('la-{0}', variables('uniqueName')))]" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('la-{0}', variables('uniqueName')))]" + ] + }, + { + "type": "Microsoft.Web/sites/siteextensions", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-webapi', variables('uniqueName')), 'Microsoft.ApplicationInsights.AzureWebSites')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites/extensions', format('app-{0}-webapi', variables('uniqueName')), 'MSDeploy')]" + ] + }, + { + "type": "Microsoft.Web/sites/siteextensions", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-memorypipeline', variables('uniqueName')), 'Microsoft.ApplicationInsights.AzureWebSites')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-{0}-memorypipeline', variables('uniqueName')))]", + "[resourceId('Microsoft.Web/sites/extensions', format('app-{0}-memorypipeline', variables('uniqueName')), 'MSDeploy')]" + ] + }, + { + "condition": "[parameters('deployWebSearcherPlugin')]", + "type": "Microsoft.Web/sites/siteextensions", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('function-{0}-websearcher-plugin', variables('uniqueName')), 'Microsoft.ApplicationInsights.AzureWebSites')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/extensions', format('function-{0}-websearcher-plugin', variables('uniqueName')), 'MSDeploy')]", + "[resourceId('Microsoft.Web/sites', format('function-{0}-websearcher-plugin', variables('uniqueName')))]" + ] + }, + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2022-10-01", + "name": "[format('la-{0}', variables('uniqueName'))]", + "location": "[parameters('location')]", + "tags": { + "displayName": "Log Analytics" + }, + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 90, + "features": { + "searchVersion": 1, + "legacy": 0, + "enableLogAccessUsingOnlyResourcePermissions": true + } + } + }, + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2022-09-01", + "name": "[format('st{0}', variables('rgIdHash'))]", + "location": "[parameters('location')]", + "kind": "StorageV2", + "sku": { + "name": "Standard_LRS" + }, + "properties": { + "supportsHttpsTrafficOnly": true, + "allowBlobPublicAccess": false + } + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2022-03-01", + "name": "[format('asp-{0}-qdrant', variables('uniqueName'))]", + "location": "[parameters('location')]", + "kind": "linux", + "sku": { + "name": "P1v3" + }, + "properties": { + "reserved": true + } + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Web/sites", + "apiVersion": "2022-09-01", + "name": "[format('app-{0}-qdrant', variables('uniqueName'))]", + "location": "[parameters('location')]", + "kind": "app,linux,container", + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-qdrant', variables('uniqueName')))]", + "httpsOnly": true, + "reserved": true, + "clientCertMode": "Required", + "virtualNetworkSubnetId": "[if(equals(parameters('memoryStore'), 'Qdrant'), reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[1].id, null())]", + "siteConfig": { + "numberOfWorkers": 1, + "linuxFxVersion": "DOCKER|qdrant/qdrant:latest", + "alwaysOn": true, + "vnetRouteAllEnabled": true, + "ipSecurityRestrictions": [ + { + "vnetSubnetResourceId": "[if(equals(parameters('memoryStore'), 'Qdrant'), reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[0].id, null())]", + "action": "Allow", + "priority": 300, + "name": "Allow front vnet" + }, + { + "ipAddress": "Any", + "action": "Deny", + "priority": 2147483647, + "name": "Deny all" + } + ], + "azureStorageAccounts": { + "aciqdrantshare": { + "type": "AzureFiles", + "accountName": "[if(equals(parameters('memoryStore'), 'Qdrant'), format('st{0}', variables('rgIdHash')), 'notdeployed')]", + "shareName": "[variables('storageFileShareName')]", + "mountPath": "/qdrant/storage", + "accessKey": "[if(equals(parameters('memoryStore'), 'Qdrant'), listKeys(resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash'))), '2022-09-01').keys[0].value, '')]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', format('asp-{0}-qdrant', variables('uniqueName')))]", + "[resourceId('Microsoft.Storage/storageAccounts', format('st{0}', variables('rgIdHash')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + ] + }, + { + "condition": "[equals(parameters('memoryStore'), 'AzureAISearch')]", + "type": "Microsoft.Search/searchServices", + "apiVersion": "2022-09-01", + "name": "[format('acs-{0}', variables('uniqueName'))]", + "location": "[parameters('location')]", + "sku": { + "name": "basic" + }, + "properties": { + "replicaCount": 1, + "partitionCount": 1 + } + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2021-05-01", + "name": "[format('vnet-{0}', variables('uniqueName'))]", + "location": "[parameters('location')]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "10.0.0.0/16" + ] + }, + "subnets": [ + { + "name": "webSubnet", + "properties": { + "addressPrefix": "10.0.1.0/24", + "networkSecurityGroup": { + "id": "[resourceId('Microsoft.Network/networkSecurityGroups', format('nsg-{0}-webapi', variables('uniqueName')))]" + }, + "serviceEndpoints": [ + { + "service": "Microsoft.Web", + "locations": [ + "*" + ] + } + ], + "delegations": [ + { + "name": "delegation", + "properties": { + "serviceName": "Microsoft.Web/serverfarms" + } + } + ], + "privateEndpointNetworkPolicies": "Disabled", + "privateLinkServiceNetworkPolicies": "Enabled" + } + }, + { + "name": "qdrantSubnet", + "properties": { + "addressPrefix": "10.0.2.0/24", + "networkSecurityGroup": { + "id": "[resourceId('Microsoft.Network/networkSecurityGroups', format('nsg-{0}-qdrant', variables('uniqueName')))]" + }, + "serviceEndpoints": [ + { + "service": "Microsoft.Web", + "locations": [ + "*" + ] + } + ], + "delegations": [ + { + "name": "delegation", + "properties": { + "serviceName": "Microsoft.Web/serverfarms" + } + } + ], + "privateEndpointNetworkPolicies": "Disabled", + "privateLinkServiceNetworkPolicies": "Enabled" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/networkSecurityGroups', format('nsg-{0}-qdrant', variables('uniqueName')))]", + "[resourceId('Microsoft.Network/networkSecurityGroups', format('nsg-{0}-webapi', variables('uniqueName')))]" + ] + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Network/networkSecurityGroups", + "apiVersion": "2022-11-01", + "name": "[format('nsg-{0}-webapi', variables('uniqueName'))]", + "location": "[parameters('location')]", + "properties": { + "securityRules": [ + { + "name": "AllowAnyHTTPSInbound", + "properties": { + "protocol": "TCP", + "sourcePortRange": "*", + "destinationPortRange": "443", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 100, + "direction": "Inbound" + } + } + ] + } + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Network/networkSecurityGroups", + "apiVersion": "2022-11-01", + "name": "[format('nsg-{0}-qdrant', variables('uniqueName'))]", + "location": "[parameters('location')]", + "properties": { + "securityRules": [] + } + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Web/sites/virtualNetworkConnections", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-webapi', variables('uniqueName')), 'webSubnetConnection')]", + "properties": { + "vnetResourceId": "[if(equals(parameters('memoryStore'), 'Qdrant'), reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[0].id, null())]", + "isSwift": true + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + ] + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Web/sites/virtualNetworkConnections", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-memorypipeline', variables('uniqueName')), 'memSubnetConnection')]", + "properties": { + "vnetResourceId": "[if(equals(parameters('memoryStore'), 'Qdrant'), reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[0].id, null())]", + "isSwift": true + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-{0}-memorypipeline', variables('uniqueName')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + ] + }, + { + "condition": "[equals(parameters('memoryStore'), 'Qdrant')]", + "type": "Microsoft.Web/sites/virtualNetworkConnections", + "apiVersion": "2022-09-01", + "name": "[format('{0}/{1}', format('app-{0}-qdrant', variables('uniqueName')), 'qdrantSubnetConnection')]", + "properties": { + "vnetResourceId": "[if(equals(parameters('memoryStore'), 'Qdrant'), reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[1].id, null())]", + "isSwift": true + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-{0}-qdrant', variables('uniqueName')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + ] + }, + { + "condition": "[parameters('deployCosmosDB')]", + "type": "Microsoft.DocumentDB/databaseAccounts", + "apiVersion": "2023-04-15", + "name": "[toLower(format('cosmos-{0}', variables('uniqueName')))]", + "location": "[parameters('location')]", + "kind": "GlobalDocumentDB", + "properties": { + "consistencyPolicy": { + "defaultConsistencyLevel": "Session" + }, + "locations": [ + { + "locationName": "[parameters('location')]", + "failoverPriority": 0, + "isZoneRedundant": false + } + ], + "databaseAccountOfferType": "Standard" + } + }, + { + "condition": "[parameters('deployCosmosDB')]", + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", + "apiVersion": "2023-04-15", + "name": "[format('{0}/{1}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]", + "properties": { + "resource": { + "id": "CopilotChat" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('cosmos-{0}', variables('uniqueName'))))]" + ] + }, + { + "condition": "[parameters('deployCosmosDB')]", + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2023-04-15", + "name": "[format('{0}/{1}/{2}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat', 'chatmessages')]", + "properties": { + "resource": { + "id": "chatmessages", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ] + }, + "partitionKey": { + "paths": [ + "/chatId" + ], + "kind": "Hash", + "version": 2 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" + ] + }, + { + "condition": "[parameters('deployCosmosDB')]", + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2023-04-15", + "name": "[format('{0}/{1}/{2}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat', 'chatsessions')]", + "properties": { + "resource": { + "id": "chatsessions", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ] + }, + "partitionKey": { + "paths": [ + "/id" + ], + "kind": "Hash", + "version": 2 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" + ] + }, + { + "condition": "[parameters('deployCosmosDB')]", + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2023-04-15", + "name": "[format('{0}/{1}/{2}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat', 'chatparticipants')]", + "properties": { + "resource": { + "id": "chatparticipants", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ] + }, + "partitionKey": { + "paths": [ + "/userId" + ], + "kind": "Hash", + "version": 2 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" + ] + }, + { + "condition": "[parameters('deployCosmosDB')]", + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2023-04-15", + "name": "[format('{0}/{1}/{2}', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat', 'chatmemorysources')]", + "properties": { + "resource": { + "id": "chatmemorysources", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ] + }, + "partitionKey": { + "paths": [ + "/chatId" + ], + "kind": "Hash", + "version": 2 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" + ] + }, + { + "condition": "[parameters('deploySpeechServices')]", + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2022-12-01", + "name": "[format('cog-speech-{0}', variables('uniqueName'))]", + "location": "[parameters('location')]", + "sku": { + "name": "S0" + }, + "kind": "SpeechServices", + "identity": { + "type": "None" + }, + "properties": { + "customSubDomainName": "[format('cog-speech-{0}', variables('uniqueName'))]", + "networkAcls": { + "defaultAction": "Allow" + }, + "publicNetworkAccess": "Enabled" + } + }, + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2022-12-01", + "name": "[format('cog-ocr-{0}', variables('uniqueName'))]", + "location": "[parameters('location')]", + "sku": { + "name": "S0" + }, + "kind": "FormRecognizer", + "identity": { + "type": "None" + }, + "properties": { + "customSubDomainName": "[format('cog-ocr-{0}', variables('uniqueName'))]", + "networkAcls": { + "defaultAction": "Allow" + }, + "publicNetworkAccess": "Enabled" + } + }, + { + "condition": "[parameters('deployWebSearcherPlugin')]", + "type": "Microsoft.Bing/accounts", + "apiVersion": "2020-06-10", + "name": "[format('bing-search-{0}', variables('uniqueName'))]", + "location": "global", + "sku": { + "name": "S1" + }, + "kind": "Bing.Search.v7" + } + ], + "outputs": { + "webapiUrl": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', format('app-{0}-webapi', variables('uniqueName'))), '2022-09-01').defaultHostName]" + }, + "webapiName": { + "type": "string", + "value": "[format('app-{0}-webapi', variables('uniqueName'))]" + }, + "memoryPipelineName": { + "type": "string", + "value": "[format('app-{0}-memorypipeline', variables('uniqueName'))]" + }, + "pluginNames": { + "type": "array", + "value": "[concat(createArray(), if(parameters('deployWebSearcherPlugin'), createArray(format('function-{0}-websearcher-plugin', variables('uniqueName'))), createArray()))]" + } + } +} \ No newline at end of file diff --git a/deploy/package-webapi.ps1 b/scripts/deploy/package-memorypipeline.ps1 similarity index 58% rename from deploy/package-webapi.ps1 rename to scripts/deploy/package-memorypipeline.ps1 index 3966b35c6..93dbf8f08 100644 --- a/deploy/package-webapi.ps1 +++ b/scripts/deploy/package-memorypipeline.ps1 @@ -1,6 +1,6 @@ -<# +<# .SYNOPSIS -Package CopilotChat's WebAPI for deployment to Azure +Package CopilotChat's MemoryPipeline for deployment to Azure #> param( @@ -10,7 +10,7 @@ param( [string] # .NET framework to publish. - $DotNetFramework = "net6.0", + $DotNetFramework = "net8.0", [string] # Target runtime to publish. @@ -18,7 +18,15 @@ param( [string] # Output directory for published assets. - $OutputDirectory = "$PSScriptRoot" + $OutputDirectory = "$PSScriptRoot", + + [string] + # Version to give to assemblies and files. + $Version = "1.0.0", + + [string] + # Additional information given in version info. + $InformationalVersion = "" ) Write-Host "BuildConfiguration: $BuildConfiguration" @@ -28,7 +36,7 @@ Write-Host "OutputDirectory: $OutputDirectory" $publishOutputDirectory = "$OutputDirectory/publish" $publishedZipDirectory = "$OutputDirectory/out" -$publishedZipFilePath = "$publishedZipDirectory/webapi.zip" +$publishedZipFilePath = "$publishedZipDirectory/memorypipeline.zip" if (!(Test-Path $publishedZipDirectory)) { New-Item -ItemType Directory -Force -Path $publishedZipDirectory | Out-Null } @@ -37,7 +45,7 @@ if (!(Test-Path $publishOutputDirectory)) { } Write-Host "Build configuration: $BuildConfiguration" -dotnet publish "$PSScriptRoot/../webapi/CopilotChatWebApi.csproj" --configuration $BuildConfiguration --framework $DotNetFramework --runtime $TargetRuntime --self-contained --output "$publishOutputDirectory" +dotnet publish "$PSScriptRoot/../../memorypipeline/CopilotChatMemoryPipeline.csproj" --configuration $BuildConfiguration --framework $DotNetFramework --runtime $TargetRuntime --self-contained --output "$publishOutputDirectory" /p:AssemblyVersion=$Version /p:FileVersion=$Version /p:InformationalVersion=$InformationalVersion if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -45,4 +53,4 @@ if ($LASTEXITCODE -ne 0) { Write-Host "Compressing to $publishedZipFilePath" Compress-Archive -Path $publishOutputDirectory\* -DestinationPath $publishedZipFilePath -Force -Write-Host "Published webapi package to '$publishedZipFilePath'" \ No newline at end of file +Write-Host "Published memorypipeline package to '$publishedZipFilePath'" \ No newline at end of file diff --git a/deploy/package-webapi.sh b/scripts/deploy/package-memorypipeline.sh similarity index 58% rename from deploy/package-webapi.sh rename to scripts/deploy/package-memorypipeline.sh index 8f5da09eb..4dbcbe002 100644 --- a/deploy/package-webapi.sh +++ b/scripts/deploy/package-memorypipeline.sh @@ -1,6 +1,6 @@ -#!/bin/bash +#!/bin/bash -# Package CiopilotChat's WebAPI for deployment to Azure +# Package CiopilotChat's MemoryPipeline for deployment to Azure set -e @@ -8,13 +8,15 @@ SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OUTPUT_DIRECTORY="$SCRIPT_ROOT" usage() { - echo "Usage: $0 -d DEPLOYMENT_NAME -s SUBSCRIPTION --ai AI_SERVICE_TYPE -aikey AI_SERVICE_KEY [OPTIONS]" + echo "Usage: $0 [OPTIONS]" echo "" echo "Arguments:" echo " -c, --configuration CONFIGURATION Build configuration (default: Release)" - echo " -d, --dotnet DOTNET_FRAMEWORK_VERSION Target dotnet framework (default: net6.0)" - echo " -r, --runtime TARGET_RUNTIME Runtime identifier (default: linux-x64)" - echo " -p, --output OUTPUT_DIRECTORY Output directory (default: $SCRIPT_ROOT)" + echo " -d, --dotnet DOTNET_FRAMEWORK_VERSION Target dotnet framework (default: net8.0)" + echo " -r, --runtime TARGET_RUNTIME Runtime identifier (default: win-x64)" + echo " -o, --output OUTPUT_DIRECTORY Output directory (default: $SCRIPT_ROOT)" + echo " -v --version VERSION Version to set files to (default: 1.0.0)" + echo " -i --info INFO Additional info to put in version details" echo " -nz, --no-zip Do not zip package (default: false)" } @@ -22,31 +24,41 @@ usage() { while [[ $# -gt 0 ]]; do key="$1" case $key in - -c|--configuration) + -c | --configuration) CONFIGURATION="$2" shift shift ;; - -d|--dotnet) + -d | --dotnet) DOTNET="$2" shift shift ;; - -r|--runtime) + -r | --runtime) RUNTIME="$2" shift shift ;; - -o|--output) + -o | --output) OUTPUT_DIRECTORY="$2" shift shift ;; - -nz|--no-zip) + -v | --version) + VERSION="$2" + shift + shift + ;; + -i | --info) + INFO="$2" + shift + shift + ;; + -nz | --no-zip) NO_ZIP=true shift ;; - *) + *) echo "Unknown option $1" usage exit 1 @@ -56,13 +68,15 @@ done # Set defaults : "${CONFIGURATION:="Release"}" -: "${DOTNET:="net6.0"}" +: "${DOTNET:="net8.0"}" : "${RUNTIME:="win-x64"}" +: "${VERSION:="1.0.0"}" +: "${INFO:=""}" : "${OUTPUT_DIRECTORY:="$SCRIPT_ROOT"}" PUBLISH_OUTPUT_DIRECTORY="$OUTPUT_DIRECTORY/publish" PUBLISH_ZIP_DIRECTORY="$OUTPUT_DIRECTORY/out" -PACKAGE_FILE_PATH="$PUBLISH_ZIP_DIRECTORY/webapi.zip" +PACKAGE_FILE_PATH="$PUBLISH_ZIP_DIRECTORY/memorypipeline.zip" if [[ ! -d "$PUBLISH_OUTPUT_DIRECTORY" ]]; then mkdir -p "$PUBLISH_OUTPUT_DIRECTORY" @@ -72,7 +86,16 @@ if [[ ! -d "$PUBLISH_ZIP_DIRECTORY" ]]; then fi echo "Build configuration: $CONFIGURATION" -dotnet publish "$SCRIPT_ROOT/../webapi/CopilotChatWebApi.csproj" --configuration $CONFIGURATION --framework $DOTNET --runtime $RUNTIME --self-contained --output "$PUBLISH_OUTPUT_DIRECTORY" +dotnet publish "$SCRIPT_ROOT/../../memorypipeline/CopilotChatMemoryPipeline.csproj" \ + --configuration $CONFIGURATION \ + --framework $DOTNET \ + --runtime $RUNTIME \ + --self-contained \ + --output "$PUBLISH_OUTPUT_DIRECTORY" \ + -p:AssemblyVersion=$VERSION \ + -p:FileVersion=$VERSION \ + -p:InformationalVersion=$INFO + if [ $? -ne 0 ]; then exit 1 fi @@ -84,5 +107,3 @@ if [[ -z "$NO_ZIP" ]]; then zip -r $PACKAGE_FILE_PATH . popd fi - - diff --git a/scripts/deploy/package-plugins.ps1 b/scripts/deploy/package-plugins.ps1 new file mode 100644 index 000000000..12a65bfb4 --- /dev/null +++ b/scripts/deploy/package-plugins.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS +Package CopilotChat's plugins for deployment to Azure +#> + +param( + [string] + # Build configuration to publish. + $BuildConfiguration = "Release", + + [string] + # .NET framework to publish. + $DotNetFramework = "net8.0", + + [string] + # Output directory for published assets. + $OutputDirectory = "$PSScriptRoot", + + [string] + # Version to give to assemblies and files. + $Version = "1.0.0", + + [string] + # Additional information given in version info. + $InformationalVersion = "" +) + +Write-Host "BuildConfiguration: $BuildConfiguration" +Write-Host "DotNetFramework: $DotNetFramework" +Write-Host "TargetRuntime: $TargetRuntime" +Write-Host "OutputDirectory: $OutputDirectory" + +$publishOutputDirectory = "$OutputDirectory/publish" +$publishedZipDirectory = "$OutputDirectory/out/plugins" +if (!(Test-Path $publishedZipDirectory)) { + New-Item -ItemType Directory -Force -Path $publishedZipDirectory | Out-Null +} +if (!(Test-Path $publishOutputDirectory)) { + New-Item -ItemType Directory -Force -Path $publishOutputDirectory | Out-Null +} + +$pluginProjectFiles = Get-ChildItem -Path "$PSScriptRoot/../../plugins" -Recurse -Filter "*.csproj" -Exclude "PluginShared.csproj" +foreach ($pluginProjectFile in $pluginProjectFiles) { + $pluginName = $pluginProjectFile.Name.Replace(".csproj", "").ToLowerInvariant() + $publishedZipFilePath = "$publishedZipDirectory/$pluginName.zip" + + Write-Host "Packaging $pluginName from $pluginProjectFile" + + dotnet publish $pluginProjectFile ` + --configuration $BuildConfiguration ` + --framework $DotNetFramework ` + --output "$publishOutputDirectory" ` + /p:AssemblyVersion=$Version ` + /p:FileVersion=$Version ` + /p:InformationalVersion=$InformationalVersion + + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + Write-Host "Compressing to $publishedZipFilePath" + Compress-Archive -Path $publishOutputDirectory\* -DestinationPath $publishedZipFilePath -Force + + Write-Host "Published $pluginName package to '$publishedZipFilePath'" +} \ No newline at end of file diff --git a/scripts/deploy/package-plugins.sh b/scripts/deploy/package-plugins.sh new file mode 100644 index 000000000..8ede9a283 --- /dev/null +++ b/scripts/deploy/package-plugins.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +# Package CopilotChat's plugins for deployment to Azure + +set -e + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DIRECTORY="$SCRIPT_ROOT" + +usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Arguments:" + echo " -c, --configuration CONFIGURATION Build configuration (default: Release)" + echo " -d, --dotnet DOTNET_FRAMEWORK_VERSION Target dotnet framework (default: net8.0)" + echo " -o, --output OUTPUT_DIRECTORY Output directory (default: $SCRIPT_ROOT)" + echo " -v --version VERSION Version to set files to (default: 1.0.0)" + echo " -i --info INFO Additional info to put in version details" + echo " -nz, --no-zip Do not zip package (default: false)" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + -c | --configuration) + CONFIGURATION="$2" + shift + shift + ;; + -d | --dotnet) + DOTNET="$2" + shift + shift + ;; + -o | --output) + OUTPUT_DIRECTORY="$2" + shift + shift + ;; + -v | --version) + VERSION="$2" + shift + shift + ;; + -i | --info) + INFO="$2" + shift + shift + ;; + -nz | --no-zip) + NO_ZIP=true + shift + ;; + *) + echo "Unknown option $1" + usage + exit 1 + ;; + esac +done + +# Set defaults +: "${CONFIGURATION:="Release"}" +: "${DOTNET:="net8.0"}" +: "${VERSION:="1.0.0"}" +: "${INFO:=""}" +: "${OUTPUT_DIRECTORY:="$SCRIPT_ROOT"}" + +PUBLISH_OUTPUT_DIRECTORY="$OUTPUT_DIRECTORY/publish" +PUBLISH_ZIP_DIRECTORY="$OUTPUT_DIRECTORY/out/plugins" + +if [[ ! -d "$PUBLISH_OUTPUT_DIRECTORY" ]]; then + mkdir -p "$PUBLISH_OUTPUT_DIRECTORY" +fi +if [[ ! -d "$PUBLISH_ZIP_DIRECTORY" ]]; then + mkdir -p "$PUBLISH_ZIP_DIRECTORY" +fi + +PLUGIN_PROJECT_FILES=() +mapfile -d $'\0' PLUGIN_PROJECT_FILES < <(find "${SCRIPT_ROOT}/../../plugins" -name "*.csproj") + +for PLUGIN_PROJECT_FILE in $PLUGIN_PROJECT_FILES; do + PLUGIN_PROJECT_FILE=$(realpath "$PLUGIN_PROJECT_FILE") + PLUGIN_NAME=$(basename "$PLUGIN_PROJECT_FILE" .csproj) + PLUGIN_NAME="${PLUGIN_NAME,,}" # Lowercase + + if [[ "$PLUGIN_NAME" = "pluginshared" ]]; then + continue + fi + + echo "Packaging $PLUGIN_NAME from $PLUGIN_PROJECT_FILE" + + dotnet publish "$PLUGIN_PROJECT_FILE" \ + --configuration $CONFIGURATION \ + --framework $DOTNET \ + --output "$PUBLISH_OUTPUT_DIRECTORY" \ + -p:AssemblyVersion=$VERSION \ + -p:FileVersion=$VERSION \ + -p:InformationalVersion=$INFO + + if [ $? -ne 0 ]; then + exit 1 + fi + + # if not NO_ZIP then zip the package + PACKAGE_FILE_PATH="$PUBLISH_ZIP_DIRECTORY/$PLUGIN_NAME.zip" + if [[ -z "$NO_ZIP" ]]; then + pushd "$PUBLISH_OUTPUT_DIRECTORY" + echo "Compressing to $PACKAGE_FILE_PATH" + zip -r $PACKAGE_FILE_PATH . + popd + fi +done diff --git a/scripts/deploy/package-webapi.ps1 b/scripts/deploy/package-webapi.ps1 new file mode 100755 index 000000000..94d325b56 --- /dev/null +++ b/scripts/deploy/package-webapi.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS +Package Chat Copilot application for deployment to Azure +#> + +param( + [string] + # Build configuration to publish. + $BuildConfiguration = "Release", + + [string] + # .NET framework to publish. + $DotNetFramework = "net8.0", + + [string] + # Target runtime to publish. + $TargetRuntime = "win-x64", + + [string] + # Output directory for published assets. + $OutputDirectory = "$PSScriptRoot", + + [string] + # Version to give to assemblies and files. + $Version = "0.0.0", + + [string] + # Additional information given in version info. + $InformationalVersion = "", + + [bool] + # Whether to skip building frontend files (false by default) + $SkipFrontendFiles = $false +) + +Write-Host "Building backend executables..." + +Write-Host "BuildConfiguration: $BuildConfiguration" +Write-Host "DotNetFramework: $DotNetFramework" +Write-Host "TargetRuntime: $TargetRuntime" +Write-Host "OutputDirectory: $OutputDirectory" + +$publishOutputDirectory = "$OutputDirectory/publish" +$publishedZipDirectory = "$OutputDirectory/out" +$publishedZipFilePath = "$publishedZipDirectory/webapi.zip" +if (!(Test-Path $publishedZipDirectory)) { + New-Item -ItemType Directory -Force -Path $publishedZipDirectory | Out-Null +} +if (Test-Path $publishOutputDirectory) { + rm $publishOutputDirectory/* -r -force +} + +New-Item -ItemType Directory -Force -Path $publishOutputDirectory | Out-Null + +Write-Host "Build configuration: $BuildConfiguration" +dotnet publish "$PSScriptRoot/../../webapi/CopilotChatWebApi.csproj" --configuration $BuildConfiguration --framework $DotNetFramework --runtime $TargetRuntime --self-contained --output "$publishOutputDirectory" /p:AssemblyVersion=$Version /p:FileVersion=$Version /p:InformationalVersion=$InformationalVersion +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +if (-Not $SkipFrontendFiles) { + Write-Host "Building static frontend files..." + + Push-Location -Path "$PSScriptRoot/../../webapp" + + $filePath = "./.env.production" + if (Test-path $filePath -PathType leaf) { + Remove-Item $filePath + } + + Add-Content -Path $filePath -Value "REACT_APP_BACKEND_URI=" + Add-Content -Path $filePath -Value "REACT_APP_SK_VERSION=$Version" + Add-Content -Path $filePath -Value "REACT_APP_SK_BUILD_INFO=$InformationalVersion" + + Write-Host "Installing yarn dependencies..." + yarn install + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + Write-Host "Building webapp..." + yarn build + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + Pop-Location + + Write-Host "Copying frontend files to package" + Copy-Item -Path "$PSScriptRoot/../../webapp/build/*" -Destination "$publishOutputDirectory\wwwroot" -Recurse -Force +} + +Write-Host "Compressing package to $publishedZipFilePath" +Compress-Archive -Path $publishOutputDirectory\* -DestinationPath $publishedZipFilePath -Force + +Write-Host "Published package to '$publishedZipFilePath'" \ No newline at end of file diff --git a/scripts/deploy/package-webapi.sh b/scripts/deploy/package-webapi.sh new file mode 100644 index 000000000..eb0429680 --- /dev/null +++ b/scripts/deploy/package-webapi.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash + +# Package Chat Copilot application for deployment to Azure + +set -e + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DIRECTORY="$SCRIPT_ROOT" + +usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Arguments:" + echo " -c, --configuration CONFIGURATION Build configuration (default: Release)" + echo " -d, --dotnet DOTNET_FRAMEWORK_VERSION Target dotnet framework (default: net8.0)" + echo " -r, --runtime TARGET_RUNTIME Runtime identifier (default: win-x64)" + echo " -o, --output OUTPUT_DIRECTORY Output directory (default: $SCRIPT_ROOT)" + echo " -v --version VERSION Version to set files to (default: 1.0.0)" + echo " -i --info INFO Additional info to put in version details" + echo " -nz, --no-zip Do not zip package (default: false)" + echo " -s, --skip-frontend Do not build frontend files" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + -c | --configuration) + CONFIGURATION="$2" + shift + shift + ;; + -d | --dotnet) + DOTNET="$2" + shift + shift + ;; + -r | --runtime) + RUNTIME="$2" + shift + shift + ;; + -o | --output) + OUTPUT_DIRECTORY="$2" + shift + shift + ;; + -v | --version) + VERSION="$2" + shift + shift + ;; + -i | --info) + INFO="$2" + shift + shift + ;; + -nz | --no-zip) + NO_ZIP=true + shift + ;; + -s|--skip-frontend) + SKIP_FRONTEND=true + shift + ;; + *) + echo "Unknown option $1" + usage + exit 1 + ;; + esac +done + +echo "Building backend executables..." + +# Set defaults +: "${CONFIGURATION:="Release"}" +: "${DOTNET:="net8.0"}" +: "${RUNTIME:="win-x64"}" +: "${VERSION:="0.0.0"}" +: "${INFO:=""}" +: "${OUTPUT_DIRECTORY:="$SCRIPT_ROOT"}" + +PUBLISH_OUTPUT_DIRECTORY="$OUTPUT_DIRECTORY/publish" +PUBLISH_ZIP_DIRECTORY="$OUTPUT_DIRECTORY/out" +PACKAGE_FILE_PATH="$PUBLISH_ZIP_DIRECTORY/webapi.zip" + +if [[ ! -d "$PUBLISH_OUTPUT_DIRECTORY" ]]; then + mkdir -p "$PUBLISH_OUTPUT_DIRECTORY" +fi +if [[ ! -d "$PUBLISH_ZIP_DIRECTORY" ]]; then + mkdir -p "$PUBLISH_ZIP_DIRECTORY" +fi + +echo "Build configuration: $CONFIGURATION" +dotnet publish "$SCRIPT_ROOT/../../webapi/CopilotChatWebApi.csproj" \ + --configuration $CONFIGURATION \ + --framework $DOTNET \ + --runtime $RUNTIME \ + --self-contained \ + --output "$PUBLISH_OUTPUT_DIRECTORY" \ + -p:AssemblyVersion=$VERSION \ + -p:FileVersion=$VERSION \ + -p:InformationalVersion=$INFO + +if [ $? -ne 0 ]; then + exit 1 +fi + +if [[ -z "$SKIP_FRONTEND" ]]; then + echo "Building static frontend files..." + + pushd "$SCRIPT_ROOT/../../webapp" + + filePath="./.env.production" + if [ -f "$filePath" ]; then + rm "$filePath" + fi + + echo "REACT_APP_BACKEND_URI=" >> "$filePath" + echo "REACT_APP_SK_VERSION=$Version" >> "$filePath" + echo "REACT_APP_SK_BUILD_INFO=$InformationalVersion" >> "$filePath" + + echo "Installing yarn dependencies..." + yarn install + if [ $? -ne 0 ]; then + echo "Failed to install yarn dependencies" + exit 1 + fi + + echo "Building webapp..." + yarn build + if [ $? -ne 0 ]; then + echo "Failed to build webapp" + exit 1 + fi + + popd + + echo "Copying frontend files to package" + cp -R "$SCRIPT_ROOT/../../webapp/build/." "$PUBLISH_OUTPUT_DIRECTORY/wwwroot" +fi + +# if not NO_ZIP then zip the package +if [[ -z "$NO_ZIP" ]]; then + pushd "$PUBLISH_OUTPUT_DIRECTORY" + echo "Compressing to $PACKAGE_FILE_PATH" + zip -r $PACKAGE_FILE_PATH . + popd +fi diff --git a/scripts/Install-Requirements-UbuntuDebian.sh b/scripts/install-apt.sh old mode 100644 new mode 100755 similarity index 83% rename from scripts/Install-Requirements-UbuntuDebian.sh rename to scripts/install-apt.sh index 5a012d029..5e66c27fd --- a/scripts/Install-Requirements-UbuntuDebian.sh +++ b/scripts/install-apt.sh @@ -1,6 +1,6 @@ -#!/bin/bash +#!/usr/bin/env bash -# Installs the requirements for running Copilot Chat. +# Installs the requirements for running Chat Copilot. set -e @@ -19,10 +19,10 @@ curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - # Install the requirements sudo apt update; sudo apt install yarn -y; -sudo apt install dotnet-sdk-6.0 -y; +sudo apt install dotnet-sdk-8.0 -y; sudo apt install nodejs -y; echo "" echo "YARN $(yarn --version) installed." echo "NODEJS $(node --version) installed." -echo "DOTNET $(dotnet --version) installed." \ No newline at end of file +echo "DOTNET $(dotnet --version) installed." diff --git a/scripts/install-brew.sh b/scripts/install-brew.sh new file mode 100755 index 000000000..4b4be44e9 --- /dev/null +++ b/scripts/install-brew.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Installs the requirements for running Chat Copilot. + +set -e + +# Install the requirements +brew install yarn; +brew install --cask dotnet-sdk; +brew install nodejs; + +echo "" +echo "YARN $(yarn --version) installed." +echo "NODEJS $(node --version) installed." +echo "DOTNET $(dotnet --version) installed." diff --git a/scripts/start-backend.sh b/scripts/start-backend.sh new file mode 100755 index 000000000..c5bc2b820 --- /dev/null +++ b/scripts/start-backend.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Builds and runs the Chat Copilot backend. + +set -e + +# Stop any existing backend API process +while pid=$(pgrep CopilotChatWebA); do + echo $pid | sed 's/\([0-9]\{4\}\) .*/\1/' | xargs kill +done + +# Get defaults and constants +SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. $SCRIPT_DIRECTORY/.env +cd "$SCRIPT_DIRECTORY/../webapi" + +# Environment variable `ASPNETCORE_ENVIRONMENT` required to override appsettings.json with +# appsettings.$ENV_ASPNETCORE.json. See `webapi/ConfigurationExtensions.cs` +export ASPNETCORE_ENVIRONMENT=$ENV_ASPNETCORE + +# Build and run the backend API server +dotnet build && dotnet run diff --git a/scripts/start-frontend.sh b/scripts/start-frontend.sh new file mode 100755 index 000000000..0421a979a --- /dev/null +++ b/scripts/start-frontend.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# Builds and runs the Chat Copilot frontend. + +set -e + +SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIRECTORY/../webapp" + +# Build and run the frontend application +yarn install && yarn start diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 000000000..9496ca11f --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +# Initializes and runs both the backend and frontend for Copilot Chat. + +set -e + +ScriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ScriptDir" + +# get the port from the REACT_APP_BACKEND_URI env variable +envContent=$(grep -v '^#' ../webapp/.env | xargs) +backendPort=$(echo $envContent | sed -n 's/.*:\([0-9]*\).*/\1/p') + +# Start backend (in background) +./start-backend.sh & + +maxRetries=5 +retryCount=0 +retryWait=5 # set the number of seconds to wait before retrying + +# while the backend is not running wait. +while [ $retryCount -lt $maxRetries ] +do + if nc -z localhost $backendPort + then + # if the backend is running, start the frontend and break out of the loop + ./start-frontend.sh + break + else + # if the backend is not running, sleep, then increment the retry count + sleep $retryWait + retryCount=$((retryCount+1)) + fi +done + +# if we have exceeded the number of retries +if [ $retryCount -eq $maxRetries ] +then +# write to the console that the backend is not running and we have exceeded the number of retries and we are exiting + echo "*************************************************" + echo "Backend is not running and we have exceeded " + echo "the maximum number of retries." + echo "" + echo "Therefore, we are exiting." + echo "*************************************************" +fi diff --git a/shared/ConfigurationBuilderExtensions.cs b/shared/ConfigurationBuilderExtensions.cs new file mode 100644 index 000000000..1bd786495 --- /dev/null +++ b/shared/ConfigurationBuilderExtensions.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Reflection; +using Microsoft.Extensions.Configuration; + +namespace CopilotChat.Shared; + +internal static class ConfigurationBuilderExtensions +{ + // ASP.NET env var + private const string AspnetEnvVar = "ASPNETCORE_ENVIRONMENT"; + + public static void AddKMConfigurationSources( + this IConfigurationBuilder builder, + bool useAppSettingsFiles = true, + bool useEnvVars = true, + bool useSecretManager = true, + string? settingsDirectory = null) + { + // Load env var name, either Development or Production + var env = Environment.GetEnvironmentVariable(AspnetEnvVar) ?? string.Empty; + + // Detect the folder containing configuration files + settingsDirectory ??= Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + ?? Directory.GetCurrentDirectory(); + builder.SetBasePath(settingsDirectory); + + // Add configuration files as sources + if (useAppSettingsFiles) + { + // Add appsettings.json, typically used for default settings, without credentials + var main = Path.Join(settingsDirectory, "appsettings.json"); + if (!File.Exists(main)) + { + throw new FileNotFoundException($"appsettings.json not found. Directory: {settingsDirectory}"); + } + + builder.AddJsonFile(main, optional: false); + + // Add appsettings.development.json, used for local overrides and credentials + if (env.Equals("development", StringComparison.OrdinalIgnoreCase)) + { + var f1 = Path.Join(settingsDirectory, "appsettings.development.json"); + var f2 = Path.Join(settingsDirectory, "appsettings.Development.json"); + if (File.Exists(f1)) + { + builder.AddJsonFile(f1, optional: false); + } + else if (File.Exists(f2)) + { + builder.AddJsonFile(f2, optional: false); + } + } + + // Add appsettings.production.json, used for production settings and credentials + if (env.Equals("production", StringComparison.OrdinalIgnoreCase)) + { + var f1 = Path.Join(settingsDirectory, "appsettings.production.json"); + var f2 = Path.Join(settingsDirectory, "appsettings.Production.json"); + if (File.Exists(f1)) + { + builder.AddJsonFile(f1, optional: false); + } + else if (File.Exists(f2)) + { + builder.AddJsonFile(f2, optional: false); + } + } + } + + // Add Secret Manager as source + if (useSecretManager) + { + // GetEntryAssembly method can return null if the library is loaded + // from an unmanaged application, in which case UserSecrets are not supported. + var entryAssembly = Assembly.GetEntryAssembly(); + + // Support for user secrets. Secret Manager doesn't encrypt the stored secrets and + // shouldn't be treated as a trusted store. It's for development purposes only. + // see: https://learn.microsoft.com/aspnet/core/security/app-secrets?#secret-manager + if (entryAssembly != null && env.Equals("development", StringComparison.OrdinalIgnoreCase)) + { + builder.AddUserSecrets(entryAssembly, optional: true); + } + } + + // Add environment variables as source. + // Environment variables can override all the settings provided by the previous sources. + if (useEnvVars) + { + // Support for environment variables overriding the config files + builder.AddEnvironmentVariables(); + } + } +} diff --git a/shared/CopilotChatShared.csproj b/shared/CopilotChatShared.csproj new file mode 100644 index 000000000..a89a74758 --- /dev/null +++ b/shared/CopilotChatShared.csproj @@ -0,0 +1,24 @@ + + + + CopilotChat.Shared + net8.0 + + + + + + + + + + + + + + + + + + + diff --git a/shared/KernelMemoryBuilderExtensions.cs b/shared/KernelMemoryBuilderExtensions.cs new file mode 100644 index 000000000..1b82ff732 --- /dev/null +++ b/shared/KernelMemoryBuilderExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Configuration; +using Microsoft.KernelMemory; + +namespace CopilotChat.Shared; + +/// +/// Kernel Memory builder extensions for apps using settings in appsettings.json +/// and using IConfiguration. +/// +public static class KernelMemoryBuilderExtensions +{ + /// + /// Configure the builder using settings stored in the specified directory. + /// If directory is empty, use the current assembly folder + /// + /// KernelMemory builder instance + /// Directory containing appsettings.json (incl. dev/prod) + public static IKernelMemoryBuilder FromAppSettings(this IKernelMemoryBuilder builder, string? settingsDirectory = null) + { + return new ServiceConfiguration(settingsDirectory).PrepareBuilder(builder); + } + + /// + /// Configure the builder using settings from the given KernelMemoryConfig and IConfiguration instances. + /// + /// KernelMemory builder instance + /// KM configuration + /// Dependencies configuration, e.g. queue, embedding, storage, etc. + public static IKernelMemoryBuilder FromMemoryConfiguration( + this IKernelMemoryBuilder builder, + KernelMemoryConfig memoryConfiguration, + IConfiguration servicesConfiguration) + { + return new ServiceConfiguration(servicesConfiguration, memoryConfiguration).PrepareBuilder(builder); + } +} diff --git a/shared/MemoryConfiguration.cs b/shared/MemoryConfiguration.cs new file mode 100644 index 000000000..285d12fce --- /dev/null +++ b/shared/MemoryConfiguration.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace CopilotChat.Shared; + +/// +/// Configuration constants for kernel memory. +/// +public static class MemoryConfiguration +{ + public const string KernelMemorySection = "KernelMemory"; + public const string ServicesSection = "Services"; + public const string OrchestrationTypeDistributed = "Distributed"; + public const string NoneType = "None"; +} diff --git a/webapi/CopilotChat/Options/TesseractOptions.cs b/shared/Ocr/Tesseract/TesseractConfig.cs similarity index 66% rename from webapi/CopilotChat/Options/TesseractOptions.cs rename to shared/Ocr/Tesseract/TesseractConfig.cs index 0fe50f104..361d1fb86 100644 --- a/webapi/CopilotChat/Options/TesseractOptions.cs +++ b/shared/Ocr/Tesseract/TesseractConfig.cs @@ -1,26 +1,23 @@ // Copyright (c) Microsoft. All rights reserved. using System.ComponentModel.DataAnnotations; -using SemanticKernel.Service.Options; -namespace SemanticKernel.Service.CopilotChat.Options; +namespace CopilotChat.Shared.Ocr.Tesseract; /// /// Configuration options for Tesseract OCR support. /// -public sealed class TesseractOptions +public sealed class TesseractConfig { - public const string PropertyName = "Tesseract"; - /// /// The file path where the Tesseract language file is stored (e.g. "./data") /// - [Required, NotEmptyOrWhitespace] + [Required] public string? FilePath { get; set; } = string.Empty; /// /// The language file prefix name (e.g. "eng") /// - [Required, NotEmptyOrWhitespace] + [Required] public string? Language { get; set; } = string.Empty; } diff --git a/shared/Ocr/Tesseract/TesseractOcrEngine.cs b/shared/Ocr/Tesseract/TesseractOcrEngine.cs new file mode 100644 index 000000000..6b5d7c7ae --- /dev/null +++ b/shared/Ocr/Tesseract/TesseractOcrEngine.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.KernelMemory.DataFormats; +using Tesseract; + +namespace CopilotChat.Shared.Ocr.Tesseract; + +/// +/// Wrapper for the TesseractEngine within the Tesseract OCR library. +/// +public sealed class TesseractOcrEngine : IOcrEngine, IDisposable +{ + private readonly TesseractEngine _engine; + + /// + /// Creates a new instance of the TesseractEngineWrapper passing in a valid TesseractEngine. + /// + public TesseractOcrEngine(TesseractConfig tesseractConfig) + { + if (!Directory.Exists(tesseractConfig.FilePath)) + { + throw new DirectoryNotFoundException($"{tesseractConfig.FilePath} dir not found"); + } + + this._engine = new TesseractEngine(tesseractConfig.FilePath, tesseractConfig.Language); + } + + /// + public async Task ExtractTextFromImageAsync(Stream imageContent, CancellationToken cancellationToken = default) + { + await using (var imgStream = new MemoryStream()) + { + await imageContent.CopyToAsync(imgStream, cancellationToken); + imgStream.Position = 0; + + using var img = Pix.LoadFromMemory(imgStream.ToArray()); + + using var page = this._engine.Process(img); + return page.GetText(); + } + } + + /// + public void Dispose() + { + this._engine.Dispose(); + } +} diff --git a/shared/ServiceConfiguration.cs b/shared/ServiceConfiguration.cs new file mode 100644 index 000000000..313de466f --- /dev/null +++ b/shared/ServiceConfiguration.cs @@ -0,0 +1,539 @@ +// Copyright (c) Microsoft. All rights reserved. + +using CopilotChat.Shared.Ocr.Tesseract; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.KernelMemory; +using Microsoft.KernelMemory.AI; +using Microsoft.KernelMemory.AI.Ollama; +using Microsoft.KernelMemory.AI.OpenAI; +using Microsoft.KernelMemory.DocumentStorage.DevTools; +using Microsoft.KernelMemory.MemoryDb.SQLServer; +using Microsoft.KernelMemory.MemoryStorage; +using Microsoft.KernelMemory.MemoryStorage.DevTools; +using Microsoft.KernelMemory.Pipeline.Queue.DevTools; + +namespace CopilotChat.Shared; + +internal sealed class ServiceConfiguration +{ + // Content of appsettings.json, used to access dynamic data under "Services" + private IConfiguration _rawAppSettings; + + // Normalized configuration + private KernelMemoryConfig _memoryConfiguration; + + // appsettings.json root node name + private const string ConfigRoot = "KernelMemory"; + + // OpenAI env var + private const string OpenAIEnvVar = "OPENAI_API_KEY"; + + public ServiceConfiguration(string? settingsDirectory = null) + : this(ReadAppSettings(settingsDirectory)) + { + } + + public ServiceConfiguration(IConfiguration rawAppSettings) + : this(rawAppSettings, + rawAppSettings.GetSection(ConfigRoot).Get() + ?? throw new ConfigurationException("Unable to load Kernel Memory settings from the given configuration. " + + $"There should be a '{ConfigRoot}' root node, " + + $"with data mapping to '{nameof(KernelMemoryConfig)}'")) + { + } + + public ServiceConfiguration( + IConfiguration rawAppSettings, + KernelMemoryConfig memoryConfiguration) + { + this._rawAppSettings = rawAppSettings ?? throw new ConfigurationException("The given app settings configuration is NULL"); + this._memoryConfiguration = memoryConfiguration ?? throw new ConfigurationException("The given memory configuration is NULL"); + + if (!this.MinimumConfigurationIsAvailable(false)) { this.SetupForOpenAI(); } + + this.MinimumConfigurationIsAvailable(true); + } + + public IKernelMemoryBuilder PrepareBuilder(IKernelMemoryBuilder builder) + { + return this.BuildUsingConfiguration(builder); + } + + private IKernelMemoryBuilder BuildUsingConfiguration(IKernelMemoryBuilder builder) + { + if (this._memoryConfiguration == null) + { + throw new ConfigurationException("The given memory configuration is NULL"); + } + + if (this._rawAppSettings == null) + { + throw new ConfigurationException("The given app settings configuration is NULL"); + } + + // Required by ctors expecting KernelMemoryConfig via DI + builder.AddSingleton(this._memoryConfiguration); + + this.ConfigureMimeTypeDetectionDependency(builder); + + this.ConfigureTextPartitioning(builder); + + this.ConfigureQueueDependency(builder); + + this.ConfigureStorageDependency(builder); + + // The ingestion embedding generators is a list of generators that the "gen_embeddings" handler uses, + // to generate embeddings for each partition. While it's possible to use multiple generators (e.g. to compare embedding quality) + // only one generator is used when searching by similarity, and the generator used for search is not in this list. + // - config.DataIngestion.EmbeddingGeneratorTypes => list of generators, embeddings to generate and store in memory DB + // - config.Retrieval.EmbeddingGeneratorType => one embedding generator, used to search, and usually injected into Memory DB constructor + + this.ConfigureIngestionEmbeddingGenerators(builder); + + this.ConfigureSearchClient(builder); + + this.ConfigureRetrievalEmbeddingGenerator(builder); + + // The ingestion Memory DBs is a list of DBs where handlers write records to. While it's possible + // to write to multiple DBs, e.g. for replication purpose, there is only one Memory DB used to + // read/search, and it doesn't come from this list. See "config.Retrieval.MemoryDbType". + // Note: use the aux service collection to avoid mixing ingestion and retrieval dependencies. + + this.ConfigureIngestionMemoryDb(builder); + + this.ConfigureRetrievalMemoryDb(builder); + + this.ConfigureTextGenerator(builder); + + this.ConfigureImageOCR(builder); + + return builder; + } + + private static IConfiguration ReadAppSettings(string? settingsDirectory) + { + var builder = new ConfigurationBuilder(); + builder.AddKMConfigurationSources(settingsDirectory: settingsDirectory); + return builder.Build(); + } + + private void ConfigureQueueDependency(IKernelMemoryBuilder builder) + { + if (string.Equals(this._memoryConfiguration.DataIngestion.OrchestrationType, "Distributed", StringComparison.OrdinalIgnoreCase)) + { + switch (this._memoryConfiguration.DataIngestion.DistributedOrchestration.QueueType) + { + case string x when x.Equals("AzureQueue", StringComparison.Ordinal): + throw new ConfigurationException("The configuration key 'AzureQueue' has been deprecated. Please use 'AzureQueues' instead.'"); + + case string x when x.Equals("RabbitMq", StringComparison.Ordinal): + throw new ConfigurationException("The configuration key 'RabbitMq' has been deprecated. Please use 'RabbitMQ' instead.'"); + + case string x when x.Equals("AzureQueues", StringComparison.OrdinalIgnoreCase): + builder.Services.AddAzureQueuesOrchestration(this.GetServiceConfig("AzureQueues")); + break; + + case string y when y.Equals("RabbitMQ", StringComparison.OrdinalIgnoreCase): + builder.Services.AddRabbitMQOrchestration(this.GetServiceConfig("RabbitMQ")); + break; + + case string y when y.Equals("SimpleQueues", StringComparison.OrdinalIgnoreCase): + builder.Services.AddSimpleQueues(this.GetServiceConfig("SimpleQueues")); + break; + + default: + // NOOP - allow custom implementations, via WithCustomIngestionQueueClientFactory() + break; + } + } + } + + private void ConfigureStorageDependency(IKernelMemoryBuilder builder) + { + switch (this._memoryConfiguration.DocumentStorageType) + { + case string x when x.Equals("AzureBlob", StringComparison.OrdinalIgnoreCase): + throw new ConfigurationException("The configuration key 'AzureBlob' has been deprecated. Please use 'AzureBlobs' instead.'"); + + case string x when x.Equals("AzureBlobs", StringComparison.OrdinalIgnoreCase): + builder.Services.AddAzureBlobsAsDocumentStorage(this.GetServiceConfig("AzureBlobs")); + break; + + case string x when x.Equals("SimpleFileStorage", StringComparison.OrdinalIgnoreCase): + builder.Services.AddSimpleFileStorageAsDocumentStorage(this.GetServiceConfig("SimpleFileStorage")); + break; + + default: + // NOOP - allow custom implementations, via WithCustomStorage() + break; + } + } + + private void ConfigureTextPartitioning(IKernelMemoryBuilder builder) + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (this._memoryConfiguration.DataIngestion.TextPartitioning != null) + { + this._memoryConfiguration.DataIngestion.TextPartitioning.Validate(); + builder.WithCustomTextPartitioningOptions(this._memoryConfiguration.DataIngestion.TextPartitioning); + } + } + + private void ConfigureMimeTypeDetectionDependency(IKernelMemoryBuilder builder) + { + builder.WithDefaultMimeTypeDetection(); + } + + private void ConfigureIngestionEmbeddingGenerators(IKernelMemoryBuilder builder) + { + // Note: using multiple embeddings is not fully supported yet and could cause write errors or incorrect search results + if (this._memoryConfiguration.DataIngestion.EmbeddingGeneratorTypes.Count > 1) + { + throw new NotSupportedException("Using multiple embedding generators is currently unsupported. " + + "You may contact the team if this feature is required, or workaround this exception " + + "using KernelMemoryBuilder methods explicitly."); + } + + foreach (var type in this._memoryConfiguration.DataIngestion.EmbeddingGeneratorTypes) + { + switch (type) + { + case string x when x.Equals("AzureOpenAI", StringComparison.OrdinalIgnoreCase): + case string y when y.Equals("AzureOpenAIEmbedding", StringComparison.OrdinalIgnoreCase): + { + var instance = this.GetServiceInstance(builder, + s => s.AddAzureOpenAIEmbeddingGeneration( + config: this.GetServiceConfig("AzureOpenAIEmbedding"), + textTokenizer: new GPT4oTokenizer())); + builder.AddIngestionEmbeddingGenerator(instance); + break; + } + + case string x when x.Equals("OpenAI", StringComparison.OrdinalIgnoreCase): + { + var instance = this.GetServiceInstance(builder, + s => s.AddOpenAITextEmbeddingGeneration( + config: this.GetServiceConfig("OpenAI"), + textTokenizer: new GPT4oTokenizer())); + builder.AddIngestionEmbeddingGenerator(instance); + break; + } + + case string x when x.Equals("Ollama", StringComparison.OrdinalIgnoreCase): + { + var instance = this.GetServiceInstance(builder, + s => s.AddOllamaTextEmbeddingGeneration( + config: this.GetServiceConfig("Ollama"), + textTokenizer: new GPT4oTokenizer())); + builder.AddIngestionEmbeddingGenerator(instance); + break; + } + + default: + // NOOP - allow custom implementations, via WithCustomEmbeddingGeneration() + break; + } + } + } + + private void ConfigureIngestionMemoryDb(IKernelMemoryBuilder builder) + { + foreach (var type in this._memoryConfiguration.DataIngestion.MemoryDbTypes) + { + switch (type) + { + default: + throw new ConfigurationException( + $"Unknown Memory DB option '{type}'. " + + "To use a custom Memory DB, set the configuration value to an empty string, " + + "and inject the custom implementation using `IKernelMemoryBuilder.WithCustomMemoryDb(...)`"); + + case "": + // NOOP - allow custom implementations, via WithCustomMemoryDb() + break; + + case string x when x.Equals("AzureAISearch", StringComparison.OrdinalIgnoreCase): + { + var instance = this.GetServiceInstance(builder, + s => s.AddAzureAISearchAsMemoryDb(this.GetServiceConfig("AzureAISearch")) + ); + builder.AddIngestionMemoryDb(instance); + break; + } + + case string x when x.Equals("Postgres", StringComparison.OrdinalIgnoreCase): + { + var instance = this.GetServiceInstance(builder, + s => s.AddPostgresAsMemoryDb(this.GetServiceConfig("Postgres")) + ); + builder.AddIngestionMemoryDb(instance); + break; + } + + case string x when x.Equals("Qdrant", StringComparison.OrdinalIgnoreCase): + { + var instance = this.GetServiceInstance(builder, + s => s.AddQdrantAsMemoryDb(this.GetServiceConfig("Qdrant")) + ); + builder.AddIngestionMemoryDb(instance); + break; + } + + case string x when x.Equals("SimpleVectorDb", StringComparison.OrdinalIgnoreCase): + { + var instance = this.GetServiceInstance(builder, + s => s.AddSimpleVectorDbAsMemoryDb(this.GetServiceConfig("SimpleVectorDb")) + ); + builder.AddIngestionMemoryDb(instance); + break; + } + + case string x when x.Equals("SqlServer", StringComparison.OrdinalIgnoreCase): + { + var instance = this.GetServiceInstance(builder, + s => s.AddSqlServerAsMemoryDb(this.GetServiceConfig("SqlServer")) + ); + builder.AddIngestionMemoryDb(instance); + break; + } + } + } + } + + private void ConfigureSearchClient(IKernelMemoryBuilder builder) + { + // Search settings + builder.WithSearchClientConfig(this._memoryConfiguration.Retrieval.SearchClient); + } + + private void ConfigureRetrievalEmbeddingGenerator(IKernelMemoryBuilder builder) + { + // Retrieval embeddings - ITextEmbeddingGeneration interface + switch (this._memoryConfiguration.Retrieval.EmbeddingGeneratorType) + { + case string x when x.Equals("AzureOpenAI", StringComparison.OrdinalIgnoreCase): + case string y when y.Equals("AzureOpenAIEmbedding", StringComparison.OrdinalIgnoreCase): + builder.Services.AddAzureOpenAIEmbeddingGeneration( + config: this.GetServiceConfig("AzureOpenAIEmbedding"), + textTokenizer: new GPT4oTokenizer()); + break; + + case string x when x.Equals("OpenAI", StringComparison.OrdinalIgnoreCase): + builder.Services.AddOpenAITextEmbeddingGeneration( + config: this.GetServiceConfig("OpenAI"), + textTokenizer: new GPT4oTokenizer()); + break; + + case string x when x.Equals("Ollama", StringComparison.OrdinalIgnoreCase): + builder.Services.AddOllamaTextEmbeddingGeneration( + config: this.GetServiceConfig("Ollama"), + textTokenizer: new GPT4oTokenizer()); + break; + + default: + // NOOP - allow custom implementations, via WithCustomEmbeddingGeneration() + break; + } + } + + private void ConfigureRetrievalMemoryDb(IKernelMemoryBuilder builder) + { + // Retrieval Memory DB - IMemoryDb interface + switch (this._memoryConfiguration.Retrieval.MemoryDbType) + { + case string x when x.Equals("AzureAISearch", StringComparison.OrdinalIgnoreCase): + builder.Services.AddAzureAISearchAsMemoryDb(this.GetServiceConfig("AzureAISearch")); + break; + + case string x when x.Equals("Postgres", StringComparison.OrdinalIgnoreCase): + builder.Services.AddPostgresAsMemoryDb(this.GetServiceConfig("Postgres")); + break; + + case string x when x.Equals("Qdrant", StringComparison.OrdinalIgnoreCase): + builder.Services.AddQdrantAsMemoryDb(this.GetServiceConfig("Qdrant")); + break; + + case string x when x.Equals("SimpleVectorDb", StringComparison.OrdinalIgnoreCase): + builder.Services.AddSimpleVectorDbAsMemoryDb(this.GetServiceConfig("SimpleVectorDb")); + break; + + case string x when x.Equals("SqlServer", StringComparison.OrdinalIgnoreCase): + builder.Services.AddSqlServerAsMemoryDb(this.GetServiceConfig("SqlServer")); + break; + + default: + // NOOP - allow custom implementations, via WithCustomMemoryDb() + break; + } + } + + private void ConfigureTextGenerator(IKernelMemoryBuilder builder) + { + // Text generation + switch (this._memoryConfiguration.TextGeneratorType) + { + case string x when x.Equals("AzureOpenAI", StringComparison.OrdinalIgnoreCase): + case string y when y.Equals("AzureOpenAIText", StringComparison.OrdinalIgnoreCase): + builder.Services.AddAzureOpenAITextGeneration( + config: this.GetServiceConfig("AzureOpenAIText"), + textTokenizer: new GPT4oTokenizer()); + break; + + case string x when x.Equals("OpenAI", StringComparison.OrdinalIgnoreCase): + builder.Services.AddOpenAITextGeneration( + config: this.GetServiceConfig("OpenAI"), + textTokenizer: new GPT4oTokenizer()); + break; + + case string x when x.Equals("Ollama", StringComparison.OrdinalIgnoreCase): + builder.Services.AddOllamaTextGeneration( + config: this.GetServiceConfig("Ollama"), + textTokenizer: new GPT4oTokenizer()); + break; + + default: + // NOOP - allow custom implementations, via WithCustomTextGeneration() + break; + } + } + + private void ConfigureImageOCR(IKernelMemoryBuilder builder) + { + // Image OCR + switch (this._memoryConfiguration.DataIngestion.ImageOcrType) + { + case string y when string.IsNullOrWhiteSpace(y): + case string x when x.Equals("None", StringComparison.OrdinalIgnoreCase): + break; + + case string x when x.Equals("AzureAIDocIntel", StringComparison.OrdinalIgnoreCase): + builder.Services.AddAzureAIDocIntel(this.GetServiceConfig("AzureAIDocIntel")); + break; + + case string x when x.Equals("Tesseract", StringComparison.OrdinalIgnoreCase): + builder.Services.AddSingleton(this.GetServiceConfig("Tesseract")); + builder.WithCustomImageOcr(); + break; + + default: + // NOOP - allow custom implementations, via WithCustomImageOCR() + break; + } + } + + /// + /// Check the configuration for minimum requirements + /// + /// Whether to throw or return false when the config is incomplete + /// Whether the configuration is valid + private bool MinimumConfigurationIsAvailable(bool throwOnError) + { + // Check if text generation settings + if (string.IsNullOrEmpty(this._memoryConfiguration.TextGeneratorType)) + { + if (!throwOnError) { return false; } + + throw new ConfigurationException("Text generation (TextGeneratorType) is not configured in Kernel Memory."); + } + + // Check embedding generation ingestion settings + if (this._memoryConfiguration.DataIngestion.EmbeddingGenerationEnabled) + { + if (this._memoryConfiguration.DataIngestion.EmbeddingGeneratorTypes.Count == 0) + { + if (!throwOnError) { return false; } + + throw new ConfigurationException("Data ingestion embedding generation (DataIngestion.EmbeddingGeneratorTypes) is not configured in Kernel Memory."); + } + } + + // Check embedding generation retrieval settings + if (string.IsNullOrEmpty(this._memoryConfiguration.Retrieval.EmbeddingGeneratorType)) + { + if (!throwOnError) { return false; } + + throw new ConfigurationException("Retrieval embedding generation (Retrieval.EmbeddingGeneratorType) is not configured in Kernel Memory."); + } + + return true; + } + + /// + /// Rewrite configuration using OpenAI, if possible. + /// + private void SetupForOpenAI() + { + string openAIKey = Environment.GetEnvironmentVariable(OpenAIEnvVar)?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(openAIKey)) + { + return; + } + + var inMemoryConfig = new Dictionary + { + { $"{ConfigRoot}:Services:OpenAI:APIKey", openAIKey }, + { $"{ConfigRoot}:TextGeneratorType", "OpenAI" }, + { $"{ConfigRoot}:DataIngestion:EmbeddingGeneratorTypes:0", "OpenAI" }, + { $"{ConfigRoot}:Retrieval:EmbeddingGeneratorType", "OpenAI" } + }; + + var newAppSettings = new ConfigurationBuilder(); + newAppSettings.AddConfiguration(this._rawAppSettings); + newAppSettings.AddInMemoryCollection(inMemoryConfig); + + this._rawAppSettings = newAppSettings.Build(); + this._memoryConfiguration = this._rawAppSettings.GetSection(ConfigRoot).Get()!; + } + + /// + /// Get an instance of T, using dependencies available in the builder, + /// except for existing service descriptors for T. Replace/Use the + /// given action to define T's implementation. + /// Return an instance of T built using the definition provided by + /// the action. + /// + /// KM builder + /// Action used to configure the service collection + /// Target type/interface + private T GetServiceInstance(IKernelMemoryBuilder builder, Action addCustomService) + { + // Clone the list of service descriptors, skipping T descriptor + IServiceCollection services = new ServiceCollection(); + foreach (ServiceDescriptor d in builder.Services) + { + if (d.ServiceType == typeof(T)) { continue; } + + services.Add(d); + } + + // Add the custom T descriptor + addCustomService.Invoke(services); + + // Build and return an instance of T, as defined by `addCustomService` + return services.BuildServiceProvider().GetService() + ?? throw new ConfigurationException($"Unable to build {nameof(T)}"); + } + + /// + /// Read a dependency configuration from IConfiguration + /// Data is usually retrieved from KernelMemory:Services:{serviceName}, e.g. when using appsettings.json + /// { + /// "KernelMemory": { + /// "Services": { + /// "{serviceName}": { + /// ... + /// ... + /// } + /// } + /// } + /// } + /// + /// Name of the dependency + /// Type of configuration to return + /// Configuration instance, settings for the dependency specified + private T GetServiceConfig(string serviceName) + { + return this._memoryConfiguration.GetServiceConfig(this._rawAppSettings, serviceName); + } +} diff --git a/importdocument/Config.cs b/tools/importdocument/Config.cs similarity index 63% rename from importdocument/Config.cs rename to tools/importdocument/Config.cs index abf5f59c7..7c7565283 100644 --- a/importdocument/Config.cs +++ b/tools/importdocument/Config.cs @@ -12,26 +12,42 @@ public sealed class Config /// /// Client ID for the app as registered in Azure AD. /// + public string AuthenticationType { get; set; } = "None"; + + /// + /// Client ID for the import document tool as registered in Azure AD. + /// public string ClientId { get; set; } = string.Empty; + /// + /// Client ID for the backend web api as registered in Azure AD. + /// + public string BackendClientId { get; set; } = string.Empty; + + /// + /// Tenant ID against which to authenticate users in Azure AD. + /// + public string TenantId { get; set; } = string.Empty; + + /// + /// Azure AD cloud instance for authenticating users. + /// + public string Instance { get; set; } = string.Empty; + + /// + /// Scopes that the client app requires to access the API. + /// + public string Scopes { get; set; } = string.Empty; + /// /// Redirect URI for the app as registered in Azure AD. /// -#pragma warning disable CA1056 // URI-like properties should not be strings public string RedirectUri { get; set; } = string.Empty; -#pragma warning restore CA1056 // URI-like properties should not be strings /// /// Uri for the service that is running the chat. /// -#pragma warning disable CA1056 // URI-like properties should not be strings public string ServiceUri { get; set; } = string.Empty; -#pragma warning restore CA1056 // URI-like properties should not be strings - - /// - /// Api key for the service that is running the chat. - /// - public string ApiKey { get; set; } = string.Empty; /// /// Gets configuration from appsettings.json. diff --git a/importdocument/ImportDocument.csproj b/tools/importdocument/ImportDocument.csproj similarity index 58% rename from importdocument/ImportDocument.csproj rename to tools/importdocument/ImportDocument.csproj index a9c42e30b..b2b3eacc6 100644 --- a/importdocument/ImportDocument.csproj +++ b/tools/importdocument/ImportDocument.csproj @@ -2,11 +2,6 @@ Exe net6.0 - LatestMajor - disable - enable - 10 - false @@ -16,10 +11,10 @@ - - - - + + + + diff --git a/tools/importdocument/Program.cs b/tools/importdocument/Program.cs new file mode 100644 index 000000000..5bbddc5e2 --- /dev/null +++ b/tools/importdocument/Program.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.CommandLine; +using Microsoft.Identity.Client; + +namespace ImportDocument; + +/// +/// This console app imports a list of files to Chat Copilot's WebAPI document memory store. +/// +public static class Program +{ + public static void Main(string[] args) + { + var config = Config.GetConfig(); + if (!Config.Validate(config)) + { + Console.WriteLine("Error: Failed to read appsettings.json."); + return; + } + + var filesOption = new Option>(name: "--files", description: "The files to import to document memory store.") + { + IsRequired = true, + AllowMultipleArgumentsPerToken = true, + }; + + var chatCollectionOption = new Option( + name: "--chat-id", + description: "Save the extracted context to an isolated chat collection.", + getDefaultValue: () => Guid.Empty + ); + + var rootCommand = new RootCommand( + "This console app imports files to Chat Copilot's WebAPI document memory store." + ) + { + filesOption, + chatCollectionOption + }; + + rootCommand.SetHandler(async (files, chatCollectionId) => + { + await ImportFilesAsync(files, config!, chatCollectionId); + }, + filesOption, + chatCollectionOption + ); + + rootCommand.Invoke(args); + } + + /// + /// Acquires a user account from Azure AD. + /// + /// The App configuration. + /// Sets the access token to the first account found. + /// True if the user account was acquired. + private static async Task AcquireTokenAsync( + Config config, + Action setAccessToken) + { + Console.WriteLine("Attempting to authenticate user..."); + + var webApiScope = $"api://{config.BackendClientId}/{config.Scopes}"; + string[] scopes = { webApiScope }; + try + { + var app = PublicClientApplicationBuilder.Create(config.ClientId) + .WithRedirectUri(config.RedirectUri) + .WithAuthority(config.Instance, config.TenantId) + .Build(); + var result = await app.AcquireTokenInteractive(scopes).ExecuteAsync(); + setAccessToken(result.AccessToken); + return true; + } + catch (Exception ex) when (ex is MsalServiceException or MsalClientException) + { + Console.WriteLine($"Error: {ex.Message}"); + return false; + } + } + + /// + /// Conditionally imports a list of files to the Document Store. + /// + /// A list of files to import. + /// Configuration. + /// Save the extracted context to an isolated chat collection. + private static async Task ImportFilesAsync(IEnumerable files, Config config, Guid chatCollectionId) + { + foreach (var file in files) + { + if (!file.Exists) + { + Console.WriteLine($"File {file.FullName} does not exist."); + return; + } + } + + string? accessToken = null; + if (config.AuthenticationType == "AzureAd") + { + if (await AcquireTokenAsync(config, v => { accessToken = v; }) == false) + { + Console.WriteLine("Error: Failed to acquire access token."); + return; + } + + Console.WriteLine("Successfully acquired access token. Continuing..."); + } + + using var formContent = new MultipartFormDataContent(); + List filesContent = files.Select(file => new StreamContent(file.OpenRead())).ToList(); + for (int i = 0; i < filesContent.Count; i++) + { + formContent.Add(filesContent[i], "formFiles", files.ElementAt(i).Name); + } + + if (chatCollectionId != Guid.Empty) + { + Console.WriteLine($"Uploading and parsing file to chat {chatCollectionId}..."); + + await UploadAsync(chatCollectionId); + } + else + { + Console.WriteLine("Uploading and parsing file to global collection..."); + + await UploadAsync(); + } + + // Dispose of all the file streams. + foreach (var fileContent in filesContent) + { + fileContent.Dispose(); + } + + async Task UploadAsync(Guid? chatId = null) + { + // Create a HttpClient instance and set the timeout to infinite since + // large documents will take a while to parse. + using HttpClientHandler clientHandler = new() + { + CheckCertificateRevocationList = true + }; + using HttpClient httpClient = new(clientHandler) + { + Timeout = Timeout.InfiniteTimeSpan + }; + + if (config.AuthenticationType == "AzureAd") + { + // Add required properties to the request header. + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken!}"); + } + + string uriPath = + chatId.HasValue ? $"chats/{chatId}/documents" : "documents"; + + try + { + using HttpResponseMessage response = await httpClient.PostAsync( + new Uri(new Uri(config.ServiceUri), uriPath), + formContent); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"Error: {response.StatusCode} {response.ReasonPhrase}"); + Console.WriteLine(await response.Content.ReadAsStringAsync()); + return; + } + + Console.WriteLine("Uploading and parsing successful."); + } + catch (HttpRequestException ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + } + } +} diff --git a/tools/importdocument/README.md b/tools/importdocument/README.md new file mode 100644 index 000000000..0952db9d4 --- /dev/null +++ b/tools/importdocument/README.md @@ -0,0 +1,112 @@ +# Copilot Chat Import Document App + +> **!IMPORTANT** +> This sample is for educational purposes only and is not recommended for production deployments. + +One of the exciting features of the Chat Copilot App is its ability to store contextual information +to [memories](https://github.com/microsoft/semantic-kernel/blob/main/docs/EMBEDDINGS.md) and retrieve +relevant information from memories to provide more meaningful answers to users through out the conversations. + +Memories can be generated from conversations as well as imported from external sources, such as documents. +Importing documents enables Chat Copilot to have up-to-date knowledge of specific contexts, such as enterprise and personal data. + + +## Running the app against a local Chat Copilot instance + +1. Ensure the web api is running at `https://localhost:40443/`. +2. Configure the appsettings.json file under this folder root with the following variables: + - `ServiceUri` is the address the web api is running at + - `AuthenticationType` should be set to "None" + - The remaining variables can be left blank or with their default values + +3. Change directory to this folder root. +4. **Run** the following command to import a document to the app under the global document collection where + all users will have access to: + + `dotnet run --files .\sample-docs\ms10k.txt` + + Or **Run** the following command to import a document to the app under a chat isolated document collection where + only the chat session will have access to: + + `dotnet run --files .\sample-docs\ms10k.txt --chat-id [chatId]` + + > Currently only supports txt and pdf files. A sample file is provided under ./sample-docs. + + Importing may take some time to generate embeddings for each piece/chunk of a document. + + To import multiple files, specify multiple files. For example: + + `dotnet run --files .\sample-docs\ms10k.txt .\sample-docs\Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf` + +5. Chat with the bot. + + Examples: + + With [ms10k.txt](./sample-docs/ms10k.txt): + + ![Document-Memory-Sample-1](https://github.com/microsoft/chat-copilot/assets/52973358/3d35df4d-40f1-4f12-8e40-fd190d5ce127) + + With [Microsoft Responsible AI Standard v2 General Requirements.pdf](./sample-docs/Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf): + + ![Document-Memory-Sample-2](https://github.com/microsoft/chat-copilot/assets/52973358/f0e95104-72ca-4a0a-9555-ee335d8df696) + + +## Running the app against a deployed Chat Copilot instance + +### Configure your environment + +1. Create a registered app in Azure Portal (https://learn.microsoft.com/azure/active-directory/develop/quickstart-register-app). + + > Note that this needs to be a separate app registration from those you created when deploying Chat Copilot. + + - Select Mobile and desktop applications as platform type, and the Redirect URI will be `http://localhost` + - Select **`Accounts in this organizational directory only (Microsoft only - Single tenant)`** as the supported account + type for this sample. + + > **IMPORTANT:** The supported account type should match that of the backend's app registration. If you changed this setting to allow allow multitenant and personal Microsoft accounts access to your Chat Copilot application, you should change it here as well. + + - Note the **`Application (client) ID`** from your app registration. + +2. Update the API permissions in the app registration you just created. + + - From the left-hand menu, select **API permissions** and then **Add a permission**. + - Select **My APIs** and then select the application corresponding to your backend web api. + - Check the box next to `access_as_user` and then press **Add permissions**. + +3. Update the authorized client applications for your backend web api. + - In the Azure portal, navigate to your backend web api's app registration. + - From the left-hand menu, select **Expose an API** and then **Add a client application**. + - Enter the client ID of the app registration you just created and check the box under **Authorized scopes**. Then press **Add application**. + +4. Configure the appsettings.json file under this folder root with the following variables: + - `ServiceUri` is the address the web api is running at + - `AuthenticationType` should be set to "AzureAd" + - `ClientId` is the **Application (client) ID** GUID from the app registration you just created in the Azure Portal + - `RedirectUri` is the Redirect URI also from the app registration you just created in the Azure Portal (e.g. `http://localhost`) + - `BackendClientId` is the **Application (client) ID** GUID from your backend web api's app registration in the Azure Portal, + - `TenantId` is the Azure AD tenant ID that you want to authenticate users against. For single-tenant applications, this is the same as the **Directory (tenant) ID** from your app registration in the Azure Portal. + - `Instance` is the Azure AD cloud instance to authenticate users against. For most users, this is `https://login.microsoftonline.com`. + - `Scopes` should be set to "access_as_user" + +### Run the app + +1. Change directory to this folder root. +2. **Run** the following command to import a document to the app under the global document collection where + all users will have access to: + + `dotnet run --files .\sample-docs\ms10k.txt` + + Or **Run** the following command to import a document to the app under a chat isolated document collection where + only the chat session will have access to: + + `dotnet run --files .\sample-docs\ms10k.txt --chat-id [chatId]` + + > Note that both of these commands will open a browser window for you to sign in to ensure you have access to the Chat Copilot service. + + > Currently only supports txt and pdf files. A sample file is provided under ./sample-docs. + + Importing may take some time to generate embeddings for each piece/chunk of a document. + + To import multiple files, specify multiple files. For example: + + `dotnet run --files .\sample-docs\ms10k.txt .\sample-docs\Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf` diff --git a/tools/importdocument/appsettings.json b/tools/importdocument/appsettings.json new file mode 100644 index 000000000..8409065c4 --- /dev/null +++ b/tools/importdocument/appsettings.json @@ -0,0 +1,12 @@ +{ + "Config": { + "ServiceUri": "https://localhost:40443", + "AuthenticationType": "None", // Supported authentication types are "None" or "AzureAd" + "ClientId": "", + "RedirectUri": "http://localhost", + "BackendClientId": "", + "TenantId": "", + "Instance": "https://login.microsoftonline.com", + "Scopes": "access_as_user" // Scopes that the client app requires to access the API + } +} \ No newline at end of file diff --git a/importdocument/sample-docs/Lorem_ipsum.pdf b/tools/importdocument/sample-docs/Lorem_ipsum.pdf similarity index 100% rename from importdocument/sample-docs/Lorem_ipsum.pdf rename to tools/importdocument/sample-docs/Lorem_ipsum.pdf diff --git a/importdocument/sample-docs/Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf b/tools/importdocument/sample-docs/Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf similarity index 100% rename from importdocument/sample-docs/Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf rename to tools/importdocument/sample-docs/Microsoft-Responsible-AI-Standard-v2-General-Requirements.pdf diff --git a/importdocument/sample-docs/ms10k.txt b/tools/importdocument/sample-docs/ms10k.txt similarity index 100% rename from importdocument/sample-docs/ms10k.txt rename to tools/importdocument/sample-docs/ms10k.txt diff --git a/webapi/Auth/ApiKeyAuthenticationHandler.cs b/webapi/Auth/ApiKeyAuthenticationHandler.cs deleted file mode 100644 index 550f862b1..000000000 --- a/webapi/Auth/ApiKeyAuthenticationHandler.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; - -namespace SemanticKernel.Service.Auth; - -/// -/// Class implementing API key authentication. -/// -public class ApiKeyAuthenticationHandler : AuthenticationHandler -{ - public const string AuthenticationScheme = "ApiKey"; - public const string ApiKeyHeaderName = "x-sk-api-key"; - - /// - /// Constructor - /// - public ApiKeyAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory loggerFactory, - UrlEncoder encoder, - ISystemClock clock) : base(options, loggerFactory, encoder, clock) - { - } - - protected override Task HandleAuthenticateAsync() - { - this.Logger.LogInformation("Checking API key"); - - if (string.IsNullOrWhiteSpace(this.Options.ApiKey)) - { - const string ErrorMessage = "API key not configured on server"; - - this.Logger.LogError(ErrorMessage); - - return Task.FromResult(AuthenticateResult.Fail(ErrorMessage)); - } - - if (!this.Request.Headers.TryGetValue(ApiKeyHeaderName, out StringValues apiKeyFromHeader)) - { - const string WarningMessage = "No API key provided"; - - this.Logger.LogWarning(WarningMessage); - - return Task.FromResult(AuthenticateResult.Fail(WarningMessage)); - } - - if (!string.Equals(apiKeyFromHeader, this.Options.ApiKey, StringComparison.Ordinal)) - { - const string WarningMessage = "Incorrect API key"; - - this.Logger.LogWarning(WarningMessage); - - return Task.FromResult(AuthenticateResult.Fail(WarningMessage)); - } - - var principal = new ClaimsPrincipal(new ClaimsIdentity(AuthenticationScheme)); - var ticket = new AuthenticationTicket(principal, this.Scheme.Name); - - this.Logger.LogInformation("Request authorized by API key"); - - return Task.FromResult(AuthenticateResult.Success(ticket)); - } -} diff --git a/webapi/Auth/ApiKeyAuthenticationSchemeOptions.cs b/webapi/Auth/ApiKeyAuthenticationSchemeOptions.cs deleted file mode 100644 index 33e8943f2..000000000 --- a/webapi/Auth/ApiKeyAuthenticationSchemeOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.AspNetCore.Authentication; - -namespace SemanticKernel.Service.Auth; - -/// -/// Options for API key authentication. -/// -public class ApiKeyAuthenticationSchemeOptions : AuthenticationSchemeOptions -{ - /// - /// The API key against which to authenticate. - /// - public string? ApiKey { get; set; } -} diff --git a/webapi/Auth/AuthInfo.cs b/webapi/Auth/AuthInfo.cs new file mode 100644 index 000000000..bd7c689bc --- /dev/null +++ b/webapi/Auth/AuthInfo.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.Identity; +using Microsoft.Identity.Web; + +namespace CopilotChat.WebApi.Auth; + +/// +/// Class which provides validated security information for use in controllers. +/// +public class AuthInfo : IAuthInfo +{ + private record struct AuthData( + string UserId, + string UserName); + + private readonly Lazy _data; + + public AuthInfo(IHttpContextAccessor httpContextAccessor) + { + this._data = new Lazy(() => + { + var user = httpContextAccessor.HttpContext?.User; + if (user is null) + { + throw new InvalidOperationException("HttpContext must be present to inspect auth info."); + } + + var userIdClaim = user.FindFirst(ClaimConstants.Oid) + ?? user.FindFirst(ClaimConstants.ObjectId) + ?? user.FindFirst(ClaimConstants.Sub) + ?? user.FindFirst(ClaimConstants.NameIdentifierId); + if (userIdClaim is null) + { + throw new CredentialUnavailableException("User Id was not present in the request token."); + } + + var tenantIdClaim = user.FindFirst(ClaimConstants.Tid) + ?? user.FindFirst(ClaimConstants.TenantId); + var userNameClaim = user.FindFirst(ClaimConstants.Name); + if (userNameClaim is null) + { + throw new CredentialUnavailableException("User name was not present in the request token."); + } + + return new AuthData + { + UserId = (tenantIdClaim is null) ? userIdClaim.Value : string.Join(".", userIdClaim.Value, tenantIdClaim.Value), + UserName = userNameClaim.Value, + }; + }, isThreadSafe: false); + } + + /// + /// The authenticated user's unique ID. + /// + public string UserId => this._data.Value.UserId; + + /// + /// The authenticated user's name. + /// + public string Name => this._data.Value.UserName; +} diff --git a/webapi/Auth/AuthPolicyName.cs b/webapi/Auth/AuthPolicyName.cs new file mode 100644 index 000000000..2d58aec32 --- /dev/null +++ b/webapi/Auth/AuthPolicyName.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace CopilotChat.WebApi.Auth; + +/// +/// Holds the policy names for custom authorization policies. +/// +public static class AuthPolicyName +{ + public const string RequireChatParticipant = "RequireChatParticipant"; +} diff --git a/webapi/Auth/ChatParticipantAuthorizationHandler.cs b/webapi/Auth/ChatParticipantAuthorizationHandler.cs new file mode 100644 index 000000000..add5a8d13 --- /dev/null +++ b/webapi/Auth/ChatParticipantAuthorizationHandler.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.Identity; +using CopilotChat.WebApi.Storage; +using Microsoft.AspNetCore.Authorization; + +namespace CopilotChat.WebApi.Auth; + +/// +/// Class implementing "authorization" that validates the user has access to a chat. +/// +public class ChatParticipantAuthorizationHandler : AuthorizationHandler +{ + private readonly IAuthInfo _authInfo; + private readonly ChatSessionRepository _chatSessionRepository; + private readonly ChatParticipantRepository _chatParticipantRepository; + + /// + /// Constructor + /// + public ChatParticipantAuthorizationHandler( + IAuthInfo authInfo, + ChatSessionRepository chatSessionRepository, + ChatParticipantRepository chatParticipantRepository) : base() + { + this._authInfo = authInfo; + this._chatSessionRepository = chatSessionRepository; + this._chatParticipantRepository = chatParticipantRepository; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + ChatParticipantRequirement requirement, + HttpContext resource) + { + try + { + string? chatId = resource.GetRouteValue("chatId")?.ToString(); + if (chatId == null) + { + // delegate to downstream validation + context.Succeed(requirement); + return; + } + + var session = await this._chatSessionRepository.FindByIdAsync(chatId); + if (session == null) + { + // delegate to downstream validation + context.Succeed(requirement); + return; + } + + bool isUserInChat = await this._chatParticipantRepository.IsUserInChatAsync(this._authInfo.UserId, chatId); + if (!isUserInChat) + { + context.Fail(new AuthorizationFailureReason(this, "User does not have access to the requested chat.")); + } + + context.Succeed(requirement); + } + catch (CredentialUnavailableException ex) + { + context.Fail(new AuthorizationFailureReason(this, ex.Message)); + } + } +} diff --git a/webapi/Auth/ChatParticipantRequirement.cs b/webapi/Auth/ChatParticipantRequirement.cs new file mode 100644 index 000000000..a28671643 --- /dev/null +++ b/webapi/Auth/ChatParticipantRequirement.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Authorization; + +namespace CopilotChat.WebApi.Auth; + +/// +/// Used to require the chat to be owned by the authenticated user. +/// +public class ChatParticipantRequirement : IAuthorizationRequirement +{ +} diff --git a/webapi/Auth/IAuthInfo.cs b/webapi/Auth/IAuthInfo.cs new file mode 100644 index 000000000..6769144ac --- /dev/null +++ b/webapi/Auth/IAuthInfo.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace CopilotChat.WebApi.Auth; + +public interface IAuthInfo +{ + /// + /// The authenticated user's unique ID. + /// + public string UserId { get; } + + /// + /// The authenticated user's name. + /// + public string Name { get; } +} diff --git a/webapi/Auth/PassThroughAuthenticationHandler.cs b/webapi/Auth/PassThroughAuthenticationHandler.cs index e12320b89..661e79bf6 100644 --- a/webapi/Auth/PassThroughAuthenticationHandler.cs +++ b/webapi/Auth/PassThroughAuthenticationHandler.cs @@ -2,12 +2,11 @@ using System.Security.Claims; using System.Text.Encodings.Web; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Identity.Web; -namespace SemanticKernel.Service.Auth; +namespace CopilotChat.WebApi.Auth; /// /// Class implementing "authentication" that lets all requests pass through. @@ -15,6 +14,8 @@ namespace SemanticKernel.Service.Auth; public class PassThroughAuthenticationHandler : AuthenticationHandler { public const string AuthenticationScheme = "PassThrough"; + private const string DefaultUserId = "c05c61eb-65e4-4223-915a-fe72b0c9ece1"; + private const string DefaultUserName = "Default User"; /// /// Constructor @@ -22,8 +23,7 @@ public class PassThroughAuthenticationHandler : AuthenticationHandler options, ILoggerFactory loggerFactory, - UrlEncoder encoder, - ISystemClock clock) : base(options, loggerFactory, encoder, clock) + UrlEncoder encoder) : base(options, loggerFactory, encoder) { } @@ -31,9 +31,17 @@ protected override Task HandleAuthenticateAsync() { this.Logger.LogInformation("Allowing request to pass through"); - var principal = new ClaimsPrincipal(new ClaimsIdentity(AuthenticationScheme)); - var ticket = new AuthenticationTicket(principal, this.Scheme.Name); + Claim userIdClaim = new(ClaimConstants.Sub, DefaultUserId); + Claim nameClaim = new(ClaimConstants.Name, DefaultUserName); + ClaimsIdentity identity = new(new Claim[] { userIdClaim, nameClaim }, AuthenticationScheme); + ClaimsPrincipal principal = new(identity); + AuthenticationTicket ticket = new(principal, this.Scheme.Name); return Task.FromResult(AuthenticateResult.Success(ticket)); } + + /// + /// Returns true if the given user ID is the default user guest ID. + /// + public static bool IsDefaultUser(string userId) => userId == DefaultUserId; } diff --git a/webapi/Controllers/ChatArchiveController.cs b/webapi/Controllers/ChatArchiveController.cs new file mode 100644 index 000000000..7fcaf26c2 --- /dev/null +++ b/webapi/Controllers/ChatArchiveController.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft. All rights reserved. + +using CopilotChat.WebApi.Auth; +using CopilotChat.WebApi.Extensions; +using CopilotChat.WebApi.Models.Response; +using CopilotChat.WebApi.Models.Storage; +using CopilotChat.WebApi.Options; +using CopilotChat.WebApi.Storage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.KernelMemory; + +namespace CopilotChat.WebApi.Controllers; + +[ApiController] +public class ChatArchiveController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IKernelMemory _memoryClient; + private readonly ChatSessionRepository _chatRepository; + private readonly ChatMessageRepository _chatMessageRepository; + private readonly ChatParticipantRepository _chatParticipantRepository; + private readonly ChatArchiveEmbeddingConfig _embeddingConfig; + private readonly PromptsOptions _promptOptions; + + /// + /// Constructor. + /// + /// Memory client. + /// The chat session repository. + /// The chat message repository. + /// The chat participant repository. + /// The document memory options. + /// The logger. + public ChatArchiveController( + IKernelMemory memoryClient, + ChatSessionRepository chatRepository, + ChatMessageRepository chatMessageRepository, + ChatParticipantRepository chatParticipantRepository, + ChatArchiveEmbeddingConfig embeddingConfig, + IOptions promptOptions, + ILogger logger) + { + this._memoryClient = memoryClient; + this._logger = logger; + this._chatRepository = chatRepository; + this._chatMessageRepository = chatMessageRepository; + this._chatParticipantRepository = chatParticipantRepository; + this._embeddingConfig = embeddingConfig; + this._promptOptions = promptOptions.Value; + } + + /// + /// Download a chat archive. + /// + /// The ID of chat to be downloaded. + /// Cancellation token. + /// The serialized chat archive object of the chat id. + [HttpGet] + [Route("chats/{chatId:guid}/archive")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task> DownloadAsync(Guid chatId, CancellationToken cancellationToken = default) + { + this._logger.LogDebug("Received call to download a chat archive"); + + var chatArchive = await this.CreateChatArchiveAsync(chatId, cancellationToken); + + return this.Ok(chatArchive); + } + + /// + /// Prepare a chat archive. + /// + /// The chat id of the chat archive + /// Cancellation token. + /// A ChatArchive object that represents the chat session. + private async Task CreateChatArchiveAsync(Guid chatId, CancellationToken cancellationToken) + { + var chatIdString = chatId.ToString(); + var chatArchive = new ChatArchive + { + // Get embedding configuration + EmbeddingConfigurations = this._embeddingConfig, + }; + + // get the chat title + ChatSession chat = await this._chatRepository.FindByIdAsync(chatIdString); + chatArchive.ChatTitle = chat.Title; + + // get the system description + chatArchive.SystemDescription = chat.SafeSystemDescription; + + // get the chat history + chatArchive.ChatHistory = await this.GetAllChatMessagesAsync(chatIdString); + + foreach (var memory in this._promptOptions.MemoryMap.Keys) + { + chatArchive.Embeddings.Add( + memory, + await this.GetMemoryRecordsAndAppendToEmbeddingsAsync(chatIdString, memory, cancellationToken)); + } + + // get the document memory collection names (global scope) + chatArchive.DocumentEmbeddings.Add( + "GlobalDocuments", + await this.GetMemoryRecordsAndAppendToEmbeddingsAsync( + Guid.Empty.ToString(), + this._promptOptions.DocumentMemoryName, + cancellationToken)); + + // get the document memory collection names (user scope) + chatArchive.DocumentEmbeddings.Add( + "ChatDocuments", + await this.GetMemoryRecordsAndAppendToEmbeddingsAsync( + chatIdString, + this._promptOptions.DocumentMemoryName, + cancellationToken)); + + return chatArchive; + } + + /// + /// Get memory from memory store and append the memory records to a given list. + /// It will update the memory collection name in the new list if the newCollectionName is provided. + /// + /// The current collection name. Used to query the memory storage. + /// The embeddings list where we will append the fetched memory records. + /// + /// The new collection name when appends to the embeddings list. Will use the old collection name if not provided. + /// + private async Task> GetMemoryRecordsAndAppendToEmbeddingsAsync( + string chatId, + string memoryName, + CancellationToken cancellationToken) + { + List collectionMemoryRecords; + try + { + var result = await this._memoryClient.SearchMemoryAsync( + this._promptOptions.MemoryIndexName, + query: "*", // dummy query since we don't care about relevance. An empty string will cause exception. + relevanceThreshold: -1, // no relevance required since the collection only has one entry + chatId, + memoryName, + cancellationToken); + + collectionMemoryRecords = result.Results; + } + catch (Exception connectorException) when (!connectorException.IsCriticalException()) + { + // A store exception might be thrown if the collection does not exist, depending on the memory store connector. + this._logger.LogError(connectorException, + "Cannot search collection {0}", + memoryName); + collectionMemoryRecords = new(); + } + + return collectionMemoryRecords; + } + + /// + /// Get chat messages of a given chat id. + /// + /// The chat id + /// The list of chat messages in descending order of the timestamp + private async Task> GetAllChatMessagesAsync(string chatId) + { + return (await this._chatMessageRepository.FindByChatIdAsync(chatId)).ToList(); + } +} diff --git a/webapi/Controllers/ChatController.cs b/webapi/Controllers/ChatController.cs new file mode 100644 index 000000000..49e177b6b --- /dev/null +++ b/webapi/Controllers/ChatController.cs @@ -0,0 +1,499 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using CopilotChat.WebApi.Auth; +using CopilotChat.WebApi.Hubs; +using CopilotChat.WebApi.Models.Request; +using CopilotChat.WebApi.Models.Response; +using CopilotChat.WebApi.Models.Storage; +using CopilotChat.WebApi.Options; +using CopilotChat.WebApi.Plugins.Chat; +using CopilotChat.WebApi.Services; +using CopilotChat.WebApi.Storage; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; +using Microsoft.Graph; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Plugins.MsGraph; +using Microsoft.SemanticKernel.Plugins.MsGraph.Connectors; +using Microsoft.SemanticKernel.Plugins.MsGraph.Connectors.Client; +using Microsoft.SemanticKernel.Plugins.OpenApi; + +namespace CopilotChat.WebApi.Controllers; + +/// +/// Controller responsible for handling chat messages and responses. +/// +[ApiController] +public class ChatController : ControllerBase, IDisposable +{ + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly List _disposables; + private readonly ITelemetryService _telemetryService; + private readonly ServiceOptions _serviceOptions; + private readonly MsGraphOboPluginOptions _msGraphOboPluginOptions; + private readonly PromptsOptions _promptsOptions; + private readonly IDictionary _plugins; + + private const string ChatPluginName = nameof(ChatPlugin); + private const string ChatFunctionName = "Chat"; + private const string GeneratingResponseClientCall = "ReceiveBotResponseStatus"; + + public ChatController( + ILogger logger, + IHttpClientFactory httpClientFactory, + ITelemetryService telemetryService, + IOptions serviceOptions, + IOptions msGraphOboPluginOptions, + IOptions promptsOptions, + IDictionary plugins) + { + this._logger = logger; + this._httpClientFactory = httpClientFactory; + this._telemetryService = telemetryService; + this._disposables = new List(); + this._serviceOptions = serviceOptions.Value; + this._msGraphOboPluginOptions = msGraphOboPluginOptions.Value; + this._promptsOptions = promptsOptions.Value; + this._plugins = plugins; + } + + /// + /// Invokes the chat function to get a response from the bot. + /// + /// Semantic kernel obtained through dependency injection. + /// Message Hub that performs the real time relay service. + /// Repository of chat sessions. + /// Repository of chat participants. + /// Auth info for the current request. + /// Prompt along with its parameters. + /// Chat ID. + /// Results containing the response from the model. + [Route("chats/{chatId:guid}/messages")] + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status504GatewayTimeout)] + public async Task ChatAsync( + [FromServices] Kernel kernel, + [FromServices] IHubContext messageRelayHubContext, + [FromServices] ChatSessionRepository chatSessionRepository, + [FromServices] ChatParticipantRepository chatParticipantRepository, + [FromServices] IAuthInfo authInfo, + [FromBody] Ask ask, + [FromRoute] Guid chatId) + { + this._logger.LogDebug("Chat message received."); + + string chatIdString = chatId.ToString(); + + // Put ask's variables in the context we will use. + var contextVariables = GetContextVariables(ask, authInfo, chatIdString); + + // Verify that the chat exists and that the user has access to it. + ChatSession? chat = null; + if (!(await chatSessionRepository.TryFindByIdAsync(chatIdString, callback: c => chat = c))) + { + return this.NotFound("Failed to find chat session for the chatId specified in variables."); + } + + if (!(await chatParticipantRepository.IsUserInChatAsync(authInfo.UserId, chatIdString))) + { + return this.Forbid("User does not have access to the chatId specified in variables."); + } + + // Register plugins that have been enabled + var openApiPluginAuthHeaders = this.GetPluginAuthHeaders(this.HttpContext.Request.Headers); + await this.RegisterFunctionsAsync(kernel, openApiPluginAuthHeaders, contextVariables); + + // Register hosted plugins that have been enabled + await this.RegisterHostedFunctionsAsync(kernel, chat!.EnabledPlugins); + + // Get the function to invoke + KernelFunction? chatFunction = kernel.Plugins.GetFunction(ChatPluginName, ChatFunctionName); + + // Run the function. + FunctionResult? result = null; + try + { + using CancellationTokenSource? cts = this._serviceOptions.TimeoutLimitInS is not null + // Create a cancellation token source with the timeout if specified + ? new CancellationTokenSource(TimeSpan.FromSeconds((double)this._serviceOptions.TimeoutLimitInS)) + : null; + + result = await kernel.InvokeAsync(chatFunction!, contextVariables, cts?.Token ?? default); + this._telemetryService.TrackPluginFunction(ChatPluginName, ChatFunctionName, true); + } + catch (Exception ex) + { + if (ex is OperationCanceledException || ex.InnerException is OperationCanceledException) + { + // Log the timeout and return a 504 response + this._logger.LogError("The {FunctionName} operation timed out.", ChatFunctionName); + return this.StatusCode(StatusCodes.Status504GatewayTimeout, $"The chat {ChatFunctionName} timed out."); + } + + this._telemetryService.TrackPluginFunction(ChatPluginName, ChatFunctionName, false); + + throw; + } + + AskResult chatAskResult = new() + { + Value = result.ToString() ?? string.Empty, + Variables = contextVariables.Select(v => new KeyValuePair(v.Key, v.Value)) + }; + + // Broadcast AskResult to all users + await messageRelayHubContext.Clients.Group(chatIdString).SendAsync(GeneratingResponseClientCall, chatIdString, null); + + return this.Ok(chatAskResult); + } + + /// + /// Parse plugin auth values from request headers. + /// + private Dictionary GetPluginAuthHeaders(IHeaderDictionary headers) + { + // Create a regex to match the headers + var regex = new Regex("x-sk-copilot-(.*)-auth", RegexOptions.IgnoreCase); + + // Create a dictionary to store the matched headers and values + var authHeaders = new Dictionary(); + + // Loop through the request headers and add the matched ones to the dictionary + foreach (var header in headers) + { + var match = regex.Match(header.Key); + if (match.Success) + { + // Use the first capture group as the key and the header value as the value + authHeaders.Add(match.Groups[1].Value.ToUpperInvariant(), header.Value!); + } + } + + return authHeaders; + } + + /// + /// Register functions with the kernel. + /// + private async Task RegisterFunctionsAsync(Kernel kernel, Dictionary authHeaders, KernelArguments variables) + { + // Register authenticated functions with the kernel only if the request includes an auth header for the plugin. + + var tasks = new List(); + + // GitHub + if (authHeaders.TryGetValue("GITHUB", out string? githubAuthHeader)) + { + tasks.Add(this.RegisterGithubPluginAsync(kernel, githubAuthHeader)); + } + + // Jira + if (authHeaders.TryGetValue("JIRA", out string? jiraAuthHeader)) + { + tasks.Add(this.RegisterJiraPluginAsync(kernel, jiraAuthHeader, variables)); + } + + // Microsoft Graph + if (authHeaders.TryGetValue("GRAPH", out string? graphAuthHeader)) + { + tasks.Add(this.RegisterMicrosoftGraphPlugins(kernel, graphAuthHeader)); + } + + // Microsoft Graph OBO + if (authHeaders.TryGetValue("MSGRAPHOBO", out string? graphOboAuthHeader)) + { + tasks.Add(this.RegisterMicrosoftGraphOBOPlugins(kernel, graphOboAuthHeader)); + } + + if (variables.TryGetValue("customPlugins", out object? customPluginsString)) + { + tasks.AddRange(this.RegisterCustomPlugins(kernel, customPluginsString, authHeaders)); + } + + await Task.WhenAll(tasks); + } + + private async Task RegisterGithubPluginAsync(Kernel kernel, string githubAuthHeader) + { + this._logger.LogInformation("Enabling GitHub plugin."); + BearerAuthenticationProvider authenticationProvider = new(() => Task.FromResult(githubAuthHeader)); + await kernel.ImportPluginFromOpenApiAsync( + pluginName: "GitHubPlugin", + filePath: GetPluginFullPath("OpenApi/GitHubPlugin/openapi.json"), + new OpenApiFunctionExecutionParameters + { + AuthCallback = authenticationProvider.AuthenticateRequestAsync, + }); + } + + private async Task RegisterJiraPluginAsync(Kernel kernel, string jiraAuthHeader, KernelArguments variables) + { + this._logger.LogInformation("Registering Jira plugin"); + var authenticationProvider = new BasicAuthenticationProvider(() => { return Task.FromResult(jiraAuthHeader); }); + var hasServerUrlOverride = variables.TryGetValue("jira-server-url", out object? serverUrlOverride); + + await kernel.ImportPluginFromOpenApiAsync( + pluginName: "JiraPlugin", + filePath: GetPluginFullPath("OpenApi/JiraPlugin/openapi.json"), + new OpenApiFunctionExecutionParameters + { + AuthCallback = authenticationProvider.AuthenticateRequestAsync, + ServerUrlOverride = hasServerUrlOverride ? new Uri(serverUrlOverride!.ToString()!) : null, + }); + ; + ; + } + + private Task RegisterMicrosoftGraphPlugins(Kernel kernel, string graphAuthHeader) + { + this._logger.LogInformation("Enabling Microsoft Graph plugin(s)."); + BearerAuthenticationProvider authenticationProvider = new(() => Task.FromResult(graphAuthHeader)); + GraphServiceClient graphServiceClient = this.CreateGraphServiceClient(authenticationProvider.GraphClientAuthenticateRequestAsync); + + kernel.ImportPluginFromObject(new TaskListPlugin(new MicrosoftToDoConnector(graphServiceClient)), "todo"); + kernel.ImportPluginFromObject(new CalendarPlugin(new OutlookCalendarConnector(graphServiceClient)), "calendar"); + kernel.ImportPluginFromObject(new EmailPlugin(new OutlookMailConnector(graphServiceClient)), "email"); + return Task.CompletedTask; + } + + private Task RegisterMicrosoftGraphOBOPlugins(Kernel kernel, string graphOboAuthHeader) + { + this._logger.LogInformation("Enabling Microsoft Graph OBO plugin(s)."); + kernel.ImportPluginFromObject( + new MsGraphOboPlugin(graphOboAuthHeader, this._httpClientFactory, this._msGraphOboPluginOptions, this._promptsOptions.FunctionCallingTokenLimit, this._logger), + "msGraphObo"); + return Task.CompletedTask; + } + + /// + /// Create a Microsoft Graph service client. + /// + /// The delegate to authenticate the request. + private GraphServiceClient CreateGraphServiceClient(AuthenticateRequestAsyncDelegate authenticateRequestAsyncDelegate) + { + MsGraphClientLoggingHandler graphLoggingHandler = new(this._logger); + this._disposables.Add(graphLoggingHandler); + + IList graphMiddlewareHandlers = + GraphClientFactory.CreateDefaultHandlers(new DelegateAuthenticationProvider(authenticateRequestAsyncDelegate)); + graphMiddlewareHandlers.Add(graphLoggingHandler); + + HttpClient graphHttpClient = GraphClientFactory.Create(graphMiddlewareHandlers); + this._disposables.Add(graphHttpClient); + + GraphServiceClient graphServiceClient = new(graphHttpClient); + return graphServiceClient; + } + + private IEnumerable RegisterCustomPlugins(Kernel kernel, object? customPluginsString, Dictionary authHeaders) + { + CustomPlugin[]? customPlugins = JsonSerializer.Deserialize(customPluginsString!.ToString()!); + + if (customPlugins != null) + { + foreach (CustomPlugin plugin in customPlugins) + { + if (authHeaders.TryGetValue(plugin.AuthHeaderTag.ToUpperInvariant(), out string? pluginAuthValue)) + { + this._logger.LogError("ChatGPT plugins are deprecated. Skipping plugin: {0}", plugin.NameForHuman); + + /* + // Register the ChatGPT plugin with the kernel. + this._logger.LogInformation("Enabling {0} plugin.", plugin.NameForHuman); + + // TODO: [Issue #44] Support other forms of auth. Currently, we only support user PAT or no auth. + var requiresAuth = !plugin.AuthType.Equals("none", StringComparison.OrdinalIgnoreCase); + + Task AuthCallback(HttpRequestMessage request, string _, OpenAIAuthenticationConfig __, CancellationToken ___ = default) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", pluginAuthValue); + + return Task.CompletedTask; + } + + yield return kernel.ImportPluginFromOpenAIAsync( + $"{plugin.NameForModel}Plugin", + PluginUtils.GetPluginManifestUri(plugin.ManifestDomain), + new OpenAIFunctionExecutionParameters + { + HttpClient = this._httpClientFactory.CreateClient(), + IgnoreNonCompliantErrors = true, + AuthCallback = requiresAuth ? AuthCallback : null + }); + */ + } + } + } + else + { + this._logger.LogDebug("Failed to deserialize custom plugin details: {0}", customPluginsString); + } + + yield return Task.CompletedTask; + } + + private Task RegisterHostedFunctionsAsync(Kernel kernel, HashSet enabledPlugins) + { + foreach (string enabledPlugin in enabledPlugins) + { + if (this._plugins.TryGetValue(enabledPlugin, out Plugin? plugin)) + { + this._logger.LogError("ChatGPT plugins are deprecated. Skipping plugin {0}", enabledPlugin); + + /* + this._logger.LogDebug("Enabling hosted plugin {0}.", plugin.Name); + + Task AuthCallback(HttpRequestMessage request, string _, OpenAIAuthenticationConfig __, CancellationToken ___ = default) + { + request.Headers.Add("X-Functions-Key", plugin.Key); + + return Task.CompletedTask; + } + + // Register the ChatGPT plugin with the kernel. + await kernel.ImportPluginFromOpenAIAsync( + PluginUtils.SanitizePluginName(plugin.Name), + PluginUtils.GetPluginManifestUri(plugin.ManifestDomain), + new OpenAIFunctionExecutionParameters + { + HttpClient = this._httpClientFactory.CreateClient(), + IgnoreNonCompliantErrors = true, + AuthCallback = AuthCallback + }); + */ + } + else + { + this._logger.LogWarning("Failed to find plugin {0}.", enabledPlugin); + } + } + + return Task.CompletedTask; + } + + private static KernelArguments GetContextVariables(Ask ask, IAuthInfo authInfo, string chatId) + { + const string UserIdKey = "userId"; + const string UserNameKey = "userName"; + const string ChatIdKey = "chatId"; + const string MessageKey = "message"; + + var contextVariables = new KernelArguments(); + foreach (var variable in ask.Variables) + { + contextVariables[variable.Key] = variable.Value; + } + + contextVariables[UserIdKey] = authInfo.UserId; + contextVariables[UserNameKey] = authInfo.Name; + contextVariables[ChatIdKey] = chatId; + contextVariables[MessageKey] = ask.Input; + + return contextVariables; + } + + private static string GetPluginFullPath(string pluginPath) + { + return Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "Plugins", pluginPath); + } + + /// + /// Dispose of the object. + /// + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + foreach (IDisposable disposable in this._disposables) + { + disposable.Dispose(); + } + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} + +/// +/// Retrieves authentication content (e.g. username/password, API key) via the provided delegate and +/// applies it to HTTP requests using the "basic" authentication scheme. +/// +public class BasicAuthenticationProvider +{ + private readonly Func> _credentialsDelegate; + + /// + /// Creates an instance of the class. + /// + /// Delegate for retrieving credentials. + public BasicAuthenticationProvider(Func> credentialsDelegate) + { + this._credentialsDelegate = credentialsDelegate; + } + + /// + /// Applies the authentication content to the provided HTTP request message. + /// + /// The HTTP request message. + /// The cancellation token. + public async Task AuthenticateRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + { + // Base64 encode + string encodedContent = Convert.ToBase64String(Encoding.UTF8.GetBytes(await this._credentialsDelegate().ConfigureAwait(false))); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encodedContent); + } +} + +/// +/// Retrieves a token via the provided delegate and applies it to HTTP requests using the +/// "bearer" authentication scheme. +/// +public class BearerAuthenticationProvider +{ + private readonly Func> _bearerTokenDelegate; + + /// + /// Creates an instance of the class. + /// + /// Delegate to retrieve the bearer token. + public BearerAuthenticationProvider(Func> bearerTokenDelegate) + { + this._bearerTokenDelegate = bearerTokenDelegate; + } + + /// + /// Applies the token to the provided HTTP request message. + /// + /// The HTTP request message. + public async Task AuthenticateRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + { + var token = await this._bearerTokenDelegate().ConfigureAwait(false); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + /// + /// Applies the token to the provided HTTP request message. + /// + /// The HTTP request message. + public async Task GraphClientAuthenticateRequestAsync(HttpRequestMessage request) + { + await this.AuthenticateRequestAsync(request); + } +} diff --git a/webapi/Controllers/ChatHistoryController.cs b/webapi/Controllers/ChatHistoryController.cs new file mode 100644 index 000000000..111b256b0 --- /dev/null +++ b/webapi/Controllers/ChatHistoryController.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft. All rights reserved. + +using CopilotChat.WebApi.Auth; +using CopilotChat.WebApi.Extensions; +using CopilotChat.WebApi.Hubs; +using CopilotChat.WebApi.Models.Request; +using CopilotChat.WebApi.Models.Response; +using CopilotChat.WebApi.Models.Storage; +using CopilotChat.WebApi.Options; +using CopilotChat.WebApi.Plugins.Utils; +using CopilotChat.WebApi.Storage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; +using Microsoft.KernelMemory; + +namespace CopilotChat.WebApi.Controllers; + +/// +/// Controller for chat history. +/// This controller is responsible for creating new chat sessions, retrieving chat sessions, +/// retrieving chat messages, and editing chat sessions. +/// +[ApiController] +public class ChatHistoryController : ControllerBase +{ + private const string ChatEditedClientCall = "ChatEdited"; + private const string ChatDeletedClientCall = "ChatDeleted"; + private const string GetChatRoute = "GetChatRoute"; + + private readonly ILogger _logger; + private readonly IKernelMemory _memoryClient; + private readonly ChatSessionRepository _sessionRepository; + private readonly ChatMessageRepository _messageRepository; + private readonly ChatParticipantRepository _participantRepository; + private readonly ChatMemorySourceRepository _sourceRepository; + private readonly PromptsOptions _promptOptions; + private readonly IAuthInfo _authInfo; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// Memory client. + /// The chat session repository. + /// The chat message repository. + /// The chat participant repository. + /// The chat memory resource repository. + /// The prompts options. + /// The auth info for the current request. + public ChatHistoryController( + ILogger logger, + IKernelMemory memoryClient, + ChatSessionRepository sessionRepository, + ChatMessageRepository messageRepository, + ChatParticipantRepository participantRepository, + ChatMemorySourceRepository sourceRepository, + IOptions promptsOptions, + IAuthInfo authInfo) + { + this._logger = logger; + this._memoryClient = memoryClient; + this._sessionRepository = sessionRepository; + this._messageRepository = messageRepository; + this._participantRepository = participantRepository; + this._sourceRepository = sourceRepository; + this._promptOptions = promptsOptions.Value; + this._authInfo = authInfo; + } + + /// + /// Create a new chat session and populate the session with the initial bot message. + /// + /// Contains the title of the chat. + /// The HTTP action result. + [HttpPost] + [Route("chats")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateChatSessionAsync( + [FromBody] CreateChatParameters chatParameters) + { + if (chatParameters.Title == null) + { + return this.BadRequest("Chat session parameters cannot be null."); + } + + // Create a new chat session + var newChat = new ChatSession(chatParameters.Title, this._promptOptions.SystemDescription); + await this._sessionRepository.CreateAsync(newChat); + + // Create initial bot message + var chatMessage = CopilotChatMessage.CreateBotResponseMessage( + newChat.Id, + this._promptOptions.InitialBotMessage, + string.Empty, // The initial bot message doesn't need a prompt. + null, + TokenUtils.EmptyTokenUsages()); + await this._messageRepository.CreateAsync(chatMessage); + + // Add the user to the chat session + await this._participantRepository.CreateAsync(new ChatParticipant(this._authInfo.UserId, newChat.Id)); + + this._logger.LogDebug("Created chat session with id {0}.", newChat.Id); + + return this.CreatedAtRoute(GetChatRoute, new { chatId = newChat.Id }, new CreateChatResponse(newChat, chatMessage)); + } + + /// + /// Get a chat session by id. + /// + /// The chat id. + [HttpGet] + [Route("chats/{chatId:guid}", Name = GetChatRoute)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task GetChatSessionByIdAsync(Guid chatId) + { + ChatSession? chat = null; + if (await this._sessionRepository.TryFindByIdAsync(chatId.ToString(), callback: v => chat = v)) + { + return this.Ok(chat); + } + + return this.NotFound($"No chat session found for chat id '{chatId}'."); + } + + /// + /// Get all chat sessions associated with the logged in user. Return an empty list if no chats are found. + /// + /// The user id. + /// A list of chat sessions. An empty list if the user is not in any chat session. + [HttpGet] + [Route("chats")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetAllChatSessionsAsync() + { + // Get all participants that belong to the user. + // Then get all the chats from the list of participants. + var chatParticipants = await this._participantRepository.FindByUserIdAsync(this._authInfo.UserId); + + var chats = new List(); + foreach (var chatParticipant in chatParticipants) + { + ChatSession? chat = null; + if (await this._sessionRepository.TryFindByIdAsync(chatParticipant.ChatId, callback: v => chat = v)) + { + chats.Add(chat!); + } + else + { + this._logger.LogDebug("Failed to find chat session with id {0}", chatParticipant.ChatId); + } + } + + return this.Ok(chats); + } + + /// + /// Get chat messages for a chat session. + /// Messages are returned ordered from most recent to oldest. + /// + /// The chat id. + /// Number of messages to skip before starting to return messages. + /// The number of messages to return. -1 returns all messages. + [HttpGet] + [Route("chats/{chatId:guid}/messages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task GetChatMessagesAsync( + [FromRoute] Guid chatId, + [FromQuery] int skip = 0, + [FromQuery] int count = -1) + { + var chatMessages = await this._messageRepository.FindByChatIdAsync(chatId.ToString(), skip, count); + if (!chatMessages.Any()) + { + return this.NotFound($"No messages found for chat id '{chatId}'."); + } + + return this.Ok(chatMessages); + } + + /// + /// Edit a chat session. + /// + /// Object that contains the parameters to edit the chat. + [HttpPatch] + [Route("chats/{chatId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task EditChatSessionAsync( + [FromServices] IHubContext messageRelayHubContext, + [FromBody] EditChatParameters chatParameters, + [FromRoute] Guid chatId) + { + ChatSession? chat = null; + if (await this._sessionRepository.TryFindByIdAsync(chatId.ToString(), callback: v => chat = v)) + { + chat!.Title = chatParameters.Title ?? chat!.Title; + chat!.SystemDescription = chatParameters.SystemDescription ?? chat!.SafeSystemDescription; + chat!.MemoryBalance = chatParameters.MemoryBalance ?? chat!.MemoryBalance; + await this._sessionRepository.UpsertAsync(chat); + await messageRelayHubContext.Clients.Group(chatId.ToString()).SendAsync(ChatEditedClientCall, chat); + + return this.Ok(chat); + } + + return this.NotFound($"No chat session found for chat id '{chatId}'."); + } + + /// + /// Gets list of imported documents for a given chat. + /// + [Route("chats/{chatId:guid}/documents")] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task>> GetSourcesAsync(Guid chatId) + { + this._logger.LogInformation("Get imported sources of chat session {0}", chatId); + + if (await this._sessionRepository.TryFindByIdAsync(chatId.ToString())) + { + IEnumerable sources = await this._sourceRepository.FindByChatIdAsync(chatId.ToString()); + + return this.Ok(sources); + } + + return this.NotFound($"No chat session found for chat id '{chatId}'."); + } + + /// + /// Delete a chat session. + /// + /// The chat id. + [HttpDelete] + [Route("chats/{chatId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task DeleteChatSessionAsync( + [FromServices] IHubContext messageRelayHubContext, + Guid chatId, + CancellationToken cancellationToken) + { + var chatIdString = chatId.ToString(); + ChatSession? chatToDelete = null; + try + { + // Make sure the chat session exists + chatToDelete = await this._sessionRepository.FindByIdAsync(chatIdString); + } + catch (KeyNotFoundException) + { + return this.NotFound($"No chat session found for chat id '{chatId}'."); + } + + // Delete any resources associated with the chat session. + try + { + await this.DeleteChatResourcesAsync(chatIdString, cancellationToken); + } + catch (AggregateException) + { + return this.StatusCode(500, $"Failed to delete resources for chat id '{chatId}'."); + } + + // Delete chat session and broadcast update to all participants. + await this._sessionRepository.DeleteAsync(chatToDelete); + await messageRelayHubContext.Clients.Group(chatIdString).SendAsync(ChatDeletedClientCall, chatIdString, this._authInfo.UserId, cancellationToken: cancellationToken); + + return this.NoContent(); + } + + /// + /// Deletes all associated resources (messages, memories, participants) associated with a chat session. + /// + /// The chat id. + private async Task DeleteChatResourcesAsync(string chatId, CancellationToken cancellationToken) + { + var cleanupTasks = new List(); + + // Create and store the tasks for deleting all users tied to the chat. + var participants = await this._participantRepository.FindByChatIdAsync(chatId); + foreach (var participant in participants) + { + cleanupTasks.Add(this._participantRepository.DeleteAsync(participant)); + } + + // Create and store the tasks for deleting chat messages. + var messages = await this._messageRepository.FindByChatIdAsync(chatId); + foreach (var message in messages) + { + cleanupTasks.Add(this._messageRepository.DeleteAsync(message)); + } + + // Create and store the tasks for deleting memory sources. + var sources = await this._sourceRepository.FindByChatIdAsync(chatId, false); + foreach (var source in sources) + { + cleanupTasks.Add(this._sourceRepository.DeleteAsync(source)); + } + + // Create and store the tasks for deleting semantic memories. + cleanupTasks.Add(this._memoryClient.RemoveChatMemoriesAsync(this._promptOptions.MemoryIndexName, chatId, cancellationToken)); + + // Create a task that represents the completion of all cleanupTasks + Task aggregationTask = Task.WhenAll(cleanupTasks); + try + { + // Await the completion of all tasks in parallel + await aggregationTask; + } + catch (Exception ex) + { + // Handle any exceptions that occurred during the tasks + if (aggregationTask?.Exception?.InnerExceptions != null && aggregationTask.Exception.InnerExceptions.Count != 0) + { + foreach (var innerEx in aggregationTask.Exception.InnerExceptions) + { + this._logger.LogInformation("Failed to delete an entity of chat {0}: {1}", chatId, innerEx.Message); + } + + throw aggregationTask.Exception; + } + + throw new AggregateException($"Resource deletion failed for chat {chatId}.", ex); + } + } +} diff --git a/webapi/Controllers/ChatMemoryController.cs b/webapi/Controllers/ChatMemoryController.cs new file mode 100644 index 000000000..07c439a15 --- /dev/null +++ b/webapi/Controllers/ChatMemoryController.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using CopilotChat.WebApi.Auth; +using CopilotChat.WebApi.Extensions; +using CopilotChat.WebApi.Models.Request; +using CopilotChat.WebApi.Options; +using CopilotChat.WebApi.Storage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.KernelMemory; + +namespace CopilotChat.WebApi.Controllers; + +/// +/// Controller for retrieving kernel memory data of chat sessions. +/// +[ApiController] +public class ChatMemoryController : ControllerBase +{ + private readonly ILogger _logger; + + private readonly PromptsOptions _promptOptions; + + private readonly ChatSessionRepository _chatSessionRepository; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The prompts options. + /// The chat session repository. + public ChatMemoryController( + ILogger logger, + IOptions promptsOptions, + ChatSessionRepository chatSessionRepository) + { + this._logger = logger; + this._promptOptions = promptsOptions.Value; + this._chatSessionRepository = chatSessionRepository; + } + + /// + /// Gets the kernel memory for the chat session. + /// + /// The kernel memory client. + /// The chat id. + /// Type of memory. Must map to a member of . + [HttpGet] + [Route("chats/{chatId:guid}/memories")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task GetSemanticMemoriesAsync( + [FromServices] IKernelMemory memoryClient, + [FromRoute] string chatId, + [FromQuery] string type) + { + // Sanitize the log input by removing new line characters. + // https://github.com/microsoft/chat-copilot/security/code-scanning/1 + var sanitizedChatId = GetSanitizedParameter(chatId); + var sanitizedMemoryType = GetSanitizedParameter(type); + + // Map the requested memoryType to the memory store container name + if (!this._promptOptions.TryGetMemoryContainerName(type, out string memoryContainerName)) + { + this._logger.LogWarning("Memory type: {0} is invalid.", sanitizedMemoryType); + return this.BadRequest($"Memory type: {sanitizedMemoryType} is invalid."); + } + + // Make sure the chat session exists. + if (!await this._chatSessionRepository.TryFindByIdAsync(chatId)) + { + this._logger.LogWarning("Chat session: {0} does not exist.", sanitizedChatId); + return this.BadRequest($"Chat session: {sanitizedChatId} does not exist."); + } + + // Gather the requested kernel memory. + // Will use a dummy query since we don't care about relevance. + // minRelevanceScore is set to 0.0 to return all memories. + List memories = new(); + try + { + // Search if there is already a memory item that has a high similarity score with the new item. + var filter = new MemoryFilter(); + filter.ByTag("chatid", chatId); + filter.ByTag("memory", memoryContainerName); + + var searchResult = + await memoryClient.SearchMemoryAsync( + this._promptOptions.MemoryIndexName, + "*", + relevanceThreshold: 0, + resultCount: -1, + chatId, + memoryContainerName); + + foreach (var memory in searchResult.Results.SelectMany(c => c.Partitions)) + { + memories.Add(memory.Text); + } + } + catch (Exception connectorException) when (!connectorException.IsCriticalException()) + { + // A store exception might be thrown if the collection does not exist, depending on the memory store connector. + this._logger.LogError(connectorException, "Cannot search collection {0}", memoryContainerName); + } + + return this.Ok(memories); + } + + #region Private + + private static string GetSanitizedParameter(string parameterValue) + { + return parameterValue.Replace(Environment.NewLine, string.Empty, StringComparison.Ordinal); + } + + # endregion +} diff --git a/webapi/CopilotChat/Controllers/ChatParticipantController.cs b/webapi/Controllers/ChatParticipantController.cs similarity index 75% rename from webapi/CopilotChat/Controllers/ChatParticipantController.cs rename to webapi/Controllers/ChatParticipantController.cs index b08ff8c1f..cfd7b78bc 100644 --- a/webapi/CopilotChat/Controllers/ChatParticipantController.cs +++ b/webapi/Controllers/ChatParticipantController.cs @@ -1,17 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using System.Threading.Tasks; +using CopilotChat.WebApi.Auth; +using CopilotChat.WebApi.Hubs; +using CopilotChat.WebApi.Models.Storage; +using CopilotChat.WebApi.Storage; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; -using SemanticKernel.Service.CopilotChat.Hubs; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Storage; -namespace SemanticKernel.Service.CopilotChat.Controllers; +namespace CopilotChat.WebApi.Controllers; /// /// Controller for managing invitations and participants in a chat session. @@ -21,7 +18,6 @@ namespace SemanticKernel.Service.CopilotChat.Controllers; /// 3. Managing participants in a chat session. /// [ApiController] -[Authorize] public class ChatParticipantController : ControllerBase { private const string UserJoinedClientCall = "UserJoined"; @@ -46,39 +42,41 @@ public ChatParticipantController( } /// - /// Join a use to a chat session given a chat id and a user id. + /// Join the logged in user to a chat session given a chat ID. /// /// Message Hub that performs the real time relay service. - /// Contains the user id and chat id. + /// The auth info for the current request. + /// The ID of the chat to join. [HttpPost] - [Route("chatParticipant/join")] + [Route("chats/{chatId:guid}/participants")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task JoinChatAsync( [FromServices] IHubContext messageRelayHubContext, - [FromBody] ChatParticipant chatParticipantParam) + [FromServices] IAuthInfo authInfo, + [FromRoute] Guid chatId) { - string userId = chatParticipantParam.UserId; - string chatId = chatParticipantParam.ChatId; + string userId = authInfo.UserId; // Make sure the chat session exists. - if (!await this._chatSessionRepository.TryFindByIdAsync(chatId, v => _ = v)) + if (!await this._chatSessionRepository.TryFindByIdAsync(chatId.ToString())) { return this.BadRequest("Chat session does not exist."); } // Make sure the user is not already in the chat session. - if (await this._chatParticipantRepository.IsUserInChatAsync(userId, chatId)) + if (await this._chatParticipantRepository.IsUserInChatAsync(userId, chatId.ToString())) { - return this.BadRequest("User is already in the chat session."); + return this.Conflict("User is already in the chat session."); } - var chatParticipant = new ChatParticipant(userId, chatId); + var chatParticipant = new ChatParticipant(userId, chatId.ToString()); await this._chatParticipantRepository.CreateAsync(chatParticipant); // Broadcast the user joined event to all the connected clients. // Note that the client who initiated the request may not have joined the group. - await messageRelayHubContext.Clients.Group(chatId).SendAsync(UserJoinedClientCall, chatId, userId); + await messageRelayHubContext.Clients.Group(chatId.ToString()).SendAsync(UserJoinedClientCall, chatId, userId); return this.Ok(chatParticipant); } @@ -86,20 +84,22 @@ public async Task JoinChatAsync( /// /// Get a list of chat participants that have the same chat id. /// - /// The Id of the chat to get all the participants from. + /// The ID of the chat to get all the participants from. [HttpGet] - [Route("chatParticipant/getAllParticipants/{chatId:guid}")] + [Route("chats/{chatId:guid}/participants")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] public async Task GetAllParticipantsAsync(Guid chatId) { // Make sure the chat session exists. - if (!await this._chatSessionRepository.TryFindByIdAsync(chatId.ToString(), v => _ = v)) + if (!await this._chatSessionRepository.TryFindByIdAsync(chatId.ToString())) { return this.NotFound("Chat session does not exist."); } var chatParticipants = await this._chatParticipantRepository.FindByChatIdAsync(chatId.ToString()); + return this.Ok(chatParticipants); } } diff --git a/webapi/Controllers/DocumentController.cs b/webapi/Controllers/DocumentController.cs new file mode 100644 index 000000000..f70ecf1bb --- /dev/null +++ b/webapi/Controllers/DocumentController.cs @@ -0,0 +1,502 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; +using CopilotChat.WebApi.Auth; +using CopilotChat.WebApi.Extensions; +using CopilotChat.WebApi.Hubs; +using CopilotChat.WebApi.Models.Request; +using CopilotChat.WebApi.Models.Response; +using CopilotChat.WebApi.Models.Storage; +using CopilotChat.WebApi.Options; +using CopilotChat.WebApi.Services; +using CopilotChat.WebApi.Storage; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; +using Microsoft.KernelMemory; + +namespace CopilotChat.WebApi.Controllers; + +/// +/// Controller for importing documents. +/// +/// +/// This controller is responsible for contracts that are not possible to fulfill by kernel memory components. +/// +[ApiController] +public class DocumentController : ControllerBase +{ + private const string GlobalDocumentUploadedClientCall = "GlobalDocumentUploaded"; + private const string ReceiveMessageClientCall = "ReceiveMessage"; + + private readonly ILogger _logger; + private readonly PromptsOptions _promptOptions; + private readonly DocumentMemoryOptions _options; + private readonly ContentSafetyOptions _contentSafetyOptions; + private readonly ChatSessionRepository _sessionRepository; + private readonly ChatMemorySourceRepository _sourceRepository; + private readonly ChatMessageRepository _messageRepository; + private readonly ChatParticipantRepository _participantRepository; + private readonly DocumentTypeProvider _documentTypeProvider; + private readonly IAuthInfo _authInfo; + private readonly IContentSafetyService _contentSafetyService; + + /// + /// Initializes a new instance of the class. + /// + public DocumentController( + ILogger logger, + IAuthInfo authInfo, + IOptions documentMemoryOptions, + IOptions promptOptions, + IOptions contentSafetyOptions, + ChatSessionRepository sessionRepository, + ChatMemorySourceRepository sourceRepository, + ChatMessageRepository messageRepository, + ChatParticipantRepository participantRepository, + DocumentTypeProvider documentTypeProvider, + IContentSafetyService contentSafetyService) + { + this._logger = logger; + this._options = documentMemoryOptions.Value; + this._promptOptions = promptOptions.Value; + this._contentSafetyOptions = contentSafetyOptions.Value; + this._sessionRepository = sessionRepository; + this._sourceRepository = sourceRepository; + this._messageRepository = messageRepository; + this._participantRepository = participantRepository; + this._documentTypeProvider = documentTypeProvider; + this._authInfo = authInfo; + this._contentSafetyService = contentSafetyService; + } + + /// + /// Service API for importing a document. + /// Documents imported through this route will be considered as global documents. + /// + [Route("documents")] + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public Task DocumentImportAsync( + [FromServices] IKernelMemory memoryClient, + [FromServices] IHubContext messageRelayHubContext, + [FromForm] DocumentImportForm documentImportForm) + { + return this.DocumentImportAsync( + memoryClient, + messageRelayHubContext, + DocumentScopes.Global, + DocumentMemoryOptions.GlobalDocumentChatId, + documentImportForm + ); + } + + /// + /// Service API for importing a document. + /// + [Route("chats/{chatId}/documents")] + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public Task DocumentImportAsync( + [FromServices] IKernelMemory memoryClient, + [FromServices] IHubContext messageRelayHubContext, + [FromRoute] Guid chatId, + [FromForm] DocumentImportForm documentImportForm) + { + return this.DocumentImportAsync( + memoryClient, + messageRelayHubContext, + DocumentScopes.Chat, + chatId, + documentImportForm); + } + + private async Task DocumentImportAsync( + IKernelMemory memoryClient, + IHubContext messageRelayHubContext, + DocumentScopes documentScope, + Guid chatId, + DocumentImportForm documentImportForm) + { + try + { + await this.ValidateDocumentImportFormAsync(chatId, documentScope, documentImportForm); + } + catch (ArgumentException ex) + { + return this.BadRequest(ex.Message); + } + + this._logger.LogInformation("Importing {0} document(s)...", documentImportForm.FormFiles.Count()); + + // Pre-create chat-message + DocumentMessageContent documentMessageContent = new(); + + var importResults = await this.ImportDocumentsAsync(memoryClient, chatId, documentImportForm, documentMessageContent); + + var chatMessage = await this.TryCreateDocumentUploadMessageAsync(chatId, documentMessageContent); + + if (chatMessage == null) + { + this._logger.LogWarning("Failed to create document upload message - {Content}", documentMessageContent.ToString()); + return this.BadRequest(); + } + + // Broadcast the document uploaded event to other users. + if (documentScope == DocumentScopes.Chat) + { + // If chat message isn't created, it is still broadcast and visible in the documents tab. + // The chat message won't, however, be displayed when the chat is freshly rendered. + + var userId = this._authInfo.UserId; + await messageRelayHubContext.Clients.Group(chatId.ToString()) + .SendAsync(ReceiveMessageClientCall, chatId, userId, chatMessage); + + this._logger.LogInformation("Local upload chat message: {0}", chatMessage.ToString()); + + return this.Ok(chatMessage); + } + + await messageRelayHubContext.Clients.All.SendAsync( + GlobalDocumentUploadedClientCall, + documentMessageContent.ToFormattedStringNamesOnly(), + this._authInfo.Name + ); + + this._logger.LogInformation("Global upload chat message: {0}", chatMessage.ToString()); + + return this.Ok(chatMessage); + } + + private async Task> ImportDocumentsAsync(IKernelMemory memoryClient, Guid chatId, DocumentImportForm documentImportForm, DocumentMessageContent messageContent) + { + IEnumerable importResults = new List(); + + await Task.WhenAll( + documentImportForm.FormFiles.Select( + async formFile => + await this.ImportDocumentAsync(formFile, memoryClient, chatId).ContinueWith( + task => + { + var importResult = task.Result; + if (importResult != null) + { + messageContent.AddDocument( + formFile.FileName, + this.GetReadableByteString(formFile.Length), + importResult.IsSuccessful); + + importResults = importResults.Append(importResult); + } + }, + TaskScheduler.Default))); + + return importResults.ToArray(); + } + + private async Task ImportDocumentAsync(IFormFile formFile, IKernelMemory memoryClient, Guid chatId) + { + this._logger.LogInformation("Importing document {0}", formFile.FileName); + + // Create memory source + MemorySource memorySource = new( + chatId.ToString(), + formFile.FileName, + this._authInfo.UserId, + MemorySourceType.File, + formFile.Length, + hyperlink: null + ); + + if (!(await this.TryUpsertMemorySourceAsync(memorySource))) + { + this._logger.LogDebug("Failed to upsert memory source for file {0}.", formFile.FileName); + + return ImportResult.Fail; + } + + if (!(await TryStoreMemoryAsync())) + { + await this.TryRemoveMemoryAsync(memorySource); + } + + return new ImportResult(memorySource.Id); + + async Task TryStoreMemoryAsync() + { + try + { + using var stream = formFile.OpenReadStream(); + await memoryClient.StoreDocumentAsync( + this._promptOptions.MemoryIndexName, + memorySource.Id, + chatId.ToString(), + this._promptOptions.DocumentMemoryName, + formFile.FileName, + stream); + + return true; + } + catch (Exception ex) when (ex is not SystemException) + { + return false; + } + } + } + + #region Private + + /// + /// A class to store a document import results. + /// + private sealed class ImportResult + { + /// + /// A boolean indicating whether the import is successful. + /// + public bool IsSuccessful => !string.IsNullOrWhiteSpace(this.CollectionName); + + /// + /// The name of the collection that the document is inserted to. + /// + public string CollectionName { get; set; } + + /// + /// Create a new instance of the class. + /// + /// The name of the collection that the document is inserted to. + public ImportResult(string collectionName) + { + this.CollectionName = collectionName; + } + + /// + /// Create a new instance of the class representing a failed import. + /// + public static ImportResult Fail { get; } = new(string.Empty); + } + + /// + /// Validates the document import form. + /// + /// The document import form. + /// + /// Throws ArgumentException if validation fails. + private async Task ValidateDocumentImportFormAsync(Guid chatId, DocumentScopes scope, DocumentImportForm documentImportForm) + { + // Make sure the user has access to the chat session if the document is uploaded to a chat session. + if (scope == DocumentScopes.Chat + && !(await this.UserHasAccessToChatAsync(this._authInfo.UserId, chatId))) + { + throw new ArgumentException("User does not have access to the chat session."); + } + + var formFiles = documentImportForm.FormFiles; + + if (!formFiles.Any()) + { + throw new ArgumentException("No files were uploaded."); + } + else if (formFiles.Count() > this._options.FileCountLimit) + { + throw new ArgumentException($"Too many files uploaded. Max file count is {this._options.FileCountLimit}."); + } + + // Loop through the uploaded files and validate them before importing. + foreach (var formFile in formFiles) + { + if (formFile.Length == 0) + { + throw new ArgumentException($"File {formFile.FileName} is empty."); + } + + if (formFile.Length > this._options.FileSizeLimit) + { + throw new ArgumentException($"File {formFile.FileName} size exceeds the limit."); + } + + // Make sure the file type is supported. + var fileType = Path.GetExtension(formFile.FileName); + if (!this._documentTypeProvider.IsSupported(fileType, out bool isSafetyTarget)) + { + throw new ArgumentException($"Unsupported file type: {fileType}"); + } + + if (isSafetyTarget && documentImportForm.UseContentSafety) + { + if (!this._contentSafetyOptions.Enabled) + { + throw new ArgumentException("Unable to analyze image. Content Safety is currently disabled in the backend."); + } + + var violations = new List(); + try + { + // Call the content safety controller to analyze the image + var imageAnalysisResponse = await this._contentSafetyService.ImageAnalysisAsync(formFile, default); + violations = this._contentSafetyService.ParseViolatedCategories(imageAnalysisResponse, this._contentSafetyOptions.ViolationThreshold); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + this._logger.LogError(ex, "Failed to analyze image {0} with Content Safety. Details: {{1}}", formFile.FileName, ex.Message); + throw new AggregateException($"Failed to analyze image {formFile.FileName} with Content Safety.", ex); + } + + if (violations.Count > 0) + { + throw new ArgumentException($"Unable to upload image {formFile.FileName}. Detected undesirable content with potential risk: {string.Join(", ", violations)}"); + } + } + } + } + + /// + /// Validates the document import form. + /// + /// The document import form. + /// + /// Throws ArgumentException if validation fails. + private async Task ValidateDocumentStatusFormAsync(DocumentStatusForm documentStatusForm) + { + // Make sure the user has access to the chat session if the document is uploaded to a chat session. + if (documentStatusForm.DocumentScope == DocumentScopes.Chat + && !(await this.UserHasAccessToChatAsync(documentStatusForm.UserId, documentStatusForm.ChatId))) + { + throw new ArgumentException("User does not have access to the chat session."); + } + + var fileReferences = documentStatusForm.FileReferences; + + if (!fileReferences.Any()) + { + throw new ArgumentException("No files identified."); + } + else if (fileReferences.Count() > this._options.FileCountLimit) + { + throw new ArgumentException($"Too many files requested. Max file count is {this._options.FileCountLimit}."); + } + + // Loop through the uploaded files and validate them before importing. + foreach (var fileReference in fileReferences) + { + if (string.IsNullOrWhiteSpace(fileReference)) + { + throw new ArgumentException($"File {fileReference} is empty."); + } + } + } + + /// + /// Try to upsert a memory source. + /// + /// The memory source to be uploaded + /// True if upsert is successful. False otherwise. + private async Task TryUpsertMemorySourceAsync(MemorySource memorySource) + { + try + { + await this._sourceRepository.UpsertAsync(memorySource); + return true; + } + catch (Exception ex) when (ex is not SystemException) + { + return false; + } + } + + /// + /// Try to upsert a memory source. + /// + /// The memory source to be uploaded + /// True if upsert is successful. False otherwise. + private async Task TryRemoveMemoryAsync(MemorySource memorySource) + { + try + { + await this._sourceRepository.DeleteAsync(memorySource); + return true; + } + catch (Exception ex) when (ex is ArgumentOutOfRangeException) + { + return false; + } + } + + /// + /// Try to upsert a memory source. + /// + /// The memory source to be uploaded + /// True if upsert is successful. False otherwise. + private async Task TryStoreMemoryAsync(MemorySource memorySource) + { + try + { + await this._sourceRepository.UpsertAsync(memorySource); + return true; + } + catch (Exception ex) when (ex is ArgumentOutOfRangeException) + { + return false; + } + } + + /// + /// Try to create a chat message that represents document upload. + /// + /// The target chat-id + /// The document message content + /// A ChatMessage object if successful, null otherwise + private async Task TryCreateDocumentUploadMessageAsync( + Guid chatId, + DocumentMessageContent messageContent) + { + var chatMessage = CopilotChatMessage.CreateDocumentMessage( + this._authInfo.UserId, + this._authInfo.Name, // User name + chatId.ToString(), + messageContent); + + try + { + await this._messageRepository.CreateAsync(chatMessage); + return chatMessage; + } + catch (Exception ex) when (ex is ArgumentOutOfRangeException) + { + return null; + } + } + + /// + /// Converts a `long` byte count to a human-readable string. + /// + /// Byte count + /// Human-readable string of bytes + private string GetReadableByteString(long bytes) + { + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + int i; + double dblsBytes = bytes; + for (i = 0; i < sizes.Length && bytes >= 1024; i++, bytes /= 1024) + { + dblsBytes = bytes / 1024.0; + } + + return string.Format(CultureInfo.InvariantCulture, "{0:0.#}{1}", dblsBytes, sizes[i]); + } + + /// + /// Check if the user has access to the chat session. + /// + /// The user ID. + /// The chat session ID. + /// A boolean indicating whether the user has access to the chat session. + private async Task UserHasAccessToChatAsync(string userId, Guid chatId) + { + return await this._participantRepository.IsUserInChatAsync(userId, chatId.ToString()); + } + + #endregion +} diff --git a/webapi/Controllers/MaintenanceController.cs b/webapi/Controllers/MaintenanceController.cs new file mode 100644 index 000000000..551b3689e --- /dev/null +++ b/webapi/Controllers/MaintenanceController.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using CopilotChat.WebApi.Models.Response; +using CopilotChat.WebApi.Options; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace CopilotChat.WebApi.Controllers; + +/// +/// Controller for reporting the status of chat migration. +/// +[ApiController] +public class MaintenanceController : ControllerBase +{ + internal const string GlobalSiteMaintenance = "GlobalSiteMaintenance"; + + private readonly ILogger _logger; + private readonly IOptions _serviceOptions; + + /// + /// Initializes a new instance of the class. + /// + public MaintenanceController( + ILogger logger, + IOptions serviceOptions) + { + this._logger = logger; + this._serviceOptions = serviceOptions; + } + + /// + /// Route for reporting the status of site maintenance. + /// + [Route("maintenanceStatus")] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult GetMaintenanceStatusAsync( + CancellationToken cancellationToken = default) + { + MaintenanceResult? result = null; + + if (this._serviceOptions.Value.InMaintenance) + { + result = new MaintenanceResult(); // Default maintenance message + } + + if (result != null) + { + return this.Ok(result); + } + + return this.Ok(); + } +} diff --git a/webapi/Controllers/PluginController.cs b/webapi/Controllers/PluginController.cs new file mode 100644 index 000000000..f7d48a700 --- /dev/null +++ b/webapi/Controllers/PluginController.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using CopilotChat.WebApi.Auth; +using CopilotChat.WebApi.Hubs; +using CopilotChat.WebApi.Models.Storage; +using CopilotChat.WebApi.Options; +using CopilotChat.WebApi.Storage; +using CopilotChat.WebApi.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; + +namespace CopilotChat.WebApi.Controllers; + +/// +/// Controller responsible for returning the service options to the client. +/// +[ApiController] +public class PluginController : ControllerBase +{ + private const string PluginStateChanged = "PluginStateChanged"; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IDictionary _availablePlugins; + private readonly ChatSessionRepository _sessionRepository; + + public PluginController( + ILogger logger, + IHttpClientFactory httpClientFactory, + IDictionary availablePlugins, + ChatSessionRepository sessionRepository) + { + this._logger = logger; + this._httpClientFactory = httpClientFactory; + this._availablePlugins = availablePlugins; + this._sessionRepository = sessionRepository; + } + + /// + /// Fetches a plugin's manifest. + /// + /// The domain of the manifest. + /// The plugin's manifest JSON. + [HttpGet] + [Route("pluginManifests")] // TODO: Fix name and test - use singular? + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetPluginManifestAsync([FromQuery] Uri manifestDomain) + { + using var request = new HttpRequestMessage(HttpMethod.Get, PluginUtils.GetPluginManifestUri(manifestDomain)); + // Need to set the user agent to avoid 403s from some sites. + request.Headers.Add("User-Agent", "Semantic-Kernel"); + + using HttpClient client = this._httpClientFactory.CreateClient(); + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + return this.StatusCode((int)response.StatusCode, await response.Content.ReadAsStringAsync()); + } + + return this.Ok(await response.Content.ReadAsStringAsync()); + } + + /// + /// Enable or disable a plugin for a chat session. + /// + [HttpPut] + [Route("chats/{chatId:guid}/plugins/{pluginName}/{enabled:bool}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = AuthPolicyName.RequireChatParticipant)] + public async Task SetPluginStateAsync( + [FromServices] IHubContext messageRelayHubContext, + Guid chatId, + string pluginName, + bool enabled) + { + if (!this._availablePlugins.ContainsKey(pluginName)) + { + return this.NotFound("Plugin not found."); + } + + var chatIdString = chatId.ToString(); + ChatSession? chat = null; +#pragma warning disable CA1508 // Avoid dead conditional code. It's giving out false positives on chat == null. + if (!(await this._sessionRepository.TryFindByIdAsync(chatIdString, callback: v => chat = v)) || chat == null) + { + return this.NotFound("Chat not found."); + } + + if (enabled) + { + chat.EnabledPlugins.Add(pluginName); + } + else + { + chat.EnabledPlugins.Remove(pluginName); + } + + await this._sessionRepository.UpsertAsync(chat); + await messageRelayHubContext.Clients.Group(chatIdString).SendAsync(PluginStateChanged, chatIdString, pluginName, enabled); + + return this.NoContent(); + } +} diff --git a/webapi/Controllers/ServiceInfoController.cs b/webapi/Controllers/ServiceInfoController.cs new file mode 100644 index 000000000..a00c785da --- /dev/null +++ b/webapi/Controllers/ServiceInfoController.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Reflection; +using CopilotChat.WebApi.Models.Response; +using CopilotChat.WebApi.Options; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.KernelMemory; + +namespace CopilotChat.WebApi.Controllers; + +/// +/// Controller responsible for returning information on the service. +/// +[ApiController] +public class ServiceInfoController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly KernelMemoryConfig _memoryOptions; + private readonly ChatAuthenticationOptions _chatAuthenticationOptions; + private readonly FrontendOptions _frontendOptions; + private readonly IEnumerable _availablePlugins; + private readonly ContentSafetyOptions _contentSafetyOptions; + + public ServiceInfoController( + ILogger logger, + IConfiguration configuration, + IOptions memoryOptions, + IOptions chatAuthenticationOptions, + IOptions frontendOptions, + IDictionary availablePlugins, + IOptions contentSafetyOptions) + { + this._logger = logger; + this._configuration = configuration; + this._memoryOptions = memoryOptions.Value; + this._chatAuthenticationOptions = chatAuthenticationOptions.Value; + this._frontendOptions = frontendOptions.Value; + this._availablePlugins = this.SanitizePlugins(availablePlugins); + this._contentSafetyOptions = contentSafetyOptions.Value; + } + + /// + /// Return information on running service. + /// + [Route("info")] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult GetServiceInfo() + { + var response = new ServiceInfoResponse() + { + MemoryStore = new MemoryStoreInfoResponse() + { + Types = Enum.GetNames(typeof(MemoryStoreType)), + SelectedType = this._memoryOptions.GetMemoryStoreType(this._configuration).ToString(), + }, + AvailablePlugins = this._availablePlugins, + Version = GetAssemblyFileVersion(), + IsContentSafetyEnabled = this._contentSafetyOptions.Enabled + }; + + return this.Ok(response); + } + + /// + /// Return the auth config to be used by the frontend client to access this service. + /// + [Route("authConfig")] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [AllowAnonymous] + public IActionResult GetAuthConfig() + { + string authorityUriString = string.Empty; + if (!string.IsNullOrEmpty(this._chatAuthenticationOptions.AzureAd!.Instance) && + !string.IsNullOrEmpty(this._chatAuthenticationOptions.AzureAd!.TenantId)) + { + var authorityUri = new Uri(this._chatAuthenticationOptions.AzureAd!.Instance); + authorityUri = new Uri(authorityUri, this._chatAuthenticationOptions.AzureAd!.TenantId); + authorityUriString = authorityUri.ToString(); + } + + var config = new FrontendAuthConfig + { + AuthType = this._chatAuthenticationOptions.Type.ToString(), + AadAuthority = authorityUriString, + AadClientId = this._frontendOptions.AadClientId, + AadApiScope = $"api://{this._chatAuthenticationOptions.AzureAd!.ClientId}/{this._chatAuthenticationOptions.AzureAd!.Scopes}", + }; + + return this.Ok(config); + } + + private static string GetAssemblyFileVersion() + { + Assembly assembly = Assembly.GetExecutingAssembly(); + FileVersionInfo fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location); + + return fileVersion.FileVersion ?? string.Empty; + } + + /// + /// Sanitize the plugins to only return the name and url. + /// + /// The plugins to sanitize. + /// + private IEnumerable SanitizePlugins(IDictionary plugins) + { + return plugins.Select(p => new Plugin() + { + Name = p.Value.Name, + ManifestDomain = p.Value.ManifestDomain, + }); + } +} diff --git a/webapi/CopilotChat/Controllers/SpeechTokenController.cs b/webapi/Controllers/SpeechTokenController.cs similarity index 70% rename from webapi/CopilotChat/Controllers/SpeechTokenController.cs rename to webapi/Controllers/SpeechTokenController.cs index 8dea7b3b9..2fb25fb84 100644 --- a/webapi/CopilotChat/Controllers/SpeechTokenController.cs +++ b/webapi/Controllers/SpeechTokenController.cs @@ -1,20 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; +using CopilotChat.WebApi.Models.Response; +using CopilotChat.WebApi.Options; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Options; -namespace SemanticKernel.Service.CopilotChat.Controllers; +namespace CopilotChat.WebApi.Controllers; -[Authorize] [ApiController] public class SpeechTokenController : ControllerBase { @@ -25,11 +18,15 @@ private sealed class TokenResult } private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; private readonly AzureSpeechOptions _options; - public SpeechTokenController(IOptions options, ILogger logger) + public SpeechTokenController(IOptions options, + ILogger logger, + IHttpClientFactory httpClientFactory) { this._logger = logger; + this._httpClientFactory = httpClientFactory; this._options = options.Value; } @@ -57,20 +54,20 @@ public async Task> GetAsync() private async Task FetchTokenAsync(string fetchUri, string subscriptionKey) { - // TODO: get the HttpClient from the DI container - using var client = new HttpClient(); - client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", subscriptionKey); - UriBuilder uriBuilder = new(fetchUri); + using var client = this._httpClientFactory.CreateClient(); - var result = await client.PostAsync(uriBuilder.Uri, null); + using var request = new HttpRequestMessage(HttpMethod.Post, fetchUri); + request.Headers.Add("Ocp-Apim-Subscription-Key", subscriptionKey); + + var result = await client.SendAsync(request); if (result.IsSuccessStatusCode) { var response = result.EnsureSuccessStatusCode(); - this._logger.LogDebug("Token Uri: {0}", uriBuilder.Uri.AbsoluteUri); + this._logger.LogDebug("Token Uri: {0}", fetchUri); string token = await result.Content.ReadAsStringAsync(); return new TokenResult { Token = token, ResponseCode = response.StatusCode }; } - return new TokenResult { Token = "", ResponseCode = HttpStatusCode.NotFound }; + return new TokenResult { ResponseCode = HttpStatusCode.NotFound }; } } diff --git a/webapi/CopilotChat/Controllers/BotController.cs b/webapi/CopilotChat/Controllers/BotController.cs deleted file mode 100644 index a9ab3ac76..000000000 --- a/webapi/CopilotChat/Controllers/BotController.cs +++ /dev/null @@ -1,342 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Memory; -using SemanticKernel.Service.CopilotChat.Extensions; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Options; -using SemanticKernel.Service.CopilotChat.Storage; -using SemanticKernel.Service.Options; - -namespace SemanticKernel.Service.CopilotChat.Controllers; - -[ApiController] -public class BotController : ControllerBase -{ - private readonly ILogger _logger; - private readonly IMemoryStore? _memoryStore; - private readonly ISemanticTextMemory _semanticMemory; - private readonly ChatSessionRepository _chatRepository; - private readonly ChatMessageRepository _chatMessageRepository; - private readonly ChatParticipantRepository _chatParticipantRepository; - - private readonly BotSchemaOptions _botSchemaOptions; - private readonly AIServiceOptions _embeddingOptions; - private readonly DocumentMemoryOptions _documentMemoryOptions; - - /// - /// The constructor of BotController. - /// - /// Optional memory store. - /// High level semantic memory implementations, such as Azure Cognitive Search, do not allow for providing embeddings when storing memories. - /// We wrap the memory store in an optional memory store to allow controllers to pass dependency injection validation and potentially optimize - /// for a lower-level memory implementation (e.g. Qdrant). Lower level memory implementations (i.e., IMemoryStore) allow for reusing embeddings, - /// whereas high level memory implementation (i.e., ISemanticTextMemory) assume embeddings get recalculated on every write. - /// - /// The chat session repository. - /// The chat message repository. - /// The chat participant repository. - /// The AI service options where we need the embedding settings from. - /// The bot schema options. - /// The document memory options. - /// The logger. - public BotController( - OptionalIMemoryStore optionalIMemoryStore, - ISemanticTextMemory semanticMemory, - ChatSessionRepository chatRepository, - ChatMessageRepository chatMessageRepository, - ChatParticipantRepository chatParticipantRepository, - IOptions aiServiceOptions, - IOptions botSchemaOptions, - IOptions documentMemoryOptions, - ILogger logger) - { - this._memoryStore = optionalIMemoryStore.MemoryStore; - this._logger = logger; - this._semanticMemory = semanticMemory; - this._chatRepository = chatRepository; - this._chatMessageRepository = chatMessageRepository; - this._chatParticipantRepository = chatParticipantRepository; - this._botSchemaOptions = botSchemaOptions.Value; - this._embeddingOptions = aiServiceOptions.Value; - this._documentMemoryOptions = documentMemoryOptions.Value; - } - - /// - /// Upload a bot. - /// - /// The Semantic Kernel instance. - /// The user id. - /// The bot object from the message body - /// The cancellation token. - /// The HTTP action result with new chat session object. - [Authorize] - [HttpPost] - [Route("bot/upload")] - [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> UploadAsync( - [FromServices] IKernel kernel, - [FromQuery] string userId, - [FromBody] Bot bot, - CancellationToken cancellationToken) - { - // TODO: We should get userId from server context instead of from request for privacy/security reasons when support multiple users. - this._logger.LogDebug("Received call to upload a bot"); - - if (!IsBotCompatible( - externalBotSchema: bot.Schema, - externalBotEmbeddingConfig: bot.EmbeddingConfigurations, - embeddingOptions: this._embeddingOptions, - botSchemaOptions: this._botSchemaOptions)) - { - return this.BadRequest("Incompatible schema. " + - $"The supported bot schema is {this._botSchemaOptions.Name}/{this._botSchemaOptions.Version} " + - $"for the {this._embeddingOptions.Models.Embedding} model from {this._embeddingOptions.Type}. " + - $"But the uploaded file is with schema {bot.Schema.Name}/{bot.Schema.Version} " + - $"for the {bot.EmbeddingConfigurations.DeploymentOrModelId} model from {bot.EmbeddingConfigurations.AIService}."); - } - - string chatTitle = $"{bot.ChatTitle} - Clone"; - string chatId = string.Empty; - ChatSession newChat; - - // Upload chat history into chat repository and embeddings into memory. - - // 1. Create a new chat and get the chat id. - newChat = new ChatSession(chatTitle); - await this._chatRepository.CreateAsync(newChat); - await this._chatParticipantRepository.CreateAsync(new ChatParticipant(userId, newChat.Id)); - chatId = newChat.Id; - - string oldChatId = bot.ChatHistory.First().ChatId; - - // 2. Update the app's chat storage. - foreach (var message in bot.ChatHistory) - { - var chatMessage = new ChatMessage( - message.UserId, - message.UserName, - chatId, - message.Content, - message.Prompt, - ChatMessage.AuthorRoles.Participant) - { - Timestamp = message.Timestamp - }; - await this._chatMessageRepository.CreateAsync(chatMessage); - } - - // 3. Update the memory. - await this.BulkUpsertMemoryRecordsAsync(oldChatId, chatId, bot.Embeddings, cancellationToken); - - // TODO: Revert changes if any of the actions failed - - return this.CreatedAtAction( - nameof(ChatHistoryController.GetChatSessionByIdAsync), - nameof(ChatHistoryController).Replace("Controller", "", StringComparison.OrdinalIgnoreCase), - new { chatId }, - newChat); - } - - /// - /// Download a bot. - /// - /// The Semantic Kernel instance. - /// The chat id to be downloaded. - /// The serialized Bot object of the chat id. - [Authorize] - [HttpGet] - [ActionName("DownloadAsync")] - [Route("bot/download/{chatId:guid}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> DownloadAsync( - [FromServices] IKernel kernel, - Guid chatId) - { - this._logger.LogDebug("Received call to download a bot"); - var memory = await this.CreateBotAsync(kernel: kernel, chatId: chatId); - - return JsonSerializer.Serialize(memory); - } - - /// - /// Check if an external bot file is compatible with the application. - /// - /// - /// If the embeddings are not generated from the same model, the bot file is not compatible. - /// - /// The external bot schema. - /// The external bot embedding configuration. - /// The embedding options. - /// The bot schema options. - /// True if the bot file is compatible with the app; otherwise false. - private static bool IsBotCompatible( - BotSchemaOptions externalBotSchema, - BotEmbeddingConfig externalBotEmbeddingConfig, - AIServiceOptions embeddingOptions, - BotSchemaOptions botSchemaOptions) - { - // The app can define what schema/version it supports before the community comes out with an open schema. - return externalBotSchema.Name.Equals(botSchemaOptions.Name, StringComparison.OrdinalIgnoreCase) - && externalBotSchema.Version == botSchemaOptions.Version - && externalBotEmbeddingConfig.AIService == embeddingOptions.Type - && externalBotEmbeddingConfig.DeploymentOrModelId.Equals(embeddingOptions.Models.Embedding, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Get memory from memory store and append the memory records to a given list. - /// It will update the memory collection name in the new list if the newCollectionName is provided. - /// - /// The Semantic Kernel instance. - /// The current collection name. Used to query the memory storage. - /// The embeddings list where we will append the fetched memory records. - /// - /// The new collection name when appends to the embeddings list. Will use the old collection name if not provided. - /// - private static async Task GetMemoryRecordsAndAppendToEmbeddingsAsync( - IKernel kernel, - string collectionName, - List>> embeddings, - string newCollectionName = "") - { - List collectionMemoryRecords = await kernel.Memory.SearchAsync( - collectionName, - "abc", // dummy query since we don't care about relevance. An empty string will cause exception. - limit: 999999999, // temp solution to get as much as record as a workaround. - minRelevanceScore: -1, // no relevance required since the collection only has one entry - withEmbeddings: true, - cancellationToken: default - ).ToListAsync(); - - embeddings.Add(new KeyValuePair>( - string.IsNullOrEmpty(newCollectionName) ? collectionName : newCollectionName, - collectionMemoryRecords)); - } - - /// - /// Prepare the bot information of a given chat. - /// - /// The semantic kernel object. - /// The chat id of the bot - /// A Bot object that represents the chat session. - private async Task CreateBotAsync(IKernel kernel, Guid chatId) - { - var chatIdString = chatId.ToString(); - var bot = new Bot - { - // get the bot schema version - Schema = this._botSchemaOptions, - - // get the embedding configuration - EmbeddingConfigurations = new BotEmbeddingConfig - { - AIService = this._embeddingOptions.Type, - DeploymentOrModelId = this._embeddingOptions.Models.Embedding - } - }; - - // get the chat title - ChatSession chat = await this._chatRepository.FindByIdAsync(chatIdString); - bot.ChatTitle = chat.Title; - - // get the chat history - bot.ChatHistory = await this.GetAllChatMessagesAsync(chatIdString); - - // get the memory collections associated with this chat - // TODO: filtering memory collections by name might be fragile. - var chatCollections = (await kernel.Memory.GetCollectionsAsync()) - .Where(collection => collection.StartsWith(chatIdString, StringComparison.OrdinalIgnoreCase)); - - foreach (var collection in chatCollections) - { - await GetMemoryRecordsAndAppendToEmbeddingsAsync(kernel: kernel, collectionName: collection, embeddings: bot.Embeddings); - } - - // get the document memory collection names (global scope) - await GetMemoryRecordsAndAppendToEmbeddingsAsync( - kernel: kernel, - collectionName: this._documentMemoryOptions.GlobalDocumentCollectionName, - embeddings: bot.DocumentEmbeddings); - - // get the document memory collection names (user scope) - await GetMemoryRecordsAndAppendToEmbeddingsAsync( - kernel: kernel, - collectionName: this._documentMemoryOptions.ChatDocumentCollectionNamePrefix + chatIdString, - embeddings: bot.DocumentEmbeddings); - - return bot; - } - - /// - /// Get chat messages of a given chat id. - /// - /// The chat id - /// The list of chat messages in descending order of the timestamp - private async Task> GetAllChatMessagesAsync(string chatId) - { - // TODO: We might want to set limitation on the number of messages that are pulled from the storage. - return (await this._chatMessageRepository.FindByChatIdAsync(chatId)) - .OrderByDescending(m => m.Timestamp).ToList(); - } - - /// - /// Bulk upsert memory records into memory store. - /// - /// The original chat id of the memory records. - /// The new chat id that will replace the original chat id. - /// The list of embeddings of the chat id. - /// The function doesn't return anything. - private async Task BulkUpsertMemoryRecordsAsync(string oldChatId, string chatId, List>> embeddings, CancellationToken cancellationToken = default) - { - foreach (var collection in embeddings) - { - foreach (var record in collection.Value) - { - if (record != null && record.Embedding != null) - { - var newCollectionName = collection.Key.Replace(oldChatId, chatId, StringComparison.OrdinalIgnoreCase); - - if (this._memoryStore == null) - { - await this._semanticMemory.SaveInformationAsync( - collection: newCollectionName, - text: record.Metadata.Text, - id: record.Metadata.Id, - cancellationToken: cancellationToken); - } - else - { - MemoryRecord data = MemoryRecord.LocalRecord( - id: record.Metadata.Id, - text: record.Metadata.Text, - embedding: record.Embedding.Value, - description: null, - additionalMetadata: null); - - if (!(await this._memoryStore.DoesCollectionExistAsync(newCollectionName, default))) - { - await this._memoryStore.CreateCollectionAsync(newCollectionName, default); - } - - await this._memoryStore.UpsertAsync(newCollectionName, data, default); - } - } - } - } - } -} diff --git a/webapi/CopilotChat/Controllers/ChatController.cs b/webapi/CopilotChat/Controllers/ChatController.cs deleted file mode 100644 index ae4d795f2..000000000 --- a/webapi/CopilotChat/Controllers/ChatController.cs +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; -using Microsoft.Graph; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.AI; -using Microsoft.SemanticKernel.Orchestration; -using Microsoft.SemanticKernel.SkillDefinition; -using Microsoft.SemanticKernel.Skills.MsGraph; -using Microsoft.SemanticKernel.Skills.MsGraph.Connectors; -using Microsoft.SemanticKernel.Skills.MsGraph.Connectors.Client; -using Microsoft.SemanticKernel.Skills.OpenAPI.Authentication; -using Microsoft.SemanticKernel.Skills.OpenAPI.Extensions; -using SemanticKernel.Service.CopilotChat.Hubs; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Skills.ChatSkills; -using SemanticKernel.Service.Diagnostics; -using SemanticKernel.Service.Models; - -namespace SemanticKernel.Service.CopilotChat.Controllers; - -/// -/// Controller responsible for handling chat messages and responses. -/// -[ApiController] -public class ChatController : ControllerBase, IDisposable -{ - private readonly ILogger _logger; - private readonly List _disposables; - private readonly ITelemetryService _telemetryService; - private const string ChatSkillName = "ChatSkill"; - private const string ChatFunctionName = "Chat"; - private const string ReceiveResponseClientCall = "ReceiveResponse"; - private const string GeneratingResponseClientCall = "ReceiveBotTypingState"; - - public ChatController(ILogger logger, ITelemetryService telemetryService) - { - this._logger = logger; - this._telemetryService = telemetryService; - this._disposables = new List(); - } - - /// - /// Invokes the chat skill to get a response from the bot. - /// - /// Semantic kernel obtained through dependency injection. - /// Message Hub that performs the real time relay service. - /// Planner to use to create function sequences. - /// Prompt along with its parameters. - /// Authentication headers to connect to OpenAPI Skills. - /// Results containing the response from the model. - [Authorize] - [Route("chat")] - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task ChatAsync( - [FromServices] IKernel kernel, - [FromServices] IHubContext messageRelayHubContext, - [FromServices] CopilotChatPlanner planner, - [FromBody] Ask ask, - [FromHeader] OpenApiSkillsAuthHeaders openApiSkillsAuthHeaders) - { - this._logger.LogDebug("Chat request received."); - - // Put ask's variables in the context we will use. - var contextVariables = new ContextVariables(ask.Input); - foreach (var input in ask.Variables) - { - contextVariables.Set(input.Key, input.Value); - } - - // Register plugins that have been enabled - await this.RegisterPlannerSkillsAsync(planner, openApiSkillsAuthHeaders, contextVariables); - - // Get the function to invoke - ISKFunction? function = null; - try - { - function = kernel.Skills.GetFunction(ChatSkillName, ChatFunctionName); - } - catch (KernelException ke) - { - this._logger.LogError("Failed to find {0}/{1} on server: {2}", ChatSkillName, ChatFunctionName, ke); - - return this.NotFound($"Failed to find {ChatSkillName}/{ChatFunctionName} on server"); - } - - // Broadcast bot typing state to all users - if (ask.Variables.Where(v => v.Key == "chatId").Any()) - { - var chatId = ask.Variables.Where(v => v.Key == "chatId").First().Value; - await messageRelayHubContext.Clients.Group(chatId).SendAsync(GeneratingResponseClientCall, chatId, true); - } - - // Run the function. - SKContext? result = null; - try - { - result = await kernel.RunAsync(contextVariables, function!); - } - finally - { - this._telemetryService.TrackSkillFunction(ChatSkillName, ChatFunctionName, (!result?.ErrorOccurred) ?? false); - } - - if (result.ErrorOccurred) - { - if (result.LastException is AIException aiException && aiException.Detail is not null) - { - return this.BadRequest(string.Concat(aiException.Message, " - Detail: " + aiException.Detail)); - } - - return this.BadRequest(result.LastErrorDescription); - } - - AskResult chatSkillAskResult = new() - { - Value = result.Result, - Variables = result.Variables.Select( - v => new KeyValuePair(v.Key, v.Value)) - }; - - // Broadcast AskResult to all users - if (ask.Variables.Where(v => v.Key == "chatId").Any()) - { - var chatId = ask.Variables.Where(v => v.Key == "chatId").First().Value; - await messageRelayHubContext.Clients.Group(chatId).SendAsync(ReceiveResponseClientCall, chatSkillAskResult, chatId); - await messageRelayHubContext.Clients.Group(chatId).SendAsync(GeneratingResponseClientCall, chatId, false); - } - - return this.Ok(chatSkillAskResult); - } - - /// - /// Register skills with the planner's kernel. - /// - private async Task RegisterPlannerSkillsAsync(CopilotChatPlanner planner, OpenApiSkillsAuthHeaders openApiSkillsAuthHeaders, ContextVariables variables) - { - // Register authenticated skills with the planner's kernel only if the request includes an auth header for the skill. - - // Klarna Shopping - if (openApiSkillsAuthHeaders.KlarnaAuthentication != null) - { - // Register the Klarna shopping ChatGPT plugin with the planner's kernel. There is no authentication required for this plugin. - await planner.Kernel.ImportChatGptPluginSkillFromUrlAsync("KlarnaShoppingSkill", new Uri("https://www.klarna.com/.well-known/ai-plugin.json"), new OpenApiSkillExecutionParameters()); - } - - // GitHub - if (!string.IsNullOrWhiteSpace(openApiSkillsAuthHeaders.GithubAuthentication)) - { - this._logger.LogInformation("Enabling GitHub skill."); - BearerAuthenticationProvider authenticationProvider = new(() => Task.FromResult(openApiSkillsAuthHeaders.GithubAuthentication)); - await planner.Kernel.ImportOpenApiSkillFromFileAsync( - skillName: "GitHubSkill", - filePath: Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "CopilotChat", "Skills", "OpenApiSkills/GitHubSkill/openapi.json"), - new OpenApiSkillExecutionParameters - { - AuthCallback = authenticationProvider.AuthenticateRequestAsync, - }); - } - - // Jira - if (!string.IsNullOrWhiteSpace(openApiSkillsAuthHeaders.JiraAuthentication)) - { - this._logger.LogInformation("Registering Jira Skill"); - var authenticationProvider = new BasicAuthenticationProvider(() => { return Task.FromResult(openApiSkillsAuthHeaders.JiraAuthentication); }); - var hasServerUrlOverride = variables.TryGetValue("jira-server-url", out string? serverUrlOverride); - - await planner.Kernel.ImportOpenApiSkillFromFileAsync( - skillName: "JiraSkill", - filePath: Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "CopilotChat", "Skills", "OpenApiSkills/JiraSkill/openapi.json"), - new OpenApiSkillExecutionParameters - { - AuthCallback = authenticationProvider.AuthenticateRequestAsync, - ServerUrlOverride = hasServerUrlOverride ? new Uri(serverUrlOverride!) : null, - }); - } - - // Microsoft Graph - if (!string.IsNullOrWhiteSpace(openApiSkillsAuthHeaders.GraphAuthentication)) - { - this._logger.LogInformation("Enabling Microsoft Graph skill(s)."); - BearerAuthenticationProvider authenticationProvider = new(() => Task.FromResult(openApiSkillsAuthHeaders.GraphAuthentication)); - GraphServiceClient graphServiceClient = this.CreateGraphServiceClient(authenticationProvider.AuthenticateRequestAsync); - - planner.Kernel.ImportSkill(new TaskListSkill(new MicrosoftToDoConnector(graphServiceClient)), "todo"); - planner.Kernel.ImportSkill(new CalendarSkill(new OutlookCalendarConnector(graphServiceClient)), "calendar"); - planner.Kernel.ImportSkill(new EmailSkill(new OutlookMailConnector(graphServiceClient)), "email"); - } - } - - /// - /// Create a Microsoft Graph service client. - /// - /// The delegate to authenticate the request. - private GraphServiceClient CreateGraphServiceClient(AuthenticateRequestAsyncDelegate authenticateRequestAsyncDelegate) - { - MsGraphClientLoggingHandler graphLoggingHandler = new(this._logger); - this._disposables.Add(graphLoggingHandler); - - IList graphMiddlewareHandlers = - GraphClientFactory.CreateDefaultHandlers(new DelegateAuthenticationProvider(authenticateRequestAsyncDelegate)); - graphMiddlewareHandlers.Add(graphLoggingHandler); - - HttpClient graphHttpClient = GraphClientFactory.Create(graphMiddlewareHandlers); - this._disposables.Add(graphHttpClient); - - GraphServiceClient graphServiceClient = new(graphHttpClient); - return graphServiceClient; - } - - /// - /// Dispose of the object. - /// - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - foreach (IDisposable disposable in this._disposables) - { - disposable.Dispose(); - } - } - } - - /// - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - this.Dispose(disposing: true); - GC.SuppressFinalize(this); - } -} diff --git a/webapi/CopilotChat/Controllers/ChatHistoryController.cs b/webapi/CopilotChat/Controllers/ChatHistoryController.cs deleted file mode 100644 index 8f911fec4..000000000 --- a/webapi/CopilotChat/Controllers/ChatHistoryController.cs +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel; -using SemanticKernel.Service.CopilotChat.Hubs; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Options; -using SemanticKernel.Service.CopilotChat.Storage; - -namespace SemanticKernel.Service.CopilotChat.Controllers; - -/// -/// Controller for chat history. -/// This controller is responsible for creating new chat sessions, retrieving chat sessions, -/// retrieving chat messages, and editing chat sessions. -/// -[ApiController] -[Authorize] -public class ChatHistoryController : ControllerBase -{ - private readonly ILogger _logger; - private readonly ChatSessionRepository _sessionRepository; - private readonly ChatMessageRepository _messageRepository; - private readonly ChatParticipantRepository _participantRepository; - private readonly ChatMemorySourceRepository _sourceRepository; - private readonly PromptsOptions _promptOptions; - private const string ChatEditedClientCall = "ChatEdited"; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The chat session repository. - /// The chat message repository. - /// The chat participant repository. - /// The chat memory resource repository. - /// The prompts options. - public ChatHistoryController( - ILogger logger, - ChatSessionRepository sessionRepository, - ChatMessageRepository messageRepository, - ChatParticipantRepository participantRepository, - ChatMemorySourceRepository sourceRepository, - IOptions promptsOptions) - { - this._logger = logger; - this._sessionRepository = sessionRepository; - this._messageRepository = messageRepository; - this._participantRepository = participantRepository; - this._sourceRepository = sourceRepository; - this._promptOptions = promptsOptions.Value; - } - - /// - /// Create a new chat session and populate the session with the initial bot message. - /// - /// Contains the title of the chat. - /// The HTTP action result. - [HttpPost] - [Route("chatSession/create")] - [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task CreateChatSessionAsync([FromBody] CreateChatParameters chatParameter) - { - if (chatParameter.UserId == null || chatParameter.Title == null) - { - return this.BadRequest("Chat session parameters cannot be null."); - } - - // Create a new chat session - var newChat = new ChatSession(chatParameter.Title); - await this._sessionRepository.CreateAsync(newChat); - - var initialBotMessage = this._promptOptions.InitialBotMessage; - // The initial bot message doesn't need a prompt. - var chatMessage = ChatMessage.CreateBotResponseMessage( - newChat.Id, - initialBotMessage, - string.Empty); - await this._messageRepository.CreateAsync(chatMessage); - - // Add the user to the chat session - await this._participantRepository.CreateAsync(new ChatParticipant(chatParameter.UserId, newChat.Id)); - - this._logger.LogDebug("Created chat session with id {0}.", newChat.Id); - return this.CreatedAtAction(nameof(this.GetChatSessionByIdAsync), new { chatId = newChat.Id }, newChat); - } - - /// - /// Get a chat session by id. - /// - /// The chat id. - [HttpGet] - [ActionName("GetChatSessionByIdAsync")] - [Route("chatSession/getChat/{chatId:guid}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetChatSessionByIdAsync(Guid chatId) - { - ChatSession? chat = null; - if (await this._sessionRepository.TryFindByIdAsync(chatId.ToString(), v => chat = v)) - { - return this.Ok(chat); - } - - return this.NotFound($"No chat session found for chat id '{chatId}'."); - } - - /// - /// Get all chat sessions associated with a user. Return an empty list if no chats are found. - /// The regex pattern that is used to match the user id will match the following format: - /// - 2 period separated groups of one or more hyphen-delimited alphanumeric strings. - /// The pattern matches two GUIDs in canonical textual representation separated by a period. - /// - /// The user id. - /// A list of chat sessions. An empty list if the user is not in any chat session. - [HttpGet] - [Route("chatSession/getAllChats/{userId:regex(([[a-z0-9]]+-)+[[a-z0-9]]+\\.([[a-z0-9]]+-)+[[a-z0-9]]+)}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetAllChatSessionsAsync(string userId) - { - // Get all participants that belong to the user. - // Then get all the chats from the list of participants. - var chatParticipants = await this._participantRepository.FindByUserIdAsync(userId); - - var chats = new List(); - foreach (var chatParticipant in chatParticipants) - { - ChatSession? chat = null; - if (await this._sessionRepository.TryFindByIdAsync(chatParticipant.ChatId, v => chat = v)) - { - chats.Add(chat!); - } - else - { - this._logger.LogDebug( - "Failed to find chat session with id {0} for participant {1}", chatParticipant.ChatId, chatParticipant.Id); - return this.NotFound( - $"Failed to find chat session with id {chatParticipant.ChatId} for participant {chatParticipant.Id}"); - } - } - - return this.Ok(chats); - } - - /// - /// Get all chat messages for a chat session. - /// The list will be ordered with the first entry being the most recent message. - /// - /// The chat id. - /// The start index at which the first message will be returned. - /// The number of messages to return. -1 will return all messages starting from startIdx. - /// [Authorize] - [HttpGet] - [Route("chatSession/getChatMessages/{chatId:guid}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetChatMessagesAsync( - Guid chatId, - [FromQuery] int startIdx = 0, - [FromQuery] int count = -1) - { - // TODO: the code mixes strings and Guid without being explicit about the serialization format - var chatMessages = await this._messageRepository.FindByChatIdAsync(chatId.ToString()); - if (!chatMessages.Any()) - { - return this.NotFound($"No messages found for chat id '{chatId}'."); - } - - chatMessages = chatMessages.OrderByDescending(m => m.Timestamp).Skip(startIdx); - if (count >= 0) { chatMessages = chatMessages.Take(count); } - - return this.Ok(chatMessages); - } - - /// - /// Edit a chat session. - /// - /// Object that contains the parameters to edit the chat. - [HttpPost] - [Route("chatSession/edit")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task EditChatSessionAsync( - [FromServices] IHubContext messageRelayHubContext, - [FromBody] ChatSession chatParameters) - { - string chatId = chatParameters.Id; - - ChatSession? chat = null; - if (await this._sessionRepository.TryFindByIdAsync(chatId, v => chat = v)) - { - chat!.Title = chatParameters.Title; - await this._sessionRepository.UpsertAsync(chat); - await messageRelayHubContext.Clients.Group(chatId).SendAsync(ChatEditedClientCall, chat); - return this.Ok(chat); - } - - return this.NotFound($"No chat session found for chat id '{chatId}'."); - } - - /// - /// Service API to get a list of imported sources. - /// - [Authorize] - [Route("chatSession/{chatId:guid}/sources")] - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task>> GetSourcesAsync( - [FromServices] IKernel kernel, - Guid chatId) - { - this._logger.LogInformation("Get imported sources of chat session {0}", chatId); - - if (await this._sessionRepository.TryFindByIdAsync(chatId.ToString(), v => _ = v)) - { - var sources = await this._sourceRepository.FindByChatIdAsync(chatId.ToString()); - return this.Ok(sources); - } - - return this.NotFound($"No chat session found for chat id '{chatId}'."); - } -} diff --git a/webapi/CopilotChat/Controllers/DocumentImportController.cs b/webapi/CopilotChat/Controllers/DocumentImportController.cs deleted file mode 100644 index d53020553..000000000 --- a/webapi/CopilotChat/Controllers/DocumentImportController.cs +++ /dev/null @@ -1,562 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Text; -using SemanticKernel.Service.CopilotChat.Hubs; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Options; -using SemanticKernel.Service.CopilotChat.Storage; -using SemanticKernel.Service.Services; -using Tesseract; -using UglyToad.PdfPig; -using UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor; -using static SemanticKernel.Service.CopilotChat.Models.MemorySource; - -namespace SemanticKernel.Service.CopilotChat.Controllers; - -/// -/// Controller for importing documents. -/// -[ApiController] -public class DocumentImportController : ControllerBase -{ - /// - /// Supported file types for import. - /// - private enum SupportedFileType - { - /// - /// .txt - /// - Txt, - - /// - /// .pdf - /// - Pdf, - - /// - /// .jpg - /// - Jpg, - - /// - /// .png - /// - Png, - - /// - /// .tif or .tiff - /// - Tiff - }; - - private readonly ILogger _logger; - private readonly DocumentMemoryOptions _options; - private readonly ChatSessionRepository _sessionRepository; - private readonly ChatMemorySourceRepository _sourceRepository; - private readonly ChatMessageRepository _messageRepository; - private readonly ChatParticipantRepository _participantRepository; - private const string GlobalDocumentUploadedClientCall = "GlobalDocumentUploaded"; - private const string ChatDocumentUploadedClientCall = "ChatDocumentUploaded"; - private readonly ITesseractEngine _tesseractEngine; - - /// - /// Initializes a new instance of the class. - /// - public DocumentImportController( - ILogger logger, - IOptions documentMemoryOptions, - ChatSessionRepository sessionRepository, - ChatMemorySourceRepository sourceRepository, - ChatMessageRepository messageRepository, - ChatParticipantRepository participantRepository, - ITesseractEngine tesseractEngine) - { - this._logger = logger; - this._options = documentMemoryOptions.Value; - this._sessionRepository = sessionRepository; - this._sourceRepository = sourceRepository; - this._messageRepository = messageRepository; - this._participantRepository = participantRepository; - this._tesseractEngine = tesseractEngine; - } - - /// - /// Service API for importing a document. - /// - [Authorize] - [Route("importDocuments")] - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task ImportDocumentsAsync( - [FromServices] IKernel kernel, - [FromServices] IHubContext messageRelayHubContext, - [FromForm] DocumentImportForm documentImportForm) - { - try - { - await this.ValidateDocumentImportFormAsync(documentImportForm); - } - catch (ArgumentException ex) - { - return this.BadRequest(ex.Message); - } - - this._logger.LogInformation("Importing {0} document(s)...", documentImportForm.FormFiles.Count()); - - // TODO: Perform the import in parallel. - DocumentMessageContent documentMessageContent = new(); - IEnumerable importResults = new List(); - foreach (var formFile in documentImportForm.FormFiles) - { - var importResult = await this.ImportDocumentHelperAsync(kernel, formFile, documentImportForm); - documentMessageContent.AddDocument( - formFile.FileName, - this.GetReadableByteString(formFile.Length), - importResult.IsSuccessful); - importResults = importResults.Append(importResult); - } - - // Broadcast the document uploaded event to other users. - if (documentImportForm.DocumentScope == DocumentImportForm.DocumentScopes.Chat) - { - var chatMessage = await this.TryCreateDocumentUploadMessage( - documentMessageContent, - documentImportForm); - if (chatMessage == null) - { - foreach (var importResult in importResults) - { - await this.RemoveMemoriesAsync(kernel, importResult); - } - return this.BadRequest("Failed to create chat message. All documents are removed."); - } - - var chatId = documentImportForm.ChatId.ToString(); - await messageRelayHubContext.Clients.Group(chatId) - .SendAsync(ChatDocumentUploadedClientCall, chatMessage, chatId); - - return this.Ok(chatMessage); - } - - await messageRelayHubContext.Clients.All.SendAsync( - GlobalDocumentUploadedClientCall, - documentMessageContent.ToFormattedStringNamesOnly(), - documentImportForm.UserName - ); - - return this.Ok("Documents imported successfully to global scope."); - } - - #region Private - - /// - /// A class to store a document import results. - /// - private sealed class ImportResult - { - /// - /// A boolean indicating whether the import is successful. - /// - public bool IsSuccessful => this.Keys.Any(); - - /// - /// The name of the collection that the document is inserted to. - /// - public string CollectionName { get; set; } - - /// - /// The keys of the inserted document chunks. - /// - public IEnumerable Keys { get; set; } = new List(); - - /// - /// Create a new instance of the class. - /// - /// The name of the collection that the document is inserted to. - public ImportResult(string collectionName) - { - this.CollectionName = collectionName; - } - - /// - /// Create a new instance of the class representing a failed import. - /// - public static ImportResult Fail() => new(string.Empty); - - /// - /// Add a key to the list of keys. - /// - /// The key to be added. - public void AddKey(string key) - { - this.Keys = this.Keys.Append(key); - } - } - - /// - /// Validates the document import form. - /// - /// The document import form. - /// - /// Throws ArgumentException if validation fails. - private async Task ValidateDocumentImportFormAsync(DocumentImportForm documentImportForm) - { - // Make sure the user has access to the chat session if the document is uploaded to a chat session. - if (documentImportForm.DocumentScope == DocumentImportForm.DocumentScopes.Chat - && !(await this.UserHasAccessToChatAsync(documentImportForm.UserId, documentImportForm.ChatId))) - { - throw new ArgumentException("User does not have access to the chat session."); - } - - var formFiles = documentImportForm.FormFiles; - - if (!formFiles.Any()) - { - throw new ArgumentException("No files were uploaded."); - } - else if (formFiles.Count() > this._options.FileCountLimit) - { - throw new ArgumentException($"Too many files uploaded. Max file count is {this._options.FileCountLimit}."); - } - - // Loop through the uploaded files and validate them before importing. - foreach (var formFile in formFiles) - { - if (formFile.Length == 0) - { - throw new ArgumentException($"File {formFile.FileName} is empty."); - } - - if (formFile.Length > this._options.FileSizeLimit) - { - throw new ArgumentException($"File {formFile.FileName} size exceeds the limit."); - } - - // Make sure the file type is supported. - var fileType = this.GetFileType(Path.GetFileName(formFile.FileName)); - switch (fileType) - { - case SupportedFileType.Txt: - case SupportedFileType.Pdf: - break; - default: - throw new ArgumentException($"Unsupported file type: {fileType}"); - } - } - } - - /// - /// Import a single document. - /// - /// The kernel. - /// The form file. - /// The document import form. - /// Import result. - private async Task ImportDocumentHelperAsync(IKernel kernel, IFormFile formFile, DocumentImportForm documentImportForm) - { - var fileType = this.GetFileType(Path.GetFileName(formFile.FileName)); - var documentContent = string.Empty; - switch (fileType) - { - case SupportedFileType.Txt: - documentContent = await this.ReadTxtFileAsync(formFile); - break; - case SupportedFileType.Pdf: - documentContent = this.ReadPdfFile(formFile); - break; - case SupportedFileType.Jpg: - case SupportedFileType.Png: - case SupportedFileType.Tiff: - { - documentContent = await this.ReadTextFromImageFileAsync(formFile); - break; - } - - default: - // This should never happen. Validation should have already caught this. - return ImportResult.Fail(); - } - - this._logger.LogInformation("Importing document {0}", formFile.FileName); - - // Create memory source - var memorySource = await this.TryCreateAndUpsertMemorySourceAsync(formFile, documentImportForm); - if (memorySource == null) - { - return ImportResult.Fail(); - } - - // Parse document content to memory - ImportResult importResult = ImportResult.Fail(); - try - { - importResult = await this.ParseDocumentContentToMemoryAsync( - kernel, - formFile.FileName, - documentContent, - documentImportForm, - memorySource.Id - ); - } - catch (Exception ex) when (!ex.IsCriticalException()) - { - await this._sourceRepository.DeleteAsync(memorySource); - await this.RemoveMemoriesAsync(kernel, importResult); - return ImportResult.Fail(); - } - - return importResult; - } - - /// - /// Try to create and upsert a memory source. - /// - /// The file to be uploaded - /// The document upload form that contains additional necessary info - /// A MemorySource object if successful, null otherwise - private async Task TryCreateAndUpsertMemorySourceAsync( - IFormFile formFile, - DocumentImportForm documentImportForm) - { - var chatId = documentImportForm.ChatId.ToString(); - var userId = documentImportForm.UserId; - var memorySource = new MemorySource( - chatId, - formFile.FileName, - userId, - MemorySourceType.File, - formFile.Length, - null); - - try - { - await this._sourceRepository.UpsertAsync(memorySource); - return memorySource; - } - catch (Exception ex) when (ex is ArgumentOutOfRangeException) - { - return null; - } - } - - /// - /// Try to create a chat message that represents document upload. - /// - /// The chat id - /// The user id - /// The document message content - /// The document upload form that contains additional necessary info - /// A ChatMessage object if successful, null otherwise - private async Task TryCreateDocumentUploadMessage( - DocumentMessageContent documentMessageContent, - DocumentImportForm documentImportForm) - { - var chatId = documentImportForm.ChatId.ToString(); - var userId = documentImportForm.UserId; - var userName = documentImportForm.UserName; - - var chatMessage = ChatMessage.CreateDocumentMessage( - userId, - userName, - chatId, - documentMessageContent - ); - - try - { - await this._messageRepository.CreateAsync(chatMessage); - return chatMessage; - } - catch (Exception ex) when (ex is ArgumentOutOfRangeException) - { - return null; - } - } - - /// - /// Converts a `long` byte count to a human-readable string. - /// - /// Byte count - /// Human-readable string of bytes - private string GetReadableByteString(long bytes) - { - string[] sizes = { "B", "KB", "MB", "GB", "TB" }; - int i; - double dblsBytes = bytes; - for (i = 0; i < sizes.Length && bytes >= 1024; i++, bytes /= 1024) - { - dblsBytes = bytes / 1024.0; - } - - return string.Format(CultureInfo.InvariantCulture, "{0:0.#}{1}", dblsBytes, sizes[i]); - } - - /// - /// Get the file type from the file extension. - /// - /// Name of the file. - /// A SupportedFileType. - /// - private SupportedFileType GetFileType(string fileName) - { - string extension = Path.GetExtension(fileName); - return extension switch - { - ".txt" => SupportedFileType.Txt, - ".pdf" => SupportedFileType.Pdf, - ".jpg" => SupportedFileType.Jpg, - ".jpeg" => SupportedFileType.Jpg, - ".png" => SupportedFileType.Png, - ".tif" => SupportedFileType.Tiff, - ".tiff" => SupportedFileType.Tiff, - _ => throw new ArgumentOutOfRangeException($"Unsupported file type: {extension}"), - }; - } - - /// - /// Reads the text content from an image file. - /// - /// An IFormFile object. - /// A string of the content of the file. - private async Task ReadTextFromImageFileAsync(IFormFile file) - { - await using (var ms = new MemoryStream()) - { - await file.CopyToAsync(ms); - var fileBytes = ms.ToArray(); - await using var imgStream = new MemoryStream(fileBytes); - - using var img = Pix.LoadFromMemory(imgStream.ToArray()); - - using var page = this._tesseractEngine.Process(img); - return page.GetText(); - } - } - - /// - /// Read the content of a text file. - /// - /// An IFormFile object. - /// A string of the content of the file. - private async Task ReadTxtFileAsync(IFormFile file) - { - using var streamReader = new StreamReader(file.OpenReadStream()); - return await streamReader.ReadToEndAsync(); - } - - /// - /// Read the content of a PDF file, ignoring images. - /// - /// An IFormFile object. - /// A string of the content of the file. - private string ReadPdfFile(IFormFile file) - { - var fileContent = string.Empty; - - using var pdfDocument = PdfDocument.Open(file.OpenReadStream()); - foreach (var page in pdfDocument.GetPages()) - { - var text = ContentOrderTextExtractor.GetText(page); - fileContent += text; - } - - return fileContent; - } - - /// - /// Parse the content of the document to memory. - /// - /// The kernel instance from the service - /// The name of the uploaded document - /// The file content read from the uploaded document - /// The document upload form that contains additional necessary info - /// The ID of the MemorySource that the document content is linked to - private async Task ParseDocumentContentToMemoryAsync( - IKernel kernel, - string documentName, - string content, - DocumentImportForm documentImportForm, - string memorySourceId) - { - var targetCollectionName = documentImportForm.DocumentScope == DocumentImportForm.DocumentScopes.Global - ? this._options.GlobalDocumentCollectionName - : this._options.ChatDocumentCollectionNamePrefix + documentImportForm.ChatId; - var importResult = new ImportResult(targetCollectionName); - - // Split the document into lines of text and then combine them into paragraphs. - // Note that this is only one of many strategies to chunk documents. Feel free to experiment with other strategies. - var lines = TextChunker.SplitPlainTextLines(content, this._options.DocumentLineSplitMaxTokens); - var paragraphs = TextChunker.SplitPlainTextParagraphs(lines, this._options.DocumentParagraphSplitMaxLines); - - // TODO: Perform the save in parallel. - for (var i = 0; i < paragraphs.Count; i++) - { - var paragraph = paragraphs[i]; - var key = $"{memorySourceId}-{i}"; - await kernel.Memory.SaveInformationAsync( - collection: targetCollectionName, - text: paragraph, - id: key, - description: $"Document: {documentName}"); - importResult.AddKey(key); - } - - this._logger.LogInformation( - "Parsed {0} paragraphs from local file {1}", - paragraphs.Count, - documentName - ); - - return importResult; - } - - /// - /// Check if the user has access to the chat session. - /// - /// The user ID. - /// The chat session ID. - /// A boolean indicating whether the user has access to the chat session. - private async Task UserHasAccessToChatAsync(string userId, Guid chatId) - { - return await this._participantRepository.IsUserInChatAsync(userId, chatId.ToString()); - } - - /// - /// Remove the memories that were created during the import process if subsequent steps fail. - /// - /// The kernel instance from the service - /// The import result that contains the keys of the memories to be removed - /// - private async Task RemoveMemoriesAsync(IKernel kernel, ImportResult importResult) - { - foreach (var key in importResult.Keys) - { - try - { - await kernel.Memory.RemoveAsync(importResult.CollectionName, key); - } - catch (Exception ex) when (!ex.IsCriticalException()) - { - this._logger.LogError(ex, "Failed to remove memory {0} from collection {1}. Skipped.", key, importResult.CollectionName); - continue; - } - } - } - - #endregion -} diff --git a/webapi/CopilotChat/Extensions/SemanticKernelExtensions.cs b/webapi/CopilotChat/Extensions/SemanticKernelExtensions.cs deleted file mode 100644 index ac9a47a9c..000000000 --- a/webapi/CopilotChat/Extensions/SemanticKernelExtensions.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel; -using SemanticKernel.Service.CopilotChat.Options; -using SemanticKernel.Service.CopilotChat.Skills.ChatSkills; -using SemanticKernel.Service.CopilotChat.Storage; -using SemanticKernel.Service.Options; - -namespace SemanticKernel.Service.CopilotChat.Extensions; - -/// -/// Extension methods for registering Copilot Chat components to Semantic Kernel. -/// -public static class CopilotChatSemanticKernelExtensions -{ - /// - /// Add Planner services - /// - public static IServiceCollection AddCopilotChatPlannerServices(this IServiceCollection services) - { - IOptions? plannerOptions = services.BuildServiceProvider().GetService>(); - services.AddScoped(sp => - { - IKernel plannerKernel = Kernel.Builder - .WithLogger(sp.GetRequiredService>()) - // TODO verify planner has AI service configured - .WithPlannerBackend(sp.GetRequiredService>().Value) - .Build(); - return new CopilotChatPlanner(plannerKernel, plannerOptions?.Value); - }); - - // Register Planner skills (AI plugins) here. - // TODO: Move planner skill registration from ChatController to here. - - return services; - } - - /// - /// Register the Copilot chat skills with the kernel. - /// - public static IKernel RegisterCopilotChatSkills(this IKernel kernel, IServiceProvider sp) - { - // Chat skill - kernel.ImportSkill(new ChatSkill( - kernel: kernel, - chatMessageRepository: sp.GetRequiredService(), - chatSessionRepository: sp.GetRequiredService(), - promptOptions: sp.GetRequiredService>(), - documentImportOptions: sp.GetRequiredService>(), - planner: sp.GetRequiredService(), - logger: sp.GetRequiredService>()), - nameof(ChatSkill)); - - return kernel; - } - - /// - /// Add the completion backend to the kernel config for the planner. - /// - private static KernelBuilder WithPlannerBackend(this KernelBuilder kernelBuilder, AIServiceOptions options) - { - return options.Type switch - { - AIServiceOptions.AIServiceType.AzureOpenAI => kernelBuilder.WithAzureChatCompletionService(options.Models.Planner, options.Endpoint, options.Key), - AIServiceOptions.AIServiceType.OpenAI => kernelBuilder.WithOpenAIChatCompletionService(options.Models.Planner, options.Key), - _ => throw new ArgumentException($"Invalid {nameof(options.Type)} value in '{AIServiceOptions.PropertyName}' settings."), - }; - } -} diff --git a/webapi/CopilotChat/Extensions/ServiceExtensions.cs b/webapi/CopilotChat/Extensions/ServiceExtensions.cs deleted file mode 100644 index eff7b3854..000000000 --- a/webapi/CopilotChat/Extensions/ServiceExtensions.cs +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Reflection; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Options; -using SemanticKernel.Service.CopilotChat.Storage; -using SemanticKernel.Service.Options; -using SemanticKernel.Service.Services; -using Tesseract; - -namespace SemanticKernel.Service.CopilotChat.Extensions; - -/// -/// Extension methods for . -/// Add options and services for Copilot Chat. -/// -public static class CopilotChatServiceExtensions -{ - /// - /// Parse configuration into options. - /// - public static IServiceCollection AddCopilotChatOptions(this IServiceCollection services, ConfigurationManager configuration) - { - // AI service configurations for Copilot Chat. - // They are using the same configuration section as Semantic Kernel. - services.AddOptions(AIServiceOptions.PropertyName) - .Bind(configuration.GetSection(AIServiceOptions.PropertyName)) - .ValidateOnStart() - .PostConfigure(TrimStringProperties); - - // Chat log storage configuration - services.AddOptions() - .Bind(configuration.GetSection(ChatStoreOptions.PropertyName)) - .ValidateOnStart() - .PostConfigure(TrimStringProperties); - - // Azure speech token configuration - services.AddOptions() - .Bind(configuration.GetSection(AzureSpeechOptions.PropertyName)) - .ValidateOnStart() - .PostConfigure(TrimStringProperties); - - // Bot schema configuration - services.AddOptions() - .Bind(configuration.GetSection(BotSchemaOptions.PropertyName)) - .ValidateOnStart() - .PostConfigure(TrimStringProperties); - - // Document memory options - services.AddOptions() - .Bind(configuration.GetSection(DocumentMemoryOptions.PropertyName)) - .ValidateOnStart() - .PostConfigure(TrimStringProperties); - - // Chat prompt options - services.AddOptions() - .Bind(configuration.GetSection(PromptsOptions.PropertyName)) - .ValidateOnStart() - .PostConfigure(TrimStringProperties); - - // Planner options - services.AddOptions() - .Bind(configuration.GetSection(PlannerOptions.PropertyName)) - .ValidateOnStart() - .PostConfigure(TrimStringProperties); - - // OCR support options - services.AddOptions() - .Bind(configuration.GetSection(OcrSupportOptions.PropertyName)) - .ValidateOnStart() - .PostConfigure(TrimStringProperties); - - return services; - } - - /// - /// Adds persistent OCR support service. - /// - /// - public static IServiceCollection AddPersistentOcrSupport(this IServiceCollection services) - { - OcrSupportOptions ocrSupportConfig = services.BuildServiceProvider().GetRequiredService>().Value; - - switch (ocrSupportConfig.Type) - { - case OcrSupportOptions.OcrSupportType.Tesseract: - { - services.AddSingleton(sp => new TesseractEngineWrapper(new TesseractEngine(ocrSupportConfig.Tesseract!.FilePath, ocrSupportConfig.Tesseract!.Language, EngineMode.Default))); - break; - } - - case OcrSupportOptions.OcrSupportType.None: - { - services.AddSingleton(sp => new NullTesseractEngine()); - break; - } - - default: - { - throw new InvalidOperationException($"Unsupported OcrSupport:Type '{ocrSupportConfig.Type}'"); - } - } - - return services; - } - - /// - /// Add persistent chat store services. - /// - public static IServiceCollection AddPersistentChatStore(this IServiceCollection services) - { - IStorageContext chatSessionStorageContext; - IStorageContext chatMessageStorageContext; - IStorageContext chatMemorySourceStorageContext; - IStorageContext chatParticipantStorageContext; - - ChatStoreOptions chatStoreConfig = services.BuildServiceProvider().GetRequiredService>().Value; - - switch (chatStoreConfig.Type) - { - case ChatStoreOptions.ChatStoreType.Volatile: - { - chatSessionStorageContext = new VolatileContext(); - chatMessageStorageContext = new VolatileContext(); - chatMemorySourceStorageContext = new VolatileContext(); - chatParticipantStorageContext = new VolatileContext(); - break; - } - - case ChatStoreOptions.ChatStoreType.Filesystem: - { - if (chatStoreConfig.Filesystem == null) - { - throw new InvalidOperationException("ChatStore:Filesystem is required when ChatStore:Type is 'Filesystem'"); - } - - string fullPath = Path.GetFullPath(chatStoreConfig.Filesystem.FilePath); - string directory = Path.GetDirectoryName(fullPath) ?? string.Empty; - chatSessionStorageContext = new FileSystemContext( - new FileInfo(Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(fullPath)}_sessions{Path.GetExtension(fullPath)}"))); - chatMessageStorageContext = new FileSystemContext( - new FileInfo(Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(fullPath)}_messages{Path.GetExtension(fullPath)}"))); - chatMemorySourceStorageContext = new FileSystemContext( - new FileInfo(Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(fullPath)}_memorysources{Path.GetExtension(fullPath)}"))); - chatParticipantStorageContext = new FileSystemContext( - new FileInfo(Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(fullPath)}_participants{Path.GetExtension(fullPath)}"))); - break; - } - - case ChatStoreOptions.ChatStoreType.Cosmos: - { - if (chatStoreConfig.Cosmos == null) - { - throw new InvalidOperationException("ChatStore:Cosmos is required when ChatStore:Type is 'Cosmos'"); - } -#pragma warning disable CA2000 // Dispose objects before losing scope - objects are singletons for the duration of the process and disposed when the process exits. - chatSessionStorageContext = new CosmosDbContext( - chatStoreConfig.Cosmos.ConnectionString, chatStoreConfig.Cosmos.Database, chatStoreConfig.Cosmos.ChatSessionsContainer); - chatMessageStorageContext = new CosmosDbContext( - chatStoreConfig.Cosmos.ConnectionString, chatStoreConfig.Cosmos.Database, chatStoreConfig.Cosmos.ChatMessagesContainer); - chatMemorySourceStorageContext = new CosmosDbContext( - chatStoreConfig.Cosmos.ConnectionString, chatStoreConfig.Cosmos.Database, chatStoreConfig.Cosmos.ChatMemorySourcesContainer); - chatParticipantStorageContext = new CosmosDbContext( - chatStoreConfig.Cosmos.ConnectionString, chatStoreConfig.Cosmos.Database, chatStoreConfig.Cosmos.ChatParticipantsContainer); -#pragma warning restore CA2000 // Dispose objects before losing scope - break; - } - - default: - { - throw new InvalidOperationException( - "Invalid 'ChatStore' setting 'chatStoreConfig.Type'."); - } - } - - services.AddSingleton(new ChatSessionRepository(chatSessionStorageContext)); - services.AddSingleton(new ChatMessageRepository(chatMessageStorageContext)); - services.AddSingleton(new ChatMemorySourceRepository(chatMemorySourceStorageContext)); - services.AddSingleton(new ChatParticipantRepository(chatParticipantStorageContext)); - - return services; - } - - /// - /// Trim all string properties, recursively. - /// - private static void TrimStringProperties(T options) where T : class - { - Queue targets = new(); - targets.Enqueue(options); - - while (targets.Count > 0) - { - object target = targets.Dequeue(); - Type targetType = target.GetType(); - foreach (PropertyInfo property in targetType.GetProperties()) - { - // Skip enumerations - if (property.PropertyType.IsEnum) - { - continue; - } - - // Property is a built-in type, readable, and writable. - if (property.PropertyType.Namespace == "System" && - property.CanRead && - property.CanWrite) - { - // Property is a non-null string. - if (property.PropertyType == typeof(string) && - property.GetValue(target) != null) - { - property.SetValue(target, property.GetValue(target)!.ToString()!.Trim()); - } - } - else - { - // Property is a non-built-in and non-enum type - queue it for processing. - if (property.GetValue(target) != null) - { - targets.Enqueue(property.GetValue(target)!); - } - } - } - } - } -} diff --git a/webapi/CopilotChat/Models/Bot.cs b/webapi/CopilotChat/Models/Bot.cs deleted file mode 100644 index 302ec6cfa..000000000 --- a/webapi/CopilotChat/Models/Bot.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.SemanticKernel.Memory; -using SemanticKernel.Service.CopilotChat.Options; - -namespace SemanticKernel.Service.CopilotChat.Models; - -/// -/// The data model of a bot for portability. -/// -public class Bot -{ - /// - /// The schema information of the bot data model. - /// - public BotSchemaOptions Schema { get; set; } = new BotSchemaOptions(); - - /// - /// The embedding configurations. - /// - public BotEmbeddingConfig EmbeddingConfigurations { get; set; } = new BotEmbeddingConfig(); - - /// - /// The title of the chat with the bot. - /// - public string ChatTitle { get; set; } = string.Empty; - - /// - /// The chat history. It contains all the messages in the conversation with the bot. - /// - public List ChatHistory { get; set; } = new List(); - - // TODO: Change from MemoryQueryResult to MemoryRecord - /// - /// The embeddings of the bot. - /// - public List>> Embeddings { get; set; } = new List>>(); - - // TODO: Change from MemoryQueryResult to MemoryRecord - /// - /// The embeddings of uploaded documents in Copilot Chat. It represents the document memory which is accessible to all chat sessions of a given user. - /// - public List>> DocumentEmbeddings { get; set; } = new List>>(); -} diff --git a/webapi/CopilotChat/Models/BotEmbeddingConfig.cs b/webapi/CopilotChat/Models/BotEmbeddingConfig.cs deleted file mode 100644 index e8e4768a1..000000000 --- a/webapi/CopilotChat/Models/BotEmbeddingConfig.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; -using SemanticKernel.Service.Options; - -namespace SemanticKernel.Service.CopilotChat.Models; - -/// -/// The embedding configuration of a bot. Used in the Bot object for portability. -/// -public class BotEmbeddingConfig -{ - /// - /// The AI service. - /// - [Required] - [JsonConverter(typeof(JsonStringEnumConverter))] - public AIServiceOptions.AIServiceType AIService { get; set; } = AIServiceOptions.AIServiceType.AzureOpenAI; - - /// - /// The deployment or the model id. - /// - public string DeploymentOrModelId { get; set; } = string.Empty; -} diff --git a/webapi/CopilotChat/Models/ChatSession.cs b/webapi/CopilotChat/Models/ChatSession.cs deleted file mode 100644 index 9cb58ca86..000000000 --- a/webapi/CopilotChat/Models/ChatSession.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Text.Json.Serialization; -using SemanticKernel.Service.CopilotChat.Storage; - -namespace SemanticKernel.Service.CopilotChat.Models; - -/// -/// A chat session -/// -public class ChatSession : IStorageEntity -{ - /// - /// Chat ID that is persistent and unique. - /// - [JsonPropertyName("id")] - public string Id { get; set; } - - /// - /// Title of the chat. - /// - [JsonPropertyName("title")] - public string Title { get; set; } - - /// - /// Timestamp of the chat creation. - /// - [JsonPropertyName("createdOn")] - public DateTimeOffset CreatedOn { get; set; } - - public ChatSession(string title) - { - this.Id = Guid.NewGuid().ToString(); - this.Title = title; - this.CreatedOn = DateTimeOffset.Now; - } -} diff --git a/webapi/CopilotChat/Models/OpenApiSkillsAuthHeaders.cs b/webapi/CopilotChat/Models/OpenApiSkillsAuthHeaders.cs deleted file mode 100644 index bac14f65a..000000000 --- a/webapi/CopilotChat/Models/OpenApiSkillsAuthHeaders.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.AspNetCore.Mvc; - -namespace SemanticKernel.Service.CopilotChat.Models; - -/// /// -/// Represents the authentication headers for imported OpenAPI Plugin Skills. -/// -public class OpenApiSkillsAuthHeaders -{ - /// - /// Gets or sets the MS Graph authentication header value. - /// - [FromHeader(Name = "x-sk-copilot-graph-auth")] - public string? GraphAuthentication { get; set; } - - /// - /// Gets or sets the Jira authentication header value. - /// - [FromHeader(Name = "x-sk-copilot-jira-auth")] - public string? JiraAuthentication { get; set; } - - /// - /// Gets or sets the GitHub authentication header value. - /// - [FromHeader(Name = "x-sk-copilot-github-auth")] - public string? GithubAuthentication { get; set; } - - /// - /// Gets or sets the Klarna header value. - /// - [FromHeader(Name = "x-sk-copilot-klarna-auth")] - public string? KlarnaAuthentication { get; set; } -} diff --git a/webapi/CopilotChat/Models/ProposedPlan.cs b/webapi/CopilotChat/Models/ProposedPlan.cs deleted file mode 100644 index f84990b88..000000000 --- a/webapi/CopilotChat/Models/ProposedPlan.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; -using Microsoft.SemanticKernel.Planning; - -namespace SemanticKernel.Service.CopilotChat.Models; - -// Type of Plan -public enum PlanType -{ - Action, // single-step - Sequential, // multi-step -} - -// State of Plan -public enum PlanState -{ - NoOp, // Plan has not received any user input - Approved, - Rejected, -} - -/// -/// Information about a single proposed plan. -/// -public class ProposedPlan -{ - /// - /// Plan object to be approved or invoked. - /// - [JsonPropertyName("proposedPlan")] - public Plan Plan { get; set; } - - /// - /// Indicates whether plan is Action (single-step) or Sequential (multi-step). - /// - [JsonPropertyName("type")] - public PlanType Type { get; set; } - - /// - /// State of plan - /// - [JsonPropertyName("state")] - public PlanState State { get; set; } - - /// - /// Create a new proposed plan. - /// - /// Proposed plan object - public ProposedPlan(Plan plan, PlanType type, PlanState state) - { - this.Plan = plan; - this.Type = type; - this.State = state; - } -} diff --git a/webapi/CopilotChat/Options/BotSchemaOptions.cs b/webapi/CopilotChat/Options/BotSchemaOptions.cs deleted file mode 100644 index 193b34935..000000000 --- a/webapi/CopilotChat/Options/BotSchemaOptions.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel.DataAnnotations; -using SemanticKernel.Service.Options; - -namespace SemanticKernel.Service.CopilotChat.Options; - -/// -/// Configuration options for the bot file schema that is supported by this application. -/// -public class BotSchemaOptions -{ - public const string PropertyName = "BotSchema"; - - /// - /// The name of the schema. - /// - [Required, NotEmptyOrWhitespace] - public string Name { get; set; } = string.Empty; - - /// - /// The version of the schema. - /// - [Range(0, int.MaxValue)] - public int Version { get; set; } -} diff --git a/webapi/CopilotChat/Options/OcrSupportOptions.cs b/webapi/CopilotChat/Options/OcrSupportOptions.cs deleted file mode 100644 index a1744b47b..000000000 --- a/webapi/CopilotChat/Options/OcrSupportOptions.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using SemanticKernel.Service.Options; - -namespace SemanticKernel.Service.CopilotChat.Options; - -/// -/// Ocr Support Configuration Options -/// -public class OcrSupportOptions -{ - public const string PropertyName = "OcrSupport"; - - public enum OcrSupportType - { - /// - /// No OCR Support - /// - None, - - /// - /// Tesseract OCR Support - /// - Tesseract - } - - /// - /// Gets or sets the type of OCR support to use. - /// - public OcrSupportType Type { get; set; } = OcrSupportType.None; - - /// - /// Gets or sets the configuration for the Tesseract OCR support. - /// - [RequiredOnPropertyValue(nameof(Type), OcrSupportType.Tesseract)] - public TesseractOptions? Tesseract { get; set; } -} diff --git a/webapi/CopilotChat/Options/PlannerOptions.cs b/webapi/CopilotChat/Options/PlannerOptions.cs deleted file mode 100644 index c2827035a..000000000 --- a/webapi/CopilotChat/Options/PlannerOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. -using System.ComponentModel.DataAnnotations; -using SemanticKernel.Service.CopilotChat.Models; - -namespace SemanticKernel.Service.CopilotChat.Options; - -/// -/// Configuration options for the planner. -/// -public class PlannerOptions -{ - public const string PropertyName = "Planner"; - - /// - /// Define if the planner must be Sequential or not. - /// - [Required] - public PlanType Type { get; set; } = PlanType.Action; -} diff --git a/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs b/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs deleted file mode 100644 index 2cf2fd0c1..000000000 --- a/webapi/CopilotChat/Skills/ChatSkills/ChatSkill.cs +++ /dev/null @@ -1,632 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Globalization; -using System.Linq; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.AI.TextCompletion; -using Microsoft.SemanticKernel.Orchestration; -using Microsoft.SemanticKernel.SkillDefinition; -using Microsoft.SemanticKernel.TemplateEngine; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Options; -using SemanticKernel.Service.CopilotChat.Storage; - -namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills; - -/// -/// ChatSkill offers a more coherent chat experience by using memories -/// to extract conversation history and user intentions. -/// -public class ChatSkill -{ - /// - /// A kernel instance to create a completion function since each invocation - /// of the function will generate a new prompt dynamically. - /// - private readonly IKernel _kernel; - - /// - /// A repository to save and retrieve chat messages. - /// - private readonly ChatMessageRepository _chatMessageRepository; - - /// - /// A repository to save and retrieve chat sessions. - /// - private readonly ChatSessionRepository _chatSessionRepository; - - /// - /// Settings containing prompt texts. - /// - private readonly PromptsOptions _promptOptions; - - /// - /// A semantic chat memory skill instance to query semantic memories. - /// - private readonly SemanticChatMemorySkill _semanticChatMemorySkill; - - /// - /// A document memory skill instance to query document memories. - /// - private readonly DocumentMemorySkill _documentMemorySkill; - - /// - /// A skill instance to acquire external information. - /// - private readonly ExternalInformationSkill _externalInformationSkill; - - /// - /// Create a new instance of . - /// - public ChatSkill( - IKernel kernel, - ChatMessageRepository chatMessageRepository, - ChatSessionRepository chatSessionRepository, - IOptions promptOptions, - IOptions documentImportOptions, - CopilotChatPlanner planner, - ILogger logger) - { - this._kernel = kernel; - this._chatMessageRepository = chatMessageRepository; - this._chatSessionRepository = chatSessionRepository; - this._promptOptions = promptOptions.Value; - - this._semanticChatMemorySkill = new SemanticChatMemorySkill( - promptOptions); - this._documentMemorySkill = new DocumentMemorySkill( - promptOptions, - documentImportOptions); - this._externalInformationSkill = new ExternalInformationSkill( - promptOptions, - planner); - } - - /// - /// Extract user intent from the conversation history. - /// - /// The SKContext. - [SKFunction, Description("Extract user intent")] - [SKParameter("chatId", "Chat ID to extract history from")] - [SKParameter("audience", "The audience the chat bot is interacting with.")] - public async Task ExtractUserIntentAsync(SKContext context) - { - var tokenLimit = this._promptOptions.CompletionTokenLimit; - var historyTokenBudget = - tokenLimit - - this._promptOptions.ResponseTokenLimit - - Utilities.TokenCount(string.Join("\n", new string[] - { - this._promptOptions.SystemDescription, - this._promptOptions.SystemIntent, - this._promptOptions.SystemIntentContinuation - }) - ); - - // Clone the context to avoid modifying the original context variables. - var intentExtractionContext = Utilities.CopyContextWithVariablesClone(context); - intentExtractionContext.Variables.Set("tokenLimit", historyTokenBudget.ToString(new NumberFormatInfo())); - intentExtractionContext.Variables.Set("knowledgeCutoff", this._promptOptions.KnowledgeCutoffDate); - - var completionFunction = this._kernel.CreateSemanticFunction( - this._promptOptions.SystemIntentExtraction, - skillName: nameof(ChatSkill), - description: "Complete the prompt."); - - var result = await completionFunction.InvokeAsync( - intentExtractionContext, - settings: this.CreateIntentCompletionSettings() - ); - - if (result.ErrorOccurred) - { - context.Log.LogError("{0}: {1}", result.LastErrorDescription, result.LastException); - context.Fail(result.LastErrorDescription); - return string.Empty; - } - - return $"User intent: {result}"; - } - - /// - /// Extract the list of participants from the conversation history. - /// Note that only those who have spoken will be included. - /// - [SKFunction, Description("Extract audience list")] - [SKParameter("chatId", "Chat ID to extract history from")] - public async Task ExtractAudienceAsync(SKContext context) - { - var tokenLimit = this._promptOptions.CompletionTokenLimit; - var historyTokenBudget = - tokenLimit - - this._promptOptions.ResponseTokenLimit - - Utilities.TokenCount(string.Join("\n", new string[] - { - this._promptOptions.SystemAudience, - this._promptOptions.SystemAudienceContinuation, - }) - ); - - // Clone the context to avoid modifying the original context variables. - var audienceExtractionContext = Utilities.CopyContextWithVariablesClone(context); - audienceExtractionContext.Variables.Set("tokenLimit", historyTokenBudget.ToString(new NumberFormatInfo())); - - var completionFunction = this._kernel.CreateSemanticFunction( - this._promptOptions.SystemAudienceExtraction, - skillName: nameof(ChatSkill), - description: "Complete the prompt."); - - var result = await completionFunction.InvokeAsync( - audienceExtractionContext, - settings: this.CreateIntentCompletionSettings() - ); - - if (result.ErrorOccurred) - { - context.Log.LogError("{0}: {1}", result.LastErrorDescription, result.LastException); - context.Fail(result.LastErrorDescription); - return string.Empty; - } - - return $"List of participants: {result}"; - } - - /// - /// Extract chat history. - /// - /// Contains the 'tokenLimit' controlling the length of the prompt. - [SKFunction, Description("Extract chat history")] - public async Task ExtractChatHistoryAsync( - [Description("Chat ID to extract history from")] string chatId, - [Description("Maximum number of tokens")] int tokenLimit) - { - var messages = await this._chatMessageRepository.FindByChatIdAsync(chatId); - var sortedMessages = messages.OrderByDescending(m => m.Timestamp); - - var remainingToken = tokenLimit; - - string historyText = ""; - foreach (var chatMessage in sortedMessages) - { - var formattedMessage = chatMessage.ToFormattedString(); - - // Plan object is not meaningful content in generating bot response, so shorten to intent only to save on tokens - if (formattedMessage.Contains("proposedPlan\":", StringComparison.InvariantCultureIgnoreCase)) - { - string pattern = @"(\[.*?\]).*User Intent:User intent: (.*)(?=""}})"; - Match match = Regex.Match(formattedMessage, pattern); - if (match.Success) - { - string timestamp = match.Groups[1].Value.Trim(); - string userIntent = match.Groups[2].Value.Trim(); - - formattedMessage = $"{timestamp} Bot proposed plan to fulfill user intent: {userIntent}"; - } - else - { - formattedMessage = "Bot proposed plan"; - } - } - - var tokenCount = Utilities.TokenCount(formattedMessage); - - if (remainingToken - tokenCount >= 0) - { - historyText = $"{formattedMessage}\n{historyText}"; - remainingToken -= tokenCount; - } - else - { - break; - } - } - - return $"Chat history:\n{historyText.Trim()}"; - } - - /// - /// This is the entry point for getting a chat response. It manages the token limit, saves - /// messages to memory, and fill in the necessary context variables for completing the - /// prompt that will be rendered by the template engine. - /// - [SKFunction, Description("Get chat response")] - public async Task ChatAsync( - [Description("The new message")] string message, - [Description("Unique and persistent identifier for the user")] string userId, - [Description("Name of the user")] string userName, - [Description("Unique and persistent identifier for the chat")] string chatId, - [Description("Type of the message")] string messageType, - [Description("Previously proposed plan that is approved"), DefaultValue(null), SKName("proposedPlan")] string? planJson, - [Description("ID of the response message for planner"), DefaultValue(null), SKName("responseMessageId")] string? messageId, - SKContext context) - { - // Save this new message to memory such that subsequent chat responses can use it - await this.SaveNewMessageAsync(message, userId, userName, chatId, messageType); - - // Clone the context to avoid modifying the original context variables. - var chatContext = Utilities.CopyContextWithVariablesClone(context); - chatContext.Variables.Set("knowledgeCutoff", this._promptOptions.KnowledgeCutoffDate); - - // Check if plan exists in ask's context variables. - // If plan was returned at this point, that means it was approved or cancelled. - // Update the response previously saved in chat history with state - if (!string.IsNullOrWhiteSpace(planJson) && - !string.IsNullOrEmpty(messageId)) - { - await this.UpdateResponseAsync(planJson, messageId); - } - - var response = chatContext.Variables.ContainsKey("userCancelledPlan") - ? "I am sorry the plan did not meet your goals." - : await this.GetChatResponseAsync(chatId, chatContext); - - if (chatContext.ErrorOccurred) - { - context.Fail(chatContext.LastErrorDescription); - return context; - } - - // Retrieve the prompt used to generate the response - // and return it to the caller via the context variables. - chatContext.Variables.TryGetValue("prompt", out string? prompt); - prompt ??= string.Empty; - context.Variables.Set("prompt", prompt); - - // Save this response to memory such that subsequent chat responses can use it - ChatMessage botMessage = await this.SaveNewResponseAsync(response, prompt, chatId); - context.Variables.Set("messageId", botMessage.Id); - context.Variables.Set("messageType", ((int)botMessage.Type).ToString(CultureInfo.InvariantCulture)); - - // Extract semantic chat memory - await SemanticChatMemoryExtractor.ExtractSemanticChatMemoryAsync( - chatId, - this._kernel, - chatContext, - this._promptOptions); - - context.Variables.Update(response); - return context; - } - - #region Private - - /// - /// Generate the necessary chat context to create a prompt then invoke the model to get a response. - /// - /// The SKContext. - /// A response from the model. - private async Task GetChatResponseAsync(string chatId, SKContext chatContext) - { - // 0. Get the audience - var audience = await this.GetAudienceAsync(chatContext); - if (chatContext.ErrorOccurred) - { - return string.Empty; - } - - // 1. Extract user intent from the conversation history. - var userIntent = await this.GetUserIntentAsync(chatContext); - if (chatContext.ErrorOccurred) - { - return string.Empty; - } - - // 2. Calculate the remaining token budget. - var remainingToken = this.GetChatContextTokenLimit(userIntent); - - // 3. Acquire external information from planner - var externalInformationTokenLimit = (int)(remainingToken * this._promptOptions.ExternalInformationContextWeight); - var planResult = await this.AcquireExternalInformationAsync(chatContext, userIntent, externalInformationTokenLimit); - if (chatContext.ErrorOccurred) - { - return string.Empty; - } - - // If plan is suggested, send back to user for approval before running - if (this._externalInformationSkill.ProposedPlan != null) - { - return JsonSerializer.Serialize(this._externalInformationSkill.ProposedPlan); - } - - // 4. Query relevant semantic memories - var chatMemoriesTokenLimit = (int)(remainingToken * this._promptOptions.MemoriesResponseContextWeight); - var chatMemories = await this._semanticChatMemorySkill.QueryMemoriesAsync(userIntent, chatId, chatMemoriesTokenLimit, chatContext.Memory); - if (chatContext.ErrorOccurred) - { - return string.Empty; - } - - // 5. Query relevant document memories - var documentContextTokenLimit = (int)(remainingToken * this._promptOptions.DocumentContextWeight); - var documentMemories = await this._documentMemorySkill.QueryDocumentsAsync(userIntent, chatId, documentContextTokenLimit, chatContext.Memory); - if (chatContext.ErrorOccurred) - { - return string.Empty; - } - - // 6. Fill in the chat history if there is any token budget left - var chatContextComponents = new List() { chatMemories, documentMemories, planResult }; - var chatContextText = string.Join("\n\n", chatContextComponents.Where(c => !string.IsNullOrEmpty(c))); - var chatContextTextTokenCount = remainingToken - Utilities.TokenCount(chatContextText); - if (chatContextTextTokenCount > 0) - { - var chatHistory = await this.ExtractChatHistoryAsync(chatId, chatContextTextTokenCount); - if (chatContext.ErrorOccurred) - { - return string.Empty; - } - chatContextText = $"{chatContextText}\n{chatHistory}"; - } - - // Invoke the model - chatContext.Variables.Set("audience", audience); - chatContext.Variables.Set("UserIntent", userIntent); - chatContext.Variables.Set("ChatContext", chatContextText); - - var promptRenderer = new PromptTemplateEngine(); - var renderedPrompt = await promptRenderer.RenderAsync( - this._promptOptions.SystemChatPrompt, - chatContext); - - var completionFunction = this._kernel.CreateSemanticFunction( - renderedPrompt, - skillName: nameof(ChatSkill), - description: "Complete the prompt."); - - chatContext = await completionFunction.InvokeAsync( - context: chatContext, - settings: this.CreateChatResponseCompletionSettings() - ); - - // Allow the caller to view the prompt used to generate the response - chatContext.Variables.Set("prompt", renderedPrompt); - - if (chatContext.ErrorOccurred) - { - return string.Empty; - } - - return chatContext.Result; - } - - /// - /// Helper function create the correct context variables to - /// extract audience from the conversation history. - /// - private async Task GetAudienceAsync(SKContext context) - { - var contextVariables = new ContextVariables(); - contextVariables.Set("chatId", context["chatId"]); - - var audienceContext = new SKContext( - contextVariables, - context.Memory, - context.Skills, - context.Log, - context.CancellationToken - ); - - var audience = await this.ExtractAudienceAsync(audienceContext); - - // Propagate the error - if (audienceContext.ErrorOccurred) - { - context.Fail(audienceContext.LastErrorDescription); - } - - return audience; - } - - /// - /// Helper function create the correct context variables to - /// extract user intent from the conversation history. - /// - private async Task GetUserIntentAsync(SKContext context) - { - // TODO: Regenerate user intent if plan was modified - if (!context.Variables.TryGetValue("planUserIntent", out string? userIntent)) - { - var contextVariables = new ContextVariables(); - contextVariables.Set("chatId", context["chatId"]); - contextVariables.Set("audience", context["userName"]); - - var intentContext = new SKContext( - contextVariables, - context.Memory, - context.Skills, - context.Log, - context.CancellationToken - ); - - userIntent = await this.ExtractUserIntentAsync(intentContext); - // Propagate the error - if (intentContext.ErrorOccurred) - { - context.Fail(intentContext.LastErrorDescription); - } - } - - return userIntent; - } - - /// - /// Helper function create the correct context variables to - /// query chat memories from the chat memory store. - /// - private Task QueryChatMemoriesAsync(SKContext context, string userIntent, int tokenLimit) - { - return this._semanticChatMemorySkill.QueryMemoriesAsync(userIntent, context["chatId"], tokenLimit, context.Memory); - } - - /// - /// Helper function create the correct context variables to - /// query document memories from the document memory store. - /// - private Task QueryDocumentsAsync(SKContext context, string userIntent, int tokenLimit) - { - return this._documentMemorySkill.QueryDocumentsAsync(userIntent, context["chatId"], tokenLimit, context.Memory); - } - - /// - /// Helper function create the correct context variables to acquire external information. - /// - private async Task AcquireExternalInformationAsync(SKContext context, string userIntent, int tokenLimit) - { - var contextVariables = context.Variables.Clone(); - contextVariables.Set("tokenLimit", tokenLimit.ToString(new NumberFormatInfo())); - - var planContext = new SKContext( - contextVariables, - context.Memory, - context.Skills, - context.Log, - context.CancellationToken - ); - - var plan = await this._externalInformationSkill.AcquireExternalInformationAsync(userIntent, planContext); - - // Propagate the error - if (planContext.ErrorOccurred) - { - context.Fail(planContext.LastErrorDescription); - } - - return plan; - } - - /// - /// Save a new message to the chat history. - /// - /// The message - /// The user ID - /// - /// The chat ID - /// Type of the message - private async Task SaveNewMessageAsync(string message, string userId, string userName, string chatId, string type) - { - // Make sure the chat exists. - if (!await this._chatSessionRepository.TryFindByIdAsync(chatId, v => _ = v)) - { - throw new ArgumentException("Chat session does not exist."); - } - - var chatMessage = new ChatMessage( - userId, - userName, - chatId, - message, - "", - ChatMessage.AuthorRoles.User, - // Default to a standard message if the `type` is not recognized - Enum.TryParse(type, out ChatMessage.ChatMessageType typeAsEnum) && Enum.IsDefined(typeof(ChatMessage.ChatMessageType), typeAsEnum) - ? typeAsEnum - : ChatMessage.ChatMessageType.Message); - - await this._chatMessageRepository.CreateAsync(chatMessage); - return chatMessage; - } - - /// - /// Save a new response to the chat history. - /// - /// Response from the chat. - /// Prompt used to generate the response. - /// The chat ID - /// The created chat message. - private async Task SaveNewResponseAsync(string response, string prompt, string chatId) - { - // Make sure the chat exists. - if (!await this._chatSessionRepository.TryFindByIdAsync(chatId, v => _ = v)) - { - throw new ArgumentException("Chat session does not exist."); - } - - var chatMessage = ChatMessage.CreateBotResponseMessage(chatId, response, prompt); - await this._chatMessageRepository.CreateAsync(chatMessage); - - return chatMessage; - } - - /// - /// Updates previously saved response in the chat history. - /// - /// Updated response from the chat. - /// The chat message ID - private async Task UpdateResponseAsync(string updatedResponse, string messageId) - { - // Make sure the chat exists. - var chatMessage = await this._chatMessageRepository.FindByIdAsync(messageId); - chatMessage.Content = updatedResponse; - - await this._chatMessageRepository.UpsertAsync(chatMessage); - } - - /// - /// Create a completion settings object for chat response. Parameters are read from the PromptSettings class. - /// - private CompleteRequestSettings CreateChatResponseCompletionSettings() - { - var completionSettings = new CompleteRequestSettings - { - MaxTokens = this._promptOptions.ResponseTokenLimit, - Temperature = this._promptOptions.ResponseTemperature, - TopP = this._promptOptions.ResponseTopP, - FrequencyPenalty = this._promptOptions.ResponseFrequencyPenalty, - PresencePenalty = this._promptOptions.ResponsePresencePenalty - }; - - return completionSettings; - } - - /// - /// Create a completion settings object for intent response. Parameters are read from the PromptSettings class. - /// - private CompleteRequestSettings CreateIntentCompletionSettings() - { - var completionSettings = new CompleteRequestSettings - { - MaxTokens = this._promptOptions.ResponseTokenLimit, - Temperature = this._promptOptions.IntentTemperature, - TopP = this._promptOptions.IntentTopP, - FrequencyPenalty = this._promptOptions.IntentFrequencyPenalty, - PresencePenalty = this._promptOptions.IntentPresencePenalty, - StopSequences = new string[] { "] bot:" } - }; - - return completionSettings; - } - - /// - /// Calculate the remaining token budget for the chat response prompt. - /// This is the token limit minus the token count of the user intent and the system commands. - /// - /// The user intent returned by the model. - /// The remaining token limit. - private int GetChatContextTokenLimit(string userIntent) - { - var tokenLimit = this._promptOptions.CompletionTokenLimit; - var remainingToken = - tokenLimit - - Utilities.TokenCount(userIntent) - - this._promptOptions.ResponseTokenLimit - - Utilities.TokenCount(string.Join("\n", new string[] - { - this._promptOptions.SystemDescription, - this._promptOptions.SystemResponse, - this._promptOptions.SystemChatContinuation - }) - ); - - return remainingToken; - } - - # endregion -} diff --git a/webapi/CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs b/webapi/CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs deleted file mode 100644 index f41106ace..000000000 --- a/webapi/CopilotChat/Skills/ChatSkills/CopilotChatPlanner.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Planning; -using Microsoft.SemanticKernel.SkillDefinition; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Options; - -namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills; - -/// -/// A lightweight wrapper around a planner to allow for curating which skills are available to it. -/// -public class CopilotChatPlanner -{ - /// - /// The planner's kernel. - /// - public IKernel Kernel { get; } - - /// - /// Options for the planner. - /// - private readonly PlannerOptions? _plannerOptions; - - /// - /// Gets the pptions for the planner. - /// - public PlannerOptions? PlannerOptions => this._plannerOptions; - - /// - /// Initializes a new instance of the class. - /// - /// The planner's kernel. - public CopilotChatPlanner(IKernel plannerKernel, PlannerOptions? plannerOptions) - { - this.Kernel = plannerKernel; - this._plannerOptions = plannerOptions; - } - - /// - /// Create a plan for a goal. - /// - /// The goal to create a plan for. - /// The plan. - public Task CreatePlanAsync(string goal) - { - FunctionsView plannerFunctionsView = this.Kernel.Skills.GetFunctionsView(true, true); - if (plannerFunctionsView.NativeFunctions.IsEmpty && plannerFunctionsView.SemanticFunctions.IsEmpty) - { - // No functions are available - return an empty plan. - return Task.FromResult(new Plan(goal)); - } - - if (this._plannerOptions?.Type == PlanType.Sequential) - { - return new SequentialPlanner(this.Kernel).CreatePlanAsync(goal); - } - - return new ActionPlanner(this.Kernel).CreatePlanAsync(goal); - } -} diff --git a/webapi/CopilotChat/Skills/ChatSkills/DocumentMemorySkill.cs b/webapi/CopilotChat/Skills/ChatSkills/DocumentMemorySkill.cs deleted file mode 100644 index ac9f87938..000000000 --- a/webapi/CopilotChat/Skills/ChatSkills/DocumentMemorySkill.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel.Memory; -using Microsoft.SemanticKernel.SkillDefinition; -using SemanticKernel.Service.CopilotChat.Options; - -namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills; - -/// -/// This skill provides the functions to query the document memory. -/// -public class DocumentMemorySkill -{ - /// - /// Prompt settings. - /// - private readonly PromptsOptions _promptOptions; - - /// - /// Configuration settings for importing documents to memory. - /// - private readonly DocumentMemoryOptions _documentImportOptions; - - /// - /// Create a new instance of DocumentMemorySkill. - /// - public DocumentMemorySkill( - IOptions promptOptions, - IOptions documentImportOptions) - { - this._promptOptions = promptOptions.Value; - this._documentImportOptions = documentImportOptions.Value; - } - - /// - /// Query the document memory collection for documents that match the query. - /// - /// Query to match. - /// The SkContext. - [SKFunction, Description("Query documents in the memory given a user message")] - public async Task QueryDocumentsAsync( - [Description("Query to match.")] string query, - [Description("ID of the chat that owns the documents")] string chatId, - [Description("Maximum number of tokens")] int tokenLimit, - ISemanticTextMemory textMemory) - { - var remainingToken = tokenLimit; - - // Search for relevant document snippets. - string[] documentCollections = new string[] - { - this._documentImportOptions.ChatDocumentCollectionNamePrefix + chatId, - this._documentImportOptions.GlobalDocumentCollectionName - }; - - List relevantMemories = new(); - foreach (var documentCollection in documentCollections) - { - var results = textMemory.SearchAsync( - documentCollection, - query, - limit: 100, - minRelevanceScore: this._promptOptions.DocumentMemoryMinRelevance); - await foreach (var memory in results) - { - relevantMemories.Add(memory); - } - } - - relevantMemories = relevantMemories.OrderByDescending(m => m.Relevance).ToList(); - - // Concatenate the relevant document snippets. - string documentsText = string.Empty; - foreach (var memory in relevantMemories) - { - var tokenCount = Utilities.TokenCount(memory.Metadata.Text); - if (remainingToken - tokenCount > 0) - { - documentsText += $"\n\nSnippet from {memory.Metadata.Description}: {memory.Metadata.Text}"; - remainingToken -= tokenCount; - } - else - { - break; - } - } - - if (string.IsNullOrEmpty(documentsText)) - { - // No relevant documents found - return string.Empty; - } - - return $"User has also shared some document snippets:\n{documentsText}"; - } -} diff --git a/webapi/CopilotChat/Skills/ChatSkills/ExternalInformationSkill.cs b/webapi/CopilotChat/Skills/ChatSkills/ExternalInformationSkill.cs deleted file mode 100644 index 9fea94f62..000000000 --- a/webapi/CopilotChat/Skills/ChatSkills/ExternalInformationSkill.cs +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Globalization; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel.Orchestration; -using Microsoft.SemanticKernel.Planning; -using Microsoft.SemanticKernel.SkillDefinition; -using SemanticKernel.Service.CopilotChat.Models; -using SemanticKernel.Service.CopilotChat.Options; -using SemanticKernel.Service.CopilotChat.Skills.OpenApiSkills.GitHubSkill.Model; -using SemanticKernel.Service.CopilotChat.Skills.OpenApiSkills.JiraSkill.Model; - -namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills; - -/// -/// This skill provides the functions to acquire external information. -/// -public class ExternalInformationSkill -{ - /// - /// Prompt settings. - /// - private readonly PromptsOptions _promptOptions; - - /// - /// CopilotChat's planner to gather additional information for the chat context. - /// - private readonly CopilotChatPlanner _planner; - - /// - /// Proposed plan to return for approval. - /// - public ProposedPlan? ProposedPlan { get; private set; } - - /// - /// Preamble to add to the related information text. - /// - private const string PromptPreamble = "[RELATED START]"; - - /// - /// Postamble to add to the related information text. - /// - private const string PromptPostamble = "[RELATED END]"; - - /// - /// Create a new instance of ExternalInformationSkill. - /// - public ExternalInformationSkill( - IOptions promptOptions, - CopilotChatPlanner planner) - { - this._promptOptions = promptOptions.Value; - this._planner = planner; - } - - /// - /// Extract relevant additional knowledge using a planner. - /// - [SKFunction, Description("Acquire external information")] - [SKParameter("tokenLimit", "Maximum number of tokens")] - [SKParameter("proposedPlan", "Previously proposed plan that is approved")] - public async Task AcquireExternalInformationAsync( - [Description("The intent to whether external information is needed")] string userIntent, - SKContext context) - { - FunctionsView functions = this._planner.Kernel.Skills.GetFunctionsView(true, true); - if (functions.NativeFunctions.IsEmpty && functions.SemanticFunctions.IsEmpty) - { - return string.Empty; - } - - // Check if plan exists in ask's context variables. - var planExists = context.Variables.TryGetValue("proposedPlan", out string? proposedPlanJson); - var deserializedPlan = planExists && !string.IsNullOrWhiteSpace(proposedPlanJson) ? JsonSerializer.Deserialize(proposedPlanJson) : null; - - // Run plan if it was approved - if (deserializedPlan != null && deserializedPlan.State == PlanState.Approved) - { - string planJson = JsonSerializer.Serialize(deserializedPlan.Plan); - // Reload the plan with the planner's kernel so - // it has full context to be executed - var newPlanContext = new SKContext( - null, - this._planner.Kernel.Memory, - this._planner.Kernel.Skills, - this._planner.Kernel.Log - ); - var plan = Plan.FromJson(planJson, newPlanContext); - - // Invoke plan - newPlanContext = await plan.InvokeAsync(newPlanContext); - int tokenLimit = - int.Parse(context["tokenLimit"], new NumberFormatInfo()) - - Utilities.TokenCount(PromptPreamble) - - Utilities.TokenCount(PromptPostamble); - - // The result of the plan may be from an OpenAPI skill. Attempt to extract JSON from the response. - bool extractJsonFromOpenApi = - this.TryExtractJsonFromOpenApiPlanResult(newPlanContext, newPlanContext.Result, out string planResult); - if (extractJsonFromOpenApi) - { - planResult = this.OptimizeOpenApiSkillJson(planResult, tokenLimit, plan); - } - else - { - // If not, use result of the plan execution result directly. - planResult = newPlanContext.Variables.Input; - } - - return $"{PromptPreamble}\n{planResult.Trim()}\n{PromptPostamble}\n"; - } - else - { - // Create a plan and set it in context for approval. - var contextString = string.Join("\n", context.Variables.Where(v => v.Key != "userIntent").Select(v => $"{v.Key}: {v.Value}")); - Plan plan = await this._planner.CreatePlanAsync($"Given the following context, accomplish the user intent.\nContext:{contextString}\nUser Intent:{userIntent}"); - - if (plan.Steps.Count > 0) - { - // Parameters stored in plan's top level - this.MergeContextIntoPlan(context.Variables, plan.Parameters); - - // TODO: Improve Kernel to give developers option to skip this override - // (i.e., keep functions regardless of whether they're available in the planner's context or not) - Plan sanitizedPlan = this.SanitizePlan(plan, context); - sanitizedPlan.Parameters.Update(plan.Parameters); - - this.ProposedPlan = new ProposedPlan(sanitizedPlan, this._planner.PlannerOptions!.Type, PlanState.NoOp); - } - } - - return string.Empty; - } - - #region Private - - /// - /// Scrubs plan of functions not available in Planner's kernel. - /// - private Plan SanitizePlan(Plan plan, SKContext context) - { - List sanitizedSteps = new(); - var availableFunctions = this._planner.Kernel.Skills.GetFunctionsView(true); - - foreach (var step in plan.Steps) - { - if (this._planner.Kernel.Skills.TryGetFunction(step.SkillName, step.Name, out var function)) - { - this.MergeContextIntoPlan(context.Variables, step.Parameters); - sanitizedSteps.Add(step); - } - } - - return new Plan(plan.Description, sanitizedSteps.ToArray()); - } - - /// - /// Merge any variables from the context into plan parameters as these will be used on plan execution. - /// These context variables come from user input, so they are prioritized. - /// - private void MergeContextIntoPlan(ContextVariables variables, ContextVariables planParams) - { - foreach (var param in planParams) - { - if (param.Key.Equals("INPUT", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (variables.TryGetValue(param.Key, out string? value)) - { - planParams.Set(param.Key, value); - } - } - } - - /// - /// Try to extract json from the planner response as if it were from an OpenAPI skill. - /// - private bool TryExtractJsonFromOpenApiPlanResult(SKContext context, string openApiSkillResponse, out string json) - { - try - { - JsonNode? jsonNode = JsonNode.Parse(openApiSkillResponse); - string contentType = jsonNode?["contentType"]?.ToString() ?? string.Empty; - if (contentType.StartsWith("application/json", StringComparison.InvariantCultureIgnoreCase)) - { - var content = jsonNode?["content"]?.ToString() ?? string.Empty; - if (!string.IsNullOrWhiteSpace(content)) - { - json = content; - return true; - } - } - } - catch (JsonException) - { - context.Log.LogDebug("Unable to extract JSON from planner response, it is likely not from an OpenAPI skill."); - } - catch (InvalidOperationException) - { - context.Log.LogDebug("Unable to extract JSON from planner response, it may already be proper JSON."); - } - - json = string.Empty; - return false; - } - - /// - /// Try to optimize json from the planner response - /// based on token limit - /// - private string OptimizeOpenApiSkillJson(string jsonContent, int tokenLimit, Plan plan) - { - // Remove all new line characters + leading and trailing white space - jsonContent = Regex.Replace(jsonContent.Trim(), @"[\n\r]", string.Empty); - var document = JsonDocument.Parse(jsonContent); - string lastSkillInvoked = plan.Steps[^1].SkillName; - string lastSkillFunctionInvoked = plan.Steps[^1].Name; - bool trimSkillResponse = false; - - // The json will be deserialized based on the response type of the particular operation that was last invoked by the planner - // The response type can be a custom trimmed down json structure, which is useful in staying within the token limit - Type skillResponseType = this.GetOpenApiSkillResponseType(ref document, ref lastSkillInvoked, ref lastSkillFunctionInvoked, ref trimSkillResponse); - - if (trimSkillResponse) - { - // Deserializing limits the json content to only the fields defined in the respective OpenApiSkill's Model classes - var skillResponse = JsonSerializer.Deserialize(jsonContent, skillResponseType); - jsonContent = skillResponse != null ? JsonSerializer.Serialize(skillResponse) : string.Empty; - document = JsonDocument.Parse(jsonContent); - } - - int jsonContentTokenCount = Utilities.TokenCount(jsonContent); - - // Return the JSON content if it does not exceed the token limit - if (jsonContentTokenCount < tokenLimit) - { - return jsonContent; - } - - List itemList = new(); - - // Some APIs will return a JSON response with one property key representing an embedded answer. - // Extract this value for further processing - string resultsDescriptor = ""; - - if (document.RootElement.ValueKind == JsonValueKind.Object) - { - int propertyCount = 0; - foreach (JsonProperty property in document.RootElement.EnumerateObject()) - { - propertyCount++; - } - - if (propertyCount == 1) - { - // Save property name for result interpolation - JsonProperty firstProperty = document.RootElement.EnumerateObject().First(); - tokenLimit -= Utilities.TokenCount(firstProperty.Name); - resultsDescriptor = string.Format(CultureInfo.InvariantCulture, "{0}: ", firstProperty.Name); - - // Extract object to be truncated - JsonElement value = firstProperty.Value; - document = JsonDocument.Parse(value.GetRawText()); - } - } - - // Detail Object - // To stay within token limits, attempt to truncate the list of properties - if (document.RootElement.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty property in document.RootElement.EnumerateObject()) - { - int propertyTokenCount = Utilities.TokenCount(property.ToString()); - - if (tokenLimit - propertyTokenCount > 0) - { - itemList.Add(property); - tokenLimit -= propertyTokenCount; - } - else - { - break; - } - } - } - - // Summary (List) Object - // To stay within token limits, attempt to truncate the list of results - if (document.RootElement.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement item in document.RootElement.EnumerateArray()) - { - int itemTokenCount = Utilities.TokenCount(item.ToString()); - - if (tokenLimit - itemTokenCount > 0) - { - itemList.Add(item); - tokenLimit -= itemTokenCount; - } - else - { - break; - } - } - } - - return itemList.Count > 0 - ? string.Format(CultureInfo.InvariantCulture, "{0}{1}", resultsDescriptor, JsonSerializer.Serialize(itemList)) - : string.Format(CultureInfo.InvariantCulture, "JSON response for {0} is too large to be consumed at this time.", lastSkillInvoked); - } - - private Type GetOpenApiSkillResponseType(ref JsonDocument document, ref string lastSkillInvoked, ref string lastSkillFunctionInvoked, ref bool trimSkillResponse) - { - Type skillResponseType = typeof(object); // Use a reasonable default response type - - // Different operations under the skill will return responses as json structures; - // Prune each operation response according to the most important/contextual fields only to avoid going over the token limit - // Check what the last skill invoked was and deserialize the JSON content accordingly - if (string.Equals(lastSkillInvoked, "GitHubSkill", StringComparison.Ordinal)) - { - trimSkillResponse = true; - skillResponseType = this.GetGithubSkillResponseType(ref document); - } - else if (string.Equals(lastSkillInvoked, "JiraSkill", StringComparison.Ordinal)) - { - trimSkillResponse = true; - skillResponseType = this.GetJiraSkillResponseType(ref document, ref lastSkillFunctionInvoked); - } - - return skillResponseType; - } - - private Type GetGithubSkillResponseType(ref JsonDocument document) - { - return document.RootElement.ValueKind == JsonValueKind.Array ? typeof(PullRequest[]) : typeof(PullRequest); - } - - private Type GetJiraSkillResponseType(ref JsonDocument document, ref string lastSkillFunctionInvoked) - { - if (lastSkillFunctionInvoked == "GetIssue") - { - return document.RootElement.ValueKind == JsonValueKind.Array ? typeof(IssueResponse[]) : typeof(IssueResponse); - } - - return typeof(IssueResponse); - } - - #endregion -} diff --git a/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemoryExtractor.cs b/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemoryExtractor.cs deleted file mode 100644 index e08bd64f0..000000000 --- a/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemoryExtractor.cs +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.AI.TextCompletion; -using Microsoft.SemanticKernel.Orchestration; -using SemanticKernel.Service.CopilotChat.Extensions; -using SemanticKernel.Service.CopilotChat.Options; - -namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills; - -/// -/// Helper class to extract and create semantic memory from chat history. -/// -internal static class SemanticChatMemoryExtractor -{ - /// - /// Returns the name of the semantic text memory collection that stores chat semantic memory. - /// - /// Chat ID that is persistent and unique for the chat session. - /// Name of the memory category - internal static string MemoryCollectionName(string chatId, string memoryName) => $"{chatId}-{memoryName}"; - - /// - /// Extract and save semantic memory. - /// - /// The Chat ID. - /// The semantic kernel. - /// The context containing the memory. - /// The prompts options. - internal static async Task ExtractSemanticChatMemoryAsync( - string chatId, - IKernel kernel, - SKContext context, - PromptsOptions options) - { - foreach (var memoryName in options.MemoryMap.Keys) - { - try - { - var semanticMemory = await ExtractCognitiveMemoryAsync( - memoryName, - kernel, - context, - options - ); - foreach (var item in semanticMemory.Items) - { - await CreateMemoryAsync(item, chatId, context, memoryName, options); - } - } - catch (Exception ex) when (!ex.IsCriticalException()) - { - // Skip semantic memory extraction for this item if it fails. - // We cannot rely on the model to response with perfect Json each time. - context.Log.LogInformation("Unable to extract semantic memory for {0}: {1}. Continuing...", memoryName, ex.Message); - continue; - } - } - } - - /// - /// Extracts the semantic chat memory from the chat session. - /// - /// Name of the memory category - /// The semantic kernel. - /// The SKContext - /// The prompts options. - /// A SemanticChatMemory object. - internal static async Task ExtractCognitiveMemoryAsync( - string memoryName, - IKernel kernel, - SKContext context, - PromptsOptions options) - { - if (!options.MemoryMap.TryGetValue(memoryName, out var memoryPrompt)) - { - throw new ArgumentException($"Memory name {memoryName} is not supported."); - } - - // Token limit for chat history - var tokenLimit = options.CompletionTokenLimit; - var remainingToken = - tokenLimit - - options.ResponseTokenLimit - - Utilities.TokenCount(memoryPrompt); ; - - var memoryExtractionContext = Utilities.CopyContextWithVariablesClone(context); - memoryExtractionContext.Variables.Set("tokenLimit", remainingToken.ToString(new NumberFormatInfo())); - memoryExtractionContext.Variables.Set("memoryName", memoryName); - memoryExtractionContext.Variables.Set("format", options.MemoryFormat); - memoryExtractionContext.Variables.Set("knowledgeCutoff", options.KnowledgeCutoffDate); - - var completionFunction = kernel.CreateSemanticFunction(memoryPrompt); - var result = await completionFunction.InvokeAsync( - context: memoryExtractionContext, - settings: CreateMemoryExtractionSettings(options) - ); - - SemanticChatMemory memory = SemanticChatMemory.FromJson(result.ToString()); - return memory; - } - - /// - /// Create a memory item in the memory collection. - /// If there is already a memory item that has a high similarity score with the new item, it will be skipped. - /// - /// A SemanticChatMemoryItem instance - /// The ID of the chat the memories belong to - /// The context that contains the memory - /// Name of the memory - /// The prompts options. - internal static async Task CreateMemoryAsync( - SemanticChatMemoryItem item, - string chatId, - SKContext context, - string memoryName, - PromptsOptions options) - { - var memoryCollectionName = SemanticChatMemoryExtractor.MemoryCollectionName(chatId, memoryName); - - var memories = await context.Memory.SearchAsync( - collection: memoryCollectionName, - query: item.ToFormattedString(), - limit: 1, - minRelevanceScore: options.SemanticMemoryMinRelevance, - cancellationToken: context.CancellationToken - ) - .ToListAsync() - .ConfigureAwait(false); - - if (memories.Count == 0) - { - await context.Memory.SaveInformationAsync( - collection: memoryCollectionName, - text: item.ToFormattedString(), - id: Guid.NewGuid().ToString(), - description: memoryName, - cancellationToken: context.CancellationToken - ); - } - } - - /// - /// Create a completion settings object for chat response. Parameters are read from the PromptSettings class. - /// - private static CompleteRequestSettings CreateMemoryExtractionSettings(PromptsOptions options) - { - var completionSettings = new CompleteRequestSettings - { - MaxTokens = options.ResponseTokenLimit, - Temperature = options.ResponseTemperature, - TopP = options.ResponseTopP, - FrequencyPenalty = options.ResponseFrequencyPenalty, - PresencePenalty = options.ResponsePresencePenalty - }; - - return completionSettings; - } -} diff --git a/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemorySkill.cs b/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemorySkill.cs deleted file mode 100644 index b9d7efaeb..000000000 --- a/webapi/CopilotChat/Skills/ChatSkills/SemanticChatMemorySkill.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel.Memory; -using Microsoft.SemanticKernel.SkillDefinition; -using SemanticKernel.Service.CopilotChat.Options; - -namespace SemanticKernel.Service.CopilotChat.Skills.ChatSkills; - -/// -/// This skill provides the functions to query the semantic chat memory. -/// -public class SemanticChatMemorySkill -{ - /// - /// Prompt settings. - /// - private readonly PromptsOptions _promptOptions; - - /// - /// Create a new instance of SemanticChatMemorySkill. - /// - public SemanticChatMemorySkill( - IOptions promptOptions) - { - this._promptOptions = promptOptions.Value; - } - - /// - /// Query relevant memories based on the query. - /// - /// Query to match. - /// The SKContext - /// A string containing the relevant memories. - [SKFunction, Description("Query chat memories")] - public async Task QueryMemoriesAsync( - [Description("Query to match.")] string query, - [Description("Chat ID to query history from")] string chatId, - [Description("Maximum number of tokens")] int tokenLimit, - ISemanticTextMemory textMemory) - { - var remainingToken = tokenLimit; - - // Search for relevant memories. - List relevantMemories = new(); - foreach (var memoryName in this._promptOptions.MemoryMap.Keys) - { - var results = textMemory.SearchAsync( - SemanticChatMemoryExtractor.MemoryCollectionName(chatId, memoryName), - query, - limit: 100, - minRelevanceScore: this._promptOptions.SemanticMemoryMinRelevance); - await foreach (var memory in results) - { - relevantMemories.Add(memory); - } - } - - relevantMemories = relevantMemories.OrderByDescending(m => m.Relevance).ToList(); - - string memoryText = ""; - foreach (var memory in relevantMemories) - { - var tokenCount = Utilities.TokenCount(memory.Metadata.Text); - if (remainingToken - tokenCount > 0) - { - memoryText += $"\n[{memory.Metadata.Description}] {memory.Metadata.Text}"; - remainingToken -= tokenCount; - } - else - { - break; - } - } - - if (string.IsNullOrEmpty(memoryText)) - { - // No relevant memories found - return string.Empty; - } - - return $"Past memories (format: [memory type]