diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..cc3143bd --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +const LICENSE_HEADER = `/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */`; + +module.exports = { + root: true, + extends: [ + '@elastic/eslint-config-kibana', + 'plugin:@elastic/eui/recommended', + 'plugin:react-hooks/recommended', + ], + overrides: [ + { + files: ['**/*.{js,ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + 'no-console': 0, + '@osd/eslint/require-license-header': [ + 'error', + { + licenses: [LICENSE_HEADER], + }, + ], + }, + }, + ], +}; diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..29eddb95 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: 🐛 Bug report +about: Create a report to help us improve +title: '[BUG]' +labels: 'bug, untriaged' +assignees: '' +--- + +**What is the bug?** +A clear and concise description of the bug. + +**How can one reproduce the bug?** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**What is the expected behavior?** +A clear and concise description of what you expected to happen. + +**What is your host/environment?** + - OS: [e.g. iOS] + - Version [e.g. 22] + - Plugins + +**Do you have any screenshots?** +If applicable, add screenshots to help explain your problem. + +**Do you have any additional context?** +Add any other context about the problem. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a8199a10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: OpenSearch Community Support + url: https://discuss.opendistrocommunity.dev/ + about: Please ask and answer questions here. + - name: AWS/Amazon Security + url: https://aws.amazon.com/security/vulnerability-reporting/ + about: Please report security vulnerabilities here. \ 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 00000000..6198f338 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: 🎆 Feature request +about: Request a feature in this project +title: '[FEATURE]' +labels: 'enhancement, untriaged' +assignees: '' +--- +**Is your feature request related to a problem?** +A clear and concise description of what the problem is, e.g. _I'm always frustrated when [...]_ + +**What solution would you like?** +A clear and concise description of what you want to happen. + +**What alternatives have you considered?** +A clear and concise description of any alternative solutions or features you've considered. + +**Do you have any additional context?** +Add any other context or screenshots about the feature request here. \ 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 00000000..19d54289 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +### Description +[Describe what this change achieves] + +### Issues Resolved +[List any issues this PR will resolve] + +### Check List +- [ ] New functionality includes testing. + - [ ] All tests pass, including unit test, integration test and doctest +- [ ] New functionality has been documented. + - [ ] New functionality has javadoc added + - [ ] New functionality has user manual doc added +- [ ] Commits are signed per the DCO using --signoff + +By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. +For more information on following Developer Certificate of Origin and signing off your commits, please check [here](https://github.com/opensearch-project/OpenSearch/blob/main/CONTRIBUTING.md#developer-certificate-of-origin). diff --git a/.github/draft-release-notes-config.yml b/.github/draft-release-notes-config.yml new file mode 100644 index 00000000..904e67b4 --- /dev/null +++ b/.github/draft-release-notes-config.yml @@ -0,0 +1,45 @@ +# The overall template of the release notes +template: | + Compatible with OpenSearch and OpenSearch Dashboards Version $RESOLVED_VERSION + $CHANGES + +# Setting the formatting and sorting for the release notes body +name-template: Version $RESOLVED_VERSION +change-template: "* $TITLE ([#$NUMBER](https://github.com/opensearch-project/dashboards-assistant/pull/$NUMBER))" +sort-by: merged_at +sort-direction: ascending +replacers: + - search: '##' + replace: '###' + +# Organizing the tagged PRs into unified categories +categories: + - title: 'Breaking Changes' + labels: + - 'Breaking Changes' + - title: 'Features' + labels: + - 'feature' + - title: 'Enhancements' + labels: + - 'enhancement' + - title: 'Bug Fixes' + labels: + - 'bug' + - title: 'Infrastructure' + labels: + - 'infra' + - 'test' + - 'dependencies' + - 'github actions' + - title: 'Documentation' + labels: + - 'documentation' + - title: 'Maintenance' + labels: + - "version compatibility" + - "maintenance" + - title: 'Refactoring' + labels: + - 'refactor' + - 'code quality' diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 00000000..c8dfef41 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,43 @@ +name: Backport +on: + pull_request_target: + types: + - closed + - labeled + +jobs: + backport: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + name: Backport + # Only react to merged PRs for security reasons. + # See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target. + if: > + github.event.pull_request.merged + && ( + github.event.action == 'closed' + || ( + github.event.action == 'labeled' + && contains(github.event.label.name, 'backport') + ) + ) + steps: + - name: GitHub App token + id: github_app_token + uses: tibdex/github-app-token@v1.5.0 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + # opensearch-trigger-bot installation ID + installation_id: 22958780 + + - name: Backport + uses: VachaShah/backport@v2.2.0 + with: + github_token: ${{ steps.github_app_token.outputs.token }} + head_template: backport/backport-<%= number %>-to-<%= base %> + files_to_skip: "CHANGELOG.md" + labels_template: "<%= JSON.stringify([...labels, 'autocut']) %>" + failure_labels: "failed backport" diff --git a/.github/workflows/changelog_verifier.yml b/.github/workflows/changelog_verifier.yml new file mode 100644 index 00000000..2cf959c0 --- /dev/null +++ b/.github/workflows/changelog_verifier.yml @@ -0,0 +1,19 @@ +name: "Changelog Verifier" +on: + pull_request: + branches: [ '**' ] + types: [opened, edited, review_requested, synchronize, reopened, ready_for_review, labeled, unlabeled] + +jobs: + # Enforces the update of a changelog file on every pull request + verify-changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.head.sha }} + + - uses: dangoslen/changelog-enforcer@v3 + with: + skipLabels: "autocut, Skip-Changelog" diff --git a/.github/workflows/links_checker.yml b/.github/workflows/links_checker.yml new file mode 100644 index 00000000..06cfbf33 --- /dev/null +++ b/.github/workflows/links_checker.yml @@ -0,0 +1,37 @@ +# Copyright OpenSearch Contributors +# SPDX-License-Identifier: Apache-2.0 + +name: Link Checker + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +jobs: + linkchecker: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Checkout OpenSearch-Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/OpenSearch-Dashboards + ref: main + path: OpenSearch-Dashboards + - name: Load Excludes + run: | + LYCHEE_EXCLUDE=$(sed -e :a -e 'N;s/\n/ --exclude /;ta' OpenSearch-Dashboards/.lycheeexclude) + echo "LYCHEE_EXCLUDE=$LYCHEE_EXCLUDE" >> $GITHUB_ENV + - name: Lychee Link Checker + id: lychee + uses: lycheeverse/lychee-action@v1.0.9 + with: + args: --accept=200,403,429 --exclude ${{ env.LYCHEE_EXCLUDE }} --exclude-mail "**/*.html" "**/*.md" "**/*.txt" "**/*.json" "**/*.js" "**/*.ts" "**/*.tsx" + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Fail if there were link errors + run: exit ${{ steps.lychee.outputs.exit_code }} \ No newline at end of file diff --git a/.github/workflows/unit_test_workflow.yml b/.github/workflows/unit_test_workflow.yml new file mode 100644 index 00000000..8c301874 --- /dev/null +++ b/.github/workflows/unit_test_workflow.yml @@ -0,0 +1,98 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Build and test + +# trigger on every commit push and PR for all branches except pushes for backport branches +on: + pull_request: + branches: ["**"] + push: + branches: ["**"] +env: + OPENSEARCH_DASHBOARDS_VERSION: '2.x' + NODE_OPTIONS: "--max-old-space-size=6144 --dns-result-order=ipv4first" + +jobs: + Get-CI-Image-Tag: + uses: opensearch-project/opensearch-build/.github/workflows/get-ci-image-tag.yml@main + with: + product: opensearch-dashboards + + tests-linux: + needs: Get-CI-Image-Tag + name: Run unit tests + runs-on: ubuntu-latest + container: + # using the same image which is used by opensearch-build team to build the OpenSearch Distribution + # this image tag is subject to change as more dependencies and updates will arrive over time + image: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-version-linux }} + # need to switch to root so that github actions can install runner binary on container without permission issues. + options: --user root + + steps: + # Enable longer filenames for windows + - name: Checkout OpenSearch-Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/OpenSearch-Dashboards + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + path: OpenSearch-Dashboards + - name: Checkout dashboards-assistant plugin + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards/plugins/dashboards-assistant + - name: Bootstrap / Run tests + run: | + chown -R 1000:1000 `pwd` + cd ./OpenSearch-Dashboards/ + su `id -un 1000` -c "source $NVM_DIR/nvm.sh && nvm use && node -v && yarn -v && + cd ./plugins/dashboards-assistant && + whoami && yarn osd bootstrap && yarn run test:jest --coverage" + + - name: Uploads coverage + uses: codecov/codecov-action@v1 + + tests-windows-macos: + name: Run unit tests + strategy: + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + # Enable longer filenames for windows + - name: Enable longer filenames + if: ${{ matrix.os == 'windows-latest' }} + run: git config --system core.longpaths true + - name: Checkout OpenSearch-Dashboards + uses: actions/checkout@v2 + with: + repository: opensearch-project/OpenSearch-Dashboards + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + path: OpenSearch-Dashboards + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version-file: './OpenSearch-Dashboards/.nvmrc' + registry-url: 'https://registry.npmjs.org' + - name: Install Yarn + # Need to use bash to avoid having a windows/linux specific step + shell: bash + run: | + YARN_VERSION=$(node -p "require('./OpenSearch-Dashboards/package.json').engines.yarn") + echo "Installing yarn@$YARN_VERSION" + npm i -g yarn@$YARN_VERSION + - run: node -v + - run: yarn -v + - name: Checkout dashboards-assistant plugin + uses: actions/checkout@v2 + with: + path: OpenSearch-Dashboards/plugins/dashboards-assistant + - name: Bootstrap plugin/dashboards-assistant + run: | + cd OpenSearch-Dashboards/plugins/dashboards-assistant + yarn osd bootstrap + - name: Run tests + run: | + cd OpenSearch-Dashboards/plugins/dashboards-assistant + yarn run test:jest --coverage diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d5b20ea9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +target/ +build/ +coverage/ +.cypress/screenshots +.cypress/videos +.cypress/downloads +common/query_manager/antlr/output +.eslintcache +.logs +.env +.idea/ diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 00000000..31354ec1 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..d2ae35e8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged diff --git a/.whitesource b/.whitesource index d465069a..89842729 100644 --- a/.whitesource +++ b/.whitesource @@ -30,4 +30,3 @@ "enabled": true } } -} \ No newline at end of file diff --git a/ADMINS.md b/ADMINS.md new file mode 100644 index 00000000..2e1ba5af --- /dev/null +++ b/ADMINS.md @@ -0,0 +1,9 @@ +## Admins + +| Admin | GitHub ID | Affiliation | +| ------------------ | --------------------------------------- | ----------- | +| Charlotte Henkle | [CEHENKLE](https://github.com/CEHENKLE) | Amazon | +| Daniel Doubrovkine | [dblock](https://github.com/dblock) | Amazon | +| Henri Yandell | [hyandell](https://github.com/hyandell) | Amazon | + +[This document](https://github.com/opensearch-project/.github/blob/main/ADMINS.md) explains what admins do in this repo. and how they should be doing it. If you're interested in becoming a maintainer, see [MAINTAINERS](MAINTAINERS.md). If you're interested in contributing, see [CONTRIBUTING](CONTRIBUTING.md). \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..055c2ab4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# CHANGELOG + +Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) + +### 📈 Features/Enhancements + +- Add support for registerMessageParser ([#5](https://github.com/opensearch-project/dashboards-assistant/pull/5)) +- Change implementation of basic_input_output to built-in parser ([#10](https://github.com/opensearch-project/dashboards-assistant/pull/10)) +- Add interactions into ChatState and pass specific interaction into message_bubble ([#12](https://github.com/opensearch-project/dashboards-assistant/pull/12)) \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5b627cfa..db203268 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,4 +1,24 @@ -## Code of Conduct -This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). -For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact -opensource-codeofconduct@amazon.com with any additional questions or comments. +This code of conduct applies to all spaces provided by the OpenSource project including in code, documentation, issue trackers, mailing lists, chat channels, wikis, blogs, social media and any other communication channels used by the project. + + +**Our open source communities endeavor to:** + +* Be Inclusive: We are committed to being a community where everyone can join and contribute. This means using inclusive and welcoming language. +* Be Welcoming: We are committed to maintaining a safe space for everyone to be able to contribute. +* Be Respectful: We are committed to encouraging differing viewpoints, accepting constructive criticism and work collaboratively towards decisions that help the project grow. Disrespectful and unacceptable behavior will not be tolerated. +* Be Collaborative: We are committed to supporting what is best for our community and users. When we build anything for the benefit of the project, we should document the work we do and communicate to others on how this affects their work. + + +**Our Responsibility. As contributors, members, or bystanders we each individually have the responsibility to behave professionally and respectfully at all times. Disrespectful and unacceptable behaviors include, but are not limited to:** + +* The use of violent threats, abusive, discriminatory, or derogatory language; +* Offensive comments related to gender, gender identity and expression, sexual orientation, disability, mental illness, race, political or religious affiliation; +* Posting of sexually explicit or violent content; +* The use of sexualized language and unwelcome sexual attention or advances; +* Public or private harassment of any kind; +* Publishing private information, such as physical or electronic address, without permission; +* Other conduct which could reasonably be considered inappropriate in a professional setting; +* Advocating for or encouraging any of the above behaviors. +* Enforcement and Reporting Code of Conduct Issues: + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported. [Contact us](mailto:opensource-codeofconduct@amazon.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 67db8588..261eeb9e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,3 @@ - Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -173,3 +172,30 @@ defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE index 616fc588..731cb600 100644 --- a/NOTICE +++ b/NOTICE @@ -1 +1,2 @@ -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +OpenSearch (https://opensearch.org/) +Copyright OpenSearch Contributors diff --git a/README.md b/README.md index b5085ba8..7117031b 100644 --- a/README.md +++ b/README.md @@ -59,4 +59,4 @@ This project is licensed under the [Apache v2.0 License](LICENSE). ## Copyright -Copyright OpenSearch Contributors. See [NOTICE](NOTICE.txt) for details. \ No newline at end of file +Copyright OpenSearch Contributors. See [NOTICE](NOTICE) for details. \ No newline at end of file diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..6903e716 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1 @@ +This project follows the [OpenSearch release process](https://github.com/opensearch-project/.github/blob/main/RELEASING.md). \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0b85ca04 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +## Reporting a Vulnerability + +If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/) or directly via email to aws-security@amazon.com. Please do **not** create a public GitHub issue. \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..16a64bf9 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// babelrc doesn't respect NODE_PATH anymore but using require does. +// Alternative to install them locally in node_modules +module.exports = function (api) { + // ensure env is test so that this config won't impact build or dev server + if (api.env('test')) { + return { + presets: [ + require('@babel/preset-env', { + useBuiltIns: false, + targets: { + node: 'current', + }, + }), + require('@babel/preset-react'), + require('@babel/preset-typescript'), + ], + }; + } + return {}; +}; diff --git a/common/constants/llm.ts b/common/constants/llm.ts new file mode 100644 index 00000000..176bda88 --- /dev/null +++ b/common/constants/llm.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const API_BASE = '/api/assistant'; +export const DSL_BASE = '/api/dsl'; +export const DSL_SEARCH = '/search'; +export const NOTEBOOK_PREFIX = '/api/observability/notebooks'; + +export const ASSISTANT_API = { + SEND_MESSAGE: `${API_BASE}/send_message`, + SESSION: `${API_BASE}/session`, + SESSIONS: `${API_BASE}/sessions`, + FEEDBACK: `${API_BASE}/feedback`, + ABORT_AGENT_EXECUTION: `${API_BASE}/abort`, + REGENERATE: `${API_BASE}/regenerate`, + TRACE: `${API_BASE}/trace`, +} as const; + +export const LLM_INDEX = { + FEEDBACK: '.llm-feedback', + TRACES: '.assistant-traces', + SESSIONS: '.assistant-sessions', + VECTOR_STORE: '.llm-vector-store', +}; + +export const NOTEBOOK_API = { + CREATE_NOTEBOOK: `${NOTEBOOK_PREFIX}/note`, + SET_PARAGRAPH: `${NOTEBOOK_PREFIX}/set_paragraphs/`, +}; diff --git a/common/constants/saved_objects.ts b/common/constants/saved_objects.ts new file mode 100644 index 00000000..25f846eb --- /dev/null +++ b/common/constants/saved_objects.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const CHAT_CONFIG_SAVED_OBJECT_TYPE = 'chat-config'; diff --git a/common/types/chat_saved_object_attributes.ts b/common/types/chat_saved_object_attributes.ts new file mode 100644 index 00000000..7f62a31c --- /dev/null +++ b/common/types/chat_saved_object_attributes.ts @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const CHAT_SAVED_OBJECT = 'assistant-chat'; +export const SAVED_OBJECT_VERSION = 1; + +export interface Interaction { + input: string; + response: string; + conversation_id: string; + interaction_id: string; + create_time: string; + additional_info?: { feedback?: SendFeedbackBody; [key: string]: unknown }; + parent_interaction_id?: string; +} + +export interface ISession { + title: string; + version?: number; + createdTimeMs: number; + updatedTimeMs: number; + messages: IMessage[]; + interactions: Interaction[]; +} + +export interface ISessionFindResponse { + objects: Array; + total: number; +} + +export interface IInput { + type: 'input'; + contentType: 'text'; + content: string; + context?: { + appId?: string; + }; +} +export interface IOutput { + type: 'output'; + traceId?: string; // used for tracing agent calls + toolsUsed?: string[]; + contentType: 'error' | 'markdown' | 'visualization' | 'ppl_visualization'; + content: string; + suggestedActions?: ISuggestedAction[]; +} +export type IMessage = IInput | IOutput; + +interface ISuggestedActionBase { + actionType: string; + message: string; +} +export type ISuggestedAction = ISuggestedActionBase & + ( + | { actionType: 'send_as_input' | 'copy' | 'view_in_dashboards' } + | { + actionType: 'view_ppl_visualization'; + metadata: { query: string; question: string }; + } + | { + actionType: 'view_trace'; + metadata: { traceId: string; icon: string }; + } + ); +export interface SendFeedbackBody { + satisfaction: boolean; +} diff --git a/common/utils/llm_chat/traces.ts b/common/utils/llm_chat/traces.ts new file mode 100644 index 00000000..ab3dba7f --- /dev/null +++ b/common/utils/llm_chat/traces.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface AgentFrameworkTrace { + interactionId: string; + parentInteractionId: string; + createTime: string; + input: string; + output: string; + origin: string; + traceNumber: number; +} diff --git a/opensearch-dashboards-plugin-helpers.dev.json b/opensearch-dashboards-plugin-helpers.dev.json new file mode 100644 index 00000000..23260a3b --- /dev/null +++ b/opensearch-dashboards-plugin-helpers.dev.json @@ -0,0 +1,9 @@ +{ + "serverSourcePatterns": [ + "package.json", + "yarn.lock", + "tsconfig.json", + "{common,public,server,test}/**/*", + "!__tests__" + ] +} diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json new file mode 100644 index 00000000..f7c8a96f --- /dev/null +++ b/opensearch_dashboards.json @@ -0,0 +1,16 @@ +{ + "id": "assistantDashboards", + "version": "2.11.0.0", + "opensearchDashboardsVersion": "2.11.0", + "server": true, + "ui": true, + "requiredPlugins": [ + "dashboard", + "embeddable", + "opensearchDashboardsReact", + "opensearchDashboardsUtils" + ], + "configPath": [ + "assistant" + ] +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..ca933f15 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "assistant-dashboards", + "version": "2.11.0.0", + "main": "index.ts", + "license": "Apache-2.0", + "scripts": { + "osd": "node ../../scripts/osd", + "build": "yarn plugin-helpers build", + "test:jest": "../../node_modules/.bin/jest --config ./test/jest.config.js", + "plugin-helpers": "node ../../scripts/plugin_helpers", + "prepare": "husky install", + "lint:es": "node ../../scripts/eslint", + "lint": "yarn lint:es" + }, + "lint-staged": { + "*.{ts,tsx,js,jsx}": [ + "yarn lint --fix" + ] + }, + "dependencies": { + "autosize": "^6.0.1", + "csv-parser": "^3.0.0", + "dompurify": "^2.4.1", + "jsdom": "^22.1.0", + "postinstall": "^0.7.4" + }, + "devDependencies": { + "@types/autosize": "^4.0.1", + "@types/dompurify": "^2.3.3", + "@types/enzyme-adapter-react-16": "^1.0.6", + "@types/jsdom": "^21.1.2", + "@types/react-test-renderer": "^16.9.1", + "eslint": "^6.8.0", + "husky": "^8.0.0", + "jest-dom": "^4.0.0", + "lint-staged": "^13.1.0", + "ts-jest": "^29.1.0" + }, + "eslintIgnore": [ + "node_modules/*", + "target/*" + ] +} diff --git a/public/assets/chat.svg b/public/assets/chat.svg new file mode 100644 index 00000000..4778dad4 --- /dev/null +++ b/public/assets/chat.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/assets/chat_avatar_bg.svg b/public/assets/chat_avatar_bg.svg new file mode 100644 index 00000000..b77dcf3e --- /dev/null +++ b/public/assets/chat_avatar_bg.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/assets/user_avatar.svg b/public/assets/user_avatar.svg new file mode 100644 index 00000000..f9fe7e94 --- /dev/null +++ b/public/assets/user_avatar.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/chat_flyout.test.tsx b/public/chat_flyout.test.tsx new file mode 100644 index 00000000..85b00737 --- /dev/null +++ b/public/chat_flyout.test.tsx @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { ChatFlyout } from './chat_flyout'; +import * as chatContextExports from './contexts/chat_context'; +import { TAB_ID } from './utils/constants'; + +jest.mock('./tabs/chat/chat_page', () => ({ + ChatPage: () =>
, +})); + +jest.mock('./tabs/chat_window_header', () => ({ + ChatWindowHeader: () =>
, +})); + +jest.mock('./tabs/history/chat_history_page', () => ({ + ChatHistoryPage: () =>
, +})); + +jest.mock('./components/agent_framework_traces_flyout_body', () => ({ + AgentFrameworkTracesFlyoutBody: () => ( +
+ ), +})); + +describe('', () => { + beforeEach(() => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + setFlyoutVisible: jest.fn(), + selectedTabId: TAB_ID.CHAT, + traceId: 'chat_trace_id_mock', + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should only display chat panel when current tab is TAB_ID.CHAT under non-fullscreen mode', () => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + setFlyoutVisible: jest.fn(), + selectedTabId: TAB_ID.CHAT, + traceId: 'chat_trace_id_mock', + }); + + render( + + ); + expect(screen.getByLabelText('chat panel').classList).not.toContain('llm-chat-hidden'); + expect(screen.getByLabelText('history panel').classList).toContain('llm-chat-hidden'); + }); + + it('should only display history panel when current tab is TAB_ID.HISTORY under non-fullscreen mode', () => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + setFlyoutVisible: jest.fn(), + selectedTabId: TAB_ID.HISTORY, + traceId: 'chat_trace_id_mock', + }); + + render( + + ); + expect(screen.getByLabelText('chat panel').classList).toContain('llm-chat-hidden'); + expect(screen.getByLabelText('history panel').classList).not.toContain('llm-chat-hidden'); + }); + + it('should display chat history page', () => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + setFlyoutVisible: jest.fn(), + selectedTabId: TAB_ID.HISTORY, + traceId: 'chat_trace_id_mock', + }); + + render( + + ); + + expect(screen.queryByLabelText('mock chat history page')).toBeInTheDocument(); + expect( + screen.queryByLabelText('mock agent framework traces flyout body') + ).not.toBeInTheDocument(); + }); + + it('should display traces page', () => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + setFlyoutVisible: jest.fn(), + selectedTabId: TAB_ID.TRACE, + traceId: 'chat_trace_id_mock', + }); + + render( + + ); + + expect(screen.queryByLabelText('mock chat history page')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('mock agent framework traces flyout body')).toBeInTheDocument(); + }); + + it('should always display chat panel when in fullscreen mode', () => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + setFlyoutVisible: jest.fn(), + // current tab is NOT chat + selectedTabId: TAB_ID.HISTORY, + traceId: 'chat_trace_id_mock', + }); + + render( + + ); + + expect(screen.getByLabelText('chat panel').classList).not.toContain('llm-chat-hidden'); + expect(screen.getByLabelText('history panel').classList).not.toContain('llm-chat-hidden'); + }); +}); diff --git a/public/chat_flyout.tsx b/public/chat_flyout.tsx new file mode 100644 index 00000000..d4c2e234 --- /dev/null +++ b/public/chat_flyout.tsx @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiFlyout, EuiFlyoutHeader, EuiResizableContainer } from '@elastic/eui'; +import cs from 'classnames'; +import React, { useRef } from 'react'; +import { useChatContext } from './contexts/chat_context'; +import { ChatPage } from './tabs/chat/chat_page'; +import { ChatWindowHeader } from './tabs/chat_window_header'; +import { ChatHistoryPage } from './tabs/history/chat_history_page'; +import { AgentFrameworkTracesFlyoutBody } from './components/agent_framework_traces_flyout_body'; +import { TAB_ID } from './utils/constants'; + +interface ChatFlyoutProps { + flyoutVisible: boolean; + overrideComponent: React.ReactNode | null; + flyoutProps: Partial>; + flyoutFullScreen: boolean; + toggleFlyoutFullScreen: () => void; +} + +export const ChatFlyout: React.FC = (props) => { + const chatContext = useChatContext(); + const chatHistoryPageLoadedRef = useRef(false); + + let chatPageVisible = false; + let chatHistoryPageVisible = false; + let chatTraceVisible = false; + + if (!props.overrideComponent) { + switch (chatContext.selectedTabId) { + case TAB_ID.CHAT: + chatPageVisible = true; + break; + + case TAB_ID.HISTORY: + chatHistoryPageVisible = true; + break; + + case TAB_ID.TRACE: + chatTraceVisible = true; + break; + + default: + break; + } + } + + // Always show chat page in fullscreen mode + if (!props.overrideComponent && props.flyoutFullScreen) { + chatPageVisible = true; + } + + if (!chatHistoryPageLoadedRef.current && chatHistoryPageVisible) + chatHistoryPageLoadedRef.current = true; + + const resizable = props.flyoutFullScreen && (chatHistoryPageVisible || chatTraceVisible); + const getLeftPanelSize = () => { + if (resizable) { + return undefined; + } + if (chatPageVisible) { + return 100; + } + return 0; + }; + + const getRightPanelSize = () => { + if (resizable) { + return undefined; + } + if (chatHistoryPageVisible || chatTraceVisible) { + return 100; + } + return 0; + }; + + const leftPanelSize = getLeftPanelSize(); + const rightPanelSize = getRightPanelSize(); + + return ( + chatContext.setFlyoutVisible(false)} + {...props.flyoutProps} + > + <> + + + + + {props.overrideComponent} + + {(Panel, Resizer) => ( + <> + + + + <> + {resizable && } + + {chatHistoryPageLoadedRef.current && ( + + )} + {chatTraceVisible && chatContext.traceId && } + + + + )} + + + + ); +}; diff --git a/public/chat_header_button.tsx b/public/chat_header_button.tsx new file mode 100644 index 00000000..9b3d1503 --- /dev/null +++ b/public/chat_header_button.tsx @@ -0,0 +1,190 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiBadge, EuiFieldText, EuiIcon } from '@elastic/eui'; +import classNames from 'classnames'; +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { useEffectOnce } from 'react-use'; +import { ApplicationStart } from '../../../src/core/public'; +import { ChatFlyout } from './chat_flyout'; +import { ChatContext, IChatContext } from './contexts/chat_context'; +import { SetContext } from './contexts/set_context'; +import { ChatStateProvider } from './hooks'; +import './index.scss'; +import chatIcon from './assets/chat.svg'; +import { ActionExecutor, AssistantActions, ContentRenderer, UserAccount, TabId } from './types'; +import { TAB_ID } from './utils/constants'; + +interface HeaderChatButtonProps { + application: ApplicationStart; + chatEnabled: boolean; + contentRenderers: Record; + actionExecutors: Record; + assistantActions: AssistantActions; + currentAccount: UserAccount; +} + +let flyoutLoaded = false; + +export const HeaderChatButton: React.FC = (props) => { + const [appId, setAppId] = useState(); + const [sessionId, setSessionId] = useState(); + const [title, setTitle] = useState(); + const [flyoutVisible, setFlyoutVisible] = useState(false); + const [flyoutComponent, setFlyoutComponent] = useState(null); + const [selectedTabId, setSelectedTabId] = useState(TAB_ID.CHAT); + const [preSelectedTabId, setPreSelectedTabId] = useState(undefined); + const [traceId, setTraceId] = useState(undefined); + const [chatSize, setChatSize] = useState('dock-right'); + const [query, setQuery] = useState(''); + const [inputFocus, setInputFocus] = useState(false); + const flyoutFullScreen = chatSize === 'fullscreen'; + const inputRef = useRef(null); + const [rootAgentId, setRootAgentId] = useState( + new URL(window.location.href).searchParams.get('agent_id') || '' + ); + + if (!flyoutLoaded && flyoutVisible) flyoutLoaded = true; + + useEffectOnce(() => { + const subscription = props.application.currentAppId$.subscribe((id) => setAppId(id)); + return () => subscription.unsubscribe(); + }); + + const toggleFlyoutFullScreen = useCallback(() => { + setChatSize(flyoutFullScreen ? 'dock-right' : 'fullscreen'); + }, [flyoutFullScreen, setChatSize]); + + const chatContextValue: IChatContext = useMemo( + () => ({ + appId, + sessionId, + setSessionId, + selectedTabId, + preSelectedTabId, + setSelectedTabId: (tabId: TabId) => { + setPreSelectedTabId(selectedTabId); + setSelectedTabId(tabId); + }, + flyoutVisible, + flyoutFullScreen, + setFlyoutVisible, + setFlyoutComponent, + chatEnabled: props.chatEnabled, + contentRenderers: props.contentRenderers, + actionExecutors: props.actionExecutors, + currentAccount: props.currentAccount, + title, + setTitle, + traceId, + setTraceId, + rootAgentId, + }), + [ + appId, + sessionId, + flyoutVisible, + flyoutFullScreen, + selectedTabId, + preSelectedTabId, + props.chatEnabled, + props.contentRenderers, + props.actionExecutors, + props.currentAccount, + title, + setTitle, + traceId, + setTraceId, + ] + ); + + const onKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && query.trim().length > 0) { + // open chat window + setFlyoutVisible(true); + // start a new chat + props.assistantActions.loadChat(); + // send message + props.assistantActions.send({ + type: 'input', + contentType: 'text', + content: query, + context: { appId }, + }); + // reset query to empty + setQuery(''); + if (inputRef.current) { + inputRef.current.blur(); + } + } + }; + + const onKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + inputRef.current?.blur(); + } + }; + + useEffect(() => { + const onGlobalMouseUp = (e: KeyboardEvent) => { + if (e.metaKey && e.key === '/') { + inputRef.current?.focus(); + } + }; + document.addEventListener('keydown', onGlobalMouseUp); + + return () => { + document.removeEventListener('keydown', onGlobalMouseUp); + }; + }, []); + + return ( + <> +
+ setInputFocus(true)} + onBlur={() => setInputFocus(false)} + onChange={(e) => setQuery(e.target.value)} + placeholder="Ask question" + onKeyPress={onKeyPress} + onKeyUp={onKeyUp} + prepend={ + setFlyoutVisible(!flyoutVisible)} /> + } + append={ + + {inputFocus ? ( + + ⏎ + + ) : ( + + ⌘ + / + + )} + + } + /> +
+ + + + {flyoutLoaded ? ( + + ) : null} + + + + ); +}; diff --git a/public/components/__snapshots__/agent_framework_traces.test.tsx.snap b/public/components/__snapshots__/agent_framework_traces.test.tsx.snap new file mode 100644 index 00000000..e543776a --- /dev/null +++ b/public/components/__snapshots__/agent_framework_traces.test.tsx.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +HTMLCollection [ +
+
+
+

+ How was this generated +

+ + +

+ Question +

+ + +

+ Result +

+ + +

+ output +

+
+
+
+
+

+ Response +

+
+
+
+
+
+ +
+
+
+
+
+
+                  
+                    Input: 
+                    input
+                  
+                
+
+
+
+                  
+                    Output: 
+                    output
+                  
+                
+
+
+
+
+
+
+
+
, +] +`; diff --git a/public/components/__tests__/chat_window_header_title.test.tsx b/public/components/__tests__/chat_window_header_title.test.tsx new file mode 100644 index 00000000..f417d9f2 --- /dev/null +++ b/public/components/__tests__/chat_window_header_title.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import { I18nProvider } from '@osd/i18n/react'; + +import { coreMock } from '../../../../../src/core/public/mocks'; +import * as useChatStateExports from '../../hooks/use_chat_state'; +import * as useChatActionsExports from '../../hooks/use_chat_actions'; +import * as useSaveChatExports from '../../hooks/use_save_chat'; +import * as chatContextExports from '../../contexts/chat_context'; +import * as coreContextExports from '../../contexts/core_context'; + +import { ChatWindowHeaderTitle } from '../chat_window_header_title'; + +const setup = () => { + const useCoreMock = { + services: { + ...coreMock.createStart(), + sessions: { + sessions$: new BehaviorSubject({ + objects: [ + { + id: '1', + title: 'foo', + }, + ], + total: 1, + }), + reload: jest.fn(), + }, + }, + }; + useCoreMock.services.http.put.mockImplementation(() => Promise.resolve()); + + const useChatStateMock = { + chatState: { messages: [] }, + }; + const useChatContextMock = { + sessionId: '1', + title: 'foo', + setSessionId: jest.fn(), + setTitle: jest.fn(), + }; + const useChatActionsMock = { + loadChat: jest.fn(), + }; + const useSaveChatMock = { + saveChat: jest.fn(), + }; + jest.spyOn(coreContextExports, 'useCore').mockReturnValue(useCoreMock); + jest.spyOn(useChatStateExports, 'useChatState').mockReturnValue(useChatStateMock); + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue(useChatContextMock); + jest.spyOn(useChatActionsExports, 'useChatActions').mockReturnValue(useChatActionsMock); + jest.spyOn(useSaveChatExports, 'useSaveChat').mockReturnValue(useSaveChatMock); + + const renderResult = render( + + + + ); + + return { + useCoreMock, + useChatStateMock, + useChatContextMock, + renderResult, + }; +}; + +describe('', () => { + it('should reload history list after edit conversation name', async () => { + const { renderResult, useCoreMock } = setup(); + + act(() => { + fireEvent.click(renderResult.getByText('foo')); + }); + + act(() => { + fireEvent.click(renderResult.getByText('Rename conversation')); + }); + + act(() => { + fireEvent.change(renderResult.getByLabelText('Conversation name input'), { + target: { value: 'bar' }, + }); + }); + + expect(useCoreMock.services.sessions.reload).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + + waitFor(() => { + expect(useCoreMock.services.sessions.reload).toHaveBeenCalled(); + }); + }); +}); diff --git a/public/components/__tests__/edit_conversation_name_modal.test.tsx b/public/components/__tests__/edit_conversation_name_modal.test.tsx new file mode 100644 index 00000000..5f1838ab --- /dev/null +++ b/public/components/__tests__/edit_conversation_name_modal.test.tsx @@ -0,0 +1,188 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; + +import { coreMock } from '../../../../../src/core/public/mocks'; +import * as coreContextExports from '../../contexts/core_context'; + +import { + EditConversationNameModal, + EditConversationNameModalProps, +} from '../edit_conversation_name_modal'; +import { HttpHandler } from '../../../../../src/core/public'; + +const setup = ({ onClose, defaultTitle, sessionId }: EditConversationNameModalProps) => { + const useCoreMock = { + services: coreMock.createStart(), + }; + jest.spyOn(coreContextExports, 'useCore').mockReturnValue(useCoreMock); + + const renderResult = render( + + + + ); + + return { + useCoreMock, + renderResult, + }; +}; + +describe('', () => { + it('should render default title in name input', async () => { + const { renderResult } = setup({ + sessionId: '1', + defaultTitle: 'foo', + }); + + await waitFor(async () => { + expect(renderResult.getByLabelText('Conversation name input').getAttribute('value')).toBe( + 'foo' + ); + }); + }); + + it('should call onClose with "canceled" after cancel button click', async () => { + const onCloseMock = jest.fn(); + const { renderResult, useCoreMock } = setup({ + sessionId: '1', + defaultTitle: 'foo', + onClose: onCloseMock, + }); + + act(() => { + fireEvent.change(renderResult.getByLabelText('Conversation name input'), { + target: { + value: 'bar', + }, + }); + }); + + expect(onCloseMock).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalCancelButton')); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenLastCalledWith('cancelled'); + }); + }); + + it('should show success toast and call onClose with "updated" after patch session succeed', async () => { + const onCloseMock = jest.fn(); + const { renderResult, useCoreMock } = setup({ + sessionId: '1', + defaultTitle: 'foo', + onClose: onCloseMock, + }); + useCoreMock.services.http.put.mockImplementation(() => Promise.resolve()); + + act(() => { + fireEvent.change(renderResult.getByLabelText('Conversation name input'), { + target: { + value: 'bar', + }, + }); + }); + + expect(onCloseMock).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenLastCalledWith('updated', 'bar'); + expect(useCoreMock.services.notifications.toasts.addSuccess).toHaveBeenLastCalledWith( + 'This conversation was successfully updated.' + ); + }); + }); + + it('should show error toasts and call onClose with "errored" after failed patch session', async () => { + const onCloseMock = jest.fn(); + const { renderResult, useCoreMock } = setup({ + sessionId: '1', + defaultTitle: 'foo', + onClose: onCloseMock, + }); + useCoreMock.services.http.put.mockImplementation(() => Promise.reject(new Error())); + + act(() => { + fireEvent.change(renderResult.getByLabelText('Conversation name input'), { + target: { + value: 'bar', + }, + }); + }); + + expect(onCloseMock).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenLastCalledWith('errored'); + expect(useCoreMock.services.notifications.toasts.addDanger).toHaveBeenLastCalledWith( + 'There was an error. The name failed to update.' + ); + }); + }); + + it('should call onClose with cancelled after patch session aborted', async () => { + const onCloseMock = jest.fn(); + const { renderResult, useCoreMock } = setup({ + sessionId: '1', + defaultTitle: 'foo', + onClose: onCloseMock, + }); + useCoreMock.services.http.put.mockImplementation(((_path, options) => { + return new Promise((_resolve, reject) => { + if (options?.signal) { + options.signal.onabort = () => { + reject(new Error('Aborted')); + }; + } + }); + }) as HttpHandler); + + act(() => { + fireEvent.change(renderResult.getByLabelText('Conversation name input'), { + target: { + value: 'bar', + }, + }); + }); + + expect(onCloseMock).not.toHaveBeenCalled(); + expect(useCoreMock.services.http.put).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + expect(useCoreMock.services.http.put).toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalCancelButton')); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenLastCalledWith('cancelled'); + expect(useCoreMock.services.notifications.toasts.addSuccess).not.toHaveBeenCalled(); + expect(useCoreMock.services.notifications.toasts.addDanger).not.toHaveBeenCalled(); + expect(useCoreMock.services.notifications.toasts.addError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/public/components/agent_framework_traces.test.tsx b/public/components/agent_framework_traces.test.tsx new file mode 100644 index 00000000..77e0280e --- /dev/null +++ b/public/components/agent_framework_traces.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { render, screen } from '@testing-library/react'; +import { AgentFrameworkTraces } from './agent_framework_traces'; +import * as GetTraces from '../hooks/use_fetch_agentframework_traces'; + +describe(' spec', () => { + it('renders the component', async () => { + const traces = [ + { + interactionId: 'test_interactionId', + parentInteractionId: 'test_parent_interactionId', + input: 'input', + output: 'output', + createTime: '', + origin: '', + traceNumber: 1, + }, + { + interactionId: 'test_interactionId', + parentInteractionId: 'test_parent_interactionId', + input: 'input', + output: 'output', + createTime: '', + origin: 'CatIndexTool', + traceNumber: 2, + }, + { + interactionId: 'test_interactionId', + parentInteractionId: 'test_parent_interactionId', + input: 'input', + output: '', + createTime: '', + origin: '', + traceNumber: 3, + }, + { + interactionId: 'test_interactionId', + parentInteractionId: 'test_parent_interactionId', + input: '', + output: 'output', + createTime: '', + origin: '', + traceNumber: 4, + }, + ]; + const mockedGetTracesResult = { + loading: false, + data: traces, + }; + + jest.spyOn(GetTraces, 'useFetchAgentFrameworkTraces').mockReturnValue(mockedGetTracesResult); + + render(); + expect(GetTraces.useFetchAgentFrameworkTraces).toBeCalledTimes(1); + expect(document.body.children).toMatchSnapshot(); + }); + + it('no traces available', async () => { + jest.spyOn(GetTraces, 'useFetchAgentFrameworkTraces').mockReturnValue({ + loading: false, + data: [], + }); + render(); + expect(screen.queryByText('Data not available.')).toBeInTheDocument(); + }); + + it('show loading', async () => { + jest.spyOn(GetTraces, 'useFetchAgentFrameworkTraces').mockReturnValue({ + loading: true, + data: [], + }); + render(); + expect(screen.queryByText('Loading...')).toBeInTheDocument(); + }); + + it('show error', async () => { + jest.spyOn(GetTraces, 'useFetchAgentFrameworkTraces').mockReturnValue({ + loading: false, + data: [], + error: new Error('test'), + }); + render(); + expect(screen.queryByText('Error loading details')).toBeInTheDocument(); + }); +}); diff --git a/public/components/agent_framework_traces.tsx b/public/components/agent_framework_traces.tsx new file mode 100644 index 00000000..65b39227 --- /dev/null +++ b/public/components/agent_framework_traces.tsx @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAccordion, + EuiCodeBlock, + EuiEmptyPrompt, + EuiLoadingContent, + EuiSpacer, + EuiText, + EuiMarkdownFormat, + EuiHorizontalRule, +} from '@elastic/eui'; +import React from 'react'; +import { useFetchAgentFrameworkTraces } from '../hooks/use_fetch_agentframework_traces'; + +interface AgentFrameworkTracesProps { + traceId: string; +} + +export const AgentFrameworkTraces: React.FC = (props) => { + const { data: traces, loading, error } = useFetchAgentFrameworkTraces(props.traceId); + + if (loading) { + return ( + <> + Loading... + + + ); + } + if (error) { + return ( + Error loading details} + body={error.toString()} + /> + ); + } + if (!traces?.length) { + return Data not available.; + } + + const question = traces[traces.length - 1].input; + const result = traces[traces.length - 1].output; + const questionAndResult = `# How was this generated +#### Question +${question} +#### Result +${result} +`; + + return ( + <> + {questionAndResult} + + + + +

Response

+
+ {traces + // if origin exists, it indicates that the trace was generated by a tool, we only show the non-empty traces of tools + .filter((trace) => trace.origin && (trace.input || trace.output)) + .map((trace, i) => { + const stepContent = `Step ${i + 1} - ${trace.origin}`; + return ( +
+ + + {trace.input && ( + + Input: {trace.input} + + )} + {trace.output && ( + + Output: {trace.output} + + )} + + +
+ ); + })} + + ); +}; diff --git a/public/components/agent_framework_traces_flyout_body.test.tsx b/public/components/agent_framework_traces_flyout_body.test.tsx new file mode 100644 index 00000000..0822b8be --- /dev/null +++ b/public/components/agent_framework_traces_flyout_body.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { act, waitFor, render, screen, fireEvent } from '@testing-library/react'; +import * as chatContextExports from '../contexts/chat_context'; +import { AgentFrameworkTracesFlyoutBody } from './agent_framework_traces_flyout_body'; +import { TAB_ID } from '../utils/constants'; + +jest.mock('./agent_framework_traces', () => { + return { + AgentFrameworkTraces: () =>
, + }; +}); + +describe(' spec', () => { + it('show back button if traceId exists', async () => { + const onCloseMock = jest.fn(); + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + traceId: 'test-trace-Id', + setSelectedTabId: onCloseMock, + }); + render(); + expect(screen.queryAllByLabelText('back')).toHaveLength(1); + act(() => { + fireEvent.click(screen.getByText('Back')); + }); + await waitFor(() => { + expect(onCloseMock).toHaveBeenCalledWith(TAB_ID.CHAT); + }); + }); + + it('no back button if traceId does not exist', async () => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + traceId: undefined, + }); + render(); + expect(screen.queryAllByLabelText('back')).toHaveLength(0); + }); + + it('fullscreen with opening from chat', async () => { + const onCloseMock = jest.fn(); + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + traceId: 'test-trace-id', + flyoutFullScreen: true, + setSelectedTabId: onCloseMock, + preSelectedTabId: TAB_ID.CHAT, + }); + render(); + expect(screen.queryAllByLabelText('close')).toHaveLength(1); + act(() => { + fireEvent.click(screen.queryAllByLabelText('close')[0]); + }); + await waitFor(() => { + expect(onCloseMock).toHaveBeenCalledWith(TAB_ID.CHAT); + }); + }); + + it('fullscreen with opening from history', async () => { + const onCloseMock = jest.fn(); + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + traceId: 'test-trace-id', + flyoutFullScreen: true, + setSelectedTabId: onCloseMock, + preSelectedTabId: TAB_ID.HISTORY, + }); + render(); + expect(screen.queryAllByLabelText('back')).toHaveLength(1); + act(() => { + fireEvent.click(screen.getByText('Back')); + }); + await waitFor(() => { + expect(onCloseMock).toHaveBeenCalledWith(TAB_ID.HISTORY); + }); + }); +}); diff --git a/public/components/agent_framework_traces_flyout_body.tsx b/public/components/agent_framework_traces_flyout_body.tsx new file mode 100644 index 00000000..1dcdc20b --- /dev/null +++ b/public/components/agent_framework_traces_flyout_body.tsx @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonEmpty, + EuiFlyoutBody, + EuiPage, + EuiPageBody, + EuiPageContentBody, + EuiPageHeader, + EuiButtonIcon, + EuiPageHeaderSection, +} from '@elastic/eui'; +import React from 'react'; +import { useChatContext } from '../contexts/chat_context'; +import { AgentFrameworkTraces } from './agent_framework_traces'; +import { TAB_ID } from '../utils/constants'; + +export const AgentFrameworkTracesFlyoutBody: React.FC = () => { + const chatContext = useChatContext(); + const traceId = chatContext.traceId; + if (!traceId) { + return null; + } + + // docked right or fullscreen with history open + const showBack = !chatContext.flyoutFullScreen || chatContext.preSelectedTabId === TAB_ID.HISTORY; + + return ( + + + + + + {showBack && ( + { + chatContext.setSelectedTabId( + chatContext.flyoutFullScreen ? TAB_ID.HISTORY : TAB_ID.CHAT + ); + }} + iconType="arrowLeft" + > + Back + + )} + + + {!showBack && ( + { + chatContext.setSelectedTabId(TAB_ID.CHAT); + }} + /> + )} + + + + + + + + + ); +}; diff --git a/public/components/chat_experimental_badge.tsx b/public/components/chat_experimental_badge.tsx new file mode 100644 index 00000000..4bec97a8 --- /dev/null +++ b/public/components/chat_experimental_badge.tsx @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiPopover, + EuiButtonIcon, + EuiTitle, + EuiHorizontalRule, + EuiText, + EuiLink, +} from '@elastic/eui'; + +interface ChatExperimentalBadgeProps { + onClick?: React.MouseEventHandler; +} + +export const ChatExperimentalBadge = ({ onClick }: ChatExperimentalBadgeProps) => { + const [visible, setVisible] = useState(false); + + const closePopover = () => { + setVisible(false); + }; + + const handleIconClick = () => { + setVisible((flag) => !flag); + }; + + return ( + } + closePopover={closePopover} + onClick={onClick} + > + +

Experimental

+
+ + + This is an experimental feature. +
+ Send feedback via{' '} + + Forum + {' '} + or Slack. +
+
+ ); +}; diff --git a/public/components/chat_welcome_message.tsx b/public/components/chat_welcome_message.tsx new file mode 100644 index 00000000..42ee5c4c --- /dev/null +++ b/public/components/chat_welcome_message.tsx @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; + +interface Props { + username: string; +} + +export const WelcomeMessage = (props: Props) => { + return ( + +

Welcome {props.username} to the OpenSearch Assistant!

+

I can help you analyze data, create visualizations, and get other insights.

+

How can I help?

+ + The OpenSearch Assistant may produce inaccurate information. Verify all information + before using it in any environment or workload. + + + } + /> + ); +}; diff --git a/public/components/chat_window_header_title.tsx b/public/components/chat_window_header_title.tsx new file mode 100644 index 00000000..b56ef864 --- /dev/null +++ b/public/components/chat_window_header_title.tsx @@ -0,0 +1,136 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiButtonIcon, +} from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import { useChatContext } from '../contexts'; +import { useChatActions, useChatState, useSaveChat } from '../hooks'; +import { NotebookNameModal } from './notebook/notebook_name_modal'; +import { ChatExperimentalBadge } from './chat_experimental_badge'; +import { useCore } from '../contexts/core_context'; +import { EditConversationNameModal } from './edit_conversation_name_modal'; + +export const ChatWindowHeaderTitle = React.memo(() => { + const chatContext = useChatContext(); + const { loadChat } = useChatActions(); + const core = useCore(); + const [isPopoverOpen, setPopoverOpen] = useState(false); + const [isRenameModalOpen, setRenameModalOpen] = useState(false); + const { chatState } = useChatState(); + const { saveChat } = useSaveChat(); + + const onButtonClick = useCallback(() => { + setPopoverOpen((flag) => !flag); + }, []); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, []); + + const handleEditConversationClose = useCallback( + (status: 'updated' | string, newTitle?: string) => { + if (status === 'updated') { + chatContext.setTitle(newTitle); + const sessions = core.services.sessions.sessions$.getValue(); + if (sessions?.objects.find((session) => session.id === chatContext.sessionId)) { + core.services.sessions.reload(); + } + } + setRenameModalOpen(false); + }, + [chatContext, core.services.sessions] + ); + + const button = ( + + +

+ {chatContext.sessionId ? chatContext.title : 'OpenSearch Assistant'} +

+
+ + + + + + +
+ ); + + const items = [ + { + closePopover(); + setRenameModalOpen(true); + }} + > + Rename conversation + , + { + closePopover(); + loadChat(undefined); + // Only show toast when previous session saved + if (!!chatContext.sessionId) { + core.services.notifications.toasts.addSuccess( + 'A new conversation is started and the previous one is saved.' + ); + } + }} + > + New conversation + , + { + const modal = core.overlays.openModal( + modal.close()} saveChat={saveChat} /> + ); + closePopover(); + }} + // User only can save conversation when he send a message at least. + disabled={chatState.messages.every((item) => item.type !== 'input')} + > + Save to notebook + , + ]; + + return ( + <> + + + + {isRenameModalOpen && ( + + )} + + ); +}); diff --git a/public/components/core_visualization.test.tsx b/public/components/core_visualization.test.tsx new file mode 100644 index 00000000..e9ad4d61 --- /dev/null +++ b/public/components/core_visualization.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import * as coreContextExports from '../contexts/core_context'; +import { CoreVisualization } from './core_visualization'; + +describe('', () => { + beforeEach(() => { + jest.spyOn(coreContextExports, 'useCore').mockReturnValue({ + services: { + uiSettings: { + get: jest.fn().mockReturnValue('MMM D, YYYY @ HH:mm:ss.SSS'), + }, + startDeps: { + dashboard: { + DashboardContainerByValueRenderer: () =>
, + }, + }, + }, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should display visualization of last 15 minutes by default', () => { + render( + + ); + expect(screen.queryByText('Last 15 minutes')).toBeInTheDocument(); + }); +}); diff --git a/public/components/core_visualization.tsx b/public/components/core_visualization.tsx new file mode 100644 index 00000000..5cb2a6e8 --- /dev/null +++ b/public/components/core_visualization.tsx @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiText, htmlIdGenerator, prettyDuration, ShortDate } from '@elastic/eui'; +import React, { useState } from 'react'; +import { DashboardContainerInput } from '../../../../src/plugins/dashboard/public'; +import { ViewMode } from '../../../../src/plugins/embeddable/public'; +import { IMessage } from '../../common/types/chat_saved_object_attributes'; +import { useCore } from '../contexts/core_context'; + +interface CoreVisualizationProps { + message: IMessage; +} + +export const CoreVisualization: React.FC = (props) => { + const core = useCore(); + const [visInput, setVisInput] = useState(() => + createDashboardVizObject(props.message.content) + ); + const dateFormat = core.services.uiSettings.get('dateFormat'); + + return ( + <> + + {prettyDuration(visInput.timeRange.from, visInput.timeRange.to, [], dateFormat)} + + + + ); +}; + +const createDashboardVizObject = ( + objectId: string, + from: ShortDate = 'now-15m', + to: ShortDate = 'now' +): DashboardContainerInput => { + const vizUniqueId = htmlIdGenerator()(); + // a dashboard container object for new visualization + return { + viewMode: ViewMode.VIEW, + panels: { + '1': { + gridData: { x: 0, y: 0, w: 50, h: 25, i: '1' }, + type: 'visualization', + explicitInput: { id: '1', savedObjectId: objectId, disabledActions: ['togglePanel'] }, + }, + }, + isFullScreenMode: false, + filters: [], + useMargins: false, + id: vizUniqueId, + timeRange: { from, to }, + title: 'embed_viz_' + vizUniqueId, + query: { query: '', language: 'lucene' }, + refreshConfig: { pause: true, value: 15 }, + }; +}; diff --git a/public/components/edit_conversation_name_modal.tsx b/public/components/edit_conversation_name_modal.tsx new file mode 100644 index 00000000..9ef804b6 --- /dev/null +++ b/public/components/edit_conversation_name_modal.tsx @@ -0,0 +1,75 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useRef } from 'react'; + +import { EuiConfirmModal, EuiFieldText, EuiSpacer, EuiText } from '@elastic/eui'; +import { useCore } from '../contexts/core_context'; +import { usePatchSession } from '../hooks'; + +export interface EditConversationNameModalProps { + onClose?: (status: 'updated' | 'cancelled' | 'errored', newTitle?: string) => void; + sessionId: string; + defaultTitle: string; +} + +export const EditConversationNameModal = ({ + onClose, + sessionId, + defaultTitle, +}: EditConversationNameModalProps) => { + const { + services: { + notifications: { toasts }, + }, + } = useCore(); + const titleInputRef = useRef(null); + const { loading, abort, patchSession, isAborted } = usePatchSession(); + + const handleCancel = useCallback(() => { + abort(); + onClose?.('cancelled'); + }, [onClose, abort]); + const handleConfirm = useCallback(async () => { + const title = titleInputRef.current?.value.trim(); + if (!title) { + return; + } + try { + await patchSession(sessionId, title); + toasts.addSuccess('This conversation was successfully updated.'); + } catch (_e) { + if (isAborted()) { + return; + } + onClose?.('errored'); + toasts.addDanger('There was an error. The name failed to update.'); + return; + } + onClose?.('updated', title); + }, [onClose, sessionId, patchSession, toasts.addSuccess, toasts.addDanger, isAborted]); + + return ( + + +

Please enter a new name for your conversation.

+
+ + +
+ ); +}; diff --git a/public/components/feedback_modal.tsx b/public/components/feedback_modal.tsx new file mode 100644 index 00000000..04bbf5d0 --- /dev/null +++ b/public/components/feedback_modal.tsx @@ -0,0 +1,309 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiRadioGroup, + EuiTextArea, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { HttpStart } from '../../../../src/core/public'; +import { ASSISTANT_API } from '../../common/constants/llm'; +import { getCoreStart } from '../plugin'; + +export interface LabelData { + formHeader: string; + inputPlaceholder: string; + outputPlaceholder: string; +} + +export interface FeedbackFormData { + input: string; + output: string; + correct: boolean | undefined; + expectedOutput: string; + comment: string; +} + +interface FeedbackMetaData { + type: 'event_analytics' | 'chat' | 'ppl_submit'; + sessionId?: string; + traceId?: string; + error?: boolean; + selectedIndex?: string; +} + +interface FeedbackModelProps { + input?: string; + output?: string; + metadata: FeedbackMetaData; + onClose: () => void; +} + +export const FeedbackModal: React.FC = (props) => { + const [formData, setFormData] = useState({ + input: props.input ?? '', + output: props.output ?? '', + correct: undefined, + expectedOutput: '', + comment: '', + }); + return ( + + + + ); +}; + +interface FeedbackModalContentProps { + formData: FeedbackFormData; + setFormData: React.Dispatch>; + metadata: FeedbackMetaData; + displayLabels?: Partial> & Partial; + onClose: () => void; +} + +export const FeedbackModalContent: React.FC = (props) => { + const core = getCoreStart(); + const labels: NonNullable> = Object.assign( + { + formHeader: 'Olly Skills Feedback', + inputPlaceholder: 'Your input question', + input: 'Input question', + outputPlaceholder: 'The LLM response', + output: 'Output', + correct: 'Does the output match your expectations?', + expectedOutput: 'Expected output', + comment: 'Comment', + }, + props.displayLabels + ); + const { loading, submitFeedback } = useSubmitFeedback(props.formData, props.metadata, core.http); + const [formErrors, setFormErrors] = useState< + Partial<{ [x in keyof FeedbackFormData]: string[] }> + >({ + input: [], + output: [], + expectedOutput: [], + }); + + const hasError = (key?: keyof FeedbackFormData) => { + if (!key) return Object.values(formErrors).some((e) => !!e.length); + return !!formErrors[key]?.length; + }; + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const errors = { + input: validator + .input(props.formData.input) + .concat(await validator.validateQuery(props.formData.input, props.metadata.type)), + output: validator.output(props.formData.output), + correct: validator.correct(props.formData.correct), + expectedOutput: validator.expectedOutput( + props.formData.expectedOutput, + props.formData.correct === false + ), + }; + if (Object.values(errors).some((e) => !!e.length)) { + setFormErrors(errors); + return; + } + + try { + await submitFeedback(); + props.setFormData({ + input: '', + output: '', + correct: undefined, + expectedOutput: '', + comment: '', + }); + core.notifications.toasts.addSuccess('Thanks for your feedback!'); + props.onClose(); + } catch (e) { + core.notifications.toasts.addError(e, { title: 'Failed to submit feedback' }); + } + }; + + return ( + <> + + {labels.formHeader} + + + + + + props.setFormData({ ...props.formData, input: e.target.value })} + onBlur={(e) => { + setFormErrors({ ...formErrors, input: validator.input(e.target.value) }); + }} + isInvalid={hasError('input')} + /> + + + props.setFormData({ ...props.formData, output: e.target.value })} + onBlur={(e) => { + setFormErrors({ ...formErrors, output: validator.output(e.target.value) }); + }} + isInvalid={hasError('output')} + /> + + {props.metadata.type !== 'ppl_submit' && ( + + { + props.setFormData({ ...props.formData, correct: id === 'yes' }); + setFormErrors({ ...formErrors, expectedOutput: [] }); + }} + onBlur={() => setFormErrors({ ...formErrors, correct: [] })} + /> + + )} + {props.formData.correct === false && ( + + + props.setFormData({ ...props.formData, expectedOutput: e.target.value }) + } + onBlur={(e) => { + setFormErrors({ + ...formErrors, + expectedOutput: validator.expectedOutput( + e.target.value, + props.formData.correct === false + ), + }); + }} + isInvalid={hasError('expectedOutput')} + /> + + )} + + props.setFormData({ ...props.formData, comment: e.target.value })} + /> + + + + + + Cancel + + Send + + + + ); +}; + +const useSubmitFeedback = (data: FeedbackFormData, metadata: FeedbackMetaData, http: HttpStart) => { + const [loading, setLoading] = useState(false); + return { + loading, + submitFeedback: async () => { + setLoading(true); + const auth = await http + .get<{ data: { user_name: string; user_requested_tenant: string; roles: string[] } }>( + '/api/v1/configuration/account' + ) + .then((res) => ({ user: res.data.user_name, tenant: res.data.user_requested_tenant })); + + return http + .post(ASSISTANT_API.FEEDBACK, { + body: JSON.stringify({ metadata: { ...metadata, ...auth }, ...data }), + }) + .finally(() => setLoading(false)); + }, + }; +}; + +const validatePPLQuery = async (logsQuery: string, feedBackType: FeedbackMetaData['type']) => { + return []; + // TODO remove + // let responseMessage: [] | string[] = []; + // const errorMessage = [' Invalid PPL Query, please re-check the ppl syntax']; + + // if (feedBackType === 'ppl_submit') { + // const pplService = getPPLService(); + // await pplService + // .fetch({ query: logsQuery, format: 'jdbc' }) + // .then((res) => { + // if (res === undefined) responseMessage = errorMessage; + // }) + // .catch((error: Error) => { + // responseMessage = errorMessage; + // }); + // } + // return responseMessage; +}; + +const validator = { + input: (text: string) => (text.trim().length === 0 ? ['Input is required'] : []), + output: (text: string) => (text.trim().length === 0 ? ['Output is required'] : []), + correct: (correct: boolean | undefined) => + correct === undefined ? ['Correctness is required'] : [], + expectedOutput: (text: string, required: boolean) => + required && text.trim().length === 0 ? ['expectedOutput is required'] : [], + validateQuery: async (logsQuery: string, feedBackType: FeedbackMetaData['type']) => + await validatePPLQuery(logsQuery, feedBackType), +}; diff --git a/public/components/greeting_card.tsx b/public/components/greeting_card.tsx new file mode 100644 index 00000000..b0e0a8ae --- /dev/null +++ b/public/components/greeting_card.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import React from 'react'; + +interface GreetingCardProps { + title: string; +} + +export const GreetingCard: React.FC = (props) => { + return ( + + + + + + + {props.title.toUpperCase()} + + + + {typeof props.children === 'string' ? ( + {props.children} + ) : ( + props.children + )} + + + + + + ); +}; diff --git a/public/components/invite_message.tsx b/public/components/invite_message.tsx new file mode 100644 index 00000000..55258e2d --- /dev/null +++ b/public/components/invite_message.tsx @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiButton, EuiEmptyPrompt, EuiLink } from '@elastic/eui'; +import React from 'react'; + +export const InviteMessage: React.FC = () => { + // using https://mailtolinkgenerator.com/ + const mailtoLink = + 'mailto:opensearch-assistant@amazon.com?subject=Requesting%20invite%20to%20OpenSearch%20Assistant%20Playground'; + + return ( + +

Please login with the email that has access to the Assistant.

+

+ To request access, please send an email to{' '} + + opensearch-assistant@amazon.com + + . +

+ + } + actions={ + + Request invite + + } + /> + ); +}; diff --git a/public/components/notebook/notebook_name_modal.tsx b/public/components/notebook/notebook_name_modal.tsx new file mode 100644 index 00000000..17be09c4 --- /dev/null +++ b/public/components/notebook/notebook_name_modal.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiFieldText, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import React, { useState, useCallback } from 'react'; + +interface Props { + onClose: () => void; + // SaveChat hook depends on context. Runtime modal component can't get context, so saveChat needs to be passed in. + saveChat: (name: string) => void; +} + +export const NotebookNameModal = ({ onClose, saveChat }: Props) => { + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + + const onSubmit = useCallback(async () => { + setLoading(true); + await saveChat(name); + onClose(); + }, [name, saveChat, onclose]); + + return ( + <> + + + Save to notebook + + + + + setName(e.target.value)} /> + + + + + Cancel + + Confirm name + + + + + ); +}; diff --git a/public/contexts/chat_context.tsx b/public/contexts/chat_context.tsx new file mode 100644 index 00000000..24d3a0f7 --- /dev/null +++ b/public/contexts/chat_context.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useContext } from 'react'; +import { ActionExecutor, ContentRenderer, UserAccount, TabId } from '../types'; + +export interface IChatContext { + appId?: string; + sessionId?: string; + setSessionId: React.Dispatch>; + selectedTabId: TabId; + preSelectedTabId?: TabId; + setSelectedTabId: (tabId: TabId) => void; + flyoutVisible: boolean; + flyoutFullScreen: boolean; + setFlyoutVisible: React.Dispatch>; + setFlyoutComponent: React.Dispatch>; + chatEnabled: boolean; + contentRenderers: Record; + actionExecutors: Record; + currentAccount: UserAccount; + title?: string; + setTitle: React.Dispatch>; + traceId?: string; + setTraceId: React.Dispatch>; + rootAgentId?: string; +} +export const ChatContext = React.createContext(null); + +export const useChatContext = () => { + const context = useContext(ChatContext); + if (!context) throw new Error('ChatContext is not set'); + return context; +}; diff --git a/public/contexts/core_context.tsx b/public/contexts/core_context.tsx new file mode 100644 index 00000000..6dea7168 --- /dev/null +++ b/public/contexts/core_context.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + OpenSearchDashboardsReactContextValue, + OpenSearchDashboardsServices, + useOpenSearchDashboards, +} from '../../../../src/plugins/opensearch_dashboards_react/public'; +import { AppPluginStartDependencies, SetupDependencies } from '../types'; +import { SessionLoadService } from '../services/session_load_service'; +import { SessionsService } from '../services/sessions_service'; + +export interface AssistantServices extends Required { + setupDeps: SetupDependencies; + startDeps: AppPluginStartDependencies; + sessionLoad: SessionLoadService; + sessions: SessionsService; +} + +export const useCore: () => OpenSearchDashboardsReactContextValue< + AssistantServices +> = useOpenSearchDashboards; diff --git a/public/contexts/index.ts b/public/contexts/index.ts new file mode 100644 index 00000000..469c4a1c --- /dev/null +++ b/public/contexts/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { useChatContext } from './chat_context'; +export { useCore } from './core_context'; diff --git a/public/contexts/set_context.tsx b/public/contexts/set_context.tsx new file mode 100644 index 00000000..423c3b09 --- /dev/null +++ b/public/contexts/set_context.tsx @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { useChatActions } from '../hooks'; +import { AssistantActions } from '../types'; + +interface SetContextProps { + assistantActions: AssistantActions; +} + +// TODO needs a better solution to expose hook +export const SetContext: React.FC = (props) => { + Object.assign(props.assistantActions, useChatActions()); + return null; +}; diff --git a/public/hooks/fetch_reducer.ts b/public/hooks/fetch_reducer.ts new file mode 100644 index 00000000..35b2f888 --- /dev/null +++ b/public/hooks/fetch_reducer.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Reducer } from 'react'; + +interface State { + data?: T; + loading: boolean; + error?: Error; +} + +type Action = + | { type: 'request' } + | { type: 'success'; payload: State['data'] } + | { + type: 'failure'; + error: NonNullable['error']> | { body: NonNullable['error']> }; + }; + +// TODO use instantiation expressions when typescript is upgraded to >= 4.7 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GenericReducer = Reducer, Action>; +export const genericReducer: GenericReducer = (state, action) => { + switch (action.type) { + case 'request': + return { data: state.data, loading: true }; + case 'success': + return { loading: false, data: action.payload }; + case 'failure': + return { loading: false, error: 'body' in action.error ? action.error.body : action.error }; + default: + return state; + } +}; diff --git a/public/hooks/index.ts b/public/hooks/index.ts new file mode 100644 index 00000000..346ced32 --- /dev/null +++ b/public/hooks/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { useSaveChat } from './use_save_chat'; +export { useChatState, ChatStateProvider } from './use_chat_state'; +export { useChatActions } from './use_chat_actions'; +export { usePatchSession, useDeleteSession } from './use_sessions'; diff --git a/public/hooks/use_chat_actions.tsx b/public/hooks/use_chat_actions.tsx new file mode 100644 index 00000000..fd5f3f4a --- /dev/null +++ b/public/hooks/use_chat_actions.tsx @@ -0,0 +1,200 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TAB_ID } from '../utils/constants'; +import { ASSISTANT_API } from '../../common/constants/llm'; +import { + IMessage, + ISuggestedAction, + Interaction, +} from '../../common/types/chat_saved_object_attributes'; +import { useChatContext } from '../contexts/chat_context'; +import { useCore } from '../contexts/core_context'; +import { AssistantActions } from '../types'; +import { useChatState } from './use_chat_state'; + +interface SendResponse { + sessionId: string; + title: string; + messages: IMessage[]; + interactions: Interaction[]; +} + +interface SetParagraphResponse { + objectId: string; +} + +let abortControllerRef: AbortController; + +export const useChatActions = (): AssistantActions => { + const chatContext = useChatContext(); + const core = useCore(); + const { chatState, chatStateDispatch } = useChatState(); + + const send = async (input: IMessage) => { + const abortController = new AbortController(); + abortControllerRef = abortController; + chatStateDispatch({ type: 'send', payload: input }); + try { + const response = await core.services.http.post(ASSISTANT_API.SEND_MESSAGE, { + // do not send abort signal to http client to allow LLM call run in background + body: JSON.stringify({ + sessionId: chatContext.sessionId, + rootAgentId: chatContext.rootAgentId, + ...(!chatContext.sessionId && { messages: chatState.messages }), // include all previous messages for new chats + input, + }), + }); + if (abortController.signal.aborted) return; + // Refresh history list after new session created if new session saved and history list page visible + if ( + !chatContext.sessionId && + response.sessionId && + core.services.sessions.options?.page === 1 && + chatContext.selectedTabId === TAB_ID.HISTORY + ) { + core.services.sessions.reload(); + } + chatContext.setSessionId(response.sessionId); + // set title for first time + if (!chatContext.title) { + chatContext.setTitle(response.title); + } + chatStateDispatch({ + type: 'receive', + payload: { + messages: response.messages, + interactions: response.interactions, + }, + }); + } catch (error) { + if (abortController.signal.aborted) return; + chatStateDispatch({ type: 'error', payload: error }); + } + }; + + const loadChat = async (sessionId?: string, title?: string) => { + abortControllerRef?.abort(); + core.services.sessionLoad.abortController?.abort(); + chatContext.setSessionId(sessionId); + chatContext.setTitle(title); + // Chat page will always visible in fullscreen mode, we don't need to change the tab anymore + if (!chatContext.flyoutFullScreen) { + chatContext.setSelectedTabId(TAB_ID.CHAT); + } + chatContext.setFlyoutComponent(null); + if (!sessionId) { + chatStateDispatch({ type: 'reset' }); + return; + } + const session = await core.services.sessionLoad.load(sessionId); + if (session) { + chatStateDispatch({ + type: 'receive', + payload: { + messages: session.messages, + interactions: session.interactions, + }, + }); + } + }; + + const openChatUI = () => { + chatContext.setFlyoutVisible(true); + chatContext.setSelectedTabId(TAB_ID.CHAT); + }; + + const executeAction = async (suggestedAction: ISuggestedAction, message: IMessage) => { + switch (suggestedAction.actionType) { + case 'send_as_input': { + send({ + type: 'input', + content: suggestedAction.message, + contentType: 'text', + }); + break; + } + + case 'view_in_dashboards': { + const type = message.contentType; + const id = message.content; + switch (type) { + case 'visualization': + window.open(`./visualize#/edit/${id}`, '_blank'); + break; + } + break; + } + + case 'view_ppl_visualization': { + chatContext.actionExecutors[suggestedAction.actionType]?.({ + name: suggestedAction.metadata.question, + query: suggestedAction.metadata.query, + }); + break; + } + + case 'view_trace': + if ('traceId' in message) { + if (chatContext.selectedTabId !== TAB_ID.TRACE) { + chatContext.setSelectedTabId(TAB_ID.TRACE); + } + chatContext.setTraceId(message.traceId); + } + break; + + default: + break; + } + }; + + const abortAction = async (sessionId?: string) => { + abortControllerRef.abort(); + chatStateDispatch({ type: 'abort' }); + + if (sessionId) { + // abort agent execution + await core.services.http.post(`${ASSISTANT_API.ABORT_AGENT_EXECUTION}`, { + body: JSON.stringify({ sessionId }), + }); + } + }; + + const regenerate = async (interactionId: string) => { + if (chatContext.sessionId) { + const abortController = new AbortController(); + abortControllerRef = abortController; + chatStateDispatch({ type: 'regenerate' }); + + try { + const response = await core.services.http.put(`${ASSISTANT_API.REGENERATE}`, { + body: JSON.stringify({ + sessionId: chatContext.sessionId, + rootAgentId: chatContext.rootAgentId, + interactionId, + }), + }); + + if (abortController.signal.aborted) { + return; + } + chatStateDispatch({ + type: 'receive', + payload: { + messages: response.messages, + interactions: response.interactions, + }, + }); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + chatStateDispatch({ type: 'error', payload: error }); + } + } + }; + + return { send, loadChat, executeAction, openChatUI, abortAction, regenerate }; +}; diff --git a/public/hooks/use_chat_state.tsx b/public/hooks/use_chat_state.tsx new file mode 100644 index 00000000..13bb8542 --- /dev/null +++ b/public/hooks/use_chat_state.tsx @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { produce } from 'immer'; +import React, { useContext, useMemo, useReducer } from 'react'; +import { IMessage, Interaction } from '../../common/types/chat_saved_object_attributes'; + +interface ChatState { + messages: IMessage[]; + interactions: Interaction[]; + llmResponding: boolean; + llmError?: Error; +} + +type ChatStateAction = + | { type: 'regenerate' } + | { type: 'abort' } + | { type: 'reset' } + | { type: 'send'; payload: IMessage } + | { + type: 'receive'; + payload: { + messages: ChatState['messages']; + interactions: ChatState['interactions']; + }; + } + | { + type: 'error'; + payload: NonNullable | { body: NonNullable }; + }; + +interface IChatStateContext { + chatState: ChatState; + chatStateDispatch: React.Dispatch; +} +const ChatStateContext = React.createContext(null); + +const initialState: ChatState = { + interactions: [], + messages: [], + llmResponding: false, +}; + +const chatStateReducer: React.Reducer = (state, action) => + produce(state, (draft) => { + switch (action.type) { + case 'reset': + return initialState; + + case 'send': + draft.messages.push(action.payload); + draft.llmResponding = true; + draft.llmError = undefined; + break; + + case 'receive': + draft.messages = action.payload.messages; + draft.interactions = action.payload.interactions; + draft.llmResponding = false; + draft.llmError = undefined; + break; + + case 'error': + draft.llmResponding = false; + draft.llmError = 'body' in action.payload ? action.payload.body : action.payload; + break; + case 'abort': + draft.llmResponding = false; + break; + case 'regenerate': + const lastInputIndex = draft.messages.findLastIndex((msg) => msg.type === 'input'); + // Exclude the last outputs + draft.messages = draft.messages.slice(0, lastInputIndex + 1); + draft.llmResponding = true; + draft.llmError = undefined; + break; + } + }); + +export const ChatStateProvider: React.FC = (props) => { + const [chatState, chatStateDispatch] = useReducer(chatStateReducer, initialState); + const contextValue: IChatStateContext = useMemo(() => ({ chatState, chatStateDispatch }), [ + chatState, + ]); + + return ( + {props.children} + ); +}; + +export const useChatState = () => useContext(ChatStateContext)!; diff --git a/public/hooks/use_feed_back.tsx b/public/hooks/use_feed_back.tsx new file mode 100644 index 00000000..3efbe05c --- /dev/null +++ b/public/hooks/use_feed_back.tsx @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { ASSISTANT_API } from '../../common/constants/llm'; +import { IOutput, Interaction } from '../../common/types/chat_saved_object_attributes'; +import { useCore } from '../contexts/core_context'; +import { useChatState } from './use_chat_state'; +import { SendFeedbackBody } from '../../common/types/chat_saved_object_attributes'; + +export const useFeedback = (interaction?: Interaction | null) => { + const core = useCore(); + const { chatState } = useChatState(); + const [feedbackResult, setFeedbackResult] = useState( + interaction?.additional_info?.feedback?.satisfaction ?? undefined + ); + + const sendFeedback = async (message: IOutput, correct: boolean) => { + const outputMessage = message; + // Markdown type output all has traceId. The traceId of message is equal to interaction id. + const outputMessageIndex = chatState.messages.findIndex((item) => { + return item.type === 'output' && item.traceId === message.traceId; + }); + const inputMessage = chatState.messages + .slice(0, outputMessageIndex) + .findLast((item) => item.type === 'input'); + if (!inputMessage) { + return; + } + + const body: SendFeedbackBody = { + satisfaction: correct, + }; + + try { + await core.services.http.put(`${ASSISTANT_API.FEEDBACK}/${message.traceId}`, { + body: JSON.stringify(body), + }); + setFeedbackResult(correct); + } catch (error) { + console.error('send feedback error'); + } + }; + + return { sendFeedback, feedbackResult }; +}; diff --git a/public/hooks/use_fetch_agentframework_traces.ts b/public/hooks/use_fetch_agentframework_traces.ts new file mode 100644 index 00000000..443e0960 --- /dev/null +++ b/public/hooks/use_fetch_agentframework_traces.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useReducer } from 'react'; +import { ASSISTANT_API } from '../../common/constants/llm'; +import { AgentFrameworkTrace } from '../../common/utils/llm_chat/traces'; +import { useCore } from '../contexts/core_context'; +import { GenericReducer, genericReducer } from './fetch_reducer'; + +export const useFetchAgentFrameworkTraces = (traceId: string) => { + const core = useCore(); + const reducer: GenericReducer = genericReducer; + const [state, dispatch] = useReducer(reducer, { loading: false }); + + useEffect(() => { + const abortController = new AbortController(); + dispatch({ type: 'request' }); + if (!traceId) { + dispatch({ type: 'success', payload: undefined }); + return; + } + + core.services.http + .get(`${ASSISTANT_API.TRACE}/${traceId}`) + .then((payload) => + dispatch({ + type: 'success', + payload, + }) + ) + .catch((error) => dispatch({ type: 'failure', error })); + + return () => abortController.abort(); + }, [traceId]); + + return { ...state }; +}; diff --git a/public/hooks/use_save_chat.tsx b/public/hooks/use_save_chat.tsx new file mode 100644 index 00000000..7acdac8d --- /dev/null +++ b/public/hooks/use_save_chat.tsx @@ -0,0 +1,104 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { useCallback } from 'react'; +import { EuiLink } from '@elastic/eui'; +import { NOTEBOOK_API } from '../../common/constants/llm'; +import { useCore } from '../contexts/core_context'; +import { useChatState } from './use_chat_state'; +import { convertMessagesToParagraphs, Paragraphs } from '../utils'; +import { getCoreStart } from '../plugin'; +import { toMountPoint } from '../../../../src/plugins/opensearch_dashboards_react/public'; +import { useChatContext } from '../contexts/chat_context'; + +interface SetParagraphResponse { + objectId: string; +} + +export const useSaveChat = () => { + const core = useCore(); + const { chatState } = useChatState(); + const chatContext = useChatContext(); + + const createNotebook = useCallback( + async (name: string) => { + const id = await core.services.http.post(NOTEBOOK_API.CREATE_NOTEBOOK, { + // do not send abort signal to http client to allow LLM call run in background + body: JSON.stringify({ + name, + }), + }); + if (!id) { + throw new Error('create notebook error'); + } + return id; + }, + [core] + ); + + const setParagraphs = useCallback( + async (id: string, paragraphs: Paragraphs) => { + const response = await core.services.http.post( + NOTEBOOK_API.SET_PARAGRAPH, + { + // do not send abort signal to http client to allow LLM call run in background + body: JSON.stringify({ + noteId: id, + paragraphs, + }), + } + ); + const { objectId } = response; + if (!objectId) { + throw new Error('set paragraphs error'); + } + return objectId; + }, + [core] + ); + + const saveChat = useCallback( + async (name: string) => { + try { + const id = await createNotebook(name); + const paragraphs = convertMessagesToParagraphs( + chatState.messages, + chatContext.currentAccount.username + ); + await setParagraphs(id, paragraphs); + const notebookLink = `./observability-notebooks#/${id}?view=view_both`; + + getCoreStart().notifications.toasts.addSuccess({ + text: toMountPoint( + <> +

+ This conversation was saved as{' '} + + {name} + + . +

+ + ), + }); + } catch (error) { + if (error.message === 'Not Found') { + getCoreStart().notifications.toasts.addError(error, { + title: + 'This feature depends on the observability plugin, please install it before use.', + }); + } else { + getCoreStart().notifications.toasts.addError(error, { + title: 'Failed to save to notebook', + }); + } + } + }, + [chatState, createNotebook, setParagraphs, chatContext] + ); + + return { saveChat }; +}; diff --git a/public/hooks/use_sessions.ts b/public/hooks/use_sessions.ts new file mode 100644 index 00000000..1f37e09a --- /dev/null +++ b/public/hooks/use_sessions.ts @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useReducer, useRef } from 'react'; +import { ASSISTANT_API } from '../../common/constants/llm'; +import { useCore } from '../contexts/core_context'; +import { genericReducer } from './fetch_reducer'; + +export const useDeleteSession = () => { + const core = useCore(); + const [state, dispatch] = useReducer(genericReducer, { loading: false }); + const abortControllerRef = useRef(); + + const deleteSession = useCallback( + (sessionId: string) => { + abortControllerRef.current = new AbortController(); + dispatch({ type: 'request' }); + return core.services.http + .delete(`${ASSISTANT_API.SESSION}/${sessionId}`, { + signal: abortControllerRef.current.signal, + }) + .then((payload) => { + dispatch({ type: 'success', payload }); + }) + .catch((error) => { + dispatch({ type: 'failure', error }); + throw error; + }); + }, + [core.services.http] + ); + + const abort = useCallback(() => { + abortControllerRef.current?.abort(); + }, []); + + const isAborted = useCallback(() => !!abortControllerRef.current?.signal.aborted, []); + + return { + ...state, + abort, + isAborted, + deleteSession, + }; +}; + +export const usePatchSession = () => { + const core = useCore(); + const [state, dispatch] = useReducer(genericReducer, { loading: false }); + const abortControllerRef = useRef(); + + const patchSession = useCallback( + (sessionId: string, title: string) => { + abortControllerRef.current = new AbortController(); + dispatch({ type: 'request' }); + return core.services.http + .put(`${ASSISTANT_API.SESSION}/${sessionId}`, { + body: JSON.stringify({ + title, + }), + signal: abortControllerRef.current.signal, + }) + .then((payload) => dispatch({ type: 'success', payload })) + .catch((error) => { + dispatch({ type: 'failure', error }); + throw error; + }); + }, + [core.services.http] + ); + + const abort = useCallback(() => { + abortControllerRef.current?.abort(); + }, []); + + const isAborted = useCallback(() => !!abortControllerRef.current?.signal.aborted, []); + + return { + ...state, + abort, + isAborted, + patchSession, + }; +}; diff --git a/public/index.scss b/public/index.scss new file mode 100644 index 00000000..1136487e --- /dev/null +++ b/public/index.scss @@ -0,0 +1,173 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.llm-chat-header-icon-wrapper { + margin: 0 8px; + + .euiFormControlLayout__prepend { + background-color: transparent !important; + width: 36px !important; + } + + .euiFieldText { + width: 96px; + transition: width 0.3s ease-in-out; + + &:focus { + width: 250px; + background-image: none; + background-color: transparent; + } + } + + .euiIcon { + &:hover { + cursor: pointer; + } + } + + .llm-chat-header-shortcut { + display: flex; + align-items: center; + padding-right: 6px; + } + + &::after { + content: ''; + display: block; + position: absolute; + top: 4px; + bottom: 4px; + left: 4px; + right: 4px; + border-radius: 9px; + z-index: -1; + } +} +.llm-chat-header-icon-wrapper-selected { + &::after { + background-color: $ouiColorPrimary; + } +} + +.llm-chat-avatar { + background-image: url('./assets/chat_avatar_bg.svg'); + background-size: cover; + background-position: center; + + .euiLoadingSpinner { + border-color: rgba(0, 0, 0, 0.2) rgba(255, 255, 255, 0.8) rgba(255, 255, 255, 0.8) + rgba(255, 255, 255, 0.8); + } +} + +.llm-chat-flyout { + .euiFlyoutFooter { + background: transparent; + } + // .euiPage { + // margin: 8px; + // border-radius: 8px; + // background: $euiPageBackgroundColor; + // } + .euiFlyoutBody__overflow { + mask-image: none; + -webkit-mask-image: none; + } +} + +.euiFlyoutHeader.llm-chat-flyout-header { + padding-top: 4px; +} + +.llm-chat-flyout-body { + background-color: $euiPageBackgroundColor; + margin: 8px; + border-radius: 8px; +} + +.llm-chat-bubble-wrapper { + .llm-chat-action-buttons-hidden { + opacity: 0; + transition: opacity 0.3s; + } + + &:hover { + .llm-chat-action-buttons-hidden { + opacity: 1; + transition: opacity 0.3s; + } + } +} + +.euiPanel { + &.llm-chat-bubble-panel { + word-break: break-word; + border-radius: 16px; + padding: 8px 16px 8px 16px; + max-width: 95%; + } + &.llm-chat-greeting-card-panel { + width: 357px; + background: #f2f4f7; + border-color: white; + border-radius: 18px; + .llm-chat-greeting-card-panel-title { + font-family: Inter; + font-size: 12px; + font-weight: 700; + line-height: 48px; + letter-spacing: -1px; + text-align: center; + } + } + &.llm-chat-suggestion-bubble-panel { + padding: 4px; + border-radius: 4px; + text-align: left; + } +} + +.llm-chat-bubble-panel.llm-chat-bubble-panel-input { + background: #159d8d; + margin-left: auto; + color: #fcfeff; + max-width: 85%; +} +.llm-chat-bubble-panel.llm-chat-bubble-panel-output { + margin-right: auto; +} +.llm-chat-bubble-panel.llm-chat-bubble-panel-output.llm-chat-bubble-panel-loading { + min-width: 85%; +} + +.llm-chat-greeting-header { + font-size: 24px; + font-weight: 500; + letter-spacing: -1px; +} + +.llm-chat-visualizations { + // remove some padding added by EuiPage + // margin-left: -12px; + // margin-right: -16px; + min-height: 450px; +} + +.llm-chat-hidden { + display: none; +} + +button.llm-chat-error-refresh-button.llm-chat-error-refresh-button { + color: $ouiColorFullShade; +} + +.llm-chat-horizontal-resize-panel { + display: flex; + flex-direction: column; + &.llm-chat-hidden { + display: none; + } +} diff --git a/public/index.ts b/public/index.ts new file mode 100644 index 00000000..30e31105 --- /dev/null +++ b/public/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from '../../../src/core/public'; +import { AssistantPlugin } from './plugin'; + +export { AssistantPlugin as Plugin }; + +export function plugin(initializerContext: PluginInitializerContext) { + return new AssistantPlugin(initializerContext); +} diff --git a/public/plugin.tsx b/public/plugin.tsx new file mode 100644 index 00000000..8df9b6af --- /dev/null +++ b/public/plugin.tsx @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '../../../src/core/public'; +import { + createOpenSearchDashboardsReactContext, + toMountPoint, +} from '../../../src/plugins/opensearch_dashboards_react/public'; +import { createGetterSetter } from '../../../src/plugins/opensearch_dashboards_utils/common'; +import { HeaderChatButton } from './chat_header_button'; +import { AssistantServices } from './contexts/core_context'; +import { + ActionExecutor, + AppPluginStartDependencies, + AssistantActions, + AssistantSetup, + AssistantStart, + ContentRenderer, + SetupDependencies, +} from './types'; +import { SessionLoadService } from './services/session_load_service'; +import { SessionsService } from './services/sessions_service'; + +export const [getCoreStart, setCoreStart] = createGetterSetter('CoreStart'); + +interface PublicConfig { + chat: { + enabled: boolean; + }; +} + +export class AssistantPlugin + implements Plugin { + private config: PublicConfig; + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); + } + + public setup( + core: CoreSetup, + setupDeps: SetupDependencies + ): AssistantSetup { + const contentRenderers: Record = {}; + const actionExecutors: Record = {}; + const assistantActions: AssistantActions = {} as AssistantActions; + const getAccount = async () => { + return await core.http.get<{ + data: { roles: string[]; user_name: string; user_requested_tenant: string | null }; + }>('/api/v1/configuration/account'); + }; + const assistantEnabled = (() => { + let enabled: boolean; + return async () => { + if (enabled === undefined) { + const account = await getAccount(); + enabled = account.data.roles.some((role) => + ['all_access', 'assistant_user'].includes(role) + ); + } + return enabled; + }; + })(); + + if (this.config.chat.enabled) { + core.getStartServices().then(async ([coreStart, startDeps]) => { + const CoreContext = createOpenSearchDashboardsReactContext({ + ...coreStart, + setupDeps, + startDeps, + sessionLoad: new SessionLoadService(coreStart.http), + sessions: new SessionsService(coreStart.http), + }); + const account = await getAccount(); + const username = account.data.user_name; + const tenant = account.data.user_requested_tenant ?? ''; + + coreStart.chrome.navControls.registerRight({ + order: 10000, + mount: toMountPoint( + + + ['all_access', 'assistant_user'].includes(role) + )} + contentRenderers={contentRenderers} + actionExecutors={actionExecutors} + assistantActions={assistantActions} + currentAccount={{ username, tenant }} + /> + + ), + }); + }); + } + + return { + registerContentRenderer: (contentType, render) => { + if (contentType in contentRenderers) + console.warn(`Content renderer type ${contentType} is already registered.`); + contentRenderers[contentType] = render; + }, + registerActionExecutor: (actionType, execute) => { + if (actionType in actionExecutors) + console.warn(`Action executor type ${actionType} is already registered.`); + actionExecutors[actionType] = execute; + }, + assistantEnabled, + assistantActions, + }; + } + + public start(core: CoreStart, startDeps: AppPluginStartDependencies): AssistantStart { + setCoreStart(core); + + return {}; + } + + public stop() {} +} diff --git a/public/services/session_load_service.ts b/public/services/session_load_service.ts new file mode 100644 index 00000000..6bd59538 --- /dev/null +++ b/public/services/session_load_service.ts @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { HttpStart } from '../../../../src/core/public'; +import { ISession } from '../../common/types/chat_saved_object_attributes'; +import { ASSISTANT_API } from '../../common/constants/llm'; + +export class SessionLoadService { + status$: BehaviorSubject< + 'idle' | 'loading' | { status: 'error'; error: Error } + > = new BehaviorSubject<'idle' | 'loading' | { status: 'error'; error: Error }>('idle'); + abortController?: AbortController; + + constructor(private _http: HttpStart) {} + + load = async (sessionId: string) => { + this.abortController?.abort(); + this.status$.next('loading'); + this.abortController = new AbortController(); + try { + return await this._http.get(`${ASSISTANT_API.SESSION}/${sessionId}`, { + signal: this.abortController.signal, + }); + } catch (error) { + this.status$.next({ status: 'error', error }); + } finally { + this.status$.next('idle'); + } + }; +} diff --git a/public/services/sessions_service.ts b/public/services/sessions_service.ts new file mode 100644 index 00000000..78e83e70 --- /dev/null +++ b/public/services/sessions_service.ts @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { HttpFetchQuery, HttpStart, SavedObjectsFindOptions } from '../../../../src/core/public'; +import { ISessionFindResponse } from '../../common/types/chat_saved_object_attributes'; +import { ASSISTANT_API } from '../../common/constants/llm'; + +export class SessionsService { + sessions$: BehaviorSubject = new BehaviorSubject( + null + ); + status$: BehaviorSubject<'idle' | 'loading' | { error: Error }> = new BehaviorSubject< + 'idle' | 'loading' | { error: Error } + >('idle'); + private _options?: Pick< + SavedObjectsFindOptions, + 'page' | 'perPage' | 'fields' | 'sortField' | 'sortOrder' + >; + abortController?: AbortController; + + constructor(private _http: HttpStart) {} + + public get options() { + return this._options; + } + + load = async ( + query?: Pick + ) => { + this.abortController?.abort(); + this.abortController = new AbortController(); + this._options = query; + try { + this.sessions$.next( + await this._http.get(ASSISTANT_API.SESSIONS, { + query: this._options as HttpFetchQuery, + signal: this.abortController.signal, + }) + ); + } catch (error) { + this.sessions$.next(null); + this.status$.next({ error }); + } finally { + this.status$.next('idle'); + } + }; + + reload = () => { + this.load(this._options); + }; +} diff --git a/public/tabs/chat/chat_page.test.tsx b/public/tabs/chat/chat_page.test.tsx new file mode 100644 index 00000000..d768a183 --- /dev/null +++ b/public/tabs/chat/chat_page.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import { coreMock } from '../../../../../src/core/public/mocks'; +import { SessionLoadService } from '../../services/session_load_service'; +import { ChatPage } from './chat_page'; +import * as chatContextExports from '../../contexts/chat_context'; +import * as coreContextExports from '../../contexts/core_context'; +import * as hookExports from '../../hooks/use_chat_state'; + +jest.mock('./controls/chat_input_controls', () => { + return { ChatInputControls: () =>
}; +}); + +jest.mock('./chat_page_content', () => { + return { + ChatPageContent: ({ onRefresh }: { onRefresh: () => void }) => ( + + ), + }; +}); + +describe('', () => { + const dispatchMock = jest.fn(); + const loadMock = jest.fn().mockResolvedValue({ + title: 'session title', + version: 1, + createdTimeMs: new Date().getTime(), + updatedTimeMs: new Date().getTime(), + messages: [], + interactions: [], + }); + const sessionLoadService = new SessionLoadService(coreMock.createStart().http); + + beforeEach(() => { + jest.spyOn(sessionLoadService, 'load').mockImplementation(loadMock); + + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + sessionId: 'mocked_session_id', + chatEnabled: true, + }); + + jest.spyOn(hookExports, 'useChatState').mockReturnValue({ + chatStateDispatch: dispatchMock, + chatState: { messages: [], llmResponding: false }, + }); + + jest.spyOn(coreContextExports, 'useCore').mockReturnValue({ + services: { + sessionLoad: sessionLoadService, + }, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should reload the current conversation when user click refresh', async () => { + render(); + fireEvent.click(screen.getByText('refresh')); + + expect(loadMock).toHaveBeenCalledWith('mocked_session_id'); + await waitFor(() => { + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'receive', + payload: { messages: [], interactions: [] }, + }); + }); + }); + + it('should NOT call reload if current conversation is not set', async () => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + sessionId: undefined, + chatEnabled: true, + }); + render(); + fireEvent.click(screen.getByText('refresh')); + + expect(loadMock).not.toHaveBeenCalled(); + }); +}); diff --git a/public/tabs/chat/chat_page.tsx b/public/tabs/chat/chat_page.tsx new file mode 100644 index 00000000..ff302eed --- /dev/null +++ b/public/tabs/chat/chat_page.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiFlyoutBody, EuiFlyoutFooter, EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import cs from 'classnames'; +import { useObservable } from 'react-use'; +import { useChatContext, useCore } from '../../contexts'; +import { useChatState } from '../../hooks'; +import { ChatPageContent } from './chat_page_content'; +import { ChatInputControls } from './controls/chat_input_controls'; + +interface ChatPageProps { + className?: string; +} + +export const ChatPage: React.FC = (props) => { + const core = useCore(); + const chatContext = useChatContext(); + const { chatState, chatStateDispatch } = useChatState(); + const sessionLoadStatus = useObservable(core.services.sessionLoad.status$); + const messagesLoading = sessionLoadStatus === 'loading'; + + const refresh = useCallback(async () => { + if (!chatContext.sessionId) { + return; + } + const session = await core.services.sessionLoad.load(chatContext.sessionId); + if (session) { + chatStateDispatch({ + type: 'receive', + payload: { + messages: session.messages, + interactions: session.interactions, + }, + }); + } + }, [chatContext.sessionId, chatStateDispatch]); + + return ( + <> + + + + + + + + + + + + + + ); +}; diff --git a/public/tabs/chat/chat_page_content.test.tsx b/public/tabs/chat/chat_page_content.test.tsx new file mode 100644 index 00000000..ccbb9876 --- /dev/null +++ b/public/tabs/chat/chat_page_content.test.tsx @@ -0,0 +1,243 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChatPageContent } from './chat_page_content'; +import * as chatContextExports from '../../contexts/chat_context'; +import * as chatStateHookExports from '../../hooks/use_chat_state'; +import * as chatActionHookExports from '../../hooks/use_chat_actions'; +import { IMessage } from '../../../common/types/chat_saved_object_attributes'; + +jest.mock('./messages/message_bubble', () => { + return { + MessageBubble: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +jest.mock('./messages/message_content', () => { + return { MessageContent: () =>
}; +}); + +describe('', () => { + const abortActionMock = jest.fn(); + const executeActionMock = jest.fn(); + + beforeEach(() => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + sessionId: 'test_session_id', + actionExecutors: { + view_ppl_visualization: jest.fn(), + }, + currentAccount: { + username: 'test_user', + tenant: 'private', + }, + }); + + jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({ + chatState: { messages: [], llmResponding: false, interactions: [] }, + chatStateDispatch: jest.fn(), + }); + + jest.spyOn(chatActionHookExports, 'useChatActions').mockReturnValue({ + regenerate: jest.fn(), + send: jest.fn(), + loadChat: jest.fn(), + openChatUI: jest.fn(), + executeAction: executeActionMock, + abortAction: abortActionMock, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should display welcome message by default', () => { + render(); + expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(1); + expect(screen.queryByLabelText('chat welcome message')).toBeInTheDocument(); + }); + + it('should display a default suggested action', () => { + render(); + expect(screen.queryAllByLabelText('chat suggestions')).toHaveLength(1); + expect(screen.queryByText('What are the indices in my cluster?')).toBeInTheDocument(); + }); + + it('should display messages', () => { + const messages: IMessage[] = [ + { + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + }, + { + type: 'output', + content: 'here are the indices in your cluster: .alert', + contentType: 'markdown', + suggestedActions: [{ actionType: 'send_as_input', message: 'suggested action mock' }], + }, + ]; + jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({ + chatState: { messages, llmResponding: false, interactions: [] }, + chatStateDispatch: jest.fn(), + }); + render(); + expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(3); + }); + + it('should only display the suggested actions of last output', () => { + const messages: IMessage[] = [ + { + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + }, + { + type: 'output', + content: 'here are the indices in your cluster: .kibana', + contentType: 'markdown', + suggestedActions: [{ actionType: 'send_as_input', message: 'suggested action mock' }], + }, + { + type: 'input', + content: 'Are there any alerts in my system?', + contentType: 'text', + }, + { + type: 'output', + content: 'there is no alert in the system', + contentType: 'markdown', + suggestedActions: [{ actionType: 'send_as_input', message: 'suggested action mock' }], + }, + ]; + jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({ + chatState: { messages, llmResponding: false, interactions: [] }, + chatStateDispatch: jest.fn(), + }); + render(); + expect(screen.queryAllByLabelText('chat suggestions')).toHaveLength(1); + expect(screen.queryByText('suggested action mock')).toBeInTheDocument(); + }); + + it('should NOT display the suggested actions if no suggested actions', () => { + const messages: IMessage[] = [ + { + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + }, + { + type: 'output', + content: 'here are the indices in your cluster: .kibana', + contentType: 'markdown', + suggestedActions: [], + }, + ]; + jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({ + chatState: { messages, llmResponding: false, interactions: [] }, + chatStateDispatch: jest.fn(), + }); + render(); + expect(screen.queryAllByLabelText('chat suggestions')).toHaveLength(0); + }); + + it('should not display suggested actions on user input message bubble', () => { + const messages: IMessage[] = [ + { + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + }, + { + type: 'output', + content: 'here are the indices in your cluster: .kibana', + contentType: 'markdown', + suggestedActions: [{ actionType: 'send_as_input', message: 'suggested action mock' }], + }, + { + type: 'input', + content: 'show me visualizations about sales', + contentType: 'text', + }, + ]; + jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({ + chatState: { messages, llmResponding: false, interactions: [] }, + chatStateDispatch: jest.fn(), + }); + render(); + expect(screen.queryAllByLabelText('chat suggestions')).toHaveLength(0); + }); + + it('should display loading screen when loading the messages', () => { + render(); + expect(screen.queryByText('Loading conversation')).toBeInTheDocument(); + expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(0); + }); + + it('should show error message with refresh button', () => { + const onRefreshMock = jest.fn(); + render( + + ); + expect(screen.queryByText('failed to get response')).toBeInTheDocument(); + expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(0); + + fireEvent.click(screen.getByText('Refresh')); + expect(onRefreshMock).toHaveBeenCalled(); + }); + + it('should display `Stop generating response` when llm is responding', () => { + jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({ + chatState: { messages: [], llmResponding: true, interactions: [] }, + chatStateDispatch: jest.fn(), + }); + render(); + expect(screen.queryByText('Stop generating response')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Stop generating response')); + expect(abortActionMock).toHaveBeenCalledWith('test_session_id'); + }); + + it('should display `How was this generated?`', () => { + const messages: IMessage[] = [ + { + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + }, + { + type: 'output', + content: 'here are the indices in your cluster: .kibana', + contentType: 'markdown', + suggestedActions: [], + traceId: 'trace_id_mock', + }, + ]; + jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({ + chatState: { messages, llmResponding: false, interactions: [] }, + chatStateDispatch: jest.fn(), + }); + render(); + expect(screen.queryByText('How was this generated?')).toBeInTheDocument(); + }); + + it('should call executeAction', () => { + render(); + fireEvent.click(screen.getByText('What are the indices in my cluster?')); + expect(executeActionMock).toHaveBeenCalled(); + }); +}); diff --git a/public/tabs/chat/chat_page_content.tsx b/public/tabs/chat/chat_page_content.tsx new file mode 100644 index 00000000..a57ab8b1 --- /dev/null +++ b/public/tabs/chat/chat_page_content.tsx @@ -0,0 +1,255 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useLayoutEffect, useRef } from 'react'; +import { + IMessage, + ISuggestedAction, + Interaction, +} from '../../../common/types/chat_saved_object_attributes'; +import { WelcomeMessage } from '../../components/chat_welcome_message'; +import { useChatContext } from '../../contexts'; +import { useChatState, useChatActions } from '../../hooks'; +import { MessageBubble } from './messages/message_bubble'; +import { MessageContent } from './messages/message_content'; +import { SuggestionBubble } from './suggestions/suggestion_bubble'; + +interface ChatPageContentProps { + messagesLoading: boolean; + messagesLoadingError?: Error; + onRefresh: () => void; +} + +export const ChatPageContent: React.FC = React.memo((props) => { + const chatContext = useChatContext(); + const { chatState } = useChatState(); + const pageEndRef = useRef(null); + const loading = props.messagesLoading || chatState.llmResponding; + const chatActions = useChatActions(); + + useLayoutEffect(() => { + pageEndRef.current?.scrollIntoView(); + }, [chatState.messages, loading]); + + if (props.messagesLoading) { + return ( + <> + + } + title={

Loading conversation

} + titleSize="l" + /> + + ); + } + + if (props.messagesLoadingError) { + return ( + <> + + } + title={

Error loading conversation

} + body={props.messagesLoadingError.message} + titleSize="l" + actions={ + + Refresh + + } + /> + + ); + } + + const firstInputIndex = chatState.messages.findIndex((msg) => msg.type === 'input'); + const lastInputIndex = chatState.messages.findLastIndex((msg) => msg.type === 'input'); + + return ( + <> + + + + {firstInputIndex < 0 && ( + + )} + + {chatState.messages.map((message, i) => { + // The latest llm output, just after the last user input + const isLatestOutput = lastInputIndex >= 0 && i > lastInputIndex; + // All the llm output in response to user's input, exclude outputs before user's first input + const isChatOutput = firstInputIndex >= 0 && i > firstInputIndex; + // Only show suggestion on llm outputs after last user input + const showSuggestions = i > lastInputIndex; + + let interaction: Interaction | undefined; + if (message.type === 'output' && message.traceId) { + interaction = chatState.interactions.find( + (item) => item.interaction_id === message.traceId + ); + } + + return ( + + + + + + {showSuggestions && } + + + ); + })} + {loading && ( + <> + + + + )} + {chatState.llmResponding && chatContext.sessionId && ( +
+ + + chatActions.abortAction(chatContext.sessionId)} + /> + + +
+ )} + {chatState.llmError && ( + Error from response} + body={chatState.llmError.message} + /> + )} +
+ + ); +}); + +interface ToolsUsedProps { + message: IMessage; +} + +const ToolsUsed: React.FC = (props) => { + if (props.message.type !== 'output' || !props.message.toolsUsed?.length) return null; + return ( + <> + {props.message.toolsUsed.map((tool, i) => ( + + + {tool} + + + + ))} + + ); +}; + +interface SuggestionsProps { + message: IMessage; + inputDisabled: boolean; +} + +const Suggestions: React.FC = (props) => { + const chatContext = useChatContext(); + const { executeAction } = useChatActions(); + + if (props.message.type !== 'output') { + return null; + } + const traceId = props.message.traceId; + + const suggestedActions = structuredClone(props.message.suggestedActions) || []; + if (traceId) { + const viewTraceAction: ISuggestedAction = { + actionType: 'view_trace', + metadata: { traceId, icon: 'questionInCircle' }, + message: 'How was this generated?', + }; + suggestedActions.push(viewTraceAction); + } + + if (!suggestedActions.length) { + return null; + } + + return ( +
+ + Available suggestions + + + {suggestedActions + // remove actions that are not supported by the current chat context + .filter( + (suggestedAction) => + !( + suggestedAction.actionType === 'view_ppl_visualization' && + !chatContext.actionExecutors.view_ppl_visualization + ) + ) + .map((suggestedAction, i) => ( +
+ + + + !props.inputDisabled && executeAction(suggestedAction, props.message) + } + color={props.inputDisabled ? 'subdued' : 'default'} + content={suggestedAction.message} + iconType={suggestedAction.metadata?.icon} + /> + +
+ ))} +
+
+ ); +}; diff --git a/public/tabs/chat/controls/chat_input_controls.test.tsx b/public/tabs/chat/controls/chat_input_controls.test.tsx new file mode 100644 index 00000000..c15528d7 --- /dev/null +++ b/public/tabs/chat/controls/chat_input_controls.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor, getByRole } from '@testing-library/react'; + +import { ChatInputControls } from './chat_input_controls'; +import * as contextExports from '../../../contexts/chat_context'; +import * as hookExports from '../../../hooks/use_chat_actions'; + +describe('', () => { + const sendMock = jest.fn(); + + beforeEach(() => { + jest.spyOn(contextExports, 'useChatContext').mockReturnValue({ + appId: 'mocked_app_id', + }); + jest.spyOn(hookExports, 'useChatActions').mockReturnValue({ + send: sendMock, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should display submit button and text box in different state accordingly', () => { + const { rerender } = render(); + expect(screen.getByRole('button')).toBeDisabled(); + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('button')).toHaveTextContent('Generating...'); + + rerender(); + expect(screen.getByRole('button')).toBeDisabled(); + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('button')).toHaveTextContent('Go'); + + rerender(); + expect(screen.getByRole('button')).toBeEnabled(); + expect(screen.getByRole('textbox')).toBeEnabled(); + expect(screen.getByRole('button')).toHaveTextContent('Generating...'); + + rerender(); + expect(screen.getByRole('button')).toBeEnabled(); + expect(screen.getByRole('textbox')).toBeEnabled(); + expect(screen.getByRole('button')).toHaveTextContent('Go'); + }); + + it('should send message when clicking submit button', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'what indices are in my cluster?' }, + }); + fireEvent.click(screen.getByRole('button')); + expect(sendMock).toHaveBeenCalledWith({ + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + context: { + appId: 'mocked_app_id', + }, + }); + }); + + it('should send message when pressing `Enter`', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'what indices are in my cluster?' }, + }); + fireEvent.keyPress(screen.getByRole('textbox'), { + key: 'Enter', + keyCode: 13, + shiftKey: false, + }); + expect(sendMock).toHaveBeenCalledWith({ + type: 'input', + content: 'what indices are in my cluster?', + contentType: 'text', + context: { + appId: 'mocked_app_id', + }, + }); + }); + + it('should NOT send message when pressing `shift+Enter`', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'what indices are in my cluster?' }, + }); + fireEvent.keyPress(screen.getByRole('textbox'), { + key: 'Enter', + keyCode: 13, + shiftKey: true, + }); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it('should NOT send message if disabled', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'what indices are in my cluster?' }, + }); + fireEvent.click(screen.getByRole('button')); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it('should NOT send message if input is trimmed empty', () => { + render(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: ' ' }, + }); + fireEvent.click(screen.getByRole('button')); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); diff --git a/public/tabs/chat/controls/chat_input_controls.tsx b/public/tabs/chat/controls/chat_input_controls.tsx new file mode 100644 index 00000000..066be183 --- /dev/null +++ b/public/tabs/chat/controls/chat_input_controls.tsx @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiTextArea } from '@elastic/eui'; +import autosize from 'autosize'; +import React, { useRef } from 'react'; +import { useEffectOnce } from 'react-use'; +import { IMessage } from '../../../../common/types/chat_saved_object_attributes'; +import { useChatContext } from '../../../contexts'; +import { useChatActions } from '../../../hooks'; + +interface ChatInputControlsProps { + disabled: boolean; + loading: boolean; +} + +export const ChatInputControls: React.FC = (props) => { + const chatContext = useChatContext(); + const { send } = useChatActions(); + const inputRef = useRef(null); + + useEffectOnce(() => { + if (inputRef.current) { + autosize(inputRef.current); + } + }); + + const onSubmit = async () => { + if (props.disabled || !inputRef.current) return; + + const userInput = inputRef.current.value.trim(); + if (!userInput) return; + + const inputMessage: IMessage = { + type: 'input', + content: userInput, + contentType: 'text', + context: { + appId: chatContext.appId, + }, + }; + inputRef.current.value = ''; + inputRef.current.style.height = '40px'; + send(inputMessage); + }; + + return ( + + + + { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSubmit(); + } + }} + /> + + + + {props.loading ? 'Generating...' : 'Go'} + + + + + ); +}; diff --git a/public/tabs/chat/messages/message_bubble.test.tsx b/public/tabs/chat/messages/message_bubble.test.tsx new file mode 100644 index 00000000..d4652f32 --- /dev/null +++ b/public/tabs/chat/messages/message_bubble.test.tsx @@ -0,0 +1,225 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { MessageBubble } from './message_bubble'; +import { IOutput } from '../../../../common/types/chat_saved_object_attributes'; +import * as useFeedbackHookExports from '../../../hooks/use_feed_back'; + +describe('', () => { + const sendFeedbackMock = jest.fn(); + + beforeEach(() => { + jest + .spyOn(useFeedbackHookExports, 'useFeedback') + .mockReturnValue({ feedbackResult: undefined, sendFeedback: sendFeedbackMock }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should display message bubble', () => { + // input message + const { rerender } = render( + + ); + expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(1); + + // output message + rerender( + + ); + expect(screen.queryAllByLabelText('chat message bubble')).toHaveLength(1); + }); + + it('should display loading indicator', () => { + render(); + expect(screen.queryAllByLabelText('chat message loading')).toHaveLength(1); + }); + + it('should display message action bar', () => { + render( + + ); + expect(screen.queryAllByLabelText('message actions')).toHaveLength(1); + }); + + it('should NOT display message action bar', () => { + render( + + ); + expect(screen.queryAllByLabelText('message actions')).toHaveLength(0); + }); + + it('should display action(copy message) on text output', () => { + render( + + ); + expect(screen.queryAllByTitle('copy message')).toHaveLength(1); + }); + + it('should NOT display action(copy message) on non-text output', () => { + const { rerender } = render( + + ); + expect(screen.queryAllByTitle('copy message')).toHaveLength(0); + + rerender( + + ); + expect(screen.queryAllByTitle('copy message')).toHaveLength(0); + }); + + it('should display action: regenerate message', () => { + render( + + ); + expect(screen.queryAllByTitle('regenerate message')).toHaveLength(1); + }); + + it('should NOT display action: regenerate message', () => { + render( + + ); + expect(screen.queryAllByTitle('regenerate message')).toHaveLength(0); + }); + + it('should display actions: thumbs up and thumbs down on markdown output', () => { + render( + + ); + expect(screen.queryAllByLabelText('feedback thumbs up')).toHaveLength(1); + expect(screen.queryAllByLabelText('feedback thumbs down')).toHaveLength(1); + }); + + it('should NOT display actions: thumbs up and thumbs down on non-markdown output', () => { + render( + + ); + expect(screen.queryAllByLabelText('feedback thumbs up')).toHaveLength(0); + expect(screen.queryAllByLabelText('feedback thumbs down')).toHaveLength(0); + }); + + it('should send thumbs up feedback', () => { + const message: IOutput = { + type: 'output', + contentType: 'markdown', + content: 'here are the indices in your cluster: .alert', + }; + render(); + fireEvent.click(screen.getByLabelText('feedback thumbs up')); + expect(sendFeedbackMock).toHaveBeenCalledWith(message, true); + }); + + it('should send thumbs down feedback', () => { + const message: IOutput = { + type: 'output', + contentType: 'markdown', + content: 'here are the indices in your cluster: .alert', + }; + render(); + fireEvent.click(screen.getByLabelText('feedback thumbs down')); + expect(sendFeedbackMock).toHaveBeenCalledWith(message, false); + }); + + it('should not send feedback if message has already rated', () => { + jest + .spyOn(useFeedbackHookExports, 'useFeedback') + .mockReturnValue({ feedbackResult: true, sendFeedback: sendFeedbackMock }); + const message: IOutput = { + type: 'output', + contentType: 'markdown', + content: 'here are the indices in your cluster: .alert', + }; + render(); + fireEvent.click(screen.getByLabelText('feedback thumbs up')); + expect(sendFeedbackMock).not.toHaveBeenCalled(); + }); +}); diff --git a/public/tabs/chat/messages/message_bubble.tsx b/public/tabs/chat/messages/message_bubble.tsx new file mode 100644 index 00000000..8a704e2a --- /dev/null +++ b/public/tabs/chat/messages/message_bubble.tsx @@ -0,0 +1,239 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAvatar, + EuiButtonIcon, + EuiCopy, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiLoadingSpinner, + EuiPanel, + EuiSpacer, + EuiIcon, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { IconType } from '@elastic/eui/src/components/icon/icon'; +import cx from 'classnames'; +import chatIcon from '../../../assets/chat.svg'; +import { + IMessage, + IOutput, + Interaction, +} from '../../../../common/types/chat_saved_object_attributes'; +import { useFeedback } from '../../../hooks/use_feed_back'; + +type MessageBubbleProps = { + showActionBar: boolean; + showRegenerate?: boolean; + shouldActionBarVisibleOnHover?: boolean; + onRegenerate?: (interactionId: string) => void; +} & ( + | { + message: IMessage; + interaction?: Interaction; + } + | { + loading: boolean; + } +); + +export const MessageBubble: React.FC = React.memo((props) => { + const { feedbackResult, sendFeedback } = useFeedback( + 'interaction' in props ? props.interaction : null + ); + + // According to the design of the feedback, only markdown type output is supported. + const showFeedback = + 'message' in props && + props.message.type === 'output' && + props.message.contentType === 'markdown'; + + const feedbackOutput = useCallback( + (correct: boolean, result: boolean | undefined) => { + // No repeated feedback. + if (result !== undefined || !('message' in props)) { + return; + } + sendFeedback(props.message as IOutput, correct); + }, + [props, sendFeedback] + ); + + const createAvatar = (iconType?: IconType) => { + if (iconType) { + return ( + + ); + } else { + return ; + } + }; + + if ('loading' in props && props.loading) { + return ( + + + {createAvatar(() => ( + + ))} + + + + + + + + ); + } + + if ('message' in props) { + if (props.message.type === 'input') { + return ( + + + + {props.children} + + + + ); + } + + // if (['visualization', 'ppl_visualization'].includes(props.contentType)) { + // return <>{props.children}; + // } + + const isVisualization = ['visualization', 'ppl_visualization'].includes( + props.message.contentType + ); + + return ( + + + {props.message.contentType === 'error' ? createAvatar('alert') : createAvatar()} + + + + {props.children} + + {props.showActionBar && ( + <> + + + {!isVisualization && ( + + + {(copy) => ( + + )} + + + )} + {props.showRegenerate && props.interaction?.interaction_id ? ( + + props.onRegenerate?.(props.interaction?.interaction_id || '')} + title="regenerate message" + color="text" + iconType="refresh" + /> + + ) : null} + {showFeedback && ( + // After feedback, only corresponding thumb icon will be kept and disabled. + <> + {feedbackResult !== false ? ( + + feedbackOutput(true, feedbackResult)} + /> + + ) : null} + {feedbackResult !== true ? ( + + feedbackOutput(false, feedbackResult)} + /> + + ) : null} + + )} + + + )} + + + ); + } + return null; +}); diff --git a/public/tabs/chat/messages/message_content.test.tsx b/public/tabs/chat/messages/message_content.test.tsx new file mode 100644 index 00000000..f6bed2ba --- /dev/null +++ b/public/tabs/chat/messages/message_content.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MessageContent } from './message_content'; +import * as chatContextExports from '../../../contexts/chat_context'; + +jest.mock('../../../components/core_visualization', () => { + return { + CoreVisualization: () =>
, + }; +}); + +describe('', () => { + const pplVisualizationRenderMock = jest.fn(); + + beforeEach(() => { + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue({ + contentRenderers: { ppl_visualization: pplVisualizationRenderMock }, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should display message(text)', () => { + render( + + ); + expect(screen.queryAllByText('what indices are in my cluster?')).toHaveLength(1); + }); + + it('should display message(error)', () => { + render( + + ); + expect(screen.queryAllByText('what indices are in my cluster?')).toHaveLength(1); + }); + + it('should display message(visualization)', () => { + render( + + ); + expect(screen.queryAllByLabelText('visualization')).toHaveLength(1); + }); + + it('should display message(markdown)', () => { + render( + + ); + expect(screen.queryAllByText('title')).toHaveLength(1); + }); + + it('should render ppl visualization', () => { + render( + + ); + expect(pplVisualizationRenderMock).toHaveBeenCalledWith({ query: 'mock ppl query' }); + }); +}); diff --git a/public/tabs/chat/messages/message_content.tsx b/public/tabs/chat/messages/message_content.tsx new file mode 100644 index 00000000..cb857524 --- /dev/null +++ b/public/tabs/chat/messages/message_content.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiMarkdownFormat, EuiText } from '@elastic/eui'; +import React from 'react'; +import { IMessage } from '../../../../common/types/chat_saved_object_attributes'; +import { CoreVisualization } from '../../../components/core_visualization'; +import { useChatContext } from '../../../contexts/chat_context'; + +interface MessageContentProps { + message: IMessage; +} + +export const MessageContent: React.FC = React.memo((props) => { + const chatContext = useChatContext(); + + switch (props.message.contentType) { + case 'text': + return {props.message.content}; + + case 'error': + return ( + + {props.message.content} + + ); + + case 'markdown': + return {props.message.content}; + + case 'visualization': + return ( +
+ +
+ ); + + case 'ppl_visualization': { + const render = chatContext.contentRenderers[props.message.contentType]; + if (!render) return null; + return ( +
{render({ query: props.message.content })}
+ ); + } + + // content types registered by plugins unknown to assistant + default: { + const message = props.message as IMessage; + return chatContext.contentRenderers[message.contentType]?.(message.content) ?? null; + } + } +}); diff --git a/public/tabs/chat/suggestions/suggestion_bubble.tsx b/public/tabs/chat/suggestions/suggestion_bubble.tsx new file mode 100644 index 00000000..91d55b61 --- /dev/null +++ b/public/tabs/chat/suggestions/suggestion_bubble.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, EuiText, IconType } from '@elastic/eui'; +import React from 'react'; +import { TextColor } from '@elastic/eui/src/components/text/text_color'; + +interface SuggestionBubbleProps { + onClick: () => void; + color: TextColor; + content: string; + iconType?: IconType; +} + +export const SuggestionBubble: React.FC = ({ + onClick, + color, + content, + iconType = 'chatRight', +}: SuggestionBubbleProps) => { + return ( + + + + + + + + {content} + + + + + ); +}; diff --git a/public/tabs/chat_window_header.tsx b/public/tabs/chat_window_header.tsx new file mode 100644 index 00000000..c0b1d5e2 --- /dev/null +++ b/public/tabs/chat_window_header.tsx @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import React from 'react'; +import { useChatContext } from '../contexts/chat_context'; +import { ChatWindowHeaderTitle } from '../components/chat_window_header_title'; +import chatIcon from '../assets/chat.svg'; +import { TAB_ID } from '../utils/constants'; +interface ChatWindowHeaderProps { + flyoutFullScreen: boolean; + toggleFlyoutFullScreen: () => void; +} + +export const ChatWindowHeader: React.FC = React.memo((props) => { + const chatContext = useChatContext(); + + const dockBottom = () => ( + + + + + + + ); + + const dockRight = () => ( + + + + + + + ); + + return ( + <> + + + + + + + + + + + { + chatContext.setFlyoutComponent(undefined); + // Back to chat tab if history page already visible + chatContext.setSelectedTabId( + chatContext.selectedTabId === TAB_ID.HISTORY ? TAB_ID.CHAT : TAB_ID.HISTORY + ); + }} + display={chatContext.selectedTabId === TAB_ID.HISTORY ? 'fill' : undefined} + /> + + + + + + + + { + chatContext.setFlyoutVisible(false); + }} + /> + + + + + ); +}); diff --git a/public/tabs/history/__tests__/chat_history_page.test.tsx b/public/tabs/history/__tests__/chat_history_page.test.tsx new file mode 100644 index 00000000..2f8fbbd4 --- /dev/null +++ b/public/tabs/history/__tests__/chat_history_page.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import { I18nProvider } from '@osd/i18n/react'; + +import * as useChatStateExports from '../../../hooks/use_chat_state'; +import * as chatContextExports from '../../../contexts/chat_context'; +import * as coreContextExports from '../../../contexts/core_context'; + +import { ChatHistoryPage } from '../chat_history_page'; + +const setup = () => { + const useCoreMock = { + services: { + notifications: { + toasts: { + addSuccess: jest.fn(), + addDanger: jest.fn(), + addError: jest.fn(), + }, + }, + sessions: { + sessions$: new BehaviorSubject({ + objects: [ + { + id: '1', + title: 'foo', + }, + ], + total: 1, + }), + status$: new BehaviorSubject('idle'), + load: jest.fn(), + }, + sessionLoad: {}, + }, + }; + const useChatStateMock = { + chatStateDispatch: jest.fn(), + }; + const useChatContextMock = { + sessionId: '1', + setSessionId: jest.fn(), + setTitle: jest.fn(), + }; + jest.spyOn(coreContextExports, 'useCore').mockReturnValue(useCoreMock); + jest.spyOn(useChatStateExports, 'useChatState').mockReturnValue(useChatStateMock); + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue(useChatContextMock); + + const renderResult = render( + + + + ); + + return { + useCoreMock, + useChatStateMock, + useChatContextMock, + renderResult, + }; +}; + +describe('', () => { + it('should clear old session data after current session deleted', async () => { + const { renderResult, useChatStateMock, useChatContextMock } = setup(); + + act(() => { + fireEvent.click(renderResult.getByLabelText('Delete conversation')); + }); + + expect(useChatContextMock.setSessionId).not.toHaveBeenCalled(); + expect(useChatContextMock.setTitle).not.toHaveBeenCalled(); + expect(useChatStateMock.chatStateDispatch).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + + expect(useChatContextMock.setSessionId).toHaveBeenLastCalledWith(undefined); + expect(useChatContextMock.setTitle).toHaveBeenLastCalledWith(undefined); + expect(useChatStateMock.chatStateDispatch).toHaveBeenLastCalledWith({ type: 'reset' }); + }); +}); diff --git a/public/tabs/history/__tests__/chat_history_search_list.test.tsx b/public/tabs/history/__tests__/chat_history_search_list.test.tsx new file mode 100644 index 00000000..ec929a7a --- /dev/null +++ b/public/tabs/history/__tests__/chat_history_search_list.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; + +import { coreMock } from '../../../../../../src/core/public/mocks'; +import * as chatContextExports from '../../../contexts/chat_context'; +import * as coreContextExports from '../../../contexts/core_context'; + +import { ChatHistorySearchList } from '../chat_history_search_list'; + +const setup = () => { + const useChatContextMock = { + sessionId: '1', + setTitle: jest.fn(), + }; + const useCoreMock = { + services: coreMock.createStart(), + }; + useCoreMock.services.http.put.mockImplementation(() => Promise.resolve()); + jest.spyOn(coreContextExports, 'useCore').mockReturnValue(useCoreMock); + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue(useChatContextMock); + + const renderResult = render( + + + + ); + + return { + useChatContextMock, + renderResult, + }; +}; + +describe('', () => { + it('should set new window title after edit conversation name', async () => { + const { renderResult, useChatContextMock } = setup(); + + act(() => { + fireEvent.click(renderResult.getByLabelText('Edit conversation name')); + }); + + act(() => { + fireEvent.change(renderResult.getByLabelText('Conversation name input'), { + target: { value: 'bar' }, + }); + }); + + expect(useChatContextMock.setTitle).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + + waitFor(() => { + expect(useChatContextMock.setTitle).toHaveBeenLastCalledWith('bar'); + }); + }); +}); diff --git a/public/tabs/history/__tests__/delete_conversation_confirm_modal.test.tsx b/public/tabs/history/__tests__/delete_conversation_confirm_modal.test.tsx new file mode 100644 index 00000000..7060ecdb --- /dev/null +++ b/public/tabs/history/__tests__/delete_conversation_confirm_modal.test.tsx @@ -0,0 +1,148 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; + +import { coreMock } from '../../../../../../src/core/public/mocks'; +import * as coreContextExports from '../../../contexts/core_context'; + +import { + DeleteConversationConfirmModal, + DeleteConversationConfirmModalProps, +} from '../delete_conversation_confirm_modal'; +import { HttpHandler } from '../../../../../../src/core/public'; + +const setup = ({ onClose, sessionId }: DeleteConversationConfirmModalProps) => { + const useCoreMock = { + services: coreMock.createStart(), + }; + jest.spyOn(coreContextExports, 'useCore').mockReturnValue(useCoreMock); + + const renderResult = render( + + + + ); + + return { + useCoreMock, + renderResult, + }; +}; + +describe('', () => { + it('should render confirm text and button', async () => { + const { renderResult } = setup({ + sessionId: '1', + }); + + await waitFor(async () => { + expect( + renderResult.getByText( + 'Are you sure you want to delete the conversation? After it’s deleted, the conversation details will not be accessible.' + ) + ).toBeTruthy(); + expect(renderResult.getByRole('button', { name: 'Delete conversation' })).toBeTruthy(); + expect(renderResult.getByRole('button', { name: 'Cancel' })).toBeTruthy(); + }); + }); + + it('should call onClose with "canceled" after cancel button click', async () => { + const onCloseMock = jest.fn(); + const { renderResult } = setup({ + sessionId: '1', + onClose: onCloseMock, + }); + + expect(onCloseMock).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalCancelButton')); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenLastCalledWith('cancelled'); + }); + }); + + it('should show success toast and call onClose with "deleted" after delete session succeed', async () => { + const onCloseMock = jest.fn(); + const { renderResult, useCoreMock } = setup({ + sessionId: '1', + onClose: onCloseMock, + }); + useCoreMock.services.http.delete.mockImplementation(() => Promise.resolve()); + + expect(onCloseMock).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenLastCalledWith('deleted'); + expect(useCoreMock.services.notifications.toasts.addSuccess).toHaveBeenLastCalledWith( + 'The conversation was successfully deleted.' + ); + }); + }); + + it('should show error toasts and call onClose with "errored" after delete session failed', async () => { + const onCloseMock = jest.fn(); + const { renderResult, useCoreMock } = setup({ + sessionId: '1', + onClose: onCloseMock, + }); + useCoreMock.services.http.delete.mockImplementation(() => Promise.reject(new Error())); + + expect(onCloseMock).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenLastCalledWith('errored'); + }); + }); + + it('should call onClose with cancelled after delete session aborted', async () => { + const onCloseMock = jest.fn(); + const { renderResult, useCoreMock } = setup({ + sessionId: '1', + onClose: onCloseMock, + }); + useCoreMock.services.http.delete.mockImplementation(((_path, options) => { + return new Promise((_resolve, reject) => { + if (options?.signal) { + options.signal.onabort = () => { + reject(new Error('Aborted')); + }; + } + }); + }) as HttpHandler); + + expect(onCloseMock).not.toHaveBeenCalled(); + expect(useCoreMock.services.http.delete).not.toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalConfirmButton')); + }); + expect(useCoreMock.services.http.delete).toHaveBeenCalled(); + + act(() => { + fireEvent.click(renderResult.getByTestId('confirmModalCancelButton')); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenLastCalledWith('cancelled'); + expect(useCoreMock.services.notifications.toasts.addSuccess).not.toHaveBeenCalled(); + expect(useCoreMock.services.notifications.toasts.addDanger).not.toHaveBeenCalled(); + expect(useCoreMock.services.notifications.toasts.addError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/public/tabs/history/chat_history_list.tsx b/public/tabs/history/chat_history_list.tsx new file mode 100644 index 00000000..546aee87 --- /dev/null +++ b/public/tabs/history/chat_history_list.tsx @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLink, + EuiPanel, + EuiText, +} from '@elastic/eui'; +import moment from 'moment'; + +interface ChatHistory { + id: string; + title: string; + updatedTimeMs: number; +} + +interface ChatHistoryListItemProps extends ChatHistory { + hasBottomBorder?: boolean; + onTitleClick?: (id: string, title: string) => void; + onDeleteClick?: (conversation: { id: string }) => void; + onEditClick?: (conversation: { id: string; title: string }) => void; +} + +export const ChatHistoryListItem = ({ + id, + title, + updatedTimeMs, + hasBottomBorder = true, + onTitleClick, + onDeleteClick, + onEditClick, +}: ChatHistoryListItemProps) => { + const handleTitleClick = useCallback(() => { + onTitleClick?.(id, title); + }, [onTitleClick, id, title]); + + const handleDeleteClick = useCallback(() => { + onDeleteClick?.({ id }); + }, [onDeleteClick, id]); + + const handleEditClick = useCallback(() => { + onEditClick?.({ id, title }); + }, [onEditClick, id, title]); + + return ( + <> + + + + +

+ {title} +

+
+
+ + {moment(updatedTimeMs).format('MMMM D, YYYY')} at{' '} + {moment(updatedTimeMs).format('h:m A')} + +
+ + + + + + + + + + +
+ {hasBottomBorder && } + + ); +}; + +export interface ChatHistoryListProps { + chatHistories: ChatHistory[]; + onChatHistoryTitleClick?: (id: string, title: string) => void; + onChatHistoryDeleteClick?: (conversation: { id: string }) => void; + onChatHistoryEditClick?: (conversation: { id: string; title: string }) => void; +} + +export const ChatHistoryList = ({ + chatHistories, + onChatHistoryTitleClick, + onChatHistoryEditClick, + onChatHistoryDeleteClick, +}: ChatHistoryListProps) => { + return ( + <> + + {chatHistories.map((item, index) => ( + + ))} + + + ); +}; diff --git a/public/tabs/history/chat_history_page.tsx b/public/tabs/history/chat_history_page.tsx new file mode 100644 index 00000000..99adc4aa --- /dev/null +++ b/public/tabs/history/chat_history_page.tsx @@ -0,0 +1,164 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonEmpty, + EuiFlyoutBody, + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiSpacer, + EuiText, + EuiTitle, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { useDebounce, useObservable } from 'react-use'; +import cs from 'classnames'; +import { useChatActions, useChatState } from '../../hooks'; +import { useChatContext, useCore } from '../../contexts'; +import { TAB_ID } from '../../utils/constants'; +import { ChatHistorySearchList } from './chat_history_search_list'; + +interface ChatHistoryPageProps { + shouldRefresh: boolean; + className?: string; +} + +export const ChatHistoryPage: React.FC = React.memo((props) => { + const { services } = useCore(); + const { loadChat } = useChatActions(); + const { chatStateDispatch } = useChatState(); + const { + setSelectedTabId, + flyoutFullScreen, + sessionId, + setSessionId, + setTitle, + } = useChatContext(); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [searchName, setSearchName] = useState(); + const [debouncedSearchName, setDebouncedSearchName] = useState(); + const bulkGetOptions = useMemo( + () => ({ + page: pageIndex + 1, + perPage: pageSize, + fields: ['createdTimeMs', 'updatedTimeMs', 'title'], + sortField: 'updatedTimeMs', + sortOrder: 'DESC', + ...(debouncedSearchName ? { search: debouncedSearchName, searchFields: ['title'] } : {}), + }), + [pageIndex, pageSize, debouncedSearchName] + ); + const sessions = useObservable(services.sessions.sessions$); + const loading = useObservable(services.sessions.status$) === 'loading'; + const chatHistories = useMemo(() => sessions?.objects || [], [sessions]); + const hasNoConversations = !debouncedSearchName && !!sessions && sessions.total === 0 && !loading; + + const handleSearchChange = useCallback((e) => { + setSearchName(e.target.value); + }, []); + + const handleItemsPerPageChange = useCallback((itemsPerPage: number) => { + setPageIndex(0); + setPageSize(itemsPerPage); + }, []); + + const handleBack = useCallback(() => { + setSelectedTabId(TAB_ID.CHAT); + }, [setSelectedTabId]); + + const handleHistoryDeleted = useCallback( + (id: string) => { + if (sessionId === id) { + // Clear old session chat states + setTitle(undefined); + setSessionId(undefined); + chatStateDispatch({ type: 'reset' }); + } + }, + [sessionId, setSessionId, setTitle, chatStateDispatch] + ); + + useDebounce( + () => { + setPageIndex(0); + setDebouncedSearchName(searchName); + }, + 150, + [searchName] + ); + + useEffect(() => { + if (props.shouldRefresh) services.sessions.reload(); + }, [props.shouldRefresh, services.sessions]); + + useEffect(() => { + services.sessions.load(bulkGetOptions); + return () => { + services.sessions.abortController?.abort(); + }; + }, [services.sessions, bulkGetOptions]); + + return ( + + + + + {flyoutFullScreen ? ( + + + + + + ) : ( + + Back + + )} + + +

+ +

+
+ + + {hasNoConversations ? ( + +

+ No conversation has been recorded. Start a conversation in the assistant to have it + saved. +

+
+ ) : ( + + )} +
+
+
+ ); +}); diff --git a/public/tabs/history/chat_history_search_list.tsx b/public/tabs/history/chat_history_search_list.tsx new file mode 100644 index 00000000..979fd54f --- /dev/null +++ b/public/tabs/history/chat_history_search_list.tsx @@ -0,0 +1,131 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiFieldSearch, + EuiFieldSearchProps, + EuiPanel, + EuiSpacer, + EuiTablePagination, + EuiTablePaginationProps, + EuiText, +} from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import { ChatHistoryList, ChatHistoryListProps } from './chat_history_list'; +import { EditConversationNameModal } from '../../components/edit_conversation_name_modal'; +import { DeleteConversationConfirmModal } from './delete_conversation_confirm_modal'; +import { useChatContext } from '../../contexts'; + +interface ChatHistorySearchListProps + extends Pick< + EuiTablePaginationProps, + 'activePage' | 'itemsPerPage' | 'onChangeItemsPerPage' | 'onChangePage' | 'pageCount' + > { + search?: string; + loading: boolean; + histories: ChatHistoryListProps['chatHistories']; + onSearchChange: EuiFieldSearchProps['onChange']; + onLoadChat: (sessionId?: string | undefined, title?: string | undefined) => void; + onRefresh: () => void; + onHistoryDeleted: (id: string) => void; +} + +export const ChatHistorySearchList = ({ + search, + loading, + histories, + pageCount, + activePage, + itemsPerPage, + onRefresh, + onLoadChat, + onChangePage, + onSearchChange, + onHistoryDeleted, + onChangeItemsPerPage, +}: ChatHistorySearchListProps) => { + const { sessionId, setTitle } = useChatContext(); + const [editingConversation, setEditingConversation] = useState<{ + id: string; + title: string; + } | null>(null); + const [deletingConversation, setDeletingConversation] = useState<{ id: string } | null>(null); + + const handleEditConversationModalClose = useCallback( + (status: 'updated' | string, newTitle?: string) => { + if (status === 'updated') { + onRefresh(); + if (sessionId === editingConversation?.id) { + setTitle(newTitle); + } + } + setEditingConversation(null); + }, + [setEditingConversation, onRefresh, editingConversation, sessionId, setTitle] + ); + + const handleDeleteConversationConfirmModalClose = useCallback( + (status: 'deleted' | string) => { + if (status === 'deleted') { + onRefresh(); + } + if (!deletingConversation) { + return; + } + onHistoryDeleted(deletingConversation.id); + setDeletingConversation(null); + }, + [setDeletingConversation, onRefresh, deletingConversation, onHistoryDeleted] + ); + return ( + <> + + + + {!loading && histories.length === 0 ? ( + + +

There were no results found.

+
+ +
+ ) : ( + <> + + + {editingConversation && ( + + )} + {deletingConversation && ( + + )} + + )} + + ); +}; diff --git a/public/tabs/history/delete_conversation_confirm_modal.tsx b/public/tabs/history/delete_conversation_confirm_modal.tsx new file mode 100644 index 00000000..05b205de --- /dev/null +++ b/public/tabs/history/delete_conversation_confirm_modal.tsx @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; + +import { EuiConfirmModal, EuiText } from '@elastic/eui'; + +import { useDeleteSession } from '../../hooks'; +import { useCore } from '../../contexts/core_context'; + +export interface DeleteConversationConfirmModalProps { + onClose?: (status: 'cancelled' | 'errored' | 'deleted') => void; + sessionId: string; +} + +export const DeleteConversationConfirmModal = ({ + onClose, + sessionId, +}: DeleteConversationConfirmModalProps) => { + const { + services: { + notifications: { toasts }, + }, + } = useCore(); + const { loading, deleteSession, abort, isAborted } = useDeleteSession(); + + const handleCancel = useCallback(() => { + abort(); + onClose?.('cancelled'); + }, [onClose, abort]); + const handleConfirm = useCallback(async () => { + try { + await deleteSession(sessionId); + toasts.addSuccess('The conversation was successfully deleted.'); + } catch (_e) { + if (isAborted()) { + return; + } + onClose?.('errored'); + toasts.addDanger('There was an error. The conversation failed to delete.'); + return; + } + onClose?.('deleted'); + }, [onClose, deleteSession, sessionId, toasts.addSuccess, toasts.addDanger, isAborted]); + + return ( + + +

+ Are you sure you want to delete the conversation? After it’s deleted, the conversation + details will not be accessible. +

+
+
+ ); +}; diff --git a/public/types.ts b/public/types.ts new file mode 100644 index 00000000..5fd027e2 --- /dev/null +++ b/public/types.ts @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DashboardStart } from '../../../src/plugins/dashboard/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../../src/plugins/embeddable/public'; +import { IMessage, ISuggestedAction } from '../common/types/chat_saved_object_attributes'; + +// TODO should pair with server side registered output parser +export type ContentRenderer = (content: unknown) => React.ReactElement; +export type ActionExecutor = (params: Record) => void; +export interface AssistantActions { + send: (input: IMessage) => void; + loadChat: (sessionId?: string, title?: string) => void; + openChatUI: (sessionId?: string) => void; + executeAction: (suggestedAction: ISuggestedAction, message: IMessage) => void; + abortAction: (sessionId?: string) => void; + regenerate: (interactionId: string) => void; +} + +export interface AppPluginStartDependencies { + embeddable: EmbeddableStart; + dashboard: DashboardStart; +} + +export interface SetupDependencies { + embeddable: EmbeddableSetup; +} + +export interface AssistantSetup { + registerContentRenderer: (contentType: string, render: ContentRenderer) => void; + registerActionExecutor: (actionType: string, execute: ActionExecutor) => void; + assistantEnabled: () => Promise; + assistantActions: Omit; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AssistantStart {} + +export interface UserAccount { + username: string; + tenant: string; +} + +export interface ChatConfig { + terms_accepted: boolean; +} + +export type TabId = 'chat' | 'compose' | 'insights' | 'history' | 'trace'; diff --git a/public/utils/constants.ts b/public/utils/constants.ts new file mode 100644 index 00000000..9c35746e --- /dev/null +++ b/public/utils/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum TAB_ID { + CHAT = 'chat', + COMPOSE = 'compose', + INSIGHTS = 'insights', + HISTORY = 'history', + TRACE = 'trace', +} diff --git a/public/utils/index.ts b/public/utils/index.ts new file mode 100644 index 00000000..5c14d390 --- /dev/null +++ b/public/utils/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './notebook'; diff --git a/public/utils/notebook.ts b/public/utils/notebook.ts new file mode 100644 index 00000000..b9ead03d --- /dev/null +++ b/public/utils/notebook.ts @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { v4 as uuid } from 'uuid'; +import { htmlIdGenerator } from '@elastic/eui'; +import { IMessage } from '../../common/types/chat_saved_object_attributes'; + +const buildBasicGraph = () => ({ + id: 'paragraph_' + uuid(), + dateCreated: new Date().toISOString(), + dateModified: new Date().toISOString(), + input: { + inputText: '', + inputType: '', + }, + output: [{ result: '', outputType: '', execution_time: '0 ms' }], +}); + +const ASSISTANT_MESSAGE_PREFIX = 'OpenSearch Assistant: '; + +const createDashboardVizObject = (objectId: string) => { + const vizUniqueId = htmlIdGenerator()(); + // a dashboard container object for new visualization + const basicVizObject = { + viewMode: 'view', + panels: { + '1': { + gridData: { + x: 0, + y: 0, + w: 50, + h: 20, + i: '1', + }, + type: 'visualization', + explicitInput: { + id: '1', + savedObjectId: objectId, + }, + }, + }, + isFullScreenMode: false, + filters: [], + useMargins: false, + id: vizUniqueId, + timeRange: { + // We support last 15minutes here to keep consistent with chat bot preview. + to: 'now', + from: 'now-15m', + }, + title: 'embed_viz_' + vizUniqueId, + query: { + query: '', + language: 'lucene', + }, + refreshConfig: { + pause: true, + value: 15, + }, + } as const; + return basicVizObject; +}; + +export const convertMessagesToParagraphs = (messages: IMessage[], username: string) => { + const userMessagePrefix = `${username}: `; + + return messages.map((message: IMessage) => { + const paragraph = buildBasicGraph(); + + switch (message.contentType) { + // markdown,text and error are all text formatted in notebook. + case 'markdown': + case 'text': + case 'error': + const messageText = + // markdown and error represents assistant, text represents user. + message.contentType === 'text' + ? userMessagePrefix + message.content + : ASSISTANT_MESSAGE_PREFIX + message.content; + + Object.assign(paragraph, { + input: { inputText: `%md\n${messageText}`, inputType: 'MARKDOWN' }, + output: [ + { + result: messageText, + outputType: 'MARKDOWN', + execution_time: '0 ms', + }, + ], + }); + break; + + case 'visualization': + const visualizationObjectId = message.content; + const inputText = JSON.stringify(createDashboardVizObject(visualizationObjectId)); + Object.assign(paragraph, { + input: { inputText, inputType: 'VISUALIZATION' }, + output: [ + { + result: '', + outputType: 'VISUALIZATION', + execution_time: '0 ms', + }, + ], + }); + break; + + // error and ppl_visualization contentType will not be handled currently. + default: + break; + } + return paragraph; + }); +}; + +export type Paragraphs = ReturnType; diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..b7905c80 --- /dev/null +++ b/server/README.md @@ -0,0 +1,27 @@ +# `registerMessageParser` — Register your customized parser logic into Chatbot. + +**Interaction** refers to a question-answer pair in Chatbot application. In most cases, an interaction consists of two messages: an `Input` message and an `Output` message. However, as the Chatbot evolves to become more powerful, it may display new messages such as visualizations, data explorers, or data grids. Therefore, it is crucial to implement a mechanism that allows other plugins to register their customized parser logic based on each interaction body. + +![message parser](https://github.com/opensearch-project/dashboards-assistant/assets/13493605/b4ec1ff8-5339-4119-ad20-b2c31057bb0b) + +## API + +### registerMessageParser + +```typescript +dashboardAssistant.registerMessageParser({ + id: 'foo_parser', + parserProvider: async (interaction) => { + if (interaction.input) { + return [ + { + type: 'input', + contentType: 'text', + content: interaction.input, + }, + ]; + } + return []; + }, +}); +``` diff --git a/server/global.d.ts b/server/global.d.ts new file mode 100644 index 00000000..1e8a9b6f --- /dev/null +++ b/server/global.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +type RequiredKey = T & Required>; + +// TODO remove when typescript is upgraded to >= 4.5 +type Awaited = T extends Promise ? U : T; +type AgentResponse = Awaited['run']>>; diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 00000000..8e2e488d --- /dev/null +++ b/server/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../src/core/server'; +import { AssistantPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new AssistantPlugin(initializerContext); +} + +export { AssistantPluginSetup, AssistantPluginStart } from './types'; + +const assistantConfig = { + schema: schema.object({ + chat: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), + }), +}; + +export type AssistantConfig = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: assistantConfig.schema, + exposeToBrowser: { + chat: true, + }, +}; diff --git a/server/parsers/basic_input_output_parser.test.ts b/server/parsers/basic_input_output_parser.test.ts new file mode 100644 index 00000000..0e6c7420 --- /dev/null +++ b/server/parsers/basic_input_output_parser.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BasicInputOutputParser } from './basic_input_output_parser'; + +describe('BasicInputOutputParser', () => { + it('return input and output', async () => { + expect( + await BasicInputOutputParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + }) + ).toEqual([ + { + type: 'input', + contentType: 'text', + content: 'input', + }, + { + type: 'output', + contentType: 'markdown', + content: 'response', + traceId: 'interaction_id', + suggestedActions: [], + }, + ]); + }); + + it('return suggestions when additional_info has related info', async () => { + expect( + await BasicInputOutputParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: { + 'QuestionSuggestor.output': '["Foo", "Bar"]', + }, + }) + ).toEqual([ + { + type: 'input', + contentType: 'text', + content: 'input', + }, + { + type: 'output', + contentType: 'markdown', + content: 'response', + traceId: 'interaction_id', + suggestedActions: [ + { + actionType: 'send_as_input', + message: 'Foo', + }, + { + actionType: 'send_as_input', + message: 'Bar', + }, + ], + }, + ]); + }); + + it('sanitizes markdown outputs', async () => { + const outputs = await BasicInputOutputParser.parserProvider({ + input: 'test question', + response: + 'normal text image !!!!!!![](http://evil.com/) ![image](http://evil.com/) [good link](https://link)', + conversation_id: 'test-session', + interaction_id: 'interaction_id', + create_time: '', + }); + + expect(outputs).toEqual([ + { + type: 'input', + contentType: 'text', + content: 'test question', + }, + { + content: + 'normal text [](http://evil.com/) [image](http://evil.com/) [good link](https://link)', + contentType: 'markdown', + traceId: 'interaction_id', + type: 'output', + suggestedActions: [], + }, + ]); + }); +}); diff --git a/server/parsers/basic_input_output_parser.ts b/server/parsers/basic_input_output_parser.ts new file mode 100644 index 00000000..d71e26f3 --- /dev/null +++ b/server/parsers/basic_input_output_parser.ts @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import createDOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; +import { IInput, IOutput, Interaction } from '../../common/types/chat_saved_object_attributes'; + +const sanitize = (content: string) => { + const window = new JSDOM('').window; + const DOMPurify = createDOMPurify((window as unknown) as Window); + return DOMPurify.sanitize(content, { FORBID_TAGS: ['img'] }).replace(/!+\[/g, '['); +}; + +export const BasicInputOutputParser = { + order: 0, + id: 'output_message', + async parserProvider(interaction: Interaction) { + const suggestedOutputString = interaction.additional_info?.['QuestionSuggestor.output'] as + | string + | null; + let suggestedActions: string[] = []; + try { + suggestedActions = JSON.parse(suggestedOutputString || '[]'); + } catch (e) { + suggestedActions = []; + } + const inputItem: IInput = { + type: 'input', + contentType: 'text', + content: interaction.input, + }; + const outputItems: IOutput[] = [ + { + type: 'output', + contentType: 'markdown', + content: sanitize(interaction.response), + traceId: interaction.interaction_id, + suggestedActions: suggestedActions + .filter((item) => item) + .map((item) => ({ + actionType: 'send_as_input', + message: item, + })), + }, + ]; + return [inputItem, ...outputItems]; + }, +}; diff --git a/server/parsers/visualization_card_parser.test.ts b/server/parsers/visualization_card_parser.test.ts new file mode 100644 index 00000000..32aa0757 --- /dev/null +++ b/server/parsers/visualization_card_parser.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisualizationCardParser } from './visualization_card_parser'; + +describe('VisualizationCardParser', () => { + it('return visualizations when there is VisualizationTool.output', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: { + 'VisualizationTool.output': [ + 'row_number,Id,title\n' + + '1,id1,[Flights] Total Flights\n' + + '2,id2,[Flights] Controls\n' + + '3,id3,[Flights] Airline Carrier', + ], + }, + }) + ).toEqual([ + { + content: 'id1', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + { + content: 'id2', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + { + content: 'id3', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + ]); + }); + + it('return visualizations when there are multiple VisualizationTool.outputs', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: { + 'VisualizationTool.output': [ + 'row_number,Id,title\n' + '1,id1,[Flights] Total Flights\n', + 'row_number,Id,title\n' + '2,id2,[Flights] Controls\n', + ], + }, + }) + ).toEqual([ + { + content: 'id1', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + { + content: 'id2', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + ]); + }); + + it('do not return visualizations when VisualizationTool.output is null', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: {}, + }) + ).toEqual([]); + }); + + it('do not return visualizations when VisualizationTool.output is not in correct format', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: { + 'VisualizationTool.output': [ + 'row_number\n' + '1', + 'row_number,Id,title\n' + '2,id2,[Flights] Controls\n', + ], + }, + }) + ).toEqual([ + { + content: 'id2', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + ]); + }); + + it('filter duplicate visualization id in a single interaction', async () => { + expect( + await VisualizationCardParser.parserProvider({ + input: 'input', + response: 'response', + conversation_id: '', + interaction_id: 'interaction_id', + create_time: '', + additional_info: { + 'VisualizationTool.output': [ + 'row_number,Id,title\n' + + '1,id1,[Flights] Total Flights\n' + + '2,id2,[Flights] Total Flights\n', + 'row_number,Id,title\n' + '2,id2,[Flights] Controls\n', + ], + }, + }) + ).toEqual([ + { + content: 'id1', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + { + content: 'id2', + contentType: 'visualization', + suggestedActions: [{ actionType: 'view_in_dashboards', message: 'View in Visualize' }], + type: 'output', + }, + ]); + }); +}); diff --git a/server/parsers/visualization_card_parser.ts b/server/parsers/visualization_card_parser.ts new file mode 100644 index 00000000..7b4c9898 --- /dev/null +++ b/server/parsers/visualization_card_parser.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IMessage, Interaction } from '../../common/types/chat_saved_object_attributes'; +import { getJsonFromString } from '../utils/csv-parser-helper'; + +const extractIdsFromCsvString = async (csv: string) => { + const lines = (await getJsonFromString(csv)) as Array<{ Id: string }>; + return lines + .map((line) => line.Id) + .filter((v: T | null | undefined): v is T => v !== null && v !== undefined); +}; + +export const VisualizationCardParser = { + id: 'core_visualization', + async parserProvider(interaction: Interaction) { + const visualizationOutputs = interaction.additional_info?.['VisualizationTool.output'] as + | string[] + | undefined; + if (!visualizationOutputs) { + return []; + } + const visualizationIds = ( + await Promise.all(visualizationOutputs.map((output) => extractIdsFromCsvString(output))) + ).flatMap((id) => id); + + const visOutputs: IMessage[] = [...new Set(visualizationIds)] + /** + * Empty id will be filtered + */ + .filter((id) => id) + .map((id) => ({ + type: 'output', + content: id, + contentType: 'visualization', + suggestedActions: [ + { + message: 'View in Visualize', + actionType: 'view_in_dashboards', + }, + ], + })); + + return visOutputs; + }, +}; diff --git a/server/plugin.ts b/server/plugin.ts new file mode 100644 index 00000000..e4ebc3f5 --- /dev/null +++ b/server/plugin.ts @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { first } from 'rxjs/operators'; +import { AssistantConfig } from '.'; +import { + CoreSetup, + CoreStart, + Logger, + Plugin, + PluginInitializerContext, +} from '../../../src/core/server'; +import { setupRoutes } from './routes/index'; +import { AssistantPluginSetup, AssistantPluginStart, MessageParser } from './types'; +import { BasicInputOutputParser } from './parsers/basic_input_output_parser'; +import { VisualizationCardParser } from './parsers/visualization_card_parser'; + +export class AssistantPlugin implements Plugin { + private readonly logger: Logger; + private messageParsers: MessageParser[] = []; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public async setup(core: CoreSetup) { + this.logger.debug('Assistant: Setup'); + const config = await this.initializerContext.config + .create() + .pipe(first()) + .toPromise(); + const router = core.http.createRouter(); + + core.http.registerRouteHandlerContext('assistant_plugin', () => { + return { + config, + logger: this.logger, + }; + }); + + // Register server side APIs + setupRoutes(router, { + messageParsers: this.messageParsers, + }); + + core.capabilities.registerProvider(() => ({ + observability: { + show: true, + }, + })); + + const registerMessageParser = (messageParser: MessageParser) => { + const findItem = this.messageParsers.find((item) => item.id === messageParser.id); + if (findItem) { + throw new Error(`There is already a messageParser whose id is ${messageParser.id}`); + } + + this.messageParsers.push(messageParser); + }; + + registerMessageParser(BasicInputOutputParser); + registerMessageParser(VisualizationCardParser); + + return { + registerMessageParser, + removeMessageParser: (parserId: MessageParser['id']) => { + const findIndex = this.messageParsers.findIndex((item) => item.id === parserId); + if (findIndex < 0) { + this.logger.error(`There is not a messageParser whose id is ${parserId}`); + } + + this.messageParsers.splice(findIndex, 1); + }, + }; + } + + public start(core: CoreStart) { + this.logger.debug('Assistant: Started'); + return {}; + } + + public stop() {} +} diff --git a/server/routes/chat_routes.ts b/server/routes/chat_routes.ts new file mode 100644 index 00000000..5e195820 --- /dev/null +++ b/server/routes/chat_routes.ts @@ -0,0 +1,375 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; +import { schema, TypeOf } from '@osd/config-schema'; +import { + HttpResponsePayload, + IOpenSearchDashboardsResponse, + IRouter, + RequestHandlerContext, +} from '../../../../src/core/server'; +import { ASSISTANT_API } from '../../common/constants/llm'; +import { OllyChatService } from '../services/chat/olly_chat_service'; +import { AgentFrameworkStorageService } from '../services/storage/agent_framework_storage_service'; +import { RoutesOptions } from '../types'; +import { ChatService } from '../services/chat/chat_service'; + +const llmRequestRoute = { + path: ASSISTANT_API.SEND_MESSAGE, + validate: { + body: schema.object({ + sessionId: schema.maybe(schema.string()), + messages: schema.maybe(schema.arrayOf(schema.any())), + rootAgentId: schema.string(), + input: schema.object({ + type: schema.literal('input'), + context: schema.object({ + appId: schema.maybe(schema.string()), + }), + content: schema.string(), + contentType: schema.literal('text'), + }), + }), + }, +}; +export type LLMRequestSchema = TypeOf; + +const getSessionRoute = { + path: `${ASSISTANT_API.SESSION}/{sessionId}`, + validate: { + params: schema.object({ + sessionId: schema.string(), + }), + }, +}; +export type GetSessionSchema = TypeOf; + +const abortAgentExecutionRoute = { + path: `${ASSISTANT_API.ABORT_AGENT_EXECUTION}`, + validate: { + body: schema.object({ + sessionId: schema.string(), + }), + }, +}; +export type AbortAgentExecutionSchema = TypeOf; + +const regenerateRoute = { + path: `${ASSISTANT_API.REGENERATE}`, + validate: { + body: schema.object({ + sessionId: schema.string(), + rootAgentId: schema.string(), + interactionId: schema.string(), + }), + }, +}; +export type RegenerateSchema = TypeOf; + +const getSessionsRoute = { + path: ASSISTANT_API.SESSIONS, + validate: { + query: schema.object({ + perPage: schema.number({ min: 0, defaultValue: 20 }), + page: schema.number({ min: 0, defaultValue: 1 }), + sortOrder: schema.maybe(schema.string()), + sortField: schema.maybe(schema.string()), + fields: schema.maybe(schema.arrayOf(schema.string())), + search: schema.maybe(schema.string()), + searchFields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + }), + }, +}; +export type GetSessionsSchema = TypeOf; + +const deleteSessionRoute = { + path: `${ASSISTANT_API.SESSION}/{sessionId}`, + validate: { + params: schema.object({ + sessionId: schema.string(), + }), + }, +}; + +const updateSessionRoute = { + path: `${ASSISTANT_API.SESSION}/{sessionId}`, + validate: { + params: schema.object({ + sessionId: schema.string(), + }), + body: schema.object({ + title: schema.string(), + }), + }, +}; + +const getTracesRoute = { + path: `${ASSISTANT_API.TRACE}/{traceId}`, + validate: { + params: schema.object({ + traceId: schema.string(), + }), + }, +}; + +const feedbackRoute = { + path: `${ASSISTANT_API.FEEDBACK}/{interactionId}`, + validate: { + params: schema.object({ + interactionId: schema.string(), + }), + body: schema.object({ + satisfaction: schema.boolean(), + }), + }, +}; + +export function registerChatRoutes(router: IRouter, routeOptions: RoutesOptions) { + const createStorageService = (context: RequestHandlerContext) => + new AgentFrameworkStorageService( + context.core.opensearch.client.asCurrentUser, + routeOptions.messageParsers + ); + const createChatService = () => new OllyChatService(); + + router.post( + llmRequestRoute, + async ( + context, + request, + response + ): Promise> => { + const { messages = [], input, sessionId: sessionIdInRequestBody, rootAgentId } = request.body; + const storageService = createStorageService(context); + const chatService = createChatService(); + + let outputs: Awaited> | undefined; + + /** + * Get final answer from Agent framework + */ + try { + outputs = await chatService.requestLLM( + { messages, input, sessionId: sessionIdInRequestBody, rootAgentId }, + context + ); + } catch (error) { + context.assistant_plugin.logger.error(error); + const sessionId = outputs?.memoryId || sessionIdInRequestBody; + if (!sessionId) { + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + + /** + * Retrieve latest interactions from memory + */ + const sessionId = outputs?.memoryId || (sessionIdInRequestBody as string); + try { + if (!sessionId) { + throw new Error('Not a valid conversation'); + } + const conversation = await storageService.getSession(sessionId); + + return response.ok({ + body: { + messages: conversation.messages, + sessionId, + title: conversation.title, + interactions: conversation.interactions, + }, + }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.get( + getSessionRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + + try { + const getResponse = await storageService.getSession(request.params.sessionId); + return response.ok({ body: getResponse }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.get( + getSessionsRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + + try { + const getResponse = await storageService.getSessions(request.query); + return response.ok({ body: getResponse }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.delete( + deleteSessionRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + + try { + const getResponse = await storageService.deleteSession(request.params.sessionId); + return response.ok({ body: getResponse }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.put( + updateSessionRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + + try { + const getResponse = await storageService.updateSession( + request.params.sessionId, + request.body.title + ); + return response.ok({ body: getResponse }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.get( + getTracesRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + + try { + const getResponse = await storageService.getTraces(request.params.traceId); + return response.ok({ body: getResponse }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.post( + abortAgentExecutionRoute, + async ( + context, + request, + response + ): Promise> => { + const chatService = createChatService(); + + try { + chatService.abortAgentExecution(request.body.sessionId); + context.assistant_plugin.logger.info(`Abort agent execution: ${request.body.sessionId}`); + return response.ok(); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.put( + regenerateRoute, + async ( + context, + request, + response + ): Promise> => { + const { sessionId, rootAgentId, interactionId } = request.body; + const storageService = createStorageService(context); + const chatService = createChatService(); + + let outputs: Awaited> | undefined; + + /** + * Get final answer from Agent framework + */ + try { + outputs = await chatService.regenerate({ sessionId, rootAgentId, interactionId }, context); + } catch (error) { + context.assistant_plugin.logger.error(error); + } + + /** + * Retrieve latest interactions from memory + */ + try { + const conversation = await storageService.getSession(sessionId); + + return response.ok({ + body: { + ...conversation, + sessionId, + }, + }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.put( + feedbackRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + const { interactionId } = request.params; + + try { + const updateResponse = await storageService.updateInteraction(interactionId, { + feedback: request.body, + }); + return response.ok({ body: { ...updateResponse, success: true } }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); +} diff --git a/server/routes/get_session.test.ts b/server/routes/get_session.test.ts new file mode 100644 index 00000000..58d2cb21 --- /dev/null +++ b/server/routes/get_session.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ResponseObject } from '@hapi/hapi'; +import { Boom } from '@hapi/boom'; +import { Router } from '../../../../src/core/server/http/router'; +import { enhanceWithContext, triggerHandler } from './router.mock'; +import { httpServerMock } from '../../../../src/core/server/http/http_server.mocks'; +import { mockAgentFrameworkStorageService } from '../services/storage/agent_framework_storage_service.mock'; +import { loggerMock } from '../../../../src/core/server/logging/logger.mock'; +import { GetSessionSchema, registerChatRoutes } from './chat_routes'; +import { ASSISTANT_API } from '../../common/constants/llm'; + +const mockedLogger = loggerMock.create(); + +const router = new Router( + '', + mockedLogger, + enhanceWithContext({ + assistant_plugin: { + logger: mockedLogger, + }, + }) +); +registerChatRoutes(router, { + messageParsers: [], +}); + +describe('getSession route', () => { + const getSessionRequest = (payload: GetSessionSchema) => + triggerHandler(router, { + method: 'get', + path: `${ASSISTANT_API.SESSION}/{sessionId}`, + req: httpServerMock.createRawRequest({ + params: payload, + }), + }); + beforeEach(() => { + loggerMock.clear(mockedLogger); + }); + it('return back successfully when getSession returns session back', async () => { + mockAgentFrameworkStorageService.getSession.mockImplementationOnce(async () => { + return { + messages: [], + title: 'foo', + interactions: [], + createdTimeMs: 0, + updatedTimeMs: 0, + }; + }); + const result = (await getSessionRequest({ + sessionId: '1', + })) as ResponseObject; + expect(result.source).toMatchInlineSnapshot(` + Object { + "createdTimeMs": 0, + "interactions": Array [], + "messages": Array [], + "title": "foo", + "updatedTimeMs": 0, + } + `); + }); + + it('return 500 when getSession throws error', async () => { + mockAgentFrameworkStorageService.getSession.mockImplementationOnce(() => { + throw new Error('getSession error'); + }); + const result = (await getSessionRequest({ + sessionId: '1', + })) as Boom; + expect(mockedLogger.error).toBeCalledTimes(1); + expect(result.output).toMatchInlineSnapshot(` + Object { + "headers": Object {}, + "payload": Object { + "error": "Internal Server Error", + "message": "getSession error", + "statusCode": 500, + }, + "statusCode": 500, + } + `); + }); +}); diff --git a/server/routes/get_sessions.test.ts b/server/routes/get_sessions.test.ts new file mode 100644 index 00000000..c07e516b --- /dev/null +++ b/server/routes/get_sessions.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ResponseObject } from '@hapi/hapi'; +import { Boom } from '@hapi/boom'; +import { Router } from '../../../../src/core/server/http/router'; +import { enhanceWithContext, triggerHandler } from './router.mock'; +import { mockAgentFrameworkStorageService } from '../services/storage/agent_framework_storage_service.mock'; +import { httpServerMock } from '../../../../src/core/server/http/http_server.mocks'; +import { loggerMock } from '../../../../src/core/server/logging/logger.mock'; +import { GetSessionsSchema, registerChatRoutes } from './chat_routes'; +import { ASSISTANT_API } from '../../common/constants/llm'; + +const mockedLogger = loggerMock.create(); + +const router = new Router( + '', + mockedLogger, + enhanceWithContext({ + assistant_plugin: { + logger: mockedLogger, + }, + }) +); +registerChatRoutes(router, { + messageParsers: [], +}); + +describe('getSessions route', () => { + const getSessionsRequest = (payload: GetSessionsSchema) => + triggerHandler(router, { + method: 'get', + path: `${ASSISTANT_API.SESSIONS}`, + req: httpServerMock.createRawRequest({ + query: payload, + }), + }); + beforeEach(() => { + loggerMock.clear(mockedLogger); + }); + it('return back successfully when getSessions returns sessions back', async () => { + mockAgentFrameworkStorageService.getSessions.mockImplementationOnce(async () => { + return { + objects: [], + total: 0, + }; + }); + const result = (await getSessionsRequest({ + perPage: 10, + page: 1, + })) as ResponseObject; + expect(result.source).toMatchInlineSnapshot(` + Object { + "objects": Array [], + "total": 0, + } + `); + }); + + it('return 500 when getSessions throws error', async () => { + mockAgentFrameworkStorageService.getSessions.mockImplementationOnce(() => { + throw new Error('getSessions error'); + }); + const result = (await getSessionsRequest({ + perPage: 10, + page: 1, + })) as Boom; + expect(mockedLogger.error).toBeCalledTimes(1); + expect(result.output).toMatchInlineSnapshot(` + Object { + "headers": Object {}, + "payload": Object { + "error": "Internal Server Error", + "message": "getSessions error", + "statusCode": 500, + }, + "statusCode": 500, + } + `); + }); +}); diff --git a/server/routes/index.ts b/server/routes/index.ts new file mode 100644 index 00000000..6fc930e0 --- /dev/null +++ b/server/routes/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RoutesOptions } from '../types'; +import { IRouter } from '../../../../src/core/server'; +import { registerChatRoutes } from './chat_routes'; + +export function setupRoutes(router: IRouter, routeOptions: RoutesOptions) { + registerChatRoutes(router, routeOptions); +} diff --git a/server/routes/router.mock.ts b/server/routes/router.mock.ts new file mode 100644 index 00000000..94400675 --- /dev/null +++ b/server/routes/router.mock.ts @@ -0,0 +1,125 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Auth, + AuthenticationData, + Request, + ResponseObject, + ResponseToolkit, + ServerRealm, + ServerStateCookieOptions, +} from '@hapi/hapi'; +// @ts-ignore +import Response from '@hapi/hapi/lib/response'; +import { ProxyHandlerOptions } from '@hapi/h2o2'; +import { ReplyFileHandlerOptions } from '@hapi/inert'; +import { httpServerMock } from '../../../../src/core/server/http/http_server.mocks'; +import { + OpenSearchDashboardsRequest, + OpenSearchDashboardsResponseFactory, + Router, +} from '../../../../src/core/server/http/router'; +import { CoreRouteHandlerContext } from '../../../../src/core/server/core_route_handler_context'; +import { coreMock } from '../../../../src/core/server/mocks'; + +/** + * For hapi, ResponseToolkit is an internal implementation + * so we have to create a MockResponseToolkit to mock the behavior. + * This class should be put under OSD core, + */ +export class MockResponseToolkit implements ResponseToolkit { + abandon: symbol = Symbol('abandon'); + close: symbol = Symbol('close'); + context: unknown; + continue: symbol = Symbol('continue'); + realm: ServerRealm = { + modifiers: { + route: { + prefix: '', + vhost: '', + }, + }, + parent: null, + plugin: '', + pluginOptions: {}, + plugins: [], + settings: { + files: { + relativeTo: '', + }, + bind: {}, + }, + }; + request: Readonly = httpServerMock.createRawRequest(); + authenticated(): Auth { + throw new Error('Method not implemented.'); + } + entity( + options?: + | { etag?: string | undefined; modified?: string | undefined; vary?: boolean | undefined } + | undefined + ): ResponseObject | undefined { + throw new Error('Method not implemented.'); + } + redirect(uri?: string | undefined): ResponseObject { + throw new Error('Method not implemented.'); + } + state( + name: string, + value: string | object, + options?: ServerStateCookieOptions | undefined + ): void { + throw new Error('Method not implemented.'); + } + unauthenticated(error: Error, data?: AuthenticationData | undefined): void { + throw new Error('Method not implemented.'); + } + unstate(name: string, options?: ServerStateCookieOptions | undefined): void { + throw new Error('Method not implemented.'); + } + file(path: string, options?: ReplyFileHandlerOptions | undefined): ResponseObject { + throw new Error('Method not implemented.'); + } + proxy(options: ProxyHandlerOptions): Promise { + throw new Error('Method not implemented.'); + } + response(payload: unknown) { + return new Response(payload); + } +} + +const enhanceWithContext = (otherContext?: object) => (fn: (...args: unknown[]) => unknown) => ( + req: OpenSearchDashboardsRequest, + res: OpenSearchDashboardsResponseFactory +) => { + const context = new CoreRouteHandlerContext(coreMock.createInternalStart(), req); + return fn.call( + null, + { + core: context, + ...otherContext, + }, + req, + res + ); +}; + +const triggerHandler = async ( + router: Router, + options: { + method: string; + path: string; + req: Request; + } +) => { + const allRoutes = router.getRoutes(); + const findRoute = allRoutes.find( + (item) => item.method === options.method && item.path === options.path + ); + return await findRoute?.handler(options.req, new MockResponseToolkit()); +}; + +export { enhanceWithContext, triggerHandler }; diff --git a/server/routes/send_message.test.ts b/server/routes/send_message.test.ts new file mode 100644 index 00000000..75554a58 --- /dev/null +++ b/server/routes/send_message.test.ts @@ -0,0 +1,208 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ResponseObject } from '@hapi/hapi'; +import { Boom } from '@hapi/boom'; +import { Router } from '../../../../src/core/server/http/router'; +import { enhanceWithContext, triggerHandler } from './router.mock'; +import { mockOllyChatService } from '../services/chat/olly_chat_service.mock'; +import { mockAgentFrameworkStorageService } from '../services/storage/agent_framework_storage_service.mock'; +import { httpServerMock } from '../../../../src/core/server/http/http_server.mocks'; +import { loggerMock } from '../../../../src/core/server/logging/logger.mock'; +import { registerChatRoutes, LLMRequestSchema } from './chat_routes'; +import { ASSISTANT_API } from '../../common/constants/llm'; + +const mockedLogger = loggerMock.create(); + +const router = new Router( + '', + mockedLogger, + enhanceWithContext({ + assistant_plugin: { + logger: mockedLogger, + }, + }) +); +registerChatRoutes(router, { + messageParsers: [], +}); + +describe('send_message route', () => { + const sendMessageRequest = (payload: LLMRequestSchema) => + triggerHandler(router, { + method: 'post', + path: ASSISTANT_API.SEND_MESSAGE, + req: httpServerMock.createRawRequest({ + payload: JSON.stringify(payload), + }), + }); + beforeEach(() => { + loggerMock.clear(mockedLogger); + }); + it('return back successfully when requestLLM returns momery back', async () => { + mockOllyChatService.requestLLM.mockImplementationOnce(async () => { + return { + messages: [], + memoryId: 'foo', + }; + }); + mockAgentFrameworkStorageService.getSession.mockImplementationOnce(async () => { + return { + messages: [], + title: 'foo', + interactions: [], + createdTimeMs: 0, + updatedTimeMs: 0, + }; + }); + const result = (await sendMessageRequest({ + rootAgentId: 'foo', + input: { + content: '1', + contentType: 'text', + type: 'input', + context: {}, + }, + })) as ResponseObject; + expect(result.source).toMatchInlineSnapshot(` + Object { + "interactions": Array [], + "messages": Array [], + "sessionId": "foo", + "title": "foo", + } + `); + }); + + it('return 500 when requestLLM throws an error and no conversation id provided', async () => { + mockOllyChatService.requestLLM.mockImplementationOnce(() => { + throw new Error('something went wrong'); + }); + const result = (await sendMessageRequest({ + rootAgentId: 'foo', + input: { + content: '1', + contentType: 'text', + type: 'input', + context: {}, + }, + })) as Boom; + expect(mockedLogger.error).toBeCalledTimes(1); + expect(result.output).toMatchInlineSnapshot(` + Object { + "headers": Object {}, + "payload": Object { + "error": "Internal Server Error", + "message": "something went wrong", + "statusCode": 500, + }, + "statusCode": 500, + } + `); + }); + + it('return 500 when requestLLM return without memoryId and no conversation id provided', async () => { + mockOllyChatService.requestLLM.mockImplementationOnce(async () => { + return { + messages: [], + memoryId: '', + }; + }); + const result = (await sendMessageRequest({ + rootAgentId: 'foo', + input: { + content: '1', + contentType: 'text', + type: 'input', + context: {}, + }, + })) as Boom; + expect(mockedLogger.error).toBeCalledTimes(1); + expect(result.output).toMatchInlineSnapshot(` + Object { + "headers": Object {}, + "payload": Object { + "error": "Internal Server Error", + "message": "Not a valid conversation", + "statusCode": 500, + }, + "statusCode": 500, + } + `); + }); + + it('return successfully when requestLLM throws an error but conversation id provided', async () => { + mockOllyChatService.requestLLM.mockImplementationOnce(() => { + throw new Error('something went wrong'); + }); + mockAgentFrameworkStorageService.getSession.mockImplementationOnce(async () => { + return { + messages: [], + title: 'foo', + interactions: [], + createdTimeMs: 0, + updatedTimeMs: 0, + }; + }); + const result = (await sendMessageRequest({ + rootAgentId: 'foo', + input: { + content: '1', + contentType: 'text', + type: 'input', + context: { + appId: '', + }, + }, + sessionId: 'foo', + })) as ResponseObject; + expect(mockedLogger.error).toBeCalledWith(new Error('something went wrong')); + expect(result.source).toMatchInlineSnapshot(` + Object { + "interactions": Array [], + "messages": Array [], + "sessionId": "foo", + "title": "foo", + } + `); + }); + + it('return 500 when get session throws an error', async () => { + mockOllyChatService.requestLLM.mockImplementationOnce(async () => { + return { + messages: [], + memoryId: 'foo', + }; + }); + mockAgentFrameworkStorageService.getSession.mockImplementationOnce(() => { + throw new Error('foo'); + }); + const result = (await sendMessageRequest({ + rootAgentId: 'foo', + input: { + content: '1', + contentType: 'text', + type: 'input', + context: { + appId: '', + }, + }, + sessionId: 'foo', + })) as Boom; + expect(mockedLogger.error).toBeCalledTimes(1); + expect(mockedLogger.error).toBeCalledWith(new Error('foo')); + expect(result.output).toMatchInlineSnapshot(` + Object { + "headers": Object {}, + "payload": Object { + "error": "Internal Server Error", + "message": "foo", + "statusCode": 500, + }, + "statusCode": 500, + } + `); + }); +}); diff --git a/server/services/chat/chat_service.ts b/server/services/chat/chat_service.ts new file mode 100644 index 00000000..25fe703f --- /dev/null +++ b/server/services/chat/chat_service.ts @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenSearchDashboardsRequest, RequestHandlerContext } from '../../../../../src/core/server'; +import { IMessage, IInput } from '../../../common/types/chat_saved_object_attributes'; +import { LLMRequestSchema } from '../../routes/chat_routes'; + +export interface ChatService { + requestLLM( + payload: { messages: IMessage[]; input: IInput; sessionId?: string }, + context: RequestHandlerContext + ): Promise<{ + messages: IMessage[]; + memoryId: string; + }>; + + regenerate( + payload: { sessionId: string; interactionId: string; rootAgentId: string }, + context: RequestHandlerContext + ): Promise<{ + messages: IMessage[]; + memoryId: string; + }>; + + abortAgentExecution(sessionId: string): void; +} diff --git a/server/services/chat/olly_chat_service.mock.ts b/server/services/chat/olly_chat_service.mock.ts new file mode 100644 index 00000000..c4b91df3 --- /dev/null +++ b/server/services/chat/olly_chat_service.mock.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OllyChatService } from './olly_chat_service'; + +const mockOllyChatService: jest.Mocked = { + requestLLM: jest.fn(), + abortAgentExecution: jest.fn(), +}; + +jest.mock('./olly_chat_service', () => { + return { + OllyChatService: () => mockOllyChatService, + }; +}); + +export { mockOllyChatService }; diff --git a/server/services/chat/olly_chat_service.test.ts b/server/services/chat/olly_chat_service.test.ts new file mode 100644 index 00000000..1d5f563d --- /dev/null +++ b/server/services/chat/olly_chat_service.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OllyChatService } from './olly_chat_service'; +import { CoreRouteHandlerContext } from '../../../../../src/core/server/core_route_handler_context'; +import { coreMock, httpServerMock } from '../../../../../src/core/server/mocks'; +import { loggerMock } from '../../../../../src/core/server/logging/logger.mock'; + +describe('OllyChatService', () => { + const ollyChatService = new OllyChatService(); + const coreContext = new CoreRouteHandlerContext( + coreMock.createInternalStart(), + httpServerMock.createOpenSearchDashboardsRequest() + ); + const mockedTransport = coreContext.opensearch.client.asCurrentUser.transport + .request as jest.Mock; + const contextMock = { + core: coreContext, + assistant_plugin: { + logger: loggerMock.create(), + }, + }; + beforeEach(() => { + mockedTransport.mockClear(); + }); + it('requestLLM should invoke client call with correct params', async () => { + mockedTransport.mockImplementationOnce(() => { + return { + body: { + inference_results: [ + { + output: [ + { + name: 'memory_id', + result: 'foo', + }, + ], + }, + ], + }, + }; + }); + const result = await ollyChatService.requestLLM( + { + messages: [], + input: { + type: 'input', + contentType: 'text', + content: 'content', + }, + sessionId: '', + rootAgentId: 'rootAgentId', + }, + contextMock + ); + expect(mockedTransport.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "body": Object { + "parameters": Object { + "question": "content", + "verbose": true, + }, + }, + "method": "POST", + "path": "/_plugins/_ml/agents/rootAgentId/_execute", + }, + Object { + "maxRetries": 0, + "requestTimeout": 300000, + }, + ], + ] + `); + expect(result).toMatchInlineSnapshot(` + Object { + "memoryId": "foo", + "messages": Array [], + } + `); + }); + + it('requestLLM should throw error when transport.request throws error', async () => { + mockedTransport.mockImplementationOnce(() => { + throw new Error('error'); + }); + expect( + ollyChatService.requestLLM( + { + messages: [], + input: { + type: 'input', + contentType: 'text', + content: 'content', + }, + sessionId: '', + rootAgentId: 'rootAgentId', + }, + contextMock + ) + ).rejects.toMatchInlineSnapshot(`[Error: error]`); + }); + + it('regenerate should invoke client call with correct params', async () => { + mockedTransport.mockImplementationOnce(() => { + return { + body: { + inference_results: [ + { + output: [ + { + name: 'memory_id', + result: 'foo', + }, + ], + }, + ], + }, + }; + }); + const result = await ollyChatService.regenerate( + { + sessionId: 'sessionId', + rootAgentId: 'rootAgentId', + interactionId: 'interactionId', + }, + contextMock + ); + expect(mockedTransport.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "body": Object { + "parameters": Object { + "memory_id": "sessionId", + "regenerate_interaction_id": "interactionId", + "verbose": true, + }, + }, + "method": "POST", + "path": "/_plugins/_ml/agents/rootAgentId/_execute", + }, + Object { + "maxRetries": 0, + "requestTimeout": 300000, + }, + ], + ] + `); + expect(result).toMatchInlineSnapshot(` + Object { + "memoryId": "foo", + "messages": Array [], + } + `); + }); + + it('regenerate should throw error when transport.request throws error', async () => { + mockedTransport.mockImplementationOnce(() => { + throw new Error('error'); + }); + expect( + ollyChatService.regenerate( + { + sessionId: 'sessionId', + rootAgentId: 'rootAgentId', + interactionId: 'interactionId', + }, + contextMock + ) + ).rejects.toMatchInlineSnapshot(`[Error: error]`); + }); +}); diff --git a/server/services/chat/olly_chat_service.ts b/server/services/chat/olly_chat_service.ts new file mode 100644 index 00000000..ed2cb57b --- /dev/null +++ b/server/services/chat/olly_chat_service.ts @@ -0,0 +1,121 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApiResponse } from '@opensearch-project/opensearch'; +import { RequestHandlerContext } from '../../../../../src/core/server'; +import { IMessage, IInput } from '../../../common/types/chat_saved_object_attributes'; +import { ChatService } from './chat_service'; +import { ML_COMMONS_BASE_API } from '../../utils/constants'; + +interface AgentRunPayload { + question?: string; + verbose?: boolean; + memory_id?: string; + regenerate_interaction_id?: string; +} + +const MEMORY_ID_FIELD = 'memory_id'; + +export class OllyChatService implements ChatService { + static abortControllers: Map = new Map(); + + private async requestAgentRun( + rootAgentId: string, + payload: AgentRunPayload, + context: RequestHandlerContext + ) { + if (payload.memory_id) { + OllyChatService.abortControllers.set(payload.memory_id, new AbortController()); + } + const opensearchClient = context.core.opensearch.client.asCurrentUser; + + try { + const agentFrameworkResponse = (await opensearchClient.transport.request( + { + method: 'POST', + path: `${ML_COMMONS_BASE_API}/agents/${rootAgentId}/_execute`, + body: { + parameters: payload, + }, + }, + { + /** + * It is time-consuming for LLM to generate final answer + * Give it a large timeout window + */ + requestTimeout: 5 * 60 * 1000, + /** + * Do not retry + */ + maxRetries: 0, + } + )) as ApiResponse<{ + inference_results: Array<{ + output: Array<{ name: string; result?: string }>; + }>; + }>; + const outputBody = agentFrameworkResponse.body.inference_results?.[0]?.output; + const memoryIdItem = outputBody?.find((item) => item.name === MEMORY_ID_FIELD); + return { + /** + * Interactions will be stored in Agent framework, + * thus we do not need to return the latest message back. + */ + messages: [], + memoryId: memoryIdItem?.result || '', + }; + } catch (error) { + throw error; + } finally { + if (payload.memory_id) { + OllyChatService.abortControllers.delete(payload.memory_id); + } + } + } + + public async requestLLM( + payload: { messages: IMessage[]; input: IInput; sessionId?: string; rootAgentId: string }, + context: RequestHandlerContext + ): Promise<{ + messages: IMessage[]; + memoryId: string; + }> { + const { input, sessionId, rootAgentId } = payload; + + const parametersPayload: Pick = { + question: input.content, + verbose: true, + }; + + if (sessionId) { + parametersPayload.memory_id = sessionId; + } + + return await this.requestAgentRun(rootAgentId, parametersPayload, context); + } + + async regenerate( + payload: { sessionId: string; interactionId: string; rootAgentId: string }, + context: RequestHandlerContext + ): Promise<{ messages: IMessage[]; memoryId: string }> { + const { sessionId, interactionId, rootAgentId } = payload; + const parametersPayload: Pick< + AgentRunPayload, + 'regenerate_interaction_id' | 'verbose' | 'memory_id' + > = { + memory_id: sessionId, + regenerate_interaction_id: interactionId, + verbose: true, + }; + + return await this.requestAgentRun(rootAgentId, parametersPayload, context); + } + + abortAgentExecution(sessionId: string) { + if (OllyChatService.abortControllers.has(sessionId)) { + OllyChatService.abortControllers.get(sessionId)?.abort(); + } + } +} diff --git a/server/services/storage/agent_framework_storage_service.mock.ts b/server/services/storage/agent_framework_storage_service.mock.ts new file mode 100644 index 00000000..f4bf5a87 --- /dev/null +++ b/server/services/storage/agent_framework_storage_service.mock.ts @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PublicContract } from '@osd/utility-types'; +import { AgentFrameworkStorageService } from './agent_framework_storage_service'; + +const mockAgentFrameworkStorageService: jest.Mocked> = { + getSession: jest.fn(), + getSessions: jest.fn(), + saveMessages: jest.fn(), + deleteSession: jest.fn(), + updateSession: jest.fn(), + getTraces: jest.fn(), + updateInteraction: jest.fn(), +}; + +jest.mock('./agent_framework_storage_service', () => { + return { + AgentFrameworkStorageService: () => mockAgentFrameworkStorageService, + }; +}); + +export { mockAgentFrameworkStorageService }; diff --git a/server/services/storage/agent_framework_storage_service.ts b/server/services/storage/agent_framework_storage_service.ts new file mode 100644 index 00000000..c7a2c4be --- /dev/null +++ b/server/services/storage/agent_framework_storage_service.ts @@ -0,0 +1,253 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TransportRequestPromise, ApiResponse } from '@opensearch-project/opensearch/lib/Transport'; +import { AgentFrameworkTrace } from '../../../common/utils/llm_chat/traces'; +import { OpenSearchClient } from '../../../../../src/core/server'; +import { + IMessage, + ISession, + ISessionFindResponse, + Interaction, +} from '../../../common/types/chat_saved_object_attributes'; +import { GetSessionsSchema } from '../../routes/chat_routes'; +import { StorageService } from './storage_service'; +import { MessageParser } from '../../types'; +import { MessageParserRunner } from '../../utils/message_parser_runner'; +import { ML_COMMONS_BASE_API } from '../../utils/constants'; + +export interface SessionOptResponse { + success: boolean; + statusCode?: number | null; + message?: string; +} + +export class AgentFrameworkStorageService implements StorageService { + constructor( + private readonly client: OpenSearchClient, + private readonly messageParsers: MessageParser[] = [] + ) {} + async getSession(sessionId: string): Promise { + const [interactionsResp, conversation] = await Promise.all([ + this.client.transport.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/memory/conversation/${sessionId}/_list?max_results=1000`, + }) as TransportRequestPromise< + ApiResponse<{ + interactions: Interaction[]; + }> + >, + this.client.transport.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/memory/conversation/${sessionId}`, + }) as TransportRequestPromise< + ApiResponse<{ + conversation_id: string; + create_time: string; + updated_time: string; + name: string; + }> + >, + ]); + const messageParserRunner = new MessageParserRunner(this.messageParsers); + const finalInteractions = interactionsResp.body.interactions; + + let finalMessages: IMessage[] = []; + for (const interaction of finalInteractions) { + finalMessages = [...finalMessages, ...(await messageParserRunner.run(interaction))]; + } + return { + title: conversation.body.name, + createdTimeMs: +new Date(conversation.body.create_time), + updatedTimeMs: +new Date(conversation.body.updated_time), + messages: finalMessages, + interactions: finalInteractions, + }; + } + + async getSessions(query: GetSessionsSchema): Promise { + let sortField = ''; + if (query.sortField === 'updatedTimeMs') { + sortField = 'updated_time'; + } else if (query.sortField === 'createTimeMs') { + sortField = 'create_time'; + } + let searchFields: string[] = []; + if (query.search && query.searchFields) { + if (typeof query.searchFields === 'string') { + searchFields = [...searchFields, query.searchFields.replace('title', 'name')]; + } else { + searchFields = query.searchFields.map((item) => item.replace('title', 'name')); + } + } + + const requestParams = { + from: (query.page - 1) * query.perPage, + size: query.perPage, + ...(searchFields.length > 0 && { + query: { + multi_match: { + query: query.search, + fields: searchFields, + }, + }, + }), + ...(searchFields.length === 0 && { + query: { + match_all: {}, + }, + }), + ...(sortField && query.sortOrder && { sort: [{ [sortField]: query.sortOrder }] }), + }; + + const sessions = await this.client.transport.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/memory/conversation/_search`, + body: requestParams, + }); + + return { + objects: sessions.body.hits.hits + .filter( + (hit: { + _source: { name: string; create_time: string; updated_time: string }; + }): hit is RequiredKey => + hit._source !== null && hit._source !== undefined + ) + .map( + (item: { + _id: string; + _source: { name: string; create_time: string; updated_time: string }; + }) => ({ + id: item._id, + title: item._source.name, + version: 1, + createdTimeMs: Date.parse(item._source.create_time), + updatedTimeMs: Date.parse(item._source.updated_time), + messages: [] as IMessage[], + }) + ), + total: + typeof sessions.body.hits.total === 'number' + ? sessions.body.hits.total + : sessions.body.hits.total.value, + }; + } + + async saveMessages( + title: string, + sessionId: string | undefined, + messages: IMessage[] + ): Promise<{ sessionId: string; messages: IMessage[] }> { + throw new Error('Method is not needed'); + } + + async deleteSession(sessionId: string): Promise { + try { + const response = await this.client.transport.request({ + method: 'DELETE', + path: `${ML_COMMONS_BASE_API}/memory/conversation/${sessionId}/_delete`, + }); + if (response.statusCode === 200) { + return { + success: true, + }; + } else { + return { + success: false, + statusCode: response.statusCode, + message: JSON.stringify(response.body), + }; + } + } catch (error) { + throw new Error('delete converstaion failed, reason:' + JSON.stringify(error.meta?.body)); + } + } + + async updateSession(sessionId: string, title: string): Promise { + try { + const response = await this.client.transport.request({ + method: 'PUT', + path: `${ML_COMMONS_BASE_API}/memory/conversation/${sessionId}/_update`, + body: { + name: title, + }, + }); + if (response.statusCode === 200) { + return { + success: true, + }; + } else { + return { + success: false, + statusCode: response.statusCode, + message: JSON.stringify(response.body), + }; + } + } catch (error) { + throw new Error('update converstaion failed, reason:' + JSON.stringify(error.meta?.body)); + } + } + + async getTraces(interactionId: string): Promise { + try { + const response = (await this.client.transport.request({ + method: 'GET', + path: `${ML_COMMONS_BASE_API}/memory/trace/${interactionId}/_list`, + })) as ApiResponse<{ + traces: Array<{ + conversation_id: string; + interaction_id: string; + create_time: string; + input: string; + response: string; + origin: string; + parent_interaction_id: string; + trace_number: number; + }>; + }>; + + return response.body.traces.map((item) => ({ + interactionId: item.interaction_id, + parentInteractionId: item.parent_interaction_id, + input: item.input, + output: item.response, + createTime: item.create_time, + origin: item.origin, + traceNumber: item.trace_number, + })); + } catch (error) { + throw new Error('get traces failed, reason:' + JSON.stringify(error.meta?.body)); + } + } + + async updateInteraction( + interactionId: string, + additionalInfo: Record> + ): Promise { + try { + const response = await this.client.transport.request({ + method: 'PUT', + path: `${ML_COMMONS_BASE_API}/memory/interaction/${interactionId}/_update`, + body: { + additional_info: additionalInfo, + }, + }); + if (response.statusCode === 200) { + return { + success: true, + }; + } else { + return { + success: false, + statusCode: response.statusCode, + message: JSON.stringify(response.body), + }; + } + } catch (error) { + throw new Error('update interaction failed, reason:' + JSON.stringify(error.meta?.body)); + } + } +} diff --git a/server/services/storage/assistant_index_storage_service.ts b/server/services/storage/assistant_index_storage_service.ts new file mode 100644 index 00000000..e164a9d0 --- /dev/null +++ b/server/services/storage/assistant_index_storage_service.ts @@ -0,0 +1,110 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenSearchClient } from '../../../../../src/core/server'; +import { LLM_INDEX } from '../../../common/constants/llm'; +import { + IMessage, + ISession, + ISessionFindResponse, +} from '../../../common/types/chat_saved_object_attributes'; +import { GetSessionsSchema } from '../../routes/chat_routes'; +import { StorageService } from './storage_service'; + +export class AssistantIndexStorageService implements StorageService { + constructor(private readonly client: OpenSearchClient) {} + async getSession(sessionId: string): Promise { + const session = await this.client.get({ + index: LLM_INDEX.SESSIONS, + id: sessionId, + }); + if (!session.body._source) throw new Error('Session not found'); + return session.body._source; + } + + async getSessions(query: GetSessionsSchema): Promise { + await this.createIndex(); + const sessions = await this.client.search({ + index: LLM_INDEX.SESSIONS, + body: { + from: (query.page - 1) * query.perPage, + size: query.perPage, + ...(query.sortField && + query.sortOrder && { sort: [{ [query.sortField]: query.sortOrder }] }), + }, + }); + + return { + objects: sessions.body.hits.hits + .filter( + (hit): hit is RequiredKey => + hit._source !== null && hit._source !== undefined + ) + .map((session) => ({ ...session._source, id: session._id })), + total: + typeof sessions.body.hits.total === 'number' + ? sessions.body.hits.total + : sessions.body.hits.total.value, + }; + } + + async saveMessages( + title: string, + sessionId: string | undefined, + messages: IMessage[] + ): Promise<{ sessionId: string; messages: IMessage[] }> { + await this.createIndex(); + const timestamp = new Date().getTime(); + if (!sessionId) { + const createResponse = await this.client.index({ + index: LLM_INDEX.SESSIONS, + body: { + title, + version: 1, + createdTimeMs: timestamp, + updatedTimeMs: timestamp, + messages, + }, + }); + return { sessionId: createResponse.body._id, messages }; + } + const updateResponse = await this.client.update>({ + index: LLM_INDEX.SESSIONS, + id: sessionId, + body: { + doc: { + messages, + updatedTimeMs: timestamp, + }, + }, + }); + return { sessionId, messages }; + } + + private async createIndex() { + const existsResponse = await this.client.indices.exists({ index: LLM_INDEX.SESSIONS }); + if (!existsResponse.body) { + return this.client.indices.create({ + index: LLM_INDEX.SESSIONS, + body: { + settings: { + index: { + number_of_shards: '1', + auto_expand_replicas: '0-2', + mapping: { ignore_malformed: true }, + }, + }, + mappings: { + properties: { + title: { type: 'keyword' }, + createdTimeMs: { type: 'date' }, + updatedTimeMs: { type: 'date' }, + }, + }, + }, + }); + } + } +} diff --git a/server/services/storage/saved_objects_storage_service.ts b/server/services/storage/saved_objects_storage_service.ts new file mode 100644 index 00000000..f85bba48 --- /dev/null +++ b/server/services/storage/saved_objects_storage_service.ts @@ -0,0 +1,94 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MessageParser } from '../../types'; +import { SavedObjectsClientContract } from '../../../../../src/core/server'; +import { + CHAT_SAVED_OBJECT, + IMessage, + ISession, + ISessionFindResponse, + SAVED_OBJECT_VERSION, +} from '../../../common/types/chat_saved_object_attributes'; +import { GetSessionsSchema } from '../../routes/chat_routes'; +import { StorageService } from './storage_service'; + +export class SavedObjectsStorageService implements StorageService { + constructor( + private readonly client: SavedObjectsClientContract, + private readonly messageParsers: MessageParser[] + ) {} + + private convertUpdatedTimeField(updatedAt: string | undefined) { + return updatedAt ? new Date(updatedAt).getTime() : undefined; + } + + async getSession(sessionId: string): Promise { + const session = await this.client.get(CHAT_SAVED_OBJECT, sessionId); + if (session.error) throw session.error; + return { + ...session.attributes, + ...(session.updated_at && { + updatedTimeMs: this.convertUpdatedTimeField(session.updated_at), + }), + }; + } + + async getSessions(query: GetSessionsSchema): Promise { + const sessions = await this.client.find({ + ...query, + // saved objects by default provides updated_at field + ...(query.sortField === 'updatedTimeMs' && { sortField: 'updated_at' }), + type: CHAT_SAVED_OBJECT, + searchFields: + typeof query.searchFields === 'string' ? [query.searchFields] : query.searchFields, + }); + return { + objects: sessions.saved_objects.map((session) => ({ + ...session.attributes, + ...(session.updated_at && { + updatedTimeMs: this.convertUpdatedTimeField(session.updated_at), + }), + id: session.id, + })), + total: sessions.total, + }; + } + + public async saveMessages( + title: string, + sessionId: string | undefined, + messages: IMessage[] + ): Promise<{ sessionId: string; messages: IMessage[] }> { + if (!sessionId) { + const createResponse = await this.client.create>( + CHAT_SAVED_OBJECT, + { + title, + version: SAVED_OBJECT_VERSION, + createdTimeMs: new Date().getTime(), + messages, + } + ); + return { sessionId: createResponse.id, messages: createResponse.attributes.messages }; + } + const updateResponse = await this.client.update>( + CHAT_SAVED_OBJECT, + sessionId, + { messages } + ); + return { sessionId, messages: updateResponse.attributes.messages! }; + } + + deleteSession(sessionId: string) { + return this.client.delete(CHAT_SAVED_OBJECT, sessionId); + } + + updateSession(sessionId: string, title: string) { + return this.client.update(CHAT_SAVED_OBJECT, sessionId, { + title, + }); + } +} diff --git a/server/services/storage/storage_service.ts b/server/services/storage/storage_service.ts new file mode 100644 index 00000000..0fe27df6 --- /dev/null +++ b/server/services/storage/storage_service.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IMessage, + ISession, + ISessionFindResponse, +} from '../../../common/types/chat_saved_object_attributes'; +import { GetSessionsSchema } from '../../routes/chat_routes'; + +export interface StorageService { + getSession(sessionId: string): Promise; + getSessions(query: GetSessionsSchema): Promise; + saveMessages( + title: string, + sessionId: string | undefined, + messages: IMessage[] + ): Promise<{ sessionId: string; messages: IMessage[] }>; + deleteSession(sessionId: string): Promise<{}>; + updateSession(sessionId: string, title: string): Promise<{}>; +} diff --git a/server/types.ts b/server/types.ts new file mode 100644 index 00000000..3e93ce8f --- /dev/null +++ b/server/types.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IMessage, Interaction } from '../common/types/chat_saved_object_attributes'; +import { Logger } from '../../../src/core/server'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AssistantPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AssistantPluginStart {} + +export interface MessageParser { + /** + * The id of the parser, should be unique among the parsers. + */ + id: string; + /** + * Order field declares the order message parser will be execute. + * parser with order 2 will be executed after parser with order 1. + * If not specified, the default order will be 999. + * @default 999 + */ + order?: number; + /** + * parserProvider is the callback that will be triggered in each message + */ + parserProvider: (interaction: Interaction) => Promise; +} + +export interface RoutesOptions { + messageParsers: MessageParser[]; +} + +declare module '../../../src/core/server' { + interface RequestHandlerContext { + assistant_plugin: { + logger: Logger; + }; + } +} diff --git a/server/utils/constants.ts b/server/utils/constants.ts new file mode 100644 index 00000000..3b442fb8 --- /dev/null +++ b/server/utils/constants.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const ML_COMMONS_BASE_API = '/_plugins/_ml'; diff --git a/server/utils/csv-parser-helper.test.ts b/server/utils/csv-parser-helper.test.ts new file mode 100644 index 00000000..6671ae39 --- /dev/null +++ b/server/utils/csv-parser-helper.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getJsonFromString } from './csv-parser-helper'; + +describe('getJsonFromString', () => { + it('return correct answer', async () => { + expect(await getJsonFromString('title,id\n1,2')).toEqual([ + { + title: '1', + id: '2', + }, + ]); + }); + + it('return empty array when string is not in correct format', async () => { + expect(await getJsonFromString('1,2')).toEqual([]); + }); +}); diff --git a/server/utils/csv-parser-helper.ts b/server/utils/csv-parser-helper.ts new file mode 100644 index 00000000..690d4aee --- /dev/null +++ b/server/utils/csv-parser-helper.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Readable } from 'stream'; +import csvParser from 'csv-parser'; + +export const getJsonFromString = ( + csvString: string, + options?: csvParser.Options +): Promise> | string[][]> => { + const results: string[][] | Array> = []; + return new Promise((resolve, reject) => { + Readable.from(csvString) + .pipe(csvParser(options)) + .on('data', (data) => results.push(data)) + .on('end', () => { + resolve(results); + }) + .on('error', (err) => { + reject(err); + }); + }); +}; diff --git a/server/utils/message_parser_runner.test.ts b/server/utils/message_parser_runner.test.ts new file mode 100644 index 00000000..69f7f9d8 --- /dev/null +++ b/server/utils/message_parser_runner.test.ts @@ -0,0 +1,165 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MessageParserRunner } from './message_parser_runner'; + +describe('MessageParserRunner', () => { + it('run with correct result', async () => { + const messageParserRunner = new MessageParserRunner([ + { + id: 'test', + parserProvider(interaction) { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: interaction.response, + }, + ]); + }, + }, + ]); + + expect( + await messageParserRunner.run({ + response: 'output', + input: 'input', + conversation_id: '', + interaction_id: '', + create_time: '', + additional_info: {}, + parent_interaction_id: '', + }) + ).toEqual([ + { + type: 'output', + contentType: 'markdown', + content: 'output', + }, + ]); + }); + + it('run with correct result when different order is present', async () => { + const messageParserRunner = new MessageParserRunner([ + { + id: 'testA', + order: 2, + parserProvider() { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: 'A', + }, + ]); + }, + }, + { + id: 'testOrder1000', + order: 1000, + parserProvider() { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: 'order1000', + }, + ]); + }, + }, + { + id: 'testNoOrder', + parserProvider(interaction) { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: 'NoOrder', + }, + ]); + }, + }, + { + id: 'testB', + order: 1, + parserProvider() { + return Promise.resolve([ + { + type: 'output', + contentType: 'markdown', + content: 'B', + }, + ]); + }, + }, + ]); + + expect( + await messageParserRunner.run({ + response: 'output', + input: 'input', + conversation_id: '', + interaction_id: '', + create_time: '', + additional_info: {}, + parent_interaction_id: '', + }) + ).toEqual([ + { + type: 'output', + contentType: 'markdown', + content: 'B', + }, + { + type: 'output', + contentType: 'markdown', + content: 'A', + }, + { + type: 'output', + contentType: 'markdown', + content: 'NoOrder', + }, + { + type: 'output', + contentType: 'markdown', + content: 'order1000', + }, + ]); + }); + + it('Do not append messages that are throwed with error or not an array', async () => { + const messageParserRunner = new MessageParserRunner([ + { + id: 'test_with_error', + parserProvider() { + throw new Error('error'); + }, + }, + { + id: 'test_with_incorrect_format_of_return', + parserProvider() { + return Promise.resolve({ + type: 'output', + contentType: 'markdown', + content: 'order1000', + }); + }, + }, + ]); + + expect( + await messageParserRunner.run({ + response: 'output', + input: 'input', + conversation_id: '', + interaction_id: '', + create_time: '', + additional_info: {}, + parent_interaction_id: '', + }) + ).toEqual([]); + }); +}); diff --git a/server/utils/message_parser_runner.ts b/server/utils/message_parser_runner.ts new file mode 100644 index 00000000..60534247 --- /dev/null +++ b/server/utils/message_parser_runner.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IMessage, Interaction } from '../../common/types/chat_saved_object_attributes'; +import { MessageParser } from '../types'; + +export class MessageParserRunner { + constructor(private readonly messageParsers: MessageParser[]) {} + async run(interaction: Interaction): Promise { + const sortedParsers = [...this.messageParsers]; + sortedParsers.sort((parserA, parserB) => { + const { order: orderA = 999 } = parserA; + const { order: orderB = 999 } = parserB; + return orderA - orderB; + }); + let results: IMessage[] = []; + for (const messageParser of sortedParsers) { + let tempResult: IMessage[] = []; + try { + tempResult = await messageParser.parserProvider(interaction); + /** + * Make sure the tempResult is an array. + */ + if (!Array.isArray(tempResult)) { + tempResult = []; + } + } catch (e) { + tempResult = []; + } + results = [...results, ...tempResult]; + } + return results; + } +} diff --git a/test/__mocks__/fileMock.js b/test/__mocks__/fileMock.js new file mode 100644 index 00000000..cac247b7 --- /dev/null +++ b/test/__mocks__/fileMock.js @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +module.exports = 'file-stub'; diff --git a/test/__mocks__/styleMock.js b/test/__mocks__/styleMock.js new file mode 100644 index 00000000..28de3c8b --- /dev/null +++ b/test/__mocks__/styleMock.js @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +module.exports = {}; diff --git a/test/jest.config.js b/test/jest.config.js new file mode 100644 index 00000000..f5a1419f --- /dev/null +++ b/test/jest.config.js @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +process.env.TZ = 'UTC'; + +module.exports = { + rootDir: '../', + setupFiles: ['/test/setupTests.ts'], + setupFilesAfterEnv: ['/test/setup.jest.ts'], + roots: [''], + testMatch: ['**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx'], + clearMocks: true, + modulePathIgnorePatterns: ['/offline-module-cache/'], + testPathIgnorePatterns: ['/build/', '/node_modules/', '/__utils__/'], + snapshotSerializers: ['enzyme-to-json/serializer'], + coveragePathIgnorePatterns: [ + '/build/', + '/node_modules/', + '/test/', + '/public/requests/', + '/__utils__/', + ], + moduleNameMapper: { + '\\.(css|less|sass|scss)$': '/test/__mocks__/styleMock.js', + '\\.(gif|ttf|eot|svg|png)$': '/test/__mocks__/fileMock.js', + '^!!raw-loader!.*': 'jest-raw-loader', + }, + testEnvironment: 'jsdom', +}; diff --git a/test/setup.jest.ts b/test/setup.jest.ts new file mode 100644 index 00000000..9d195032 --- /dev/null +++ b/test/setup.jest.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure } from '@testing-library/react'; +import { TextDecoder, TextEncoder } from 'util'; +import '@testing-library/jest-dom'; + +configure({ testIdAttribute: 'data-test-subj' }); + +// https://github.com/inrupt/solid-client-authn-js/issues/1676#issuecomment-917016646 +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder as typeof global.TextDecoder; + +window.URL.createObjectURL = () => ''; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +HTMLCanvasElement.prototype.getContext = () => '' as any; +Element.prototype.scrollIntoView = jest.fn(); +window.IntersectionObserver = (class IntersectionObserver { + constructor() {} + + disconnect() { + return null; + } + + observe() { + return null; + } + + takeRecords() { + return null; + } + + unobserve() { + return null; + } +} as unknown) as typeof window.IntersectionObserver; + +jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'random-id'); + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => { + return () => 'random_html_id'; + }, +})); + +jest.setTimeout(30000); diff --git a/test/setupTests.ts b/test/setupTests.ts new file mode 100644 index 00000000..5a996f6f --- /dev/null +++ b/test/setupTests.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +require('babel-polyfill'); +require('core-js/stable'); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..4df21bf9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + // extend OpenSearch Dashboards's tsconfig, or use your own settings + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react", + "allowJs": true, + "baseUrl": ".", + "target": "esnext", + "module": "commonjs", + "outDir": "./target", + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowUnusedLabels": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "useUnknownInCatchVariables": false, + "alwaysStrict": false, + "noImplicitUseStrict": false, + "types": ["jest", "node"] + }, + "include": [ + "test/**/*", + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "common/**/*.ts", + "../../typings/**/*" + ], + "exclude": ["node_modules", "*/node_modules/"] +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..2d7dbacf --- /dev/null +++ b/yarn.lock @@ -0,0 +1,1732 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0": + version "7.22.13" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e" + integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w== + dependencies: + "@babel/highlight" "^7.22.13" + chalk "^2.4.2" + +"@babel/helper-validator-identifier@^7.22.5": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.15.tgz#601fa28e4cc06786c18912dca138cec73b882044" + integrity sha512-4E/F9IIEi8WR94324mbDUMo074YTheJmd7eZF5vITTeYchqAi6sYXRLHUVsmkdmY4QjfKTcB2jB7dVP3NaBElQ== + +"@babel/highlight@^7.22.13": + version "7.22.13" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.13.tgz#9cda839e5d3be9ca9e8c26b6dd69e7548f0cbf16" + integrity sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ== + dependencies: + "@babel/helper-validator-identifier" "^7.22.5" + chalk "^2.4.2" + js-tokens "^4.0.0" + +"@danieldietrich/copy@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@danieldietrich/copy/-/copy-0.4.2.tgz#c1cabfa499d8b473ba95413c446c1c1efae64d24" + integrity sha512-ZVNZIrgb2KeomfNahP77rL445ho6aQj0HHqU6hNlQ61o4rhvca+NS+ePj0d82zQDq2UPk1mjVZBTXgP+ErsDgw== + +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + +"@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== + +"@types/autosize@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/autosize/-/autosize-4.0.1.tgz#999a7c305b96766248044ebaac1a0299961f3b61" + integrity sha512-iPJT/FCaSO79G6j+9n6gmFc5nhxZ1gDrA2UAvb5FslJ6FJQZnDfbBU0qp5vpp0Cbjj7+gOyjuWZ7RrXvRuETaA== + +"@types/cheerio@*": + version "0.22.32" + resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.32.tgz#1a41608fd072544c3ed6c0d6cc3fcf08d07715de" + integrity sha512-4RrpCp5ufWTLb6/1RCOjazRhUM6DTD79l763det29n8kLmPB7XeN46cxlUf2GsSF+0g6CbWT5nYl8C/Gs15bdg== + dependencies: + "@types/node" "*" + +"@types/dompurify@^2.3.3": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.4.0.tgz#fd9706392a88e0e0e6d367f3588482d817df0ab9" + integrity sha512-IDBwO5IZhrKvHFUl+clZxgf3hn2b/lU6H1KaBShPkQyGJUQ0xwebezIPSuiyGwfz1UzJWQl4M7BDxtHtCCPlTg== + dependencies: + "@types/trusted-types" "*" + +"@types/enzyme-adapter-react-16@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.6.tgz#8aca7ae2fd6c7137d869b6616e696d21bb8b0cec" + integrity sha512-VonDkZ15jzqDWL8mPFIQnnLtjwebuL9YnDkqeCDYnB4IVgwUm0mwKkqhrxLL6mb05xm7qqa3IE95m8CZE9imCg== + dependencies: + "@types/enzyme" "*" + +"@types/enzyme@*": + version "3.10.13" + resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.10.13.tgz#332c0ed59b01f7b1c398c532a1c21a5feefabeb1" + integrity sha512-FCtoUhmFsud0Yx9fmZk179GkdZ4U9B0GFte64/Md+W/agx0L5SxsIIbhLBOxIb9y2UfBA4WQnaG1Od/UsUQs9Q== + dependencies: + "@types/cheerio" "*" + "@types/react" "^16" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" + integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== + +"@types/istanbul-lib-report@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" + integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" + integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/jsdom@^21.1.2": + version "21.1.2" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.2.tgz#d04db019ad62174d28c63c927761f2f196825f04" + integrity sha512-bGj+7TaCkOwkJfx7HtS9p22Ij0A2aKMuz8a1+owpkxa1wU/HUBy/WAXhdv90uDdVI9rSjGvUrXmLSeA9VP3JeA== + dependencies: + "@types/node" "*" + "@types/tough-cookie" "*" + parse5 "^7.0.0" + +"@types/node@*": + version "20.6.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.0.tgz#9d7daa855d33d4efec8aea88cd66db1c2f0ebe16" + integrity sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg== + +"@types/prop-types@*": + version "15.7.5" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" + integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== + +"@types/react-test-renderer@^16.9.1": + version "16.9.6" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.9.6.tgz#cacda4066a869f562c99a2158fda4609a971a231" + integrity sha512-EZbtXp2xiuxIYJuyzXnG+5rIK34oGmmcW95FG/x3yN+p0j+jgC947MjpgFuGwYzcLZVymmvXlOADaEtUOiP6GA== + dependencies: + "@types/react" "^16" + +"@types/react@^16": + version "16.14.46" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.46.tgz#42ac91aece416176e6b6127cd9ec9e381ea67e16" + integrity sha512-Am4pyXMrr6cWWw/TN3oqHtEZl0j+G6Up/O8m65+xF/3ZaUgkv1GAtTPWw4yNRmH0HJXmur6xKCKoMo3rBGynuw== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.3" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" + integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== + +"@types/tough-cookie@*": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.3.tgz#3d06b6769518450871fbc40770b7586334bdfd90" + integrity sha512-THo502dA5PzG/sfQH+42Lw3fvmYkceefOspdCwpHRul8ik2Jv1K8I5OZz1AT3/rs46kwgMCe9bSBmDLYkkOMGg== + +"@types/trusted-types@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65" + integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ== + +"@types/yargs-parser@*": + version "21.0.0" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" + integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== + +"@types/yargs@^17.0.8": + version "17.0.24" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" + integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== + dependencies: + "@types/yargs-parser" "*" + +abab@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + +acorn-jsx@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^7.1.1: + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +ajv@^6.10.0, ajv@^6.10.2: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-escapes@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-5.0.0.tgz#b6a0caf0eef0c41af190e9a749e0c00ec04bb2a6" + integrity sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA== + dependencies: + type-fest "^1.0.2" + +ansi-regex@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" + integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.0.0, ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" + integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +autosize@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/autosize/-/autosize-6.0.1.tgz#64ee78dd7029be959eddd3afbbd33235b957e10f" + integrity sha512-f86EjiUKE6Xvczc4ioP1JBlWG7FKrE13qe/DxBCpe8GCipCq2nFw73aO8QEBKHfSbYGDN5eB9jXWKen7tspDqQ== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +bs-logger@0.x: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +chalk@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" + integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== + +chalk@^2.1.0, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + +ci-info@^3.2.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" + integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-4.0.0.tgz#3cecfe3734bf4fe02a8361cbdc0f6fe28c6a57ea" + integrity sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== + dependencies: + restore-cursor "^4.0.0" + +cli-truncate@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-3.1.0.tgz#3f23ab12535e3d73e839bb43e73c9de487db1389" + integrity sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA== + dependencies: + slice-ansi "^5.0.0" + string-width "^5.0.0" + +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colorette@^2.0.20: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" + integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +cssstyle@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a" + integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg== + dependencies: + rrweb-cssom "^0.6.0" + +csstype@^3.0.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" + integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== + +csv-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/csv-parser/-/csv-parser-3.0.0.tgz#b88a6256d79e090a97a1b56451f9327b01d710e7" + integrity sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ== + dependencies: + minimist "^1.2.0" + +data-urls@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4" + integrity sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g== + dependencies: + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.0" + +debug@4, debug@4.3.4, debug@^4.0.1: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + +deep-is@~0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== + dependencies: + webidl-conversions "^7.0.0" + +dompurify@^2.4.1: + version "2.4.7" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.7.tgz#277adeb40a2c84be2d42a8bcd45f582bfa4d0cfc" + integrity sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +eslint-scope@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +eslint-utils@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" + integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== + dependencies: + eslint-visitor-keys "^1.1.0" + +eslint-visitor-keys@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" + integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== + +eslint@^6.8.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb" + integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig== + dependencies: + "@babel/code-frame" "^7.0.0" + ajv "^6.10.0" + chalk "^2.1.0" + cross-spawn "^6.0.5" + debug "^4.0.1" + doctrine "^3.0.0" + eslint-scope "^5.0.0" + eslint-utils "^1.4.3" + eslint-visitor-keys "^1.1.0" + espree "^6.1.2" + esquery "^1.0.1" + esutils "^2.0.2" + file-entry-cache "^5.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.0.0" + globals "^12.1.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + inquirer "^7.0.0" + is-glob "^4.0.0" + js-yaml "^3.13.1" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.3.0" + lodash "^4.17.14" + minimatch "^3.0.4" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + optionator "^0.8.3" + progress "^2.0.0" + regexpp "^2.0.1" + semver "^6.1.2" + strip-ansi "^5.2.0" + strip-json-comments "^3.0.1" + table "^5.2.3" + text-table "^0.2.0" + v8-compile-cache "^2.0.3" + +espree@^6.1.2: + version "6.2.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" + integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw== + dependencies: + acorn "^7.1.1" + acorn-jsx "^5.2.0" + eslint-visitor-keys "^1.1.0" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.0.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + +execa@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-7.2.0.tgz#657e75ba984f42a70f38928cedc87d6f2d4fe4e9" + integrity sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.1" + human-signals "^4.3.0" + is-stream "^3.0.0" + merge-stream "^2.0.0" + npm-run-path "^5.1.0" + onetime "^6.0.0" + signal-exit "^3.0.7" + strip-final-newline "^3.0.0" + +external-editor@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" + integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +fast-deep-equal@^3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +figures@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + +file-entry-cache@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" + integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== + dependencies: + flat-cache "^2.0.1" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +flat-cache@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" + integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== + dependencies: + flatted "^2.0.0" + rimraf "2.6.3" + write "1.0.3" + +flatted@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" + integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== + +get-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +glob-parent@^5.0.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^7.1.3, glob@^7.1.7: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^12.1.0: + version "12.4.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-12.4.0.tgz#a18813576a41b00a24a97e7f815918c2e19925f8" + integrity sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg== + dependencies: + type-fest "^0.8.1" + +graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== + dependencies: + whatwg-encoding "^2.0.0" + +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +human-signals@^4.3.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2" + integrity sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ== + +husky@^8.0.0: + version "8.0.3" + resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" + integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@^0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== + +import-fresh@^3.0.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inquirer@^7.0.0: + version "7.3.3" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" + integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.19" + mute-stream "0.0.8" + run-async "^2.4.0" + rxjs "^6.6.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== + +is-glob@^4.0.0, is-glob@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + +is-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" + integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jest-dom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jest-dom/-/jest-dom-4.0.0.tgz#94eba3cbc6576e7bd6821867c92d176de28920eb" + integrity sha512-gBxYZlZB1Jgvf2gP2pRfjjUWF8woGBHj/g5rAQgFPB/0K2atGuhVcPO+BItyjWeKg9zM+dokgcMOH01vrWVMFA== + +jest-util@^29.0.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsdom@^22.1.0: + version "22.1.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-22.1.0.tgz#0fca6d1a37fbeb7f4aac93d1090d782c56b611c8" + integrity sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw== + dependencies: + abab "^2.0.6" + cssstyle "^3.0.0" + data-urls "^4.0.0" + decimal.js "^10.4.3" + domexception "^4.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.4" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.2" + w3c-xmlserializer "^4.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.1" + ws "^8.13.0" + xml-name-validator "^4.0.0" + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +levn@^0.3.0, levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA== + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +lilconfig@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" + integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== + +lint-staged@^13.1.0: + version "13.3.0" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.3.0.tgz#7965d72a8d6a6c932f85e9c13ccf3596782d28a5" + integrity sha512-mPRtrYnipYYv1FEE134ufbWpeggNTo+O/UPzngoaKzbzHAthvR55am+8GfHTnqNRQVRRrYQLGW9ZyUoD7DsBHQ== + dependencies: + chalk "5.3.0" + commander "11.0.0" + debug "4.3.4" + execa "7.2.0" + lilconfig "2.1.0" + listr2 "6.6.1" + micromatch "4.0.5" + pidtree "0.6.0" + string-argv "0.3.2" + yaml "2.3.1" + +listr2@6.6.1: + version "6.6.1" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-6.6.1.tgz#08b2329e7e8ba6298481464937099f4a2cd7f95d" + integrity sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg== + dependencies: + cli-truncate "^3.1.0" + colorette "^2.0.20" + eventemitter3 "^5.0.1" + log-update "^5.0.1" + rfdc "^1.3.0" + wrap-ansi "^8.1.0" + +lodash.memoize@4.x: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== + +lodash@^4.17.14, lodash@^4.17.19: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-update@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-5.0.1.tgz#9e928bf70cb183c1f0c9e91d9e6b7115d597ce09" + integrity sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw== + dependencies: + ansi-escapes "^5.0.0" + cli-cursor "^4.0.0" + slice-ansi "^5.0.0" + strip-ansi "^7.0.1" + wrap-ansi "^8.0.1" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +make-error@1.x: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +micromatch@4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +mimic-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" + integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@^0.5.1: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +npm-run-path@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.1.0.tgz#bc62f7f3f6952d9894bd08944ba011a6ee7b7e00" + integrity sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q== + dependencies: + path-key "^4.0.0" + +nwsapi@^2.2.4: + version "2.2.7" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" + integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +onetime@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" + integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== + dependencies: + mimic-fn "^4.0.0" + +optionator@^0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + +os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse5@^7.0.0, parse5@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-key@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" + integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== + +picomatch@^2.2.3, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pidtree@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" + integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== + +postinstall@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/postinstall/-/postinstall-0.7.4.tgz#ab8de950f5d0350d753747c8b2606e347c80b3c8" + integrity sha512-jrItKnoJJCY6wuhP/LpTy5KyWJYUOOs+2477PUAXDCrJOZX2vgzCD3jgXawhJG4qdFxvAepaJLum1trieFbEuw== + dependencies: + "@danieldietrich/copy" "^0.4.2" + glob "^7.1.7" + minimist "^1.2.5" + resolve-from "^5.0.0" + resolve-pkg "^2.0.0" + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== + +progress@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" + integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + +regexpp@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" + integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve-pkg@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg/-/resolve-pkg-2.0.0.tgz#ac06991418a7623edc119084edc98b0e6bf05a41" + integrity sha512-+1lzwXehGCXSeryaISr6WujZzowloigEofRB+dj75y9RRa/obVcYgbHJd53tdYw8pvZj8GojXaaENws8Ktw/hQ== + dependencies: + resolve-from "^5.0.0" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +restore-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-4.0.0.tgz#519560a4318975096def6e609d44100edaa4ccb9" + integrity sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +rfdc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" + integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== + +rimraf@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + +run-async@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + +rxjs@^6.6.0: + version "6.6.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== + dependencies: + tslib "^1.9.0" + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + +semver@^5.5.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^6.1.2: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.5.3: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + dependencies: + lru-cache "^6.0.0" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg== + dependencies: + shebang-regex "^1.0.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ== + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^3.0.2, signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +slice-ansi@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" + integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== + dependencies: + ansi-styles "^3.2.0" + astral-regex "^1.0.0" + is-fullwidth-code-point "^2.0.0" + +slice-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" + integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== + dependencies: + ansi-styles "^6.0.0" + is-fullwidth-code-point "^4.0.0" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +string-argv@0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" + integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== + +string-width@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.0, string-width@^5.0.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +strip-final-newline@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" + integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== + +strip-json-comments@^3.0.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +table@^5.2.3: + version "5.4.6" + resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" + integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug== + dependencies: + ajv "^6.10.2" + lodash "^4.17.14" + slice-ansi "^2.1.0" + string-width "^3.0.0" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +through@^2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tough-cookie@^4.1.2: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" + integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== + dependencies: + punycode "^2.3.0" + +ts-jest@^29.1.0: + version "29.1.1" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.1.tgz#f58fe62c63caf7bfcc5cc6472082f79180f0815b" + integrity sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA== + dependencies: + bs-logger "0.x" + fast-json-stable-stringify "2.x" + jest-util "^29.0.0" + json5 "^2.2.3" + lodash.memoize "4.x" + make-error "1.x" + semver "^7.5.3" + yargs-parser "^21.0.1" + +tslib@^1.9.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg== + dependencies: + prelude-ls "~1.1.2" + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + +type-fest@^1.0.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" + integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== + +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +v8-compile-cache@^2.0.3: + version "2.4.0" + resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz#cdada8bec61e15865f05d097c5f4fd30e94dc128" + integrity sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw== + +w3c-xmlserializer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" + integrity sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw== + dependencies: + xml-name-validator "^4.0.0" + +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + +whatwg-url@^12.0.0, whatwg-url@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-12.0.1.tgz#fd7bcc71192e7c3a2a97b9a8d6b094853ed8773c" + integrity sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ== + dependencies: + tr46 "^4.1.1" + webidl-conversions "^7.0.0" + +which@^1.2.9: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@~1.2.3: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" + integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== + dependencies: + mkdirp "^0.5.1" + +ws@^8.13.0: + version "8.14.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.1.tgz#4b9586b4f70f9e6534c7bb1d3dc0baa8b8cf01e0" + integrity sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A== + +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" + integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== + +yargs-parser@^21.0.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==