diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..93f63e5d1 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +max-line-length = 88 +extend-ignore = E501 +exclude = .venv, frontend +ignore = E203, W503, G004, G200 \ No newline at end of file diff --git a/.github/workflows/CODEOWNERS b/.github/CODEOWNER similarity index 100% rename from .github/workflows/CODEOWNERS rename to .github/CODEOWNER diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..6257f2e76 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Microsoft Open Source Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..720e097ba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,45 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +# Describe the bug +A clear and concise description of what the bug is. + +# Expected behavior +A clear and concise description of what you expected to happen. + +# How does this bug make you feel? +_Share a gif from [giphy](https://giphy.com/) to tells us how you'd feel_ + +--- + +# Debugging information + +## Steps to reproduce +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +## Screenshots +If applicable, add screenshots to help explain your problem. + +## Logs + +If applicable, add logs to help the engineer debug the problem. + +--- + +# Tasks + +_To be filled in by the engineer picking up the issue_ + +- [ ] Task 1 +- [ ] Task 2 +- [ ] ... \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..648f517a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +# Motivation + +A clear and concise description of why this feature would be useful and the value it would bring. +Explain any alternatives considered and why they are not sufficient. + +# How would you feel if this feature request was implemented? + +_Share a gif from [giphy](https://giphy.com/) to tells us how you'd feel. Format: ![alt_text](https://media.giphy.com/media/xxx/giphy.gif)_ + +# Requirements + +A list of requirements to consider this feature delivered +- Requirement 1 +- Requirement 2 +- ... + +# Tasks + +_To be filled in by the engineer picking up the issue_ + +- [ ] Task 1 +- [ ] Task 2 +- [ ] ... \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/subtask.md b/.github/ISSUE_TEMPLATE/subtask.md new file mode 100644 index 000000000..2451f8b3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/subtask.md @@ -0,0 +1,22 @@ +--- +name: Sub task +about: A sub task +title: '' +labels: subtask +assignees: '' + +--- + +Required by + +# Description + +A clear and concise description of what this subtask is. + +# Tasks + +_To be filled in by the engineer picking up the subtask + +- [ ] Task 1 +- [ ] Task 2 +- [ ] ... \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..0f377b2d2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,46 @@ +## Purpose + +* ... + +## Does this introduce a breaking change? + + +- [ ] Yes +- [ ] No + + + +## How to Test +* Get the code + +``` +git clone [repo-address] +cd [repo-name] +git checkout [branch-name] +npm install +``` + +* Test the code + +``` +``` + +## What to Check +Verify that the following are valid +* ... + +## Other Information + \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2e5a1296b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +# Dependabot configuration file +# For more details, refer to: https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + # GitHub Actions dependencies + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "build" + target-branch: "dependabotchanges" + open-pull-requests-limit: 10 + + - package-ecosystem: "pip" + directory: "/src/backend" + schedule: + interval: "monthly" + commit-message: + prefix: "build" + target-branch: "dependabotchanges" + open-pull-requests-limit: 10 + + - package-ecosystem: "pip" + directory: "/src/frontend" + schedule: + interval: "monthly" + commit-message: + prefix: "build" + target-branch: "dependabotchanges" + open-pull-requests-limit: 10 \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..14ce486fd --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,94 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main", "dev", "demo" ] + pull_request: + branches: [ "main", "dev", "demo" ] + schedule: + - cron: '44 20 * * 2' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # 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. + + # For more 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 + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ 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: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 000000000..62b0b35a1 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,64 @@ +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +name: create-release + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - uses: codfish/semantic-release-action@v3 + id: semantic + with: + tag-format: 'v${version}' + additional-packages: | + ['conventional-changelog-conventionalcommits@7'] + plugins: | + [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits" + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { type: 'feat', section: 'Features', hidden: false }, + { type: 'fix', section: 'Bug Fixes', hidden: false }, + { type: 'perf', section: 'Performance Improvements', hidden: false }, + { type: 'revert', section: 'Reverts', hidden: false }, + { type: 'docs', section: 'Other Updates', hidden: false }, + { type: 'style', section: 'Other Updates', hidden: false }, + { type: 'chore', section: 'Other Updates', hidden: false }, + { type: 'refactor', section: 'Other Updates', hidden: false }, + { type: 'test', section: 'Other Updates', hidden: false }, + { type: 'build', section: 'Other Updates', hidden: false }, + { type: 'ci', section: 'Other Updates', hidden: false } + ] + } + } + ], + '@semantic-release/github' + ] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: echo ${{ steps.semantic.outputs.release-version }} + + - run: echo "$OUTPUTS" + env: + OUTPUTS: ${{ toJson(steps.semantic.outputs) }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..1eeb8b1b2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,242 @@ +name: CI-Validate Deployment-Multi-Agent-Custom-Automation-Engine-Solution-Accelerator + +on: + push: + branches: + - main + schedule: + - cron: '0 6,18 * * *' # Runs at 6:00 AM and 6:00 PM GMT + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Azure CLI + run: | + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + az --version # Verify installation + + - name: Login to Azure + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + + - name: Install Bicep CLI + run: az bicep install + + - name: Generate Resource Group Name + id: generate_rg_name + run: | + echo "Generating a unique resource group name..." + TIMESTAMP=$(date +%Y%m%d%H%M%S) + COMMON_PART="ci-biab" + UNIQUE_RG_NAME="${COMMON_PART}${TIMESTAMP}" + echo "RESOURCE_GROUP_NAME=${UNIQUE_RG_NAME}" >> $GITHUB_ENV + echo "Generated Resource_GROUP_PREFIX: ${UNIQUE_RG_NAME}" + + + - name: Check and Create Resource Group + id: check_create_rg + run: | + set -e + echo "Checking if resource group exists..." + rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }}) + if [ "$rg_exists" = "false" ]; then + echo "Resource group does not exist. Creating..." + az group create --name ${{ env.RESOURCE_GROUP_NAME }} --location eastus || { echo "Error creating resource group"; exit 1; } + else + echo "Resource group already exists." + fi + + + - name: Deploy Bicep Template + id: deploy + run: | + set -e + az deployment group create \ + --resource-group ${{ env.RESOURCE_GROUP_NAME }} \ + --template-file deploy/macae.bicep \ + --parameters azureOpenAILocation=eastus cosmosLocation=eastus2 + + + - name: Send Notification on Failure + if: failure() + run: | + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # Construct the email body + EMAIL_BODY=$(cat <Dear Team,

We would like to inform you that the Multi-Agent-Custom-Automation-Engine-Solution-Accelerator Automation process has encountered an issue and has failed to complete successfully.

Build URL: ${RUN_URL}
${OUTPUT}

Please investigate the matter at your earliest convenience.

Best regards,
Your Automation Team

" + } + EOF + ) + + # Send the notification + curl -X POST "${{ secrets.LOGIC_APP_URL }}" \ + -H "Content-Type: application/json" \ + -d "$EMAIL_BODY" || echo "Failed to send notification" + + + - name: Get OpenAI, App Service and Container Registry Resource from Resource Group + id: get_openai_resource + run: | + + + set -e + echo "Fetching OpenAI resource from resource group ${{ env.RESOURCE_GROUP_NAME }}..." + + # Run the az resource list command to get the OpenAI resource name + openai_resource_name=$(az resource list --resource-group ${{ env.RESOURCE_GROUP_NAME }} --resource-type "Microsoft.CognitiveServices/accounts" --query "[0].name" -o tsv) + + if [ -z "$openai_resource_name" ]; then + echo "No OpenAI resource found in resource group ${{ env.RESOURCE_GROUP_NAME }}." + exit 1 + else + echo "OPENAI_RESOURCE_NAME=${openai_resource_name}" >> $GITHUB_ENV + echo "OpenAI resource name: ${openai_resource_name}" + fi + + echo "Fetching Azure App Service resource from resource group ${{ env.RESOURCE_GROUP_NAME }}..." + + # Run the az resource list command to get the App Service resource name + app_service_name=$(az resource list --resource-group ${{ env.RESOURCE_GROUP_NAME }} --resource-type "Microsoft.Web/sites" --query "[0].name" -o tsv) + + if [ -z "$app_service_name" ]; then + echo "No Azure App Service resource found in resource group ${{ env.RESOURCE_GROUP_NAME }}." + exit 1 + else + echo "APP_SERVICE_NAME=${app_service_name}" >> $GITHUB_ENV + echo "Azure App Service resource name: ${app_service_name}" + fi + + echo "Fetching container registry resource from resource group ${{ env.RESOURCE_GROUP_NAME }}..." + + # Fetch Azure Container Registry name + acr_name=$(az acr list --resource-group ${{ env.RESOURCE_GROUP_NAME }} --query "[0].name" -o tsv) + + if [ -z "$acr_name" ]; then + echo "No Azure Container Registry found in resource group ${{ env.RESOURCE_GROUP_NAME }}." + exit 1 + else + echo "ACR_NAME=${acr_name}" >> $GITHUB_ENV + echo "Azure Container Registry name: ${acr_name}" + fi + + + - name: Build the image and update the container app + id: build-and-update + run: | + + set -e + # Define variables for acr and container app names + acr_name="${{ env.ACR_NAME }}" + echo "ACR name: {$acr_name}" + backend_container_app_name="macae-backend" + backend_build_image_tag="backend:latest" + + echo "Building the container image..." + # Build the image + az acr build -r ${acr_name} -t ${backend_build_image_tag} ./src/backend + echo "Backend image build completed successfully." + + frontend_container_app_name="${{ env.APP_SERVICE_NAME }}" + frontend_build_image_tag="frontend:latest" + + echo "Building the container image..." + # Build the image + az acr build -r ${acr_name} -t ${frontend_build_image_tag} ./src/frontend + echo "Frontend image build completed successfully." + + # Add the new container to the website + az webapp config container set --resource-group ${{ env.RESOURCE_GROUP_NAME }} --name ${frontend_container_app_name} --container-image-name ${acr_name}.azurecr.io/frontend:latest --container-registry-url https://${acr_name}.azurecr.io + + + - name: Delete Bicep Deployment + if: success() + run: | + set -e + echo "Checking if resource group exists..." + rg_exists=$(az group exists --name ${{ env.RESOURCE_GROUP_NAME }}) + if [ "$rg_exists" = "true" ]; then + echo "Resource group exist. Cleaning..." + az group delete \ + --name ${{ env.RESOURCE_GROUP_NAME }} \ + --yes \ + --no-wait + echo "Resource group deleted... ${{ env.RESOURCE_GROUP_NAME }}" + else + echo "Resource group does not exists." + fi + + + - name: Wait for resource deletion to complete + run: | + + + # Add resources to the array + resources_to_check=("${{ env.OPENAI_RESOURCE_NAME }}") + + echo "List of resources to check: ${resources_to_check[@]}" + + # Maximum number of retries + max_retries=3 + + # Retry intervals in seconds (30, 60, 120) + retry_intervals=(30 60 120) + + # Retry mechanism to check resources + retries=0 + while true; do + resource_found=false + + # Get the list of resources in YAML format again on each retry + resource_list=$(az resource list --resource-group ${{ env.RESOURCE_GROUP_NAME }} --output yaml) + + # Iterate through the resources to check + for resource in "${resources_to_check[@]}"; do + echo "Checking resource: $resource" + if echo "$resource_list" | grep -q "name: $resource"; then + echo "Resource '$resource' exists in the resource group." + resource_found=true + else + echo "Resource '$resource' does not exist in the resource group." + fi + done + + # If any resource exists, retry + if [ "$resource_found" = true ]; then + retries=$((retries + 1)) + if [ "$retries" -gt "$max_retries" ]; then + echo "Maximum retry attempts reached. Exiting." + break + else + # Wait for the appropriate interval for the current retry + echo "Waiting for ${retry_intervals[$retries-1]} seconds before retrying..." + sleep ${retry_intervals[$retries-1]} + fi + else + echo "No resources found. Exiting." + break + fi + done + + + - name: Purging the Resources + if: success() + run: | + + set -e + echo "Azure OpenAI: ${{ env.OPENAI_RESOURCE_NAME }}" + + # Purge OpenAI Resource + echo "Purging the OpenAI Resource..." + if ! az resource delete --ids /subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/providers/Microsoft.CognitiveServices/locations/eastus/resourceGroups/${{ env.RESOURCE_GROUP_NAME }}/deletedAccounts/${{ env.OPENAI_RESOURCE_NAME }} --verbose; then + echo "Failed to purge openai resource: ${{ env.OPENAI_RESOURCE_NAME }}" + else + echo "Purged the openai resource: ${{ env.OPENAI_RESOURCE_NAME }}" + fi + + echo "Resource purging completed successfully" \ No newline at end of file diff --git a/.github/workflows/docker-build-and-push.yml b/.github/workflows/docker-build-and-push.yml new file mode 100644 index 000000000..747181fb8 --- /dev/null +++ b/.github/workflows/docker-build-and-push.yml @@ -0,0 +1,83 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + - dev + - demo + - hotfix + pull_request: + types: + - opened + - ready_for_review + - reopened + - synchronize + branches: + - main + - dev + - demo + - hotfix + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Log in to Azure Container Registry + if: ${{ github.ref_name == 'main' }} + uses: azure/docker-login@v2 + with: + login-server: ${{ secrets.ACR_LOGIN_SERVER }} + username: ${{ secrets.ACR_USERNAME }} + password: ${{ secrets.ACR_PASSWORD }} + + - name: Log in to Azure Container Registry (Dev/Demo) + if: ${{ github.ref_name == 'dev' || github.ref_name == 'demo' || github.ref_name == 'hotfix' }} + uses: azure/docker-login@v2 + with: + login-server: ${{ secrets.ACR_DEV_LOGIN_SERVER }} + username: ${{ secrets.ACR_DEV_USERNAME }} + password: ${{ secrets.ACR_DEV_PASSWORD }} + + - name: Set Docker image tag + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "TAG=latest" >> $GITHUB_ENV + elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "TAG=dev" >> $GITHUB_ENV + elif [[ "${{ github.ref }}" == "refs/heads/demo" ]]; then + echo "TAG=demo" >> $GITHUB_ENV + elif [[ "${{ github.ref }}" == "refs/heads/hotfix" ]]; then + echo "TAG=hotfix" >> $GITHUB_ENV + fi + - name: Build and push Docker images + if: ${{ github.ref_name == 'main' }} + run: | + cd src/backend + docker build -t ${{ secrets.ACR_LOGIN_SERVER }}/macae-backend:${{ env.TAG }} -f Dockerfile . && \ + docker push ${{ secrets.ACR_LOGIN_SERVER }}/macae-backend:${{ env.TAG }} && \ + echo "Backend image built and pushed successfully." + cd ../frontend + docker build -t ${{ secrets.ACR_LOGIN_SERVER }}/mac-webapp:${{ env.TAG }} -f Dockerfile . && \ + docker push ${{ secrets.ACR_LOGIN_SERVER }}/mac-webapp:${{ env.TAG }} && \ + echo "Frontend image built and pushed successfully." + - name: Build and push Docker images (Dev/Demo/hotfix) + if: ${{ github.ref_name == 'dev' || github.ref_name == 'demo' || github.ref_name == 'hotfix' }} + run: | + cd src/backend + docker build -t ${{ secrets.ACR_DEV_LOGIN_SERVER }}/macae-backend:${{ env.TAG }} -f Dockerfile . && \ + docker push ${{ secrets.ACR_DEV_LOGIN_SERVER }}/macae-backend:${{ env.TAG }} && \ + echo "Dev/Demo/Hotfix Backend image built and pushed successfully." + cd ../frontend + docker build -t ${{ secrets.ACR_DEV_LOGIN_SERVER }}/mac-webapp:${{ env.TAG }} -f Dockerfile . && \ + docker push ${{ secrets.ACR_DEV_LOGIN_SERVER }}/mac-webapp:${{ env.TAG }} && \ + echo "Dev/Demo/Hotfix Frontend image built and pushed successfully." + diff --git a/.github/workflows/pr-title-checker.yml b/.github/workflows/pr-title-checker.yml new file mode 100644 index 000000000..0ed320d90 --- /dev/null +++ b/.github/workflows/pr-title-checker.yml @@ -0,0 +1,22 @@ +name: "pr-title-checker" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + merge_group: + +permissions: + pull-requests: read + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + if: ${{ github.event_name != 'merge_group' }} + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 000000000..74fc73c7a --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,27 @@ +name: Pylint and Flake8 + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r src/backend/requirements.txt + pip install flake8 # Ensure flake8 is installed explicitly + + - name: Run flake8 and pylint + run: | + flake8 --config=.flake8 src/backend # Specify the directory to lint diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 000000000..a9ff3f542 --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -0,0 +1,19 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '0 1 * * *' + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: 'This issue is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 30 days.' + days-before-stale: 180 + days-before-close: 30 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..daf9bfd1f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,61 @@ +name: Test Workflow with Coverage + +on: + push: + branches: + - main + - dev + - demo + - hotfix + pull_request: + types: + - opened + - ready_for_review + - reopened + - synchronize + branches: + - main + - main + - dev + - demo + - hotfix + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r src/backend/requirements.txt + pip install pytest-cov + + - name: Check if test files exist + id: check_tests + run: | + if [ -z "$(find src -type f -name 'test_*.py')" ]; then + echo "No test files found, skipping tests." + echo "skip_tests=true" >> $GITHUB_ENV + else + echo "Test files found, running tests." + echo "skip_tests=false" >> $GITHUB_ENV + fi + + - name: Run tests with coverage + if: env.skip_tests == 'false' + run: | + pytest --cov=. --cov-report=term-missing --cov-report=xml + + - name: Skip coverage report if no tests + if: env.skip_tests == 'true' + run: | + echo "Skipping coverage report because no tests were found." diff --git a/README.md b/README.md index 5486a2ee9..fb82a494f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Multi-Agent -Custom Automation Engine Solution Accelerator +# Multi-Agent: Custom Automation Engine – Solution Accelerator -MENU: [**USER STORY**](#user-story) \| [**SIMPLE DEPLOY**](#quick-deploy) \| [**SUPPORTING DOCUMENTATION**](#supporting-documentation) \| +MENU: [**USER STORY**](#user-story) \| [**QUICK DEPLOY**](#quick-deploy) \| [**SUPPORTING DOCUMENTATION**](#supporting-documentation) \|


@@ -80,98 +80,132 @@ There are several ways to deploy the solution. You can deploy to run in Azure i When Deployment is complete, follow steps in [Set Up Authentication in Azure App Service](./documentation/azure_app_service_auth_setup.md) to add app authentication to your web app running on Azure App Service ## Local Deployment -To run the solution site and API backend locally, See the [local deployment guide](./documentation/LocalDeployment.md). +To run the solution site and API backend only locally for development and debugging purposes, See the [local deployment guide](./documentation/LocalDeployment.md). ## Manual Azure Deployment +Manual Deployment differs from the ‘Quick Deploy’ option in that it will install an Azure Container Registry (ACR) service, and relies on the installer to build and push the necessary containers to this ACR. This allows you to build and push your own code changes and provides a sample solution you can customize based on your requirements. + ### Prerequisites -- Azure CLI installed +- Current Azure CLI installed + You can update to the latest version using ```az upgrade``` - Azure account with appropriate permissions - Docker installed -- Azure Container Registry installed -### Get Admin Credentials from ACR +### Deploy the Azure Services +All of the necessary Azure services can be deployed using the /deploy/macae.bicep script. This script will require the following parameters: + +``` +az login +az account set --subscription +az group create --name --location +``` +To deploy the script you can use the Azure CLI. +``` +az deployment group create \ + --resource-group \ + --template-file \ + --name +``` + +Note: if you are using windows with PowerShell, the continuation character (currently ‘\’) should change to the tick mark (‘`’). + +The template will require you fill in locations for Cosmos and OpenAI services. This is to avoid the possibility of regional quota errors for either of these resources. + +### Create the Containers +#### Get admin credentials from ACR Retrieve the admin credentials for your Azure Container Registry (ACR): ```sh az acr credential show \ ---name acrcontoso7wx5mg43sbnl4 \ ---resource-group rg-ssattiraju +--name \ +--resource-group ``` -### Login to ACR +#### Login to ACR Login to your Azure Container Registry: ```sh -az acr login --name acrcontoso7wx5mg43sbnl4 +az acr login --name ``` -### Build Image +#### Build and push the image -Build the Docker image and push it to your Azure Container Registry: +Build the frontend and backend Docker images and push them to your Azure Container Registry. Run the following from the src/backend and the src/frontend directory contexts: ```sh az acr build \ ---registry acrcontoso7wx5mg43sbnl4 \ ---resource-group rg-name \ ---image macae:latest . +--registry \ +--resource-group \ +--image . ``` -### List the Image Created +### Add images to the Container APP and Web App services -List the images in your Azure Container Registry: +To add your newly created backend image: +- Navigate to the Container App Service in the Azure portal +- Click on Application/Containers in the left pane +- Click on the "Edit and deploy" button in the upper left of the containers pane +- In the "Create and deploy new revision" page, click on your container image 'backend'. This will give you the option of reconfiguring the container image, and also has an Environment variables tab +- Change the properties page to + - point to your Azure Container registry with a private image type and your image name (e.g. backendmacae:latest) + - under "Authentication type" select "Managed Identity" and choose the 'mace-containerapp-pull'... identity setup in the bicep template +- In the environment variables section add the following (each with a 'Manual entry' source): -```sh -az acr repository list --name acrcontoso7wx5mg43sbnl4 -``` + name: 'COSMOSDB_ENDPOINT' + value: \ -### Upgrade Container App Extension + name: 'COSMOSDB_DATABASE' + value: 'autogen' + Note: To change the default, you will need to create the database in Cosmos + + name: 'COSMOSDB_CONTAINER' + value: 'memory' -Ensure you have the latest version of the Azure Container Apps extension: -`az extension add --name containerapp --upgrade` + name: 'AZURE_OPENAI_ENDPOINT' + value: -### Get List of Available Locations + name: 'AZURE_OPENAI_DEPLOYMENT_NAME' + value: 'gpt-4o' -Retrieve a list of available Azure locations: -`az account list-locations -o table` + name: 'AZURE_OPENAI_API_VERSION' + value: '2024-08-01-preview' + Note: Version should be updated based on latest available -### Create Apps Environment + name: 'FRONTEND_SITE_NAME' + value: 'https://.azurewebsites.net' -Create an environment for your Azure Container Apps: +- Click 'Save' and deploy your new revision + +To add the new container to your website run the following: -```sh -az containerapp env create \ ---name python-container-env \ ---resource-group rg-name \ ---location southeastasia +``` +az webapp config container set --resource-group macae_full_deploy2_rg \ +--name macae-frontend-2t62qyozi76bs \ +--container-image-name macaeacr2t62qyozi76bs.azurecr.io/frontendmacae:latest \ +--container-registry-url https://macaeacr2t62qyozi76bs.azurecr.io ``` -### Get Credentials -```sh -az acr credential show -n acrcontoso7wx5mg43sbnl4 -``` +### Add the Entra identity provider to the Azure Web App +To add the identity provider, please follow the steps outlined in [Set Up Authentication in Azure App Service](./documentation/azure_app_service_auth_setup.md) -### Create container app +### Run locally and debug -create the container app with the config +To debug the solution, you can use the Cosmos and OpenAI services you have manually deployed. To do this, you need to ensure that your Azure identity has the required permissions on the Cosmos and OpenAI services. -```sh -az containerapp create \ - --name python-container-app \ - --resource-group rg-name \ - --image acrcontoso7wx5mg43sbnl4.azurecr.io/macae:latest \ - --environment python-container-env \ - --ingress external --target-port 8000 \ - --registry-server acrcontoso7wx5mg43sbnl4.azurecr.io \ - --registry-username password \ - --registry-password REGISTRY_PASSWORD \ - --query properties.configuration.ingress.fqdn +- For OpeAI service, you can add yourself to the ‘Cognitive Services OpenAI User’ permission in the Access Control (IAM) pane of the Azure portal. +- Cosmos is a little more difficult as it requires permissions be added through script. See these examples for more information: + - [Use data plane role-based access control - Azure Cosmos DB for NoSQL | Microsoft Learn](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/security/how-to-grant-data-plane-role-based-access?tabs=built-in-definition%2Cpython&pivots=azure-interface-cli) + - [az cosmosdb sql role assignment | Microsoft Learn](https://learn.microsoft.com/en-us/cli/azure/cosmosdb/sql/role/assignment?view=azure-cli-latest#az-cosmosdb-sql-role-assignment-create) + +Add the appropriate endpoints from Cosmos and OpenAI services to your .env file. +Note that you can configure the name of the Cosmos database in the configuration. This can be helpful if you wish to separate the data messages generated in local debugging from those associated with the cloud based solution. If you choose to use a different database, you will need to create that database in the Cosmos instance as this is not done automatically. + +If you are using VSCode, you can use the debug configuration shown in the [local deployment guide](./documentation/LocalDeployment.md). -``` -

## Supporting documentation diff --git a/deploy/macae-continer-oc.json b/deploy/macae-continer-oc.json index a27a91ac2..19e152f0c 100644 --- a/deploy/macae-continer-oc.json +++ b/deploy/macae-continer-oc.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.32.4.45862", - "templateHash": "13409532170922983631" + "templateHash": "17567587246932458853" } }, "parameters": { @@ -363,10 +363,6 @@ "name": "AZURE_OPENAI_API_VERSION", "value": "[variables('aoaiApiVersion')]" }, - { - "name": "DEV_BYPASS_AUTH", - "value": "true" - }, { "name": "FRONTEND_SITE_NAME", "value": "[format('https://{0}.azurewebsites.net', format(variables('uniqueNameFormat'), 'frontend'))]" diff --git a/deploy/macae-continer.bicep b/deploy/macae-continer.bicep index f65d18e49..b4d8aa442 100644 --- a/deploy/macae-continer.bicep +++ b/deploy/macae-continer.bicep @@ -275,10 +275,6 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { name: 'AZURE_OPENAI_API_VERSION' value: aoaiApiVersion } - { - name: 'DEV_BYPASS_AUTH' - value: 'true' - } { name: 'FRONTEND_SITE_NAME' value: 'https://${format(uniqueNameFormat, 'frontend')}.azurewebsites.net' diff --git a/deploy/macae-dev.bicep b/deploy/macae-dev.bicep index dbb8ddf32..e50d27001 100644 --- a/deploy/macae-dev.bicep +++ b/deploy/macae-dev.bicep @@ -1,8 +1,13 @@ @description('Location for all resources.') -param location string = 'EastUS2' //Fixed for model availability, change back to resourceGroup().location +param location string = resourceGroup().location + +@description('location for Cosmos DB resources.') +// prompt for this as there is often quota restrictions +param cosmosLocation string @description('Location for OpenAI resources.') -param azureOpenAILocation string = 'EastUS' //Fixed for model availability +// prompt for this as there is often quota restrictions +param azureOpenAILocation string @description('A prefix to add to the start of all resource names. Note: A "unique" suffix will also be added') param prefix string = 'macae' @@ -60,7 +65,7 @@ resource devAoaiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04- resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { name: format(uniqueNameFormat, 'cosmos') - location: location + location: cosmosLocation tags: tags kind: 'GlobalDocumentDB' properties: { @@ -69,7 +74,7 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { locations: [ { failoverPriority: 0 - locationName: location + locationName: cosmosLocation } ] } @@ -126,5 +131,3 @@ output AZURE_OPENAI_ENDPOINT string = openai.properties.endpoint output AZURE_OPENAI_DEPLOYMENT_NAME string = openai::gpt4o.name output AZURE_OPENAI_API_VERSION string = aoaiApiVersion -// For legacy purposes, output the CLI commands to assign the roles -//output cosmosAssignCli string = 'az cosmosdb sql role assignment create --resource-group "${resourceGroup().name}" --account-name "${cosmos.name}" --role-definition-id "${cosmos::contributorRoleDefinition.id}" --scope "${cosmos.id}" --principal-id "fill-in"' diff --git a/deploy/macae.bicep b/deploy/macae.bicep index 4ffb6b8fe..d969a8de6 100644 --- a/deploy/macae.bicep +++ b/deploy/macae.bicep @@ -1,22 +1,17 @@ @description('Location for all resources.') -param location string = 'EastUS2' //Fixed for model availability, change back to resourceGroup().location +param location string = resourceGroup().location + +@description('location for Cosmos DB resources.') +// prompt for this as there is often quota restrictions +param cosmosLocation string @description('Location for OpenAI resources.') -param azureOpenAILocation string = 'EastUS' //Fixed for model availability +// prompt for this as there is often quota restrictions +param azureOpenAILocation string @description('A prefix to add to the start of all resource names. Note: A "unique" suffix will also be added') param prefix string = 'macae' -@description('The container image name and tag to deploy to the backend container app, if this is not set the container app just default to an empty image') -param backendContainerImageNameTag string = '' - -@description('The container image name and tag for the frontend application, if this is not set the container app just default to an empty image') -param frontendContainerImageNameTag string = '' - -@secure() -@description('The visitor code/password that must be provided to access the container app. If left as-is, a new password will be generated and output. If this is set to empty a new code will be generated on each restart.') -param visitorPassword string = base64(newGuid()) - @description('Tags to apply to all deployed resources') param tags object = {} @@ -31,21 +26,26 @@ param resourceSize { maxReplicas: int } } = { - gpt4oCapacity: 15 - cosmosThroughput: 400 + gpt4oCapacity: 50 + cosmosThroughput: 1000 containerAppSize: { - cpu: '1.0' - memory: '2.0Gi' - minReplicas: 0 + cpu: '2.0' + memory: '4.0Gi' + minReplicas: 1 maxReplicas: 1 } } + +// var appVersion = 'latest' +// var resgistryName = 'biabcontainerreg' +// var dockerRegistryUrl = 'https://${resgistryName}.azurecr.io' +var placeholderImage = 'hello-world:latest' + var uniqueNameFormat = '${prefix}-{0}-${uniqueString(resourceGroup().id, prefix)}' var uniqueShortNameFormat = '${toLower(prefix)}{0}${uniqueString(resourceGroup().id, prefix)}' -var aoaiApiVersion = '2024-08-01-preview' -var emptyContainerImage = 'alpine:latest' -param frontendSiteName string = '${prefix}-frontend-${uniqueString(resourceGroup().id)}' +//var aoaiApiVersion = '2024-08-01-preview' + resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { name: format(uniqueNameFormat, 'logs') @@ -111,9 +111,38 @@ resource acaAoaiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04- } } +resource acr 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { + name: format(uniqueShortNameFormat, 'acr') + location: location + sku: { + name: 'Standard' + } + properties: { + adminUserEnabled: true // Add this line + } +} + +resource pullIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-07-31-preview' = { + name: format(uniqueNameFormat, 'containerapp-pull') + location: location +} + +resource acrPullDefinition 'Microsoft.Authorization/roleDefinitions@2022-05-01-preview' existing = { + name: '7f951dda-4ed3-4680-a7ca-43fe172d538d' //'AcrPull' +} + +resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acr.id, pullIdentity.id, acrPullDefinition.id) + properties: { + principalId: pullIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: acrPullDefinition.id + } +} + resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { name: format(uniqueNameFormat, 'cosmos') - location: location + location: cosmosLocation tags: tags kind: 'GlobalDocumentDB' properties: { @@ -122,7 +151,7 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { locations: [ { failoverPriority: 0 - locationName: location + locationName: cosmosLocation } ] } @@ -161,35 +190,6 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { } } -resource acr 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { - name: format(uniqueShortNameFormat, 'acr') - location: location - sku: { - name: 'Standard' - } - properties: { - adminUserEnabled: true // Add this line - } -} - -resource pullIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-07-31-preview' = { - name: format(uniqueNameFormat, 'containerapp-pull') - location: location -} - -resource acrPullDefinition 'Microsoft.Authorization/roleDefinitions@2022-05-01-preview' existing = { - name: '7f951dda-4ed3-4680-a7ca-43fe172d538d' //'AcrPull' -} - -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(acr.id, pullIdentity.id, acrPullDefinition.id) - properties: { - principalId: pullIdentity.properties.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: acrPullDefinition.id - } -} - resource containerAppEnv 'Microsoft.App/managedEnvironments@2024-03-01' = { name: format(uniqueNameFormat, 'containerapp') location: location @@ -222,12 +222,13 @@ resource acaCosomsRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleA } } +@description('') resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { name: '${prefix}-backend' location: location tags: tags identity: { - type: 'SystemAssigned,UserAssigned' + type: 'SystemAssigned, UserAssigned' userAssignedIdentities: { '${pullIdentity.id}': {} } @@ -238,14 +239,14 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { ingress: { targetPort: 8000 external: true + corsPolicy: { + allowedOrigins: [ + 'https://${format(uniqueNameFormat, 'frontend')}.azurewebsites.net' + 'http://${format(uniqueNameFormat, 'frontend')}.azurewebsites.net' + ] + } } activeRevisionsMode: 'Single' - registries: [ - { - server: acr.properties.loginServer - identity: pullIdentity.id - } - ] } template: { scale: { @@ -265,55 +266,51 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { containers: [ { name: 'backend' - image: empty(trim(backendContainerImageNameTag )) - ? emptyContainerImage - : '${acr.properties.loginServer}/${backendContainerImageNameTag }' + image: placeholderImage resources: { cpu: json(resourceSize.containerAppSize.cpu) memory: resourceSize.containerAppSize.memory } - env: [ - { - name: 'COSMOSDB_ENDPOINT' - value: cosmos.properties.documentEndpoint - } - { - name: 'COSMOSDB_DATABASE' - value: cosmos::autogenDb.name - } - { - name: 'COSMOSDB_CONTAINER' - value: cosmos::autogenDb::memoryContainer.name - } - { - name: 'AZURE_OPENAI_ENDPOINT' - value: openai.properties.endpoint - } - { - name: 'AZURE_OPENAI_DEPLOYMENT_NAME' - value: openai::gpt4o.name - } - { - name: 'AZURE_OPENAI_API_VERSION' - value: aoaiApiVersion - } - { - name: 'VISITOR_PASSWORD' - value: visitorPassword - } - { - name: 'FRONTEND_SITE_NAME' - value: 'https://${frontendSiteName}.azurewebsites.net' - } - ] } + // env: [ + // { + // name: 'COSMOSDB_ENDPOINT' + // value: cosmos.properties.documentEndpoint + // } + // { + // name: 'COSMOSDB_DATABASE' + // value: cosmos::autogenDb.name + // } + // { + // name: 'COSMOSDB_CONTAINER' + // value: cosmos::autogenDb::memoryContainer.name + // } + // { + // name: 'AZURE_OPENAI_ENDPOINT' + // value: openai.properties.endpoint + // } + // { + // name: 'AZURE_OPENAI_DEPLOYMENT_NAME' + // value: openai::gpt4o.name + // } + // { + // name: 'AZURE_OPENAI_API_VERSION' + // value: aoaiApiVersion + // } + // { + // name: 'FRONTEND_SITE_NAME' + // value: 'https://${format(uniqueNameFormat, 'frontend')}.azurewebsites.net' + // } + // ] + // } ] } + } -} + } resource frontendAppServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { - name: '${prefix}-frontend-plan-${uniqueString(resourceGroup().id)}' + name: format(uniqueNameFormat, 'frontend-plan') location: location tags: tags sku: { @@ -328,7 +325,7 @@ resource frontendAppServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { } resource frontendAppService 'Microsoft.Web/sites@2021-02-01' = { - name: frontendSiteName + name: format(uniqueNameFormat, 'frontend') location: location tags: tags kind: 'app,linux,container' // Add this line @@ -336,19 +333,11 @@ resource frontendAppService 'Microsoft.Web/sites@2021-02-01' = { serverFarmId: frontendAppServicePlan.id reserved: true siteConfig: { - linuxFxVersion:'DOCKER|nginx:latest' + linuxFxVersion:''//'DOCKER|${frontendDockerImageURL}' appSettings: [ { name: 'DOCKER_REGISTRY_SERVER_URL' - value: 'https://${acr.properties.loginServer}' - } - { - name: 'DOCKER_REGISTRY_SERVER_USERNAME' - value: acr.listCredentials().username - } - { - name: 'DOCKER_REGISTRY_SERVER_PASSWORD' - value: acr.listCredentials().passwords[0].value + value: acr.properties.loginServer } { name: 'WEBSITES_PORT' @@ -365,14 +354,13 @@ resource frontendAppService 'Microsoft.Web/sites@2021-02-01' = { ] } } + dependsOn: [containerApp] + identity: { + type: 'SystemAssigned, UserAssigned' + userAssignedIdentities: { + '${pullIdentity.id}': {} + } + } } -var backendBuildImageTag = 'backend:latest' -var frontendBuildImageTag = 'frontend:latest' - -output buildBackendCommand string = 'az acr build -r ${acr.name} -t ${acr.name}.azurecr.io/${backendBuildImageTag} ./macae/backend' -output runBackendCommand string = 'az containerapp update -n ${containerApp.name} -g ${resourceGroup().name} --image ${acr.properties.loginServer}/${backendBuildImageTag}' -output buildFrontendCommand string = 'az acr build -r ${acr.name} -t ${acr.name}.azurecr.io/${frontendBuildImageTag} ./macae/frontend' -output runFrontendCommand string = 'az webapp config container set --name ${frontendAppService.name} --resource-group ${resourceGroup().name} --docker-custom-image-name ${acr.properties.loginServer}/${frontendBuildImageTag} --docker-registry-server-url ${acr.properties.loginServer}' output cosmosAssignCli string = 'az cosmosdb sql role assignment create --resource-group "${resourceGroup().name}" --account-name "${cosmos.name}" --role-definition-id "${cosmos::contributorRoleDefinition.id}" --scope "${cosmos.id}" --principal-id "fill-in"' -output backendApiUrl string = containerApp.properties.configuration.ingress.fqdn diff --git a/documentation/CustomizeSolution.md b/documentation/CustomizeSolution.md index c319c219c..a867892cd 100644 --- a/documentation/CustomizeSolution.md +++ b/documentation/CustomizeSolution.md @@ -15,36 +15,45 @@ This application is an AI-driven orchestration system that manages a group of AI This code has not been tested as an end-to-end, reliable production application- it is a foundation to help accelerate building out multi-agent systems. You are encouraged to add your own data and functions to the agents, and then you must apply your own performance and safety evaluation testing frameworks to this system before deploying it. Below, we'll dive into the details of each component, focusing on the endpoints, data types, and the flow of information through the system. - # Table of Contents -- [Accelerating your own Multi-Agent -Custom Automation Engine MVP](#accelerating-your-own-multi-agent--custom-automation-engine-mvp) +- [Accelerating your own Multi-Agent - Custom Automation Engine MVP](#accelerating-your-own-multi-agent---custom-automation-engine-mvp) - [Technical Overview](#technical-overview) - [Table of Contents](#table-of-contents) - [Endpoints](#endpoints) - [/input\_task](#input_task) - [/human\_feedback](#human_feedback) - - [/get\_latest\_plan\_by\_session/{session\_id}](#get_latest_plan_by_sessionsession_id) - - [/get\_steps\_by\_plan/{plan\_id}](#get_steps_by_planplan_id) + - [/get\_latest\_plan\_by\_session/{session\_id}](#get_latest_plan_by_session-session_id) + - [/steps/{plan\_id}](#stepsplan_id) + - [/agent\_messages/{session\_id}](#agent_messagessession_id) + - [/messages](#messages) - [/delete\_all\_messages](#delete_all_messages) + - [/api/agent-tools](#apiagent-tools) - [Data Types and Models](#data-types-and-models) - [Messages](#messages) - - [InputTask](#inputtask) + - [BaseDataModel](#basedatamodel) + - [AgentMessage](#agentmessage) + - [Session](#session) - [Plan](#plan) - [Step](#step) - - [HumanFeedback](#humanfeedback) + - [PlanWithSteps](#planwithsteps) + - [InputTask](#inputtask) - [ApprovalRequest](#approvalrequest) + - [HumanFeedback](#humanfeedback) + - [HumanClarification](#humanclarification) - [ActionRequest](#actionrequest) - [ActionResponse](#actionresponse) - - [Agents](#agents) - - [Agent Types:](#agent-types) + - [PlanStateUpdate](#planstateupdate) + - [GroupChatMessage](#groupchatmessage) + - [RequestToSpeak](#requesttospeak) + - [Enums](#enums) + - [DataType](#datatype) + - [BAgentType](#bagenttype) + - [StepStatus](#stepstatus) + - [PlanStatus](#planstatus) + - [HumanFeedbackStatus](#humanfeedbackstatus) - [Application Flow](#application-flow) - [Initialization](#initialization) - - [Input Task Handling](#input-task-handling) - - [Planning](#planning) - - [Step Execution and Approval](#step-execution-and-approval) - - [Human Feedback](#human-feedback) - - [Action Execution by Specialized Agents](#action-execution-by-specialized-agents) - [Agents Overview](#agents-overview) - [GroupChatManager](#groupchatmanager) - [PlannerAgent](#planneragent) @@ -52,198 +61,483 @@ Below, we'll dive into the details of each component, focusing on the endpoints, - [Specialized Agents](#specialized-agents) - [Persistent Storage with Cosmos DB](#persistent-storage-with-cosmos-db) - [Utilities](#utilities) - - [`initialize` Function](#initialize-function) + - [`initialize_runtime_and_context` Function](#initialize_runtime_and_context-function) - [Summary](#summary) + ## Endpoints ### /input_task **Method:** POST **Description:** Receives the initial input task from the user. -**Request Body:** `InputTask` +**Request Headers:** + +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. + +**Request Body:** `InputTask` - `session_id`: Optional string. If not provided, a new UUID will be generated. - `description`: The description of the task the user wants to accomplish. -**Response:** +**Response:** - `status`: Confirmation message. - `session_id`: The session ID associated with the task. - `plan_id`: The ID of the plan generated. +- `description`: The task description. + **Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. Generates a `session_id` if not provided. +3. Initializes agents and context for the session. +4. Sends the `InputTask` message to the `GroupChatManager`. +5. Returns the `status`, `session_id`, `plan_id`, `description`, and `user_id`. -1. Generates a `session_id` if not provided. -2. Initializes agents and context for the session. -3. Sends the `InputTask` message to the `GroupChatManager`. -4. Returns the `session_id` and `plan_id`. ### /human_feedback **Method:** POST **Description:** Receives human feedback on a step (e.g., approval, rejection, or modification). -**Request Body:** `HumanFeedback` -- `step_id`: ID of the step the feedback is related to. -- `plan_id`: ID of the plan. +**Request Headers:** +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. + +**Request Body:** `HumanFeedback` +- `step_id`: The ID of the step to provide feedback for. +- `plan_id`: The ID of the plan. - `session_id`: The session ID. - `approved`: Boolean indicating if the step is approved. - `human_feedback`: Optional string containing any comments. - `updated_action`: Optional string if the action was modified. **Response:** - - `status`: Confirmation message. - `session_id`: The session ID. +- `step_id`: The step ID associated with the feedback. + +**Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. Initializes runtime and context for the session. +3. Sends the `HumanFeedback` message to the `HumanAgent`. +4. Returns the `status`, `session_id`, and `step_id`. + + +### /human_clarification_on_plan + +**Method:** POST +**Description:** Receives human clarification on a plan. + +**Request Headers:** +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. + +**Request Body:** `HumanClarification` +- `plan_id`: The ID of the plan requiring clarification. +- `session_id`: The session ID associated with the plan. +- `human_clarification`: Clarification details provided by the user. + +**Response:** +- `status`: Confirmation message. +- `session_id`: The session ID associated with the plan. + +**Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. Initializes runtime and context for the session. +3. Sends the `HumanClarification` message to the `PlannerAgent`. +4. Returns the `status` and `session_id`. + +### /approve_step_or_steps + +**Method:** POST +**Description:** Approves a step or multiple steps in a plan. + +**Request Headers:** + +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. + +**Request Body:** `HumanFeedback` +- `step_id`: Optional step ID to approve. If not provided, all steps are approved. +- `plan_id`: The ID of the plan. +- `session_id`: The session ID associated with the plan. +- `approved`: Boolean indicating whether the step(s) are approved. +- `human_feedback`: Optional string containing any comments. +- `updated_action`: Optional string if the action was modified. + +**Response:** +- `status`: A confirmation message indicating the approval result. **Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. Initializes runtime and context for the session. +3. Sends the `HumanFeedback` approval message to the `GroupChatManager`. +4. If `step_id` is provided, approves the specific step; otherwise, approves all steps. +5. Returns the `status` message indicating the result of the approval. + +### /plans + +**Method:** GET +**Description:** Retrieves all plans for the current user or the plan for a specific session. + +**Request Headers:** + +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. -1. Initializes runtime and context for the session. -2. Sends the `HumanFeedback` message to the `HumanAgent`. +**Query Parameters:** +- `session_id` (optional): Retrieve the plan for this specific session ID. If not provided, all plans for the user are retrieved. -### /get_latest_plan_by_session/{session_id} +**Response:** +- A list of plans with their details: + - `id`: Unique ID of the plan. + - `session_id`: The session ID associated with the plan. + - `initial_goal`: The initial goal derived from the user's input. + - `overall_status`: The status of the plan (e.g., in_progress, completed, failed). + - `steps`: A list of steps associated with the plan, each including: + - `id`: Unique ID of the step. + - `plan_id`: ID of the plan the step belongs to. + - `action`: The action to be performed. + - `agent`: The agent responsible for the step. + - `status`: The status of the step (e.g., planned, approved, completed). + - `agent_reply`: Optional response from the agent after execution. + - `human_feedback`: Optional feedback provided by the user. + - `updated_action`: Optional modified action based on feedback. + +**Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. If `session_id` is provided: + - Retrieves the plan for the specified session ID. + - Fetches the steps for the plan. +3. If `session_id` is not provided: + - Retrieves all plans for the user. + - Fetches the steps for each plan concurrently. +4. Returns the plan(s) along with their steps. + +### /steps/{plan_id} **Method:** GET -**Description:** Retrieves the plan associated with a specific session. -**Response:** List of `Plan` objects. +**Description:** Retrieves all steps associated with a specific plan. + +**Request Headers:** + +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. + +**Path Parameters:** +- `plan_id`: The ID of the plan to retrieve steps for. + +**Response:** +- A list of steps with their details: + - `id`: Unique ID of the step. + - `plan_id`: The ID of the plan the step belongs to. + - `action`: The action to be performed. + - `agent`: The agent responsible for the step. + - `status`: The status of the step (e.g., planned, approved, completed). + - `agent_reply`: Optional response from the agent after execution. + - `human_feedback`: Optional feedback provided by the user. + - `updated_action`: Optional modified action based on feedback. + +**Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. Retrieves the steps for the specified `plan_id`. +3. Returns the list of steps with their details. -### /get_steps_by_plan/{plan_id} +### /agent_messages/{session_id} **Method:** GET -**Description:** Retrieves the steps associated with a specific plan. -**Response:** List of `Step` objects. +**Description:** Retrieves all agent messages for a specific session. -### /delete_all_messages +**Request Headers:** +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. + +**Path Parameters:** +- `session_id`: The ID of the session to retrieve agent messages for. + +**Response:** +- A list of agent messages with their details: + - `id`: Unique ID of the agent message. + - `session_id`: The session ID associated with the message. + - `plan_id`: The ID of the plan related to the agent message. + - `content`: The content of the message. + - `source`: The source of the message (e.g., agent type). + - `ts`: The timestamp of the message. + - `step_id`: Optional step ID associated with the message. + +**Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. Retrieves the agent messages for the specified `session_id`. +3. Returns the list of agent messages with their details. + +### /messages **Method:** DELETE -**Description:** Deletes all messages across sessions (use with caution). -**Response:** Confirmation of deletion. +**Description:** Deletes all messages across sessions. + +**Request Headers:** + +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. -## Data Types and Models +**Response:** +- A confirmation message: + - `status`: A status message indicating all messages were deleted. -### Messages +**Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. Deletes all messages across sessions, including: + - Plans + - Sessions + - Steps + - Agent messages +3. Returns a confirmation `status` message. -#### InputTask +### /messages -Represents the initial task input from the user. +**Method:** GET +**Description:** Retrieves all messages across sessions. -**Fields:** +**Request Headers:** -- `session_id`: The session ID. Generated if not provided. -- `description`: The description of the task. +- `user_principal_id`: User ID (`user_id`) extracted from the authentication header. -#### Plan +**Response:** +- A list of all messages with their details: + - `id`: Unique ID of the message. + - `data_type`: The type of the message (e.g., session, step, plan, agent_message). + - `session_id`: The session ID associated with the message. + - `content`: The content of the message. + - `ts`: The timestamp of the message. -Represents a plan containing multiple steps to accomplish the task. +**Flow:** +1. Validates header and extracts `user_principal_id` as `user_id`. +2. Retrieves all messages across sessions. +3. Returns the list of messages with their details. -**Fields:** +### /api/agent-tools -- `id`: Unique ID of the plan. -- `session_id`: The session ID. -- `initial_goal`: The initial goal derived from the user's input. -- `overall_status`: Status of the plan (in_progress, completed, failed). -- `source`: Origin of the plan (e.g., PlannerAgent). +**Method:** GET +**Description:** Retrieves all available agent tools and their descriptions. -#### Step +**Response:** +- A list of agent tools with their details: + - `agent`: The name of the agent associated with the tool. + - `function`: The name of the tool function. + - `description`: A detailed description of what the tool does. + - `arguments`: The arguments required by the tool function. -Represents an individual step within a plan. +**Flow:** +1. Retrieves all agent tools and their metadata. +2. Returns the list of agent tools with their details. + + +## Models and Datatypes +### Models +#### **`BaseDataModel`** +The `BaseDataModel` is a foundational class for creating structured data models using Pydantic. It provides the following attributes: + +- **`id`**: A unique identifier for the data, generated using `uuid`. +- **`ts`**: An optional timestamp indicating when the model instance was created or modified. + +#### **`AgentMessage`** +The `AgentMessage` model represents communication between agents and includes the following fields: + +- **`id`**: A unique identifier for the message, generated using `uuid`. +- **`data_type`**: A literal value of `"agent_message"` to identify the message type. +- **`session_id`**: The session associated with this message. +- **`user_id`**: The ID of the user associated with this message. +- **`plan_id`**: The ID of the related plan. +- **`content`**: The content of the message. +- **`source`**: The origin or sender of the message (e.g., an agent). +- **`ts`**: An optional timestamp for when the message was created. +- **`step_id`**: An optional ID of the step associated with this message. + +#### **`Session`** +The `Session` model represents a user session and extends the `BaseDataModel`. It has the following attributes: + +- **`data_type`**: A literal value of `"session"` to identify the type of data. +- **`current_status`**: The current status of the session (e.g., `active`, `completed`). +- **`message_to_user`**: An optional field to store any messages sent to the user. +- **`ts`**: An optional timestamp for the session's creation or last update. + + +#### **`Plan`** +The `Plan` model represents a high-level structure for organizing actions or tasks. It extends the `BaseDataModel` and includes the following attributes: + +- **`data_type`**: A literal value of `"plan"` to identify the data type. +- **`session_id`**: The ID of the session associated with this plan. +- **`initial_goal`**: A description of the initial goal derived from the user’s input. +- **`overall_status`**: The overall status of the plan (e.g., `in_progress`, `completed`, `failed`). + +#### **`Step`** +The `Step` model represents a discrete action or task within a plan. It extends the `BaseDataModel` and includes the following attributes: + +- **`data_type`**: A literal value of `"step"` to identify the data type. +- **`plan_id`**: The ID of the plan the step belongs to. +- **`action`**: The specific action or task to be performed. +- **`agent`**: The name of the agent responsible for executing the step. +- **`status`**: The status of the step (e.g., `planned`, `approved`, `completed`). +- **`agent_reply`**: An optional response from the agent after executing the step. +- **`human_feedback`**: Optional feedback provided by a user about the step. +- **`updated_action`**: Optional modified action based on human feedback. +- **`session_id`**: The session ID associated with the step. +- **`user_id`**: The ID of the user providing feedback or interacting with the step. + +#### **`PlanWithSteps`** +The `PlanWithSteps` model extends the `Plan` model and includes additional information about the steps in the plan. It has the following attributes: + +- **`steps`**: A list of `Step` objects associated with the plan. +- **`total_steps`**: The total number of steps in the plan. +- **`completed_steps`**: The number of steps that have been completed. +- **`pending_steps`**: The number of steps that are pending approval or completion. + +**Additional Features**: +The `PlanWithSteps` model provides methods to update step counts: +- `update_step_counts()`: Calculates and updates the `total_steps`, `completed_steps`, and `pending_steps` fields based on the associated steps. + +#### **`InputTask`** +The `InputTask` model represents the user’s initial input for creating a plan. It includes the following attributes: + +- **`session_id`**: An optional string for the session ID. If not provided, a new UUID will be generated. +- **`description`**: A string describing the task or goal the user wants to accomplish. +- **`user_id`**: The ID of the user providing the input. + +#### **`ApprovalRequest`** +The `ApprovalRequest` model represents a request to approve a step or multiple steps. It includes the following attributes: + +- **`step_id`**: An optional string representing the specific step to approve. If not provided, the request applies to all steps. +- **`plan_id`**: The ID of the plan containing the step(s) to approve. +- **`session_id`**: The ID of the session associated with the approval request. +- **`approved`**: A boolean indicating whether the step(s) are approved. +- **`human_feedback`**: An optional string containing comments or feedback from the user. +- **`updated_action`**: An optional string representing a modified action based on feedback. +- **`user_id`**: The ID of the user making the approval request. -**Fields:** -- `id`: Unique ID of the step. -- `plan_id`: ID of the plan the step belongs to. -- `action`: The action to be performed. -- `agent`: The agent responsible for the step. -- `status`: Status of the step (e.g., planned, approved, completed). -- `agent_reply`: The response from the agent after executing the action. -- `human_feedback`: Any feedback provided by the human. -- `updated_action`: If the action was modified by human feedback. -- `session_id`: The session ID. +#### **`HumanFeedback`** +The `HumanFeedback` model captures user feedback on a specific step or plan. It includes the following attributes: + +- **`step_id`**: The ID of the step the feedback is related to. +- **`plan_id`**: The ID of the plan containing the step. +- **`session_id`**: The session ID associated with the feedback. +- **`approved`**: A boolean indicating if the step is approved. +- **`human_feedback`**: Optional comments or feedback provided by the user. +- **`updated_action`**: Optional modified action based on the feedback. +- **`user_id`**: The ID of the user providing the feedback. -#### HumanFeedback +#### **`HumanClarification`** +The `HumanClarification` model represents clarifications provided by the user about a plan. It includes the following attributes: -Contains human feedback on a step, such as approval or rejection. +- **`plan_id`**: The ID of the plan requiring clarification. +- **`session_id`**: The session ID associated with the plan. +- **`human_clarification`**: The clarification details provided by the user. +- **`user_id`**: The ID of the user providing the clarification. -**Fields:** +#### **`ActionRequest`** +The `ActionRequest` model captures a request to perform an action within the system. It includes the following attributes: -- `step_id`: ID of the step the feedback is about. -- `plan_id`: ID of the plan. -- `session_id`: The session ID. -- `approved`: Boolean indicating approval. -- `human_feedback`: Optional comments. -- `updated_action`: Optional modified action. +- **`session_id`**: The session ID associated with the action request. +- **`plan_id`**: The ID of the plan associated with the action. +- **`step_id`**: Optional ID of the step associated with the action. +- **`action`**: A string describing the action to be performed. +- **`user_id`**: The ID of the user requesting the action. -#### ApprovalRequest +#### **`ActionResponse`** +The `ActionResponse` model represents the response to an action request. It includes the following attributes: -Sent to the HumanAgent to request approval for a step. +- **`status`**: A string indicating the status of the action (e.g., `success`, `failure`). +- **`message`**: An optional string providing additional details or context about the action's result. +- **`data`**: Optional data payload containing any relevant information from the action. +- **`user_id`**: The ID of the user associated with the action response. -**Fields:** +#### **`PlanStateUpdate`** +The `PlanStateUpdate` model represents an update to the state of a plan. It includes the following attributes: -- `step_id`: ID of the step. -- `plan_id`: ID of the plan. -- `session_id`: The session ID. -- `action`: The action to be approved. -- `agent`: The agent responsible for the action. +- **`plan_id`**: The ID of the plan being updated. +- **`session_id`**: The session ID associated with the plan. +- **`new_state`**: A string representing the new state of the plan (e.g., `in_progress`, `completed`, `failed`). +- **`user_id`**: The ID of the user making the state update. +- **`timestamp`**: An optional timestamp indicating when the update was made. -#### ActionRequest +--- -Sent to specialized agents to perform an action. +#### **`GroupChatMessage`** +The `GroupChatMessage` model represents a message sent in a group chat context. It includes the following attributes: -**Fields:** +- **`message_id`**: A unique ID for the message. +- **`session_id`**: The session ID associated with the group chat. +- **`user_id`**: The ID of the user sending the message. +- **`content`**: The text content of the message. +- **`timestamp`**: A timestamp indicating when the message was sent. -- `step_id`: ID of the step. -- `plan_id`: ID of the plan. -- `session_id`: The session ID. -- `action`: The action to be performed. -- `agent`: The agent that should perform the action. +--- -#### ActionResponse +#### **`RequestToSpeak`** +The `RequestToSpeak` model represents a user's request to speak or take action in a group chat or collaboration session. It includes the following attributes: -Contains the response from an agent after performing an action. +- **`request_id`**: A unique ID for the request. +- **`session_id`**: The session ID associated with the request. +- **`user_id`**: The ID of the user making the request. +- **`reason`**: A string describing the reason or purpose of the request. +- **`timestamp`**: A timestamp indicating when the request was made. -**Fields:** -- `step_id`: ID of the step. -- `plan_id`: ID of the plan. -- `session_id`: The session ID. -- `result`: The result of the action. -- `status`: Status of the step (completed, failed). +### Data Types + +#### **`DataType`** +The `DataType` enumeration defines the types of data used in the system. Possible values include: +- **`plan`**: Represents a plan data type. +- **`session`**: Represents a session data type. +- **`step`**: Represents a step data type. +- **`agent_message`**: Represents an agent message data type. + +--- + +#### **`BAgentType`** +The `BAgentType` enumeration defines the types of agents in the system. Possible values include: +- **`human`**: Represents a human agent. +- **`ai_assistant`**: Represents an AI assistant agent. +- **`external_service`**: Represents an external service agent. -### Agents +#### **`StepStatus`** +The `StepStatus` enumeration defines the possible statuses for a step. Possible values include: +- **`planned`**: Indicates the step is planned but not yet approved or completed. +- **`approved`**: Indicates the step has been approved. +- **`completed`**: Indicates the step has been completed. +- **`failed`**: Indicates the step has failed. -#### Agent Types: -- GroupChatManager -- PlannerAgent -- HumanAgent -- HrAgent -- LegalAgent -- MarketingAgent -- ProcurementAgent -- ProductAgent -- TechSupportAgent +#### **`PlanStatus`** +The `PlanStatus` enumeration defines the possible statuses for a plan. Possible values include: +- **`in_progress`**: Indicates the plan is currently in progress. +- **`completed`**: Indicates the plan has been successfully completed. +- **`failed`**: Indicates the plan has failed. -## Application Flow -### Initialization +#### **`HumanFeedbackStatus`** +The `HumanFeedbackStatus` enumeration defines the possible statuses for human feedback. Possible values include: +- **`pending`**: Indicates the feedback is awaiting review or action. +- **`addressed`**: Indicates the feedback has been addressed. +- **`rejected`**: Indicates the feedback has been rejected. + + +### Application Flow + +#### **Initialization** The initialization process sets up the necessary agents and context for a session. This involves: -- Generating unique AgentIds that include the `session_id` to ensure uniqueness per session. -- Instantiating agents and registering them with the runtime. -- Setting up the Azure OpenAI Chat Completion Client for LLM interactions. -- Creating a `CosmosBufferedChatCompletionContext` for stateful storage. +- **Generating Unique AgentIds**: Each agent is assigned a unique `AgentId` based on the `session_id`, ensuring that multiple sessions can operate independently. +- **Instantiating Agents**: Various agents, such as `PlannerAgent`, `HrAgent`, and `GroupChatManager`, are initialized and registered with unique `AgentIds`. +- **Setting Up Azure OpenAI Client**: The Azure OpenAI Chat Completion Client is initialized to handle LLM interactions with support for function calling, JSON output, and vision handling. +- **Creating Cosmos DB Context**: A `CosmosBufferedChatCompletionContext` is established for stateful interaction storage. **Code Reference: `utils.py`** - async def initialize(session_id: Optional[str] = None) -> Tuple[SingleThreadedAgentRuntime, CosmosBufferedChatCompletionContext]: - # Generate session_id if not provided - # Check if session already initialized - # Initialize agents with unique AgentIds - # Create Cosmos DB context - # Register tool agents and specialized agents - # Start the runtime +**Steps:** +1. **Session ID Generation**: If `session_id` is not provided, a new UUID is generated. +2. **Agent Registration**: Each agent is assigned a unique `AgentId` and registered with the runtime. +3. **Azure OpenAI Initialization**: The LLM client is configured for advanced interactions. +4. **Cosmos DB Context Creation**: A buffered context is created for storing stateful interactions. +5. **Runtime Start**: The runtime is started, enabling communication and agent operation. + + ### Input Task Handling diff --git a/documentation/LocalDeployment.md b/documentation/LocalDeployment.md index 0ac70217d..ae3aa7adc 100644 --- a/documentation/LocalDeployment.md +++ b/documentation/LocalDeployment.md @@ -42,6 +42,7 @@ ```bash az ad signed-in-user show --query id -o tsv ``` + You will also be prompted for locations for Cosmos and Open AI services. This is to allow separate regions where there may be service quota restrictions 5. **Create a `.env` file:** @@ -53,22 +54,28 @@ 7. **(Optional) Set up a virtual environment:** - - If you are using `venv`, create and activate your virtual environment. + - If you are using `venv`, create and activate your virtual environment for both the frontend and backend folders. -8. **Install requirements:** +8. **Install requirements - frontend:** - - Open a terminal in the `src` folder and run: + - In each of the frontend and backend folders - + Open a terminal in the `src` folder and run: ```bash pip install -r requirements.txt ``` 9. **Run the application:** - + - From the src/backend directory: ```bash python app.py ``` + - In a new terminal from the src/frontend directory + ```bash + python frontend_server.py + ``` -10. Open a browser and navigate to `http://localhost:8000` +10. Open a browser and navigate to `http://localhost:3000` +11. To see swagger API documentation, you can navigate to `http://localhost:8000/docs` ## Debugging the solution locally diff --git a/src/backend/.env.sample b/src/backend/.env.sample index 1ae0e161f..32a8b10a6 100644 --- a/src/backend/.env.sample +++ b/src/backend/.env.sample @@ -6,4 +6,5 @@ AZURE_OPENAI_ENDPOINT= AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o AZURE_OPENAI_API_VERSION=2024-08-01-preview -DEV_BYPASS_AUTH=true +BACKEND_API_URL='http://localhost:8000' +FRONTEND_SITE_NAME='http://127.0.0.1:3000' \ No newline at end of file diff --git a/src/backend/agents/group_chat_manager.py b/src/backend/agents/group_chat_manager.py index a418cc1ee..2b62b794e 100644 --- a/src/backend/agents/group_chat_manager.py +++ b/src/backend/agents/group_chat_manager.py @@ -2,6 +2,7 @@ import logging from datetime import datetime +import re from typing import Dict, List from autogen_core.base import AgentId, MessageContext @@ -239,14 +240,22 @@ async def _execute_step(self, session_id: str, step: Step): action=action_with_history, agent=step.agent, ) - logging.info(f"Sending ActionRequest to {step.agent.value.title()}") + logging.info(f"Sending ActionRequest to {step.agent.value}") + + if step.agent != "": + agent_name = step.agent.value + formatted_agent = re.sub( + r"([a-z])([A-Z])", r"\1 \2", agent_name + ) + else: + raise ValueError(f"Check {step.agent} is missing") await self._memory.add_item( AgentMessage( session_id=session_id, user_id=self._user_id, plan_id=step.plan_id, - content=f"Requesting {step.agent.value.title()} to perform action: {step.action}", + content=f"Requesting {formatted_agent} to perform action: {step.action}", source="GroupChatManager", step_id=step.id, ) diff --git a/src/backend/app.py b/src/backend/app.py index a4f609c3e..a5ba33c80 100644 --- a/src/backend/app.py +++ b/src/backend/app.py @@ -57,13 +57,50 @@ @app.post("/input_task") async def input_task_endpoint(input_task: InputTask, request: Request): """ - Endpoint to receive the initial input task from the user. - - Args: - input_task (InputTask): The input task containing the session ID and description. - - Returns: - dict: Status message, session ID, and plan ID. + Receive the initial input task from the user. + + --- + tags: + - Input Task + parameters: + - name: user_principal_id + in: header + type: string + required: true + description: User ID extracted from the authentication header + - name: body + in: body + required: true + schema: + type: object + properties: + session_id: + type: string + description: Optional session ID, generated if not provided + description: + type: string + description: The task description + user_id: + type: string + description: The user ID associated with the task + responses: + 200: + description: Task created successfully + schema: + type: object + properties: + status: + type: string + session_id: + type: string + plan_id: + type: string + description: + type: string + user_id: + type: string + 400: + description: Missing or invalid user information """ if not rai_success(input_task.description): @@ -98,21 +135,58 @@ async def input_task_endpoint(input_task: InputTask, request: Request): @app.post("/human_feedback") async def human_feedback_endpoint(human_feedback: HumanFeedback, request: Request): """ - Endpoint to receive human feedback on a step. - - Args: - human_feedback (HumanFeedback): The human feedback message. - - class HumanFeedback(BaseModel): - step_id: str - plan_id: str - session_id: str - approved: bool - human_feedback: Optional[str] = None - updated_action: Optional[str] = None - - Returns: - dict: Status message and session ID. + Receive human feedback on a step. + + --- + tags: + - Feedback + parameters: + - name: user_principal_id + in: header + type: string + required: true + description: User ID extracted from the authentication header + - name: body + in: body + required: true + schema: + type: object + properties: + step_id: + type: string + description: The ID of the step to provide feedback for + plan_id: + type: string + description: The plan ID + session_id: + type: string + description: The session ID + approved: + type: boolean + description: Whether the step is approved + human_feedback: + type: string + description: Optional feedback details + updated_action: + type: string + description: Optional updated action + user_id: + type: string + description: The user ID providing the feedback + responses: + 200: + description: Feedback received successfully + schema: + type: object + properties: + status: + type: string + session_id: + type: string + step_id: + type: string + 400: + description: Missing or invalid user information """ authenticated_user = get_authenticated_user_details( request_headers=request.headers @@ -136,18 +210,47 @@ class HumanFeedback(BaseModel): @app.post("/human_clarification_on_plan") async def human_clarification_endpoint(human_clarification: HumanClarification, request: Request): """ - Endpoint to receive human clarification on the plan. - - Args: - human_clarification (HumanClarification): The human clarification message. - - class HumanFeedback(BaseModel): - plan_id: str - session_id: str - human_clarification: str - - Returns: - dict: Status message and session ID. + Receive human clarification on a plan. + + --- + tags: + - Clarification + parameters: + - name: user_principal_id + in: header + type: string + required: true + description: User ID extracted from the authentication header + - name: body + in: body + required: true + schema: + type: object + properties: + plan_id: + type: string + description: The plan ID requiring clarification + session_id: + type: string + description: The session ID + human_clarification: + type: string + description: Clarification details provided by the user + user_id: + type: string + description: The user ID providing the clarification + responses: + 200: + description: Clarification received successfully + schema: + type: object + properties: + status: + type: string + session_id: + type: string + 400: + description: Missing or invalid user information """ authenticated_user = get_authenticated_user_details( request_headers=request.headers @@ -170,7 +273,54 @@ class HumanFeedback(BaseModel): @app.post("/approve_step_or_steps") async def approve_step_endpoint(human_feedback: HumanFeedback, request: Request) -> dict[str, str]: """ - Endpoint to approve a step if step_id is provided, otherwise approve all the steps. + Approve a step or multiple steps in a plan. + + --- + tags: + - Approval + parameters: + - name: user_principal_id + in: header + type: string + required: true + description: User ID extracted from the authentication header + - name: body + in: body + required: true + schema: + type: object + properties: + step_id: + type: string + description: Optional step ID to approve + plan_id: + type: string + description: The plan ID + session_id: + type: string + description: The session ID + approved: + type: boolean + description: Whether the step(s) are approved + human_feedback: + type: string + description: Optional feedback details + updated_action: + type: string + description: Optional updated action + user_id: + type: string + description: The user ID providing the approval + responses: + 200: + description: Approval status returned + schema: + type: object + properties: + status: + type: string + 400: + description: Missing or invalid user information """ authenticated_user = get_authenticated_user_details( request_headers=request.headers @@ -201,14 +351,61 @@ async def approve_step_endpoint(human_feedback: HumanFeedback, request: Request) @app.get("/plans", response_model=List[PlanWithSteps]) async def get_plans(request: Request, session_id: Optional[str] = Query(None)) -> List[PlanWithSteps]: """ - Endpoint to retrieve plans. If session_id is provided, retrieve the plan for that session. - Otherwise, retrieve all plans. - - Args: - session_id (Optional[str]): The session ID. - - Returns: - List[Plan]: The list of plans. + Retrieve plans for the current user. + + --- + tags: + - Plans + parameters: + - name: session_id + in: query + type: string + required: false + description: Optional session ID to retrieve plans for a specific session + responses: + 200: + description: List of plans with steps for the user + schema: + type: array + items: + type: object + properties: + id: + type: string + description: Unique ID of the plan + session_id: + type: string + description: Session ID associated with the plan + initial_goal: + type: string + description: The initial goal derived from the user's input + overall_status: + type: string + description: Status of the plan (e.g., in_progress, completed) + steps: + type: array + items: + type: object + properties: + id: + type: string + description: Unique ID of the step + plan_id: + type: string + description: ID of the plan the step belongs to + action: + type: string + description: The action to be performed + agent: + type: string + description: The agent responsible for the step + status: + type: string + description: Status of the step (e.g., planned, approved, completed) + 400: + description: Missing or invalid user information + 404: + description: Plan not found """ authenticated_user = get_authenticated_user_details( request_headers=request.headers @@ -247,13 +444,53 @@ async def get_plans(request: Request, session_id: Optional[str] = Query(None)) - @app.get("/steps/{plan_id}", response_model=List[Step]) async def get_steps_by_plan(plan_id: str, request: Request) -> List[Step]: """ - Endpoint to retrieve steps for a specific plan. - - Args: - plan_id (str): The plan ID. - - Returns: - List[Step]: The list of steps. + Retrieve steps for a specific plan. + + --- + tags: + - Steps + parameters: + - name: plan_id + in: path + type: string + required: true + description: The ID of the plan to retrieve steps for + responses: + 200: + description: List of steps associated with the specified plan + schema: + type: array + items: + type: object + properties: + id: + type: string + description: Unique ID of the step + plan_id: + type: string + description: ID of the plan the step belongs to + action: + type: string + description: The action to be performed + agent: + type: string + description: The agent responsible for the step + status: + type: string + description: Status of the step (e.g., planned, approved, completed) + agent_reply: + type: string + description: Optional response from the agent after execution + human_feedback: + type: string + description: Optional feedback provided by a human + updated_action: + type: string + description: Optional modified action based on feedback + 400: + description: Missing or invalid user information + 404: + description: Plan or steps not found """ authenticated_user = get_authenticated_user_details( request_headers=request.headers @@ -269,13 +506,50 @@ async def get_steps_by_plan(plan_id: str, request: Request) -> List[Step]: @app.get("/agent_messages/{session_id}", response_model=List[AgentMessage]) async def get_agent_messages(session_id: str, request: Request) -> List[AgentMessage]: """ - Endpoint to retrieve agent messages for a specific session. - - Args: - session_id (str): The session ID. - - Returns: - List[AgentMessage]: The list of agent messages. + Retrieve agent messages for a specific session. + + --- + tags: + - Agent Messages + parameters: + - name: session_id + in: path + type: string + required: true + description: The ID of the session to retrieve agent messages for + responses: + 200: + description: List of agent messages associated with the specified session + schema: + type: array + items: + type: object + properties: + id: + type: string + description: Unique ID of the agent message + session_id: + type: string + description: Session ID associated with the message + plan_id: + type: string + description: Plan ID related to the agent message + content: + type: string + description: Content of the message + source: + type: string + description: Source of the message (e.g., agent type) + ts: + type: integer + description: Timestamp of the message + step_id: + type: string + description: Optional step ID associated with the message + 400: + description: Missing or invalid user information + 404: + description: Agent messages not found """ authenticated_user = get_authenticated_user_details( request_headers=request.headers @@ -291,10 +565,22 @@ async def get_agent_messages(session_id: str, request: Request) -> List[AgentMes @app.delete("/messages") async def delete_all_messages(request: Request) -> dict[str, str]: """ - Endpoint to delete all messages across sessions. - - Returns: - dict: Confirmation of deletion. + Delete all messages across sessions. + + --- + tags: + - Messages + responses: + 200: + description: Confirmation of deletion + schema: + type: object + properties: + status: + type: string + description: Status message indicating all messages were deleted + 400: + description: Missing or invalid user information """ authenticated_user = get_authenticated_user_details( request_headers=request.headers @@ -317,10 +603,39 @@ async def delete_all_messages(request: Request) -> dict[str, str]: @app.get("/messages") async def get_all_messages(request: Request): """ - Endpoint to retrieve all messages. - - Returns: - List[dict]: The list of message dictionaries. + Retrieve all messages across sessions. + + --- + tags: + - Messages + responses: + 200: + description: List of all messages across sessions + schema: + type: array + items: + type: object + properties: + id: + type: string + description: Unique ID of the message + data_type: + type: string + description: Type of the message (e.g., session, step, plan, agent_message) + session_id: + type: string + description: Session ID associated with the message + user_id: + type: string + description: User ID associated with the message + content: + type: string + description: Content of the message + ts: + type: integer + description: Timestamp of the message + 400: + description: Missing or invalid user information """ authenticated_user = get_authenticated_user_details( request_headers=request.headers @@ -335,6 +650,33 @@ async def get_all_messages(request: Request): @app.get("/api/agent-tools") async def get_agent_tools(): + """ + Retrieve all available agent tools. + + --- + tags: + - Agent Tools + responses: + 200: + description: List of all available agent tools and their descriptions + schema: + type: array + items: + type: object + properties: + agent: + type: string + description: Name of the agent associated with the tool + function: + type: string + description: Name of the tool function + description: + type: string + description: Detailed description of what the tool does + arguments: + type: string + description: Arguments required by the tool function + """ return retrieve_all_agent_tools() diff --git a/src/backend/config.py b/src/backend/config.py index 110bb04e7..bf126094c 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -40,7 +40,6 @@ class Config: AZURE_OPENAI_ENDPOINT = GetRequiredConfig("AZURE_OPENAI_ENDPOINT") AZURE_OPENAI_API_KEY = GetOptionalConfig("AZURE_OPENAI_API_KEY") - DEV_BYPASS_AUTH = GetBoolConfig("DEV_BYPASS_AUTH") FRONTEND_SITE_NAME = GetOptionalConfig("FRONTEND_SITE_NAME", "http://127.0.0.1:3000") diff --git a/src/frontend/wwwroot/app.css b/src/frontend/wwwroot/app.css index 88c2976e6..d5672fc03 100644 --- a/src/frontend/wwwroot/app.css +++ b/src/frontend/wwwroot/app.css @@ -4,191 +4,239 @@ /* App global */ -html { - overflow-y: auto; +html, +body { + overflow-x: hidden; + overflow-y: auto; + height: 100%; } body { - position: relative; - background: rgb(247, 249, 251); - min-height: 100vh; + position: relative; + background: rgb(247, 249, 251); + min-height: 100vh; } .border-right { - border-right: 1px solid hsl(221, 14%, calc(86% + 0%)); + border-right: 1px solid hsl(221, 14%, calc(86% + 0%)); } /* App template */ #app .columns { - min-height: 100vh; + min-height: 100vh; + height: 100%; +} +#app .modal, +#app .menu { + overflow: hidden; /* Prevent scrolling within modals and menus */ } - #app .asside { - background: rgba(231, 236, 243, 0.7); + background: rgba(231, 236, 243, 0.7); +} +ul#tasksStats.menu-list { + min-height: 100px; } - @media (min-width: 1800px) { - #app .asside { - max-width: 400px; - } + #app .asside { + max-width: 400px; + } } #app .menu-logo { - font-size: 1.25rem; - font-weight: 700; - cursor: pointer; + font-size: 1.25rem; + font-weight: 700; + cursor: pointer; } #app .menu-logo img { - width: 30px; + width: 30px; } #app .asside .menu-list a { - background-color: transparent; + background-color: transparent; } #app .asside .menu-list a.is-active { - background-color: rgb(71, 80, 235); + background-color: rgb(71, 80, 235); } #app .asside .menu-list a.is-active i { - color: white !important; + color: white !important; } #app .asside .menu-list a.is-active:hover { - background-color: rgb(71, 80, 235); + background-color: rgb(71, 80, 235); } #app .asside .menu-list a.menu-task { - display: flex; - align-items: center; + display: flex; + align-items: center; } #app .asside .menu-list a.menu-task span { - flex: 1; + flex: 1; } #app .asside .menu-list a:hover { - background-color: rgba(0, 0, 0, 0.1); + background-color: rgba(0, 0, 0, 0.1); } #app .iframe { - width: 100%; - background-color: transparent; - overflow-y: auto; + width: 100%; + background-color: transparent; } #app .context-switch { - position: fixed; - bottom: 50px; - right: calc(50% - 220px); - z-index: 3; + position: fixed; + bottom: 50px; + right: calc(50% - 220px); + z-index: 3; } .is-avatar.is-rounded { - border-radius: var(--bulma-radius-rounded); + border-radius: var(--bulma-radius-rounded); } .is-avatar.is-agent { - display: flex; - /* background-color: rgba(231, 236, 243, 0.7); */ - background-color: rgba(70, 79, 235, 0.25); + display: flex; + /* background-color: rgba(231, 236, 243, 0.7); */ + background-color: rgba(70, 79, 235, 0.25); } .is-avatar.is-agent img { - width: 75%; - height: 75%; - margin: 13%; + width: 75%; + height: 75%; + margin: 13%; } - - @keyframes moveImage { - 0% { - transform: rotate(0deg); - } + 0% { + transform: rotate(0deg); + } - 50% { - transform: rotate(-3deg); - } + 50% { + transform: rotate(-3deg); + } - 100% { - transform: rotate(3deg); - } + 100% { + transform: rotate(3deg); + } } .is-avatar.is-agent img.manager { - background-color: rgba(220, 56, 72, .35); - box-shadow: 0 0 0 4px rgba(220, 56, 72, .35); - animation: moveImage .3s infinite alternate; + background-color: rgba(220, 56, 72, 0.35); + box-shadow: 0 0 0 4px rgba(220, 56, 72, 0.35); + animation: moveImage 0.3s infinite alternate; } .is-avatar.is-agent img.hr_agent { - background-color: rgba(0, 209, 178, .35); - box-shadow: 0 0 0 4px rgba(0, 209, 178, .35); - animation: moveImage .5s infinite alternate; + background-color: rgba(0, 209, 178, 0.35); + box-shadow: 0 0 0 4px rgba(0, 209, 178, 0.35); + animation: moveImage 0.5s infinite alternate; } .is-avatar.is-agent img.procurement_agent { - background-color: rgba(255, 183, 15, .35); - box-shadow: 0 0 0 4px rgba(255, 183, 15, .35); - animation: moveImage .1s infinite alternate; + background-color: rgba(255, 183, 15, 0.35); + box-shadow: 0 0 0 4px rgba(255, 183, 15, 0.35); + animation: moveImage 0.1s infinite alternate; } .is-avatar.is-agent img.tech_agent { - background-color: rgba(178, 222, 39, .35); - box-shadow: 0 0 0 4px rgba(178, 222, 39, .35); - animation: moveImage .7s infinite alternate; + background-color: rgba(178, 222, 39, 0.35); + box-shadow: 0 0 0 4px rgba(178, 222, 39, 0.35); + animation: moveImage 0.7s infinite alternate; } .is-avatar.is-agent img.unknown { - background-color: rgba(39, 57, 222, 0.35); - box-shadow: 0 0 0 4px rgba(39, 57, 222, 0.35); - animation: moveImage .7s infinite alternate; + background-color: rgba(39, 57, 222, 0.35); + box-shadow: 0 0 0 4px rgba(39, 57, 222, 0.35); + animation: moveImage 0.7s infinite alternate; } .is-avatar.has-status::after { - content: ""; - position: absolute; - bottom: 0; - right: 0; - width: 30%; - height: 30%; - border-radius: 50%; - background-color: rgb(255, 255, 255); - border: 2px solid rgb(255, 255, 255); + content: ""; + position: absolute; + bottom: 0; + right: 0; + width: 30%; + height: 30%; + border-radius: 50%; + background-color: rgb(255, 255, 255); + border: 2px solid rgb(255, 255, 255); } .is-avatar.has-status.has-status-active::after { - background-color: hsl(var(--bulma-success-h), var(--bulma-success-s), var(--bulma-success-l)); + background-color: hsl( + var(--bulma-success-h), + var(--bulma-success-s), + var(--bulma-success-l) + ); } .is-avatar.has-status.has-status-busy::after { - background-color: hsl(var(--bulma-danger-h), var(--bulma-danger-s), var(--bulma-danger-l)); + background-color: hsl( + var(--bulma-danger-h), + var(--bulma-danger-s), + var(--bulma-danger-l) + ); } .is-avatar.has-status.has-status-paused::after { - background-color: hsl(var(--bulma-dark-h), var(--bulma-dark-s), var(--bulma-dark-l)); + background-color: hsl( + var(--bulma-dark-h), + var(--bulma-dark-s), + var(--bulma-dark-l) + ); } .button.is-greyed-out { - background-color: #e0e0e0; - color: lightgrey; - cursor: not-allowed; + background-color: #e0e0e0; + color: lightgrey; + cursor: not-allowed; } .button.is-selected { - background-color: #d3d3d3; - color: #000; + background-color: #d3d3d3; + color: #000; } - .notyf__toast { - max-width: 100% !important; - border-radius: var(--bulma-control-radius) !important; + max-width: 100% !important; + border-radius: var(--bulma-control-radius) !important; } .notyf__wrapper { - padding: 0.75rem 0.5rem !important; -} \ No newline at end of file + padding: 0.75rem 0.5rem !important; +} +/* Menu list scroll style start*/ +#app .asside .menu-list { + max-height: 300px; + overflow-y: scroll; + padding-right: 2px; + transition: all 0.3s ease; + box-sizing: border-box; +} +/* Hide the scrollbar initially (before hover) */ +#app .asside .menu-list::-webkit-scrollbar { + width: 8px; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0s 0.3s; +} +/* Style the scrollbar thumb (the draggable part) */ +#app .asside .menu-list::-webkit-scrollbar-thumb { + border-radius: 10px; + transition: background-color 0.3s ease; +} +/* Show the scrollbar and thumb when hovering */ +#app .asside .menu-list:hover::-webkit-scrollbar { + opacity: 1; + visibility: visible; + transition: opacity 0.3s ease, visibility 0s; +} +/* Style the thumb when hovering */ +#app .asside .menu-list:hover::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); +} +/* Menu list scroll style end*/ diff --git a/src/frontend/wwwroot/assets/Send.svg b/src/frontend/wwwroot/assets/Send.svg new file mode 100644 index 000000000..214d2ef72 --- /dev/null +++ b/src/frontend/wwwroot/assets/Send.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/frontend/wwwroot/home/home.css b/src/frontend/wwwroot/home/home.css index e1999d5f4..f7c1b6b06 100644 --- a/src/frontend/wwwroot/home/home.css +++ b/src/frontend/wwwroot/home/home.css @@ -1,194 +1,260 @@ @import "../app.css"; .container { - inset: 0; - min-height: 100vh; - overflow-y: auto; + inset: 0; + min-height: 100vh; + overflow-y: auto; } .section { - min-width: 800px; - z-index: 1; + min-width: 800px; + z-index: 1; } .app-logo { - width: 60px; - margin: 0 auto; + width: 60px; + margin: 0 auto; } .background { - inset: 0; - position: absolute; - opacity: 0.1; - overflow-y: hidden; + inset: 0; + position: absolute; + opacity: 0.1; + overflow-y: hidden; } .description { - color: Black; - text-align: center; + color: Black; + text-align: center; - /* Web/Body 1 */ - font-family: "Segoe UI"; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; /* 142.857% */ - margin-bottom: 10px; + /* Web/Body 1 */ + font-family: "Segoe UI"; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 142.857% */ + margin-bottom: 10px; } .title { - color: var(--Light-Mode-Foreground-Neutral-Primary, var(--Foreground-Neutral-Primary, #111)); - text-align: center; - font-family: "Segoe UI"; - font-size: 32px; - font-style: normal; - font-weight: 700; - line-height: 40px; /* 125% */ + color: var( + --Light-Mode-Foreground-Neutral-Primary, + var(--Foreground-Neutral-Primary, #111) + ); + text-align: center; + font-family: "Segoe UI"; + font-size: 32px; + font-style: normal; + font-weight: 700; + line-height: 40px; /* 125% */ } .assistants { - text-align: center; - font-family: "Segoe UI"; - font-size: 32px; - font-style: normal; - font-weight: 700; - line-height: 40px; /* 125% */ + text-align: center; + font-family: "Segoe UI"; + font-size: 32px; + font-style: normal; + font-weight: 700; + line-height: 40px; /* 125% */ - background: var(--Gradient-M365-Chat-Light-Accessible, linear-gradient(90deg, #464FEB 10.42%, #8330E9 100%)); - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; + background: var( + --Gradient-M365-Chat-Light-Accessible, + linear-gradient(90deg, #464feb 10.42%, #8330e9 100%) + ); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; } .orb { - width: 50%; - height: 50%; - border-radius: 50%; - filter: blur(50px); - background: radial-gradient(circle); - position: absolute; + width: 50%; + height: 50%; + border-radius: 50%; + filter: blur(50px); + background: radial-gradient(circle); + position: absolute; } .orb.one { - bottom: -40%; - left: 47.5%; - background-color: rgba(70, 79, 235, 1); + bottom: -40%; + left: 47.5%; + background-color: rgba(70, 79, 235, 1); } .orb.two { - bottom: -30%; - left: 25%; - background-color: rgb(18, 172, 149); - z-index: 1; + bottom: -30%; + left: 25%; + background-color: rgb(18, 172, 149); + z-index: 1; } .orb.three { - bottom: -40%; - left: 2.5%; - background-color: rgb(199, 20, 184); -} - -.new-task-control:has(>textarea:disabled)::before, -.new-task-control:has(>textarea:disabled)::after { - pointer-events: none; - content: ''; - position: absolute; - left: -2px; - top: -2px; - background: linear-gradient(45deg, #fb0094, #0000ff, #fb0094, #0000ff, #fb0094, #0000ff, #fb0094, #0000ff); - background-size: 400%; - width: calc(100% + 4px); - height: calc(100% + 4px); - border-radius: var(--bulma-input-radius); - opacity: 0.5; - z-index: -1; - animation: steam 20s linear infinite; + bottom: -40%; + left: 2.5%; + background-color: rgb(199, 20, 184); +} + +.new-task-control:has(> textarea:disabled)::before, +.new-task-control:has(> textarea:disabled)::after { + pointer-events: none; + content: ""; + position: absolute; + left: -2px; + top: -2px; + background: linear-gradient( + 45deg, + #fb0094, + #0000ff, + #fb0094, + #0000ff, + #fb0094, + #0000ff, + #fb0094, + #0000ff + ); + background-size: 400%; + width: calc(100% + 4px); + height: calc(100% + 4px); + border-radius: var(--bulma-input-radius); + opacity: 0.5; + z-index: -1; + animation: steam 20s linear infinite; } @keyframes steam { - 0% { - background-position: 0 0; - } + 0% { + background-position: 0 0; + } - 50% { - background-position: 400% 0; - } + 50% { + background-position: 400% 0; + } - 100% { - background-position: 0 0; - } + 100% { + background-position: 0 0; + } } .new-task-control::after { - filter: blur(25px); + filter: blur(25px); } .text-input-container { - width: 950px; - position: relative; - border: 1px solid #ccc; - border-radius: 8px; - background-color: white; - } + width: 950px; + position: relative; + border: 1px solid #ccc; + border-radius: 8px; + background-color: white; +} - textarea { - width: 98%; - padding: 0px; - border: none; - border-radius: 8px 8px 0 0; - font-size: 16px; - line-height: 1.5; - resize: none; - outline: none; - overflow: hidden; - margin: 0 10px; - align-items: center; - } +textarea { + width: 98%; + padding: 16px 0px 0px 0px; + border: none; + border-radius: 8px 8px 0 0; + font-size: 16px; + line-height: 1.5; + resize: none; + outline: none; + overflow: hidden; + margin: 0 10px; + align-items: center; + background-color: white; +} +textarea:disabled { + cursor: default; + background-color: white; +} - .middle-bar { - display: flex; - justify-content: space-between; - align-items: left; - padding: 0px 5px; - background-color: white; - } - - .bottom-bar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 3px 10px; - border-top: none; - border-bottom: 4px solid #0F6CBD; - background-color: white; +/*Spinner start*/ +#spinnerLoader { + display: flex; + flex-direction: column; + /* justify-content: center; */ + align-items: center; + position: absolute; + inset: 0; + color: black; + top: 30%; + left: 50%; + transform: translateX(-50%); + /* background-color: rgb(247, 249, 251);*/ + z-index: 9999; + font-weight: 500; +} + +#spinnerLoader span::before { + content: "Creating Tasks..."; + animation: spinLoaderAnimation infinite 3s linear; +} + +@keyframes spinLoaderAnimation { + 75% { + content: "Agents are on it..."; } +} + +#spinnerLoader i { + font-size: 3rem; +} + +#overlay { + position: fixed; + display: none; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.5); + + z-index: 1; +} + +/*Spinner end*/ + +.middle-bar { + display: flex; + justify-content: space-between; + align-items: left; + padding: 0px 5px; + background-color: white; +} + +.bottom-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 3px 10px; + border-top: none; + border-bottom: 4px solid #0f6cbd; + background-color: white; +} - .icons { - display: flex; - align-items: center; - } - - .star-icon { - margin-right: 10px; - cursor: pointer; - } - - .char-count { - font-size: 14px; - color: #888; - } - - .send-button { - border: none; - background: none; - font-size: 18px; - cursor: pointer; - color: #007bff; - padding: 4px; - outline: none; - } - - .send-button:hover { - color: #0056b3; - } + display: flex; + align-items: center; +} + +.star-icon { + margin-right: 10px; + cursor: pointer; +} + +.char-count { + font-size: 14px; + color: #888; +} + +.send-button { + border: none; + background: none; + font-size: 18px; + cursor: pointer; + color: #007bff; + padding: 4px; + outline: none; +} + +.send-button:hover { + color: #0056b3; +} diff --git a/src/frontend/wwwroot/home/home.js b/src/frontend/wwwroot/home/home.js index 29d96fbff..842ad0fc6 100644 --- a/src/frontend/wwwroot/home/home.js +++ b/src/frontend/wwwroot/home/home.js @@ -1,98 +1,207 @@ (() => { - const notyf = new Notyf({ position: { x: 'right', y: 'top' }, ripple: false, duration: 3000 }); - const apiEndpoint = sessionStorage.getItem('apiEndpoint'); - const newTaskPrompt = document.getElementById("newTaskPrompt"); - const startTaskButton = document.getElementById("startTaskButton"); - newTaskPrompt.focus(); - - - const startTask = () => { - - startTaskButton.addEventListener('click', (event) => { - - const sessionId = 'sid_' + (new Date()).getTime() + '_' + Math.floor(Math.random() * 10000); - - newTaskPrompt.disabled = true; - startTaskButton.disabled = true; - startTaskButton.classList.add('is-loading'); - - window.headers - .then(headers =>{ - fetch(apiEndpoint + '/input_task', { - method: 'POST', - headers: headers, - body: JSON.stringify({ - session_id: sessionId, - description: newTaskPrompt.value - }) - }) - .then(response => response.json()) - .then(data => { - - if (data.status == 'Plan not created') { - notyf.error('Unable to create plan for this task.'); - newTaskPrompt.disabled = false; - startTaskButton.disabled = false; - return; - } - - console.log('startTaskButton', data); - - newTaskPrompt.disabled = false; - startTaskButton.disabled = false; - startTaskButton.classList.remove('is-loading'); - - window.parent.postMessage({ - action: 'taskStarted', - session_id: data.session_id, - task_id: data.plan_id, - task_name: newTaskPrompt.value - }, '*'); - - newTaskPrompt.value = ''; - - notyf.success('Task created successfully. AI agents are on it!'); - - }) - .catch(error => { - console.error('Error:', error); - newTaskPrompt.disabled = false; - startTaskButton.disabled = false; - startTaskButton.classList.remove('is-loading'); - }) - }); - }) - }; - - const quickTasks = () => { - - document.querySelectorAll('.quick-task').forEach(task => { - task.addEventListener('click', (event) => { - const quickTaskPrompt = task.querySelector('.quick-task-prompt').innerHTML; - newTaskPrompt.value = quickTaskPrompt.trim().replace(/\s+/g, ' '); - }); - }); - + const notyf = new Notyf({ + position: { x: "right", y: "top" }, + ripple: false, + duration: 3000, + }); + const apiEndpoint = sessionStorage.getItem("apiEndpoint"); + const newTaskPrompt = document.getElementById("newTaskPrompt"); + const startTaskButton = document.getElementById("startTaskButton"); + const startTaskButtonContainer = document.querySelector(".send-button"); + const startTaskButtonImg = startTaskButtonContainer + ? startTaskButtonContainer.querySelector("img") + : null; + + newTaskPrompt.focus(); + + // Create spinner element + const createSpinner = () => { + if (!document.getElementById("spinnerContainer")) { + const spinnerContainer = document.createElement("div"); + spinnerContainer.id = "spinnerContainer"; + spinnerContainer.innerHTML = ` +
+ + +
+ + `; + document.body.appendChild(spinnerContainer); } - const handleTextAreaTyping = () => { - const newTaskPrompt = document.getElementById('newTaskPrompt'); - newTaskPrompt.addEventListener('input', () => { - // whenever text is sent to the text area, we want to update the character count and dynamically resize the text area - const textInput = document.getElementById('newTaskPrompt'); - const charCount = document.getElementById('charCount'); - - // Update character count - charCount.textContent = textInput.value.length; - - // Dynamically adjust height - textInput.style.height = 'auto'; - textInput.style.height = textInput.scrollHeight + 'px'; - }); + }; + + // Function to create and add the overlay + const createOverlay = () => { + let overlay = document.getElementById("overlay"); + if (!overlay) { + overlay = document.createElement("div"); + overlay.id = "overlay"; + document.body.appendChild(overlay); + } + }; + const showOverlay = () => { + const overlay = document.getElementById("overlay"); + if (overlay) { + overlay.style.display = "block"; } - - startTask(); - quickTasks(); - handleTextAreaTyping(); + createSpinner(); + }; -})(); \ No newline at end of file + const hideOverlay = () => { + const overlay = document.getElementById("overlay"); + if (overlay) { + overlay.style.display = "none"; + } + removeSpinner(); + }; + + // Remove spinner element + const removeSpinner = () => { + const spinnerContainer = document.getElementById("spinnerContainer"); + if (spinnerContainer) { + spinnerContainer.remove(); + } + }; + + // Function to update button image based on textarea content + const updateButtonImage = () => { + if (startTaskButtonImg) { + if (newTaskPrompt.value.trim() === "") { + startTaskButtonImg.src = "../assets/images/air-button.svg"; + startTaskButton.disabled = true; + } else { + startTaskButtonImg.src = "/assets/Send.svg"; + startTaskButtonImg.style.width = "16px"; + startTaskButtonImg.style.height = "16px"; + startTaskButton.disabled = false; + } + } + }; + + const startTask = () => { + startTaskButton.addEventListener("click", (event) => { + if (startTaskButton.disabled) { + return; + } + const sessionId = + "sid_" + new Date().getTime() + "_" + Math.floor(Math.random() * 10000); + + newTaskPrompt.disabled = true; + startTaskButton.disabled = true; + startTaskButton.classList.add("is-loading"); + createOverlay(); + showOverlay(); + window.headers.then((headers) => { + fetch(apiEndpoint + "/input_task", { + method: "POST", + headers: headers, + body: JSON.stringify({ + session_id: sessionId, + description: newTaskPrompt.value, + }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.status == "Plan not created") { + notyf.error("Unable to create plan for this task."); + newTaskPrompt.disabled = false; + startTaskButton.disabled = false; + return; + } + + console.log("startTaskButton", data); + + newTaskPrompt.disabled = false; + startTaskButton.disabled = false; + startTaskButton.classList.remove("is-loading"); + + window.parent.postMessage( + { + action: "taskStarted", + session_id: data.session_id, + task_id: data.plan_id, + task_name: newTaskPrompt.value, + }, + "*" + ); + + newTaskPrompt.value = ""; + + // Reset character count to 0 + const charCount = document.getElementById("charCount"); + if (charCount) { + charCount.textContent = "0"; + } + updateButtonImage(); + notyf.success("Task created successfully. AI agents are on it!"); + + // Remove spinner and hide overlay + removeSpinner(); + hideOverlay(); + }) + .catch((error) => { + console.error("Error:", error); + newTaskPrompt.disabled = false; + startTaskButton.disabled = false; + startTaskButton.classList.remove("is-loading"); + + // Remove spinner and hide overlay + removeSpinner(); + hideOverlay(); + }); + }); + }); + }; + + const quickTasks = () => { + document.querySelectorAll(".quick-task").forEach((task) => { + task.addEventListener("click", (event) => { + const quickTaskPrompt = + task.querySelector(".quick-task-prompt").innerHTML; + newTaskPrompt.value = quickTaskPrompt.trim().replace(/\s+/g, " "); + const charCount = document.getElementById("charCount"); + // Update character count + charCount.textContent = newTaskPrompt.value.length; + updateButtonImage(); + newTaskPrompt.focus(); + }); + }); + }; + const handleTextAreaTyping = () => { + const newTaskPrompt = document.getElementById("newTaskPrompt"); + newTaskPrompt.addEventListener("input", () => { + // const textInput = document.getElementById("newTaskPrompt"); + const charCount = document.getElementById("charCount"); + + // Update character count + charCount.textContent = newTaskPrompt.value.length; + + // Dynamically adjust height + newTaskPrompt.style.height = "auto"; + newTaskPrompt.style.height = newTaskPrompt.scrollHeight + "px"; + + updateButtonImage(); + }); + + newTaskPrompt.addEventListener("keydown", (event) => { + const textValue = newTaskPrompt.value.trim(); + // If Enter is pressed without Shift, and the textarea is empty, prevent default behavior + if (event.key === "Enter" && !event.shiftKey) { + if (textValue === "") { + event.preventDefault(); // Disable Enter when textarea is empty + } else { + // If there's content in the textarea, allow Enter to trigger the task button click + startTaskButton.click(); + } + } else if (event.key === "Enter" && event.shiftKey) { + return; + } + }); + }; + + updateButtonImage(); + startTask(); + quickTasks(); + handleTextAreaTyping(); +})(); diff --git a/src/frontend/wwwroot/task/employee.html b/src/frontend/wwwroot/task/employee.html index 4efb0337a..dd8b1a7aa 100644 --- a/src/frontend/wwwroot/task/employee.html +++ b/src/frontend/wwwroot/task/employee.html @@ -1,99 +1,136 @@ - - + Task - - - + + + - +
- - + +
-
-
- - - +
+
+ + + +
+
+
+ +
+
+

+
+
+
+ + +
+
-
-
+ - -
-
-

-
-
-
- - -
-
-
- - - -
-
-

Status: - In Progress -

-
-
-
-
- -
-
-
- -
-
- - + +
+
+

+ Status: + In Progress +

+
+
+
+
+ +
+
+
+ +
+
+ + - -
-
- -
- 0/1000 -
-
- - stars - - -
-
- -
+ +
+
+ +
+ 0/1000 +
+
+ + stars + + +
+ +
+
- - + + - \ No newline at end of file + + diff --git a/src/frontend/wwwroot/task/task.css b/src/frontend/wwwroot/task/task.css index 8e5a683c3..9f7dca6a1 100644 --- a/src/frontend/wwwroot/task/task.css +++ b/src/frontend/wwwroot/task/task.css @@ -2,241 +2,270 @@ @import "../assets/bulma-switch.css"; .task-stats { - min-height: 70px; + min-height: 70px; } .section { - min-height: 100%; + min-height: 100%; } .task-asside { - min-height: 100vh; - max-width: 500px; + min-height: 100vh; + max-width: 500px; } .task-asside.is-audit { - max-width: 700px; + max-width: 700px; } .task-asside .task-menu { - margin: 3rem 1rem; + margin: 3rem 1rem; } .task-asside .task-menu .menu-label:first-of-type { - margin-top: 137px; + margin-top: 137px; } .task-asside .title { - font-size: 1.25rem; - height: 30px; - display: flex; - align-items: center; + font-size: 1.25rem; + height: 30px; + display: flex; + align-items: center; } .task-details { - max-width: 1280px; + width: 100%; } +.colChatSec { + width: 55%; +} +/*Notification message styles start*/ +/* Ensures block-level elements (like

,

, etc.) wrap inside the message */
+.notification p,
+.notification pre {
+  margin: 0;
+  word-wrap: break-word;
+  white-space: pre-wrap; /* Allow preformatted text to wrap */
+}
+.message-content {
+  max-width: 100%;
+  overflow: hidden;
+  word-break: break-word;
+  line-height: 1.4;
+}
+/* Optional: Add word-breaking for URLs */
+.notification a {
+  word-wrap: break-word;
+  word-break: break-word;
+  text-decoration: underline;
+}
+/*Notification message styles end*/
 
 .task-progress {
-    height: 40vh;
-    overflow-y: auto;
-    background-color: white;
-    border-radius: var(--bulma-radius);
+  height: 40vh;
+  overflow-y: auto;
+  background-color: white;
+  border-radius: var(--bulma-radius);
 }
 
 @media (min-height: 1200px) {
-    .task-progress {
-        height: 50vh;
-    }
+  .task-progress {
+    height: 50vh;
+  }
 }
 
 @media (min-height: 1400px) {
-    .task-progress {
-        height: 60vh;
-    }
+  .task-progress {
+    height: 60vh;
+  }
 }
 
 .task-progress .notification {
-    display: inline-block;
-    padding: 0.5rem 1rem;
+  padding: 0.5rem 1rem;
+  display: block;
+  max-width: 100%;
+  word-wrap: break-word;
+  box-sizing: border-box;
+  overflow-wrap: break-word;
 }
 
 .menu-list .menu-item,
 .menu-list a,
 .menu-list button {
-    background-color: transparent;
+  background-color: transparent;
 }
 
 .menu-list ul.menu-stages {
-    border-inline-start: 3px solid var(--bulma-border);
-    padding-inline-start: 0;
+  border-inline-start: 3px solid var(--bulma-border);
+  padding-inline-start: 0;
 }
 
 .menu-list ul.menu-stages li {
-    margin-left: calc(-1.4rem - 5px);
+  margin-left: calc(-1.4rem - 5px);
 }
 
 .menu-list a.menu-stage {
-    display: flex;
-    align-items: center;
-    position: relative;
-    padding: 0.5em 0 0.5rem 0.75em;
-    width: calc(100% + 1.4rem - 5px);
+  display: flex;
+  align-items: center;
+  position: relative;
+  padding: 0.5em 0 0.5rem 0.75em;
+  width: calc(100% + 1.4rem - 5px);
 }
 
-.menu-list a.menu-stage>i {
-    font-size: 1.4rem;
-    margin-top: 3px;
-    border-radius: 50%;
-    background-color: rgb(247, 249, 251);
-    padding: 5px;
+.menu-list a.menu-stage > i {
+  font-size: 1.4rem;
+  margin-top: 3px;
+  border-radius: 50%;
+  background-color: rgb(247, 249, 251);
+  padding: 5px;
 }
 
 .menu-list a.menu-stage span {
-    flex: 1;
+  flex: 1;
+  word-break: break-word; /*this for stages span alignment*/
 }
 
 .menu-list a.menu-stage.rejected span {
-    text-decoration: line-through;
-    opacity: 0.5;
+  text-decoration: line-through;
+  opacity: 0.5;
 }
 
 .menu-list a.menu-stage.action_requested span {
-    font-weight: 500;
+  font-weight: 500;
 }
 
 .menu-list a.menu-stage div {
-    display: flex;
-    align-items: center;
+  display: flex;
+  align-items: center;
 }
 
 .menu-stage-actions i {
-    font-size: 1.4rem;
+  font-size: 1.4rem;
 }
 
 .business-animation {
-    position: relative;
-    border-radius: var(--bulma-radius-large);
+  position: relative;
+  border-radius: var(--bulma-radius-large);
 }
 
 #taskLoader {
-    display: flex;
-    flex-direction: column;
-    justify-content: center;
-    align-items: center;
-    position: absolute;
-    inset: 0;
-    color: black;
-    background-color: rgb(247, 249, 251);
-    z-index: 1000;
-    font-weight: 500;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  position: absolute;
+  inset: 0;
+  color: black;
+  background-color: rgb(247, 249, 251);
+  z-index: 1000;
+  font-weight: 500;
 }
 
 #taskLoader span::before {
-    content: "Getting task plan...";
-    animation: taskLoaderAnimation infinite 3s linear;
+  content: "Getting task plan...";
+  animation: taskLoaderAnimation infinite 3s linear;
 }
 
 #taskLoader.is-hidden {
-    display: none;
+  display: none;
 }
 
 @keyframes taskLoaderAnimation {
+  0% {
+    content: "Getting task plan...";
+  }
 
-    0% {
-        content: "Getting task plan...";
-    }
-
-    50% {
-        content: "Contacting agents...";
-    }
+  50% {
+    content: "Contacting agents...";
+  }
 
-    75% {
-        content: "Loading conversations...";
-    }
+  75% {
+    content: "Loading conversations...";
+  }
 }
 
 #taskLoader i {
-    font-size: 3rem;
+  font-size: 3rem;
 }
 
 .task-stage-divider {
-    text-align: center;
-    margin: 1rem 0;
-    font-size: .85rem;
-    font-weight: 500;
-    border: 1px solid rgb(71, 80, 235);
-    border-left-width: 0;
-    border-right-width: 0;
-    border-bottom-width: 0;
+  text-align: center;
+  margin: 1rem 0;
+  font-size: 0.85rem;
+  font-weight: 500;
+  border: 1px solid rgb(71, 80, 235);
+  border-left-width: 0;
+  border-right-width: 0;
+  border-bottom-width: 0;
 }
 
 .task-stage-divider legend {
-    color: rgb(71, 80, 235);
-    -webkit-padding-start: 1rem;
-    -webkit-padding-end: 1rem;
-    background: transparent;
+  color: rgb(71, 80, 235);
+  -webkit-padding-start: 1rem;
+  -webkit-padding-end: 1rem;
+  background: transparent;
 }
 
 .text-input-container {
-    position: relative;
-    border: 1px solid #ccc;
-    border-radius: 8px;
-    background-color: white;
+  position: relative;
+  border: 1px solid #ccc;
+  border-radius: 8px;
+  background-color: white;
 }
-  
+
 textarea {
-    width: 98%;
-    padding: 0px;
-    border: none;
-    border-radius: 8px 8px 0 0;
-    font-size: 16px;
-    line-height: 1.5;
-    resize: none;
-    outline: none;
-    overflow: hidden;
-    margin: 0 10px;
-    align-items: center;
+  width: 98%;
+  padding: 16px 0px 0px 0px;
+  border: none;
+  border-radius: 8px 8px 0 0;
+  font-size: 16px;
+  line-height: 1.5;
+  resize: none;
+  outline: none;
+  overflow: hidden;
+  margin: 0 10px;
+  align-items: center;
+  background-color: white;
 }
 
 .star-icon {
-    margin-right: 10px;
-    cursor: pointer;
+  margin-right: 10px;
+  cursor: pointer;
 }
-  
-  .char-count {
-    font-size: 14px;
-    color: #888;
+
+.char-count {
+  font-size: 14px;
+  color: #888;
 }
 
 .middle-bar {
-    display: flex;
-    justify-content: space-between;
-    align-items: left;
-    padding: 0px 5px;
-    background-color: white;
-  }
-  
-  .bottom-bar {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    padding: 3px 10px;
-    border-top: none;
-    border-bottom: 4px solid #0F6CBD;
-    background-color: white;
-  }
-  
-  .send-button {
-    border: none;
-    background: none;
-    font-size: 18px;
-    cursor: pointer;
-    color: #007bff;
-    padding: 4px;
-    outline: none;
-}
-  
-  .send-button:hover {
-    color: #0056b3;
+  display: flex;
+  justify-content: space-between;
+  align-items: left;
+  padding: 0px 5px;
+  background-color: white;
+}
+
+.bottom-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 3px 10px;
+  border-top: none;
+  border-bottom: 4px solid #0f6cbd;
+  background-color: white;
+}
+
+.send-button {
+  border: none;
+  background: none;
+  font-size: 18px;
+  cursor: pointer;
+  color: #007bff;
+  padding: 4px;
+  outline: none;
+}
+
+.send-button:hover {
+  color: #0056b3;
 }
diff --git a/src/frontend/wwwroot/task/task.js b/src/frontend/wwwroot/task/task.js
index 666d6deec..1282d5476 100644
--- a/src/frontend/wwwroot/task/task.js
+++ b/src/frontend/wwwroot/task/task.js
@@ -1,425 +1,469 @@
 (() => {
-    const markdownConverter = new showdown.Converter();
-    const apiEndpoint = sessionStorage.getItem('apiEndpoint');
-    const taskStore = JSON.parse(sessionStorage.getItem('task'));
-    const taskName = document.getElementById("taskName");
-    const taskStatusTag = document.getElementById("taskStatusTag");
-    const taskStagesMenu = document.getElementById("taskStagesMenu");
-    const taskPauseButton = document.getElementById("taskPauseButton");
-    const taskAgentsButton = document.getElementById("taskAgentsButton");
-    const taskWokFlowButton = document.getElementById("taskWokFlowButton");
-    const taskMessageAddButton = document.getElementById("taskMessageAddButton");
-    const taskMessages = document.getElementById("taskMessages");
-    const taskDetailsAgents = document.getElementById("taskDetailsAgents");
-    const taskProgress = document.getElementById("taskProgress");
-    const taskProgressPercentage = document.getElementById("taskProgressPercentage");
-    const taskProgressBar = document.getElementById("taskProgressBar");
-    const taskLoader = document.getElementById("taskLoader");
-    const taskAgentsHumans = document.getElementById("taskAgentsHumans");
-    const taskStatusDetails = document.getElementById("taskStatusDetails");
-    const taskCancelButton = document.getElementById("taskCancelButton");
-
-    const notyf = new Notyf({
-        position: { x: 'right', y: 'top' },
-        ripple: false,
-        duration: 3000,
-        types: [
-            {
-                type: 'info',
-                background: 'rgb(71, 80, 235)',
-                icon: ''
-            }
-        ]
-    });
-
-    let taskSessionId = null;
-    let taskLastStageId = null;
-    let taskLastAction = null;
-    let taskAgents = [];
-    let taskAgentsVsHumans = [];
-
-    const agentToIcon = (agentName) => {
-        let agentIcon = '';
-
-        switch (agentName) {
-            case 'MarketingAgent':
-                agentIcon = 'unknown';
-                break;
-            case 'HrAgent':
-                agentIcon = 'hr_agent';
-                break;
-            case 'ExpenseBillingAgent':
-                agentIcon = 'expense_billing_agent';
-                break;
-            case 'InvoiceReconciliationAgent':
-                agentIcon = 'invoice_reconciliation_agent';
-                break;
-            case 'TechSupportAgent':
-                agentIcon = 'tech_agent';
-                break;
-            case 'ProcurementAgent':
-                agentIcon = 'procurement_agent';
-                break;
-            case 'ProductAgent':
-                agentIcon = 'product_agent';
-                break;
-            case 'GroupChatManager':
-                agentIcon = 'manager';
-                break;
-            case 'GenericAgent':
-                agentIcon = 'manager';
-                break;
-            case 'HumanAgent':
-                let userNumber = sessionStorage.getItem('userNumber');
-                if (userNumber == null){
-                    // Generate a random number between 0 and 6
-                    userNumber = Math.floor(Math.random() * 6);
-                    // Create the icon name by concatenating 'user' with the random number
-                    sessionStorage.setItem('userNumber', userNumber);
-                }
-                let iconName = 'user' + userNumber;
-                agentIcon = iconName;
-                break;
-            case 'Done':
-                agentIcon = 'done';
-                break;
-            default:
-                agentIcon = 'marketing_agent';
+  const markdownConverter = new showdown.Converter();
+  const apiEndpoint = sessionStorage.getItem("apiEndpoint");
+  const taskStore = JSON.parse(sessionStorage.getItem("task"));
+  const taskName = document.getElementById("taskName");
+  const taskStatusTag = document.getElementById("taskStatusTag");
+  const taskStagesMenu = document.getElementById("taskStagesMenu");
+  const taskPauseButton = document.getElementById("taskPauseButton");
+  const taskAgentsButton = document.getElementById("taskAgentsButton");
+  const taskWokFlowButton = document.getElementById("taskWokFlowButton");
+  const taskMessageAddButton = document.getElementById("taskMessageAddButton");
+  const taskMessages = document.getElementById("taskMessages");
+  const taskDetailsAgents = document.getElementById("taskDetailsAgents");
+  const taskProgress = document.getElementById("taskProgress");
+  const taskProgressPercentage = document.getElementById(
+    "taskProgressPercentage"
+  );
+  const taskProgressBar = document.getElementById("taskProgressBar");
+  const taskLoader = document.getElementById("taskLoader");
+  const taskAgentsHumans = document.getElementById("taskAgentsHumans");
+  const taskStatusDetails = document.getElementById("taskStatusDetails");
+  const taskCancelButton = document.getElementById("taskCancelButton");
+  const startTaskButtonContainer = document.querySelector(".send-button");
+  const startTaskButtonImg = startTaskButtonContainer
+    ? startTaskButtonContainer.querySelector("img")
+    : null;
+
+  const notyf = new Notyf({
+    position: { x: "right", y: "top" },
+    ripple: false,
+    duration: 3000,
+    types: [
+      {
+        type: "info",
+        background: "rgb(71, 80, 235)",
+        icon: '',
+      },
+    ],
+  });
+
+  const updateButtonImage = () => {
+    if (startTaskButtonImg) {
+      const newTaskPrompt = document.getElementById("taskMessageTextarea");
+      if (newTaskPrompt && newTaskPrompt.value.trim() === "") {
+        startTaskButtonImg.src = "../assets/images/air-button.svg";
+        startTaskButton.disabled = true;
+      } else {
+        startTaskButtonImg.src = "/assets/Send.svg";
+        startTaskButtonImg.style.width = "16px";
+        startTaskButtonImg.style.height = "16px";
+        startTaskButton.disabled = false;
+      }
+    }
+  };
+
+  let taskSessionId = null;
+  let taskLastStageId = null;
+  let taskLastAction = null;
+  let taskAgents = [];
+  let taskAgentsVsHumans = [];
+
+  const agentToIcon = (agentName) => {
+    let agentIcon = "";
+
+    switch (agentName) {
+      case "MarketingAgent":
+        agentIcon = "unknown";
+        break;
+      case "HrAgent":
+        agentIcon = "hr_agent";
+        break;
+      case "ExpenseBillingAgent":
+        agentIcon = "expense_billing_agent";
+        break;
+      case "InvoiceReconciliationAgent":
+        agentIcon = "invoice_reconciliation_agent";
+        break;
+      case "TechSupportAgent":
+        agentIcon = "tech_agent";
+        break;
+      case "ProcurementAgent":
+        agentIcon = "procurement_agent";
+        break;
+      case "ProductAgent":
+        agentIcon = "product_agent";
+        break;
+      case "GroupChatManager":
+        agentIcon = "manager";
+        break;
+      case "GenericAgent":
+        agentIcon = "manager";
+        break;
+      case "HumanAgent":
+        let userNumber = sessionStorage.getItem("userNumber");
+        if (userNumber == null) {
+          // Generate a random number between 0 and 6
+          userNumber = Math.floor(Math.random() * 6);
+          // Create the icon name by concatenating 'user' with the random number
+          sessionStorage.setItem("userNumber", userNumber);
         }
-
-        return ``;
+        let iconName = "user" + userNumber;
+        agentIcon = iconName;
+        break;
+      case "Done":
+        agentIcon = "done";
+        break;
+      default:
+        agentIcon = "marketing_agent";
     }
 
-
-    const toDateTime = (timestamp) => {
-        const date = new Date(timestamp * 1000);
-        const options = { month: 'short', day: 'numeric' };
-        const timeOptions = { hour: 'numeric', minute: 'numeric', hour12: true };
-        return `${date.toLocaleDateString('en-US', options)} at ${date.toLocaleTimeString('en-US', timeOptions)}`;
-    };
-
-    const removeClassesExcept = (element, classToKeep) => {
-        element.className = classToKeep;
+    return ``;
+  };
+
+  const toDateTime = (timestamp) => {
+    const date = new Date(timestamp * 1000);
+    const options = { month: "short", day: "numeric" };
+    const timeOptions = { hour: "numeric", minute: "numeric", hour12: true };
+    return `${date.toLocaleDateString(
+      "en-US",
+      options
+    )} at ${date.toLocaleTimeString("en-US", timeOptions)}`;
+  };
+
+  const removeClassesExcept = (element, classToKeep) => {
+    element.className = classToKeep;
+  };
+
+  const handleDisableOfActions = (status) => {
+    if(status === "completed"){
+      taskPauseButton.disabled=true;
+      taskCancelButton.disabled=true;
+    } else {
+      taskPauseButton.disabled=false;
+      taskCancelButton.disabled=false;
     }
-
-    const taskHeaderActions = () => {
-
-        if (taskPauseButton) {
-            taskPauseButton.addEventListener('click', (event) => {
-                const iconElement = taskPauseButton.querySelector('i');
-                if (iconElement) {
-                    iconElement.classList.toggle('fa-circle-pause');
-                    iconElement.classList.toggle('fa-circle-play');
-                    if (iconElement.classList.contains('fa-circle-play')) {
-                        taskPauseButton.classList.add('has-text-success');
-                        removeClassesExcept(taskStatusTag, 'tag');
-                        taskStatusTag.classList.add('is-warning');
-                        taskStatusTag.textContent = 'Paused';
-                    } else {
-                        taskPauseButton.classList.remove('has-text-success');
-                        removeClassesExcept(taskStatusTag, 'tag');
-                        taskStatusTag.classList.add('is-info');
-                        taskStatusTag.textContent = 'Restarting';
-                        taskDetails();
-                    }
-                }
-            });
+  }
+
+  const taskHeaderActions = () => {
+    if (taskPauseButton) {
+      taskPauseButton.addEventListener("click", (event) => {
+        const iconElement = taskPauseButton.querySelector("i");
+        if (iconElement) {
+          iconElement.classList.toggle("fa-circle-pause");
+          iconElement.classList.toggle("fa-circle-play");
+          if (iconElement.classList.contains("fa-circle-play")) {
+            taskPauseButton.classList.add("has-text-success");
+            removeClassesExcept(taskStatusTag, "tag");
+            taskStatusTag.classList.add("is-warning");
+            taskStatusTag.textContent = "Paused";
+          } else {
+            taskPauseButton.classList.remove("has-text-success");
+            removeClassesExcept(taskStatusTag, "tag");
+            taskStatusTag.classList.add("is-info");
+            taskStatusTag.textContent = "Restarting";
+            taskDetails();
+          }
         }
-
-        if (taskCancelButton) {
-            taskCancelButton.addEventListener('click', (event) => {
-
-                const apiTaskStore = JSON.parse(sessionStorage.getItem('apiTask'));
-                actionStages(apiTaskStore, false);
-
-            });
-        }
-
+      });
     }
 
-    const updateTaskDetailsAgents = (agents) => {
-
-        taskDetailsAgents.innerHTML = '';
-        taskAgentsVsHumans = [];
+    if (taskCancelButton) {
+      taskCancelButton.addEventListener("click", (event) => {
+        const apiTaskStore = JSON.parse(sessionStorage.getItem("apiTask"));
+        handleDisableOfActions("completed")
+        actionStages(apiTaskStore, false);
+      });
+    }
+  };
 
-        agents.forEach(agent => {
+  const updateTaskDetailsAgents = (agents) => {
+    taskDetailsAgents.innerHTML = "";
+    taskAgentsVsHumans = [];
 
-            const isAvatar = (agent === 'HumanAgent') ? 'is-human' : 'is-avatar'
+    agents.forEach((agent) => {
+      const isAvatar = agent === "HumanAgent" ? "is-human" : "is-avatar";
 
-            taskDetailsAgents.innerHTML += `
+      taskDetailsAgents.innerHTML += `
             
${agentToIcon(agent)}
`; - (agent === 'HumanAgent') ? taskAgentsVsHumans.push('Human') : taskAgentsVsHumans.push('Agent'); - - }) - - const humansInv = taskAgentsVsHumans.filter(agent => agent === 'Human'); - const humanInvText = humansInv.length > 1 ? 'humans' : 'human'; + agent === "HumanAgent" + ? taskAgentsVsHumans.push("Human") + : taskAgentsVsHumans.push("Agent"); + }); - const agentsInv = taskAgentsVsHumans.filter(agent => agent === 'Agent'); - const agentsInvText = agentsInv.length > 1 ? 'agents' : 'agent'; + const humansInv = taskAgentsVsHumans.filter((agent) => agent === "Human"); + const humanInvText = humansInv.length > 1 ? "humans" : "human"; + const agentsInv = taskAgentsVsHumans.filter((agent) => agent === "Agent"); + const agentsInvText = agentsInv.length > 1 ? "agents" : "agent"; - taskAgentsHumans.innerHTML = `Team selected: ${humansInv.length} ${humanInvText}, ${agentsInv.length} ${agentsInvText}`; - } + taskAgentsHumans.innerHTML = `Team selected: ${humansInv.length} ${humanInvText}, ${agentsInv.length} ${agentsInvText}`; + }; - const updateTaskStatusDetails = (task) => { + const updateTaskStatusDetails = (task) => { + taskStatusDetails.innerHTML = ""; - taskStatusDetails.innerHTML = ''; - - taskStatusDetails.innerHTML = ` + taskStatusDetails.innerHTML = `

Summary: ${task.summary}

Created: ${toDateTime(task.ts)}

`; + }; + + const fetchPlanDetails = async (session_id) => { + console.log("/plans?session_id:", window.headers); + + const headers = await window.headers; + + return fetch(apiEndpoint + "/plans?session_id=" + session_id, { + method: "GET", + headers: headers, + }) + .then((response) => response.json()) + .then((data) => { + console.log("fetchPlanDetails", data[0]); + + updateTaskStatusDetails(data[0]); + updateTaskProgress(data[0]); + fetchTaskStages(data[0]); + + sessionStorage.setItem("apiTask", JSON.stringify(data[0])); + }) + .catch((error) => { + console.error("Error:", error); + }); + }; + + const fetchTaskStages = (task) => { + window.headers.then((headers) => { + fetch(apiEndpoint + "/steps/" + task.id, { + method: "GET", + headers: headers, + }) + .then((response) => response.json()) + .then((data) => { + console.log("fetchTaskStages", data); + + if (taskStagesMenu) taskStagesMenu.innerHTML = ""; + let taskStageCount = 0; + let taskStageApprovalStatus = 0; + + if (data && data.length > 0) { + taskAgents = []; + + data.forEach((stage) => { + const stageItem = document.createElement("li"); + const stageBase64 = btoa( + encodeURIComponent(JSON.stringify(stage)) + ); + + let stageStatusIcon = ""; + let stageActions = ""; + let stageRejected = ""; + + switch (stage.status) { + case "planned": + stageStatusIcon = ``; + break; + case "awaiting_feedback": + stageStatusIcon = ``; + break; + case "approved": + stageStatusIcon = ``; + break; + case "rejected": + stageStatusIcon = ``; + break; + case "action_requested": + stageStatusIcon = ``; + break; + case "completed": + stageStatusIcon = ``; + break; + case "failed": + stageStatusIcon = ``; + break; + default: + stageStatusIcon = ``; + } + + if (stage.human_approval_status === "rejected") { + stageRejected = "rejected"; + stageStatusIcon = ``; + } + + if (stage.human_approval_status === "requested") + stageActions = ` + + `; - } + stageItem.innerHTML = ` + + ${stageStatusIcon} + ${taskStageCount + 1}. ${ + stage.action + } + ${stageActions} + + `; - const fetchPlanDetails = async (session_id) => { + if (taskStagesMenu) taskStagesMenu.appendChild(stageItem); - console.log("/plans?session_id:", window.headers); + taskSessionId = stage.session_id; + taskLastStageId = stage.id; + taskLastAction = stage.action; + taskAgents.push(stage.agent); - const headers = await window.headers + stageItem + .querySelectorAll(".menu-stage-action") + .forEach((action) => { + action.addEventListener("click", (event) => { + actionStage( + event.target.dataset.action, + event.target.dataset.stage + ); - return fetch(apiEndpoint + '/plans?session_id=' + session_id, { - method: 'GET', - headers: headers, - }) - .then(response => response.json()) - .then(data => { + action.parentElement.style.display = "none"; + }); + }); - console.log('fetchPlanDetails', data[0]); + if (stage.human_approval_status === "requested") + taskStageApprovalStatus++; - updateTaskStatusDetails(data[0]); - updateTaskProgress(data[0]); - fetchTaskStages(data[0]); + taskStageCount++; + }); - sessionStorage.setItem('apiTask', JSON.stringify(data[0])); + updateTaskDetailsAgents([...new Set(taskAgents)]); - }) - .catch(error => { - console.error('Error:', error); - }) - } + sessionStorage.setItem("showApproveAll", false); - const fetchTaskStages = (task) => { - - window.headers - .then(headers => { - fetch(apiEndpoint + '/steps/' + task.id, { - method: 'GET', - headers: headers, - }) - .then(response => response.json()) - .then(data => { - - console.log('fetchTaskStages', data); - - if (taskStagesMenu) taskStagesMenu.innerHTML = ''; - let taskStageCount = 0; - let taskStageApprovalStatus = 0; - - if (data && data.length > 0) { - - taskAgents = []; - - data.forEach(stage => { - const stageItem = document.createElement('li'); - const stageBase64 = btoa(encodeURIComponent(JSON.stringify(stage))); - - let stageStatusIcon = ''; - let stageActions = ''; - let stageRejected = ''; - - switch (stage.status) { - case 'planned': - stageStatusIcon = ``; - break; - case 'awaiting_feedback': - stageStatusIcon = ``; - break; - case 'approved': - stageStatusIcon = ``; - break; - case 'rejected': - stageStatusIcon = ``; - break; - case 'action_requested': - stageStatusIcon = ``; - break; - case 'completed': - stageStatusIcon = ``; - break; - case 'failed': - stageStatusIcon = ``; - break; - default: - stageStatusIcon = ``; - } - - if (stage.human_approval_status === 'rejected') { - stageRejected = 'rejected'; - stageStatusIcon = ``; - } - - if (stage.human_approval_status === 'requested') stageActions = ` - - `; - - stageItem.innerHTML = ` - - ${stageStatusIcon} - ${taskStageCount + 1}. ${stage.action} - ${stageActions} - - `; - - if (taskStagesMenu) taskStagesMenu.appendChild(stageItem); - - taskSessionId = stage.session_id; - taskLastStageId = stage.id; - taskLastAction = stage.action; - taskAgents.push(stage.agent); - - stageItem.querySelectorAll('.menu-stage-action').forEach(action => { - action.addEventListener('click', (event) => { - - actionStage(event.target.dataset.action, event.target.dataset.stage); - - action.parentElement.style.display = 'none'; - - }); - }); - - if (stage.human_approval_status === 'requested') taskStageApprovalStatus++; - - taskStageCount++; - - }) - - updateTaskDetailsAgents([...new Set(taskAgents)]); - - sessionStorage.setItem('showApproveAll', false); - - // Feature approve all removed for this version - // if (isHumanFeedbackPending()) { - // sessionStorage.setItem('showApproveAll', false); - // console.log('showApproveAll status', "showApproveAll is false"); - - // } else { - // sessionStorage.setItem('showApproveAll', taskStageApprovalStatus === taskStageCount); - // console.log('showApproveAll status', taskStageApprovalStatus === taskStageCount); - - // } - } - - fetchTaskMessages(task); - - window.parent.postMessage({ - action: 'taskStarted', - }, '*'); - - }) - .catch(error => { - console.error('Error:', error); - }) - }) - } + // Feature approve all removed for this version + // if (isHumanFeedbackPending()) { + // sessionStorage.setItem('showApproveAll', false); + // console.log('showApproveAll status', "showApproveAll is false"); + + // } else { + // sessionStorage.setItem('showApproveAll', taskStageApprovalStatus === taskStageCount); + // console.log('showApproveAll status', taskStageApprovalStatus === taskStageCount); - const fetchTaskMessages = (task) => { - window.headers - .then(headers => { - fetch(apiEndpoint + '/agent_messages/' + task.session_id, { - method: 'GET', - headers: headers, - }) - .then(response => response.json()) - .then(data => { - - console.log('fetchTaskMessages', data); - - const toAgentName = (str) => { - return str.replace(/([a-z])([A-Z])/g, '$1 $2'); - }; - - const groupByStepId = (messages) => { - const groupedMessages = {}; - - messages.forEach(message => { - const stepId = message.step_id || 'planner'; - if (!groupedMessages[stepId]) { - groupedMessages[stepId] = []; - } - groupedMessages[stepId].push(message); - }); - - return groupedMessages; - } - - const contextFilter = (messages) => { - const filteredMessages = []; - - messages.forEach(message => { - if (message.source !== 'PlannerAgent' && message.source !== 'GroupChatManager') { - filteredMessages.push(message); - } - }); - - return filteredMessages; - } - - taskMessages.innerHTML = ''; - - // console.log(groupByStepId(data)); - - if (sessionStorage.getItem('context') && sessionStorage.getItem('context') === 'customer') { - - console.log('contextFilter', contextFilter(data)); - - data = contextFilter(data); - - } - - if (data) { - - let stageCount = 0; - let messageCount = 1; - const groupedData = groupByStepId(data); - - Object.keys(groupedData).forEach(stage => { - - const messages = groupedData[stage]; - const messageGroupItem = document.createElement('fieldset'); - - messageGroupItem.classList.add('task-stage-divider'); - messageGroupItem.classList.add('has-text-info'); - - messageGroupItem.innerHTML = (stageCount === 0) ? 'Planning' : `Stage ${stageCount}`; - taskMessages.appendChild(messageGroupItem); - - messages.forEach(message => { - const messageItem = document.createElement('div'); - const showApproveAll = sessionStorage.getItem('showApproveAll') === 'true' && data.length === messageCount; - - let approveAllStagesButton = ''; - - messageItem.classList.add('media'); - const isAvatar = (message.source === 'HumanAgent') ? 'is-human' : 'is-avatar' - const isActive = (message.source === 'PlannerAgent') ? 'has-status-busy' : 'has-status-active' - - if (sessionStorage.getItem('context') && sessionStorage.getItem('context') !== 'customer') { - if (showApproveAll) { - console.log('Creating approveAllStagesButton'); - approveAllStagesButton = `If you are happy with the plan, you can approve all stages.
`; - - } - } - const messageLeft = ` + // } + } + + fetchTaskMessages(task); + + window.parent.postMessage( + { + action: "taskStarted", + }, + "*" + ); + }) + .catch((error) => { + console.error("Error:", error); + }); + }); + }; + + const fetchTaskMessages = (task) => { + window.headers.then((headers) => { + fetch(apiEndpoint + "/agent_messages/" + task.session_id, { + method: "GET", + headers: headers, + }) + .then((response) => response.json()) + .then((data) => { + console.log("fetchTaskMessages", data); + + const toAgentName = (str) => { + return str.replace(/([a-z])([A-Z])/g, "$1 $2"); + }; + + const groupByStepId = (messages) => { + const groupedMessages = {}; + + messages.forEach((message) => { + const stepId = message.step_id || "planner"; + if (!groupedMessages[stepId]) { + groupedMessages[stepId] = []; + } + groupedMessages[stepId].push(message); + }); + + return groupedMessages; + }; + + const contextFilter = (messages) => { + const filteredMessages = []; + + messages.forEach((message) => { + if ( + message.source !== "PlannerAgent" && + message.source !== "GroupChatManager" + ) { + filteredMessages.push(message); + } + }); + + return filteredMessages; + }; + + taskMessages.innerHTML = ""; + + // console.log(groupByStepId(data)); + + if ( + sessionStorage.getItem("context") && + sessionStorage.getItem("context") === "customer" + ) { + console.log("contextFilter", contextFilter(data)); + + data = contextFilter(data); + } + + if (data) { + let stageCount = 0; + let messageCount = 1; + const groupedData = groupByStepId(data); + + Object.keys(groupedData).forEach((stage) => { + const messages = groupedData[stage]; + const messageGroupItem = document.createElement("fieldset"); + + messageGroupItem.classList.add("task-stage-divider"); + messageGroupItem.classList.add("has-text-info"); + + messageGroupItem.innerHTML = + stageCount === 0 + ? "Planning" + : `Stage ${stageCount}`; + taskMessages.appendChild(messageGroupItem); + + messages.forEach((message) => { + const messageItem = document.createElement("div"); + const showApproveAll = + sessionStorage.getItem("showApproveAll") === "true" && + data.length === messageCount; + + let approveAllStagesButton = ""; + + messageItem.classList.add("media"); + const isAvatar = + message.source === "HumanAgent" ? "is-human" : "is-avatar"; + const isActive = + message.source === "PlannerAgent" + ? "has-status-busy" + : "has-status-active"; + + if ( + sessionStorage.getItem("context") && + sessionStorage.getItem("context") !== "customer" + ) { + if (showApproveAll) { + console.log("Creating approveAllStagesButton"); + approveAllStagesButton = `If you are happy with the plan, you can approve all stages.
`; + } + } + const messageLeft = `
@@ -429,22 +473,30 @@
- ${toAgentName(message.source)} • ${toDateTime(message.ts)} AI-generated content may be incorrect + ${toAgentName( + message.source + )} • ${toDateTime( + message.ts + )} AI-generated content may be incorrect
- ${markdownConverter.makeHtml(message.content)} ${approveAllStagesButton} + ${markdownConverter.makeHtml( + message.content + )} ${approveAllStagesButton}
- ` - const messageRight = ` + `; + const messageRight = `
You • ${toDateTime(message.ts)}
- ${markdownConverter.makeHtml(message.content)} + ${markdownConverter.makeHtml( + message.content + )}
@@ -454,318 +506,354 @@ ${agentToIcon(message.source)}
- ` - - const messageTemplate = (message.source === 'HumanAgent') ? messageRight : messageLeft; - messageItem.innerHTML = messageTemplate; - taskMessages.appendChild(messageItem); - - if (sessionStorage.getItem('context') && sessionStorage.getItem('context') !== 'customer') { - if (showApproveAll) { - - document.getElementById("approveAllStagesButton").addEventListener('click', (event) => actionStages(task, true)); - - }; - }; - - messageCount++; - - }); - - stageCount++; - - }); - - const mediaContents = document.querySelectorAll('.media-content'); - if (mediaContents.length > 0) { - mediaContents[mediaContents.length - 1].scrollIntoView({ behavior: 'smooth' }); - } - - if (sessionStorage.getItem('context') && sessionStorage.getItem('context') === 'customer' && !sessionStorage.getItem('actionStagesRun').includes(task.session_id)) { - - - actionStages(task, true); - - let actionStagesRun = JSON.parse(sessionStorage.getItem('actionStagesRun') || '[]'); - - actionStagesRun.push(task.session_id); - sessionStorage.setItem('actionStagesRun', JSON.stringify(actionStagesRun)); - } - - setTimeout(() => { taskLoader.classList.add('is-hidden'); }, 500); - - } - - }) - .catch(error => { - console.error('Error:', error); - }) + `; + + const messageTemplate = + message.source === "HumanAgent" ? messageRight : messageLeft; + messageItem.innerHTML = messageTemplate; + taskMessages.appendChild(messageItem); + + if ( + sessionStorage.getItem("context") && + sessionStorage.getItem("context") !== "customer" + ) { + if (showApproveAll) { + document + .getElementById("approveAllStagesButton") + .addEventListener("click", (event) => + actionStages(task, true) + ); + } + } + + messageCount++; + }); + + stageCount++; }); - - } + const mediaContents = document.querySelectorAll(".media-content"); + if (mediaContents.length > 0) { + mediaContents[mediaContents.length - 1].scrollIntoView({ + behavior: "smooth", + }); + } - const updateTaskProgress = (task) => { + if ( + sessionStorage.getItem("context") && + sessionStorage.getItem("context") === "customer" && + !sessionStorage + .getItem("actionStagesRun") + .includes(task.session_id) + ) { + actionStages(task, true); + + let actionStagesRun = JSON.parse( + sessionStorage.getItem("actionStagesRun") || "[]" + ); + + actionStagesRun.push(task.session_id); + sessionStorage.setItem( + "actionStagesRun", + JSON.stringify(actionStagesRun) + ); + } - const taskStatusToLabel = (str) => { - return str.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); - }; + setTimeout(() => { + taskLoader.classList.add("is-hidden"); + }, 500); + } + }) + .catch((error) => { + console.error("Error:", error); + }); + }); + }; + + const updateTaskProgress = (task) => { + const taskStatusToLabel = (str) => { + return str + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + }; - const totalSteps = task.total_steps; - const completedSteps = task.completed; - const approvalRequired = task.steps_requiring_approval; + const totalSteps = task.total_steps; + const completedSteps = task.completed; + const approvalRequired = task.steps_requiring_approval; - const percentage = (completedSteps / totalSteps) * 100; - const progressString = `Progress ${completedSteps}/${totalSteps}`; - const percentageString = `${percentage.toFixed(0)}%`; + const percentage = (completedSteps / totalSteps) * 100; + const progressString = `Progress ${completedSteps}/${totalSteps}`; + const percentageString = `${percentage.toFixed(0)}%`; - taskProgress.textContent = progressString; - taskProgressPercentage.textContent = percentageString; - taskProgressBar.value = parseFloat(percentageString); + taskProgress.textContent = progressString; + taskProgressPercentage.textContent = percentageString; + taskProgressBar.value = parseFloat(percentageString); - taskStatusTag.textContent = taskStatusToLabel(task.overall_status); + taskStatusTag.textContent = taskStatusToLabel(task.overall_status); - if (task.overall_status === 'completed') { - removeClassesExcept(taskStatusTag, 'tag'); - taskStatusTag.classList.add('is-success'); - } + if (task.overall_status === "completed") { + removeClassesExcept(taskStatusTag, "tag"); + taskStatusTag.classList.add("is-success"); + } else if (task.overall_status === "in_progress") { + removeClassesExcept(taskStatusTag, "tag"); + taskStatusTag.classList.add("is-info"); + } + handleDisableOfActions(task.overall_status) + }; + + const isHumanFeedbackPending = () => { + const storedData = sessionStorage.getItem("apiTask"); + const planDetails = JSON.parse(storedData); + return ( + planDetails.human_clarification_request !== null && + planDetails.human_clarification_response === null + ); + }; + + const actionStage = (action, stage) => { + if (isHumanFeedbackPending()) { + notyf.error("You must first provide feedback to the planner."); + return; } - const isHumanFeedbackPending = () => { - const storedData = sessionStorage.getItem('apiTask'); - const planDetails = JSON.parse(storedData); - return planDetails.human_clarification_request !== null && planDetails.human_clarification_response === null; - }; - - const actionStage = (action, stage) => { - - if (isHumanFeedbackPending()) { - notyf.error('You must first provide feedback to the planner.'); - return; - } + const stageObj = JSON.parse(decodeURIComponent(atob(stage))); - const stageObj = JSON.parse(decodeURIComponent(atob(stage))); + console.log("actionStage", { + step_id: stageObj.id, + plan_id: stageObj.plan_id, + session_id: stageObj.session_id, + approved: action === "approved" ? true : false, + }); - console.log('actionStage', { - step_id: stageObj.id, - plan_id: stageObj.plan_id, - session_id: stageObj.session_id, - approved: action === 'approved' ? true : false, - }); + notyf.open({ + type: "info", + message: `Request sent for "${stageObj.action}"`, + }); - notyf.open({ - type: 'info', - message: `Request sent for "${stageObj.action}"` + window.headers.then((headers) => { + fetch(apiEndpoint + "/approve_step_or_steps", { + method: "POST", + headers: headers, + body: JSON.stringify({ + step_id: stageObj.id, + plan_id: stageObj.plan_id, + session_id: stageObj.session_id, + approved: action === "approved" ? true : false, + }), + }) + .then((response) => response.json()) + .then((data) => { + console.log("actionStage", data); + action === "approved" + ? notyf.success(`Stage "${stageObj.action}" approved.`) + : notyf.error(`Stage "${stageObj.action}" rejected.`); + + taskDetails(); + }) + .catch((error) => { + console.error("Error:", error); }); + }); + }; - window.headers - .then(headers =>{ - fetch(apiEndpoint + '/approve_step_or_steps', { - method: 'POST', - headers: headers, - body: JSON.stringify({ - step_id: stageObj.id, - plan_id: stageObj.plan_id, - session_id: stageObj.session_id, - approved: action === 'approved' ? true : false, - }) - }) - .then(response => response.json()) - .then(data => { - - console.log('actionStage', data); - action === 'approved' ? notyf.success(`Stage "${stageObj.action}" approved.`) : notyf.error(`Stage "${stageObj.action}" rejected.`); - - taskDetails(); - - }) - .catch(error => { - console.error('Error:', error); - }) - }) - } - - const actionStages = (task, approve) => { + const actionStages = (task, approve) => { + console.log("approveStages", { + plan_id: task.id, + session_id: task.session_id, + approved: approve, + }); - console.log('approveStages', { - plan_id: task.id, - session_id: task.session_id, - approved: approve, - }); + notyf.open({ + type: "info", + message: `Request sent to action on all stages.`, + }); - notyf.open({ - type: 'info', - message: `Request sent to action on all stages.` + // document.querySelectorAll('.menu-stage-actions').forEach(element => { + // element.style.display = 'none'; + // }); + + window.headers.then((headers) => { + fetch(apiEndpoint + "/approve_step_or_steps", { + method: "POST", + headers: headers, + body: JSON.stringify({ + plan_id: task.id, + session_id: task.session_id, + approved: approve, + }), + }) + .then((response) => response.json()) + .then((data) => { + console.log("approveStages", data); + approve + ? notyf.success(`All stages approved.`) + : notyf.error(`All stages cancelled.`); + taskDetails(); + }) + .catch((error) => { + console.error("Error:", error); }); - - // document.querySelectorAll('.menu-stage-actions').forEach(element => { - // element.style.display = 'none'; - // }); - - window.headers - .then(headers =>{ - fetch(apiEndpoint + '/approve_step_or_steps', { - method: 'POST', - headers: headers, - body: JSON.stringify({ - plan_id: task.id, - session_id: task.session_id, - approved: approve, - }) - }) - .then(response => response.json()) - .then(data => { - - console.log('approveStages', data); - approve ? notyf.success(`All stages approved.`) : notyf.error(`All stages rejected.`); - taskDetails(); - - }) - .catch(error => { - console.error('Error:', error); - }) - }) + }); + }; + + const taskDetailsActions = () => { + if (taskAgentsButton) { + taskAgentsButton.addEventListener("click", (event) => { + window.parent.postMessage( + { + button: "taskAgentsButton", + id: taskStore.id, + name: taskStore.name, + }, + "*" + ); + }); } - const taskDetailsActions = () => { - - if (taskAgentsButton) { - taskAgentsButton.addEventListener('click', (event) => { - window.parent.postMessage({ - button: 'taskAgentsButton', - id: taskStore.id, - name: taskStore.name - }, '*'); - }); - } - - if (taskWokFlowButton) { - taskWokFlowButton.addEventListener('click', (event) => { - window.parent.postMessage({ - button: 'taskWokFlowButton', - id: taskStore.id, - name: taskStore.name - }, '*'); - }); + if (taskWokFlowButton) { + taskWokFlowButton.addEventListener("click", (event) => { + window.parent.postMessage( + { + button: "taskWokFlowButton", + id: taskStore.id, + name: taskStore.name, + }, + "*" + ); + }); + } + }; + + let lastDataHash = null; + //Refresh timer + const taskDetails = () => { + if (taskStore) { + taskName.innerHTML = taskStore.name; + + const fetchLoop = async (id) => { + try { + // Fetch the new data from the server + const newData = await fetchPlanDetails(id); + + // Generate a hash of the new data + const newDataHash = await GenerateHash(newData); + + // Check if the new data's hash is different from the last fetched data's hash + if (newDataHash === lastDataHash) { + console.log("Data hasn't changed. Skipping next poll."); + return; // Skip polling if no changes + } + + // Update the lastDataHash to the new hash + lastDataHash = newDataHash; + + // Continue polling by calling fetchLoop again + setTimeout( + () => fetchLoop(id), + Number(sessionStorage.getItem("apiRefreshRate")) + ); + } catch (error) { + console.error("Error in fetchLoop:", error); } + }; + fetchLoop(taskStore.id); // Start the fetch loop } + }; + const taskMessage = () => { + const taskMessageTextarea = document.getElementById("taskMessageTextarea"); - let lastDataHash = null; - //Refresh timer - const taskDetails = () => { - if (taskStore) { - taskName.innerHTML = taskStore.name; - - const fetchLoop = async (id) => { - try { - // Fetch the new data from the server - const newData = await fetchPlanDetails(id); - - // Generate a hash of the new data - const newDataHash = await GenerateHash(newData); - - // Check if the new data's hash is different from the last fetched data's hash - if (newDataHash === lastDataHash) { - console.log("Data hasn't changed. Skipping next poll."); - return; // Skip polling if no changes - } - - // Update the lastDataHash to the new hash - lastDataHash = newDataHash; - - // Continue polling by calling fetchLoop again - setTimeout(() => fetchLoop(id), Number(sessionStorage.getItem('apiRefreshRate'))); - } catch (error) { - console.error('Error in fetchLoop:', error); - } - }; - - fetchLoop(taskStore.id); // Start the fetch loop + if (taskMessageAddButton) { + taskMessageAddButton.addEventListener("click", (event) => { + const messageContent = taskMessageTextarea.value; + + if (!messageContent) { + notyf.error("Please enter a message."); + return; } - }; + taskMessageTextarea.disabled = true; + taskMessageAddButton.disabled = true; + taskMessageAddButton.classList.add("is-loading"); - const taskMessage = () => { - const taskMessageTextarea = document.getElementById('taskMessageTextarea'); + console.log({ + plan_id: taskStore.id, + session_id: taskSessionId, + human_clarification: taskMessageTextarea.value, + }); - - if (taskMessageAddButton) { - taskMessageAddButton.addEventListener('click', (event) => { - const messageContent = taskMessageTextarea.value; - - if (!messageContent) { - notyf.error('Please enter a message.'); - return; - } - - taskMessageTextarea.disabled = true; - taskMessageAddButton.disabled = true; - taskMessageAddButton.classList.add('is-loading'); - - console.log({ - plan_id: taskStore.id, - session_id: taskSessionId, - human_clarification: taskMessageTextarea.value, - }) - - window.headers - .then(headers =>{ - fetch(apiEndpoint + '/human_clarification_on_plan', { - method: 'POST', - headers: headers, - body: JSON.stringify({ - plan_id: taskStore.id, - session_id: taskSessionId, - human_clarification: taskMessageTextarea.value, - }) - }) - .then(response => response.json()) - .then(data => { - - console.log('taskMessage', data); - - taskMessageTextarea.disabled = false; - taskMessageAddButton.disabled = false; - taskMessageAddButton.classList.remove('is-loading'); - - taskMessageTextarea.value = ''; - - notyf.success('Additional details registered in plan.'); - - fetchPlanDetails(taskStore.id); - - }) - .catch(error => { - console.error('Error:', error); - }) - }) + window.headers.then((headers) => { + fetch(apiEndpoint + "/human_clarification_on_plan", { + method: "POST", + headers: headers, + body: JSON.stringify({ + plan_id: taskStore.id, + session_id: taskSessionId, + human_clarification: taskMessageTextarea.value, + }), + }) + .then((response) => response.json()) + .then((data) => { + console.log("taskMessage", data); + + taskMessageTextarea.disabled = false; + taskMessageAddButton.disabled = false; + taskMessageAddButton.classList.remove("is-loading"); + + taskMessageTextarea.value = ""; + fetchPlanDetails(taskStore.id); + + // Reset character count to 0 + const charCount = document.getElementById("charCount"); + if (charCount) { + charCount.textContent = "0"; + } + updateButtonImage(); + + notyf.success("Additional details registered in plan."); + }) + .catch((error) => { + console.error("Error:", error); }); - } - - } - - const handleTextAreaTyping = () => { - const newTaskPrompt = document.getElementById('taskMessageTextarea'); - newTaskPrompt.addEventListener('input', () => { - // whenever text is sent to the text area, we want to update the character count and dynamically resize the text area - const textInput = document.getElementById('taskMessageTextarea'); - const charCount = document.getElementById('charCount'); - - // Update character count - charCount.textContent = textInput.value.length; - - // Dynamically adjust height - textInput.style.height = 'auto'; - textInput.style.height = textInput.scrollHeight + 'px'; }); + }); } - - taskHeaderActions(); - taskDetailsActions(); - taskDetails(); - taskMessage(); - handleTextAreaTyping(); - -})(); \ No newline at end of file + }; + + const handleTextAreaTyping = () => { + const newTaskPrompt = document.getElementById("taskMessageTextarea"); + newTaskPrompt.addEventListener("input", () => { + // whenever text is sent to the text area, we want to update the character count and dynamically resize the text area + const textInput = document.getElementById("taskMessageTextarea"); + const charCount = document.getElementById("charCount"); + + // Update character count + charCount.textContent = textInput.value.length; + + // Dynamically adjust height + textInput.style.height = "auto"; + textInput.style.height = textInput.scrollHeight + "px"; + updateButtonImage(); + }); + newTaskPrompt.addEventListener("keydown", (event) => { + const textValue = newTaskPrompt.value.trim(); + if (event.key === "Enter" && !event.shiftKey) { + if (textValue === "") { + event.preventDefault(); + } else { + startTaskButton.click(); + } + } else if (event.key === "Enter" && event.shiftKey) { + return; + } + }); + }; + updateButtonImage(); + taskHeaderActions(); + taskDetailsActions(); + taskDetails(); + taskMessage(); + handleTextAreaTyping(); +})();